diff --git a/backend/pom.xml b/backend/pom.xml index 0f9842c..ed63a83 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -50,6 +50,15 @@ org.springframework.boot spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-thymeleaf + + + io.github.openhtmltopdf + openhtmltopdf-pdfbox + 1.1.40 + org.springframework.boot diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java index b5718a1..0cb4bab 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java @@ -1,91 +1,48 @@ package fr.nessar.lmnporganizer.backend.service; +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import fr.nessar.lmnporganizer.backend.model.Apartment; import fr.nessar.lmnporganizer.backend.model.Lease; import fr.nessar.lmnporganizer.backend.model.Tenant; import org.springframework.stereotype.Service; -import org.springframework.core.io.ClassPathResource; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.text.Normalizer; -import java.util.ArrayList; import java.util.List; import java.util.Locale; @Service public class LeasePdfService { + private final TemplateEngine templateEngine; + + public LeasePdfService(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + public byte[] generate(Lease lease, Apartment apartment, List tenants) { - String tenantNames = tenants.stream().map(tenant -> tenant.getFirstName() + " " + tenant.getLastName()) - .reduce((first, second) -> first + ", " + second).orElse(""); - String guarantorNames = lease.getGuarantors().stream() - .map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()) - .reduce((first, second) -> first + ", " + second).orElse("Aucun"); - String renderedTemplate = loadTemplate() - .replace("{{leaseId}}", lease.getId()) - .replace("{{apartmentName}}", apartment.getName()) - .replace("{{apartmentAddress}}", apartment.getAddress()) - .replace("{{tenantNames}}", tenantNames) - .replace("{{startDate}}", String.valueOf(lease.getStartDate())) - .replace("{{endDate}}", String.valueOf(lease.getEndDate())) - .replace("{{monthlyRent}}", String.valueOf(lease.getMonthlyRent())) - .replace("{{monthlyCharges}}", String.valueOf(lease.getMonthlyCharges())) - .replace("{{securityDeposit}}", String.valueOf(lease.getSecurityDeposit())) - .replace("{{guarantorNames}}", guarantorNames); - return buildPdf(List.of(renderedTemplate.split("\\R", -1))); - } + Context context = new Context(Locale.FRANCE); + context.setVariable("lease", lease); + context.setVariable("apartment", apartment); + context.setVariable("tenantNames", tenantNamesOf(tenants)); + context.setVariable("guarantorNames", namesOf(lease.getGuarantors().stream() + .map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()).toList())); - private String loadTemplate() { - try { - return new ClassPathResource("templates/lease.txt").getContentAsString(StandardCharsets.UTF_8); + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + new PdfRendererBuilder().withHtmlContent(templateEngine.process("lease", context), null) + .toStream(output).run(); + return output.toByteArray(); } catch (IOException exception) { - throw new IllegalStateException("Lease PDF template is unavailable", exception); + throw new IllegalStateException("Lease PDF could not be generated", exception); } } - private byte[] buildPdf(List lines) { - StringBuilder stream = new StringBuilder("BT\n/F1 18 Tf\n50 790 Td\n"); - for (int index = 0; index < lines.size(); index++) { - if (index == 1) stream.append("/F1 11 Tf\n"); - stream.append('(').append(escape(lines.get(index))).append(") Tj\n0 -28 Td\n"); - } - stream.append("ET\n"); - byte[] streamBytes = stream.toString().getBytes(StandardCharsets.US_ASCII); - List objects = List.of( - ascii("<< /Type /Catalog /Pages 2 0 R >>"), - ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), - ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"), - ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), - concat(ascii("<< /Length " + streamBytes.length + " >>\nstream\n"), streamBytes, ascii("endstream"))); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - write(output, ascii("%PDF-1.4\n")); - List offsets = new ArrayList<>(); - for (int index = 0; index < objects.size(); index++) { - offsets.add(output.size()); - write(output, ascii((index + 1) + " 0 obj\n")); - write(output, objects.get(index)); - write(output, ascii("\nendobj\n")); - } - int xrefOffset = output.size(); - write(output, ascii("xref\n0 " + (objects.size() + 1) + "\n0000000000 65535 f \n")); - offsets.forEach(offset -> write(output, ascii(String.format(Locale.ROOT, "%010d 00000 n \n", offset)))); - write(output, ascii("trailer\n<< /Size " + (objects.size() + 1) + " /Root 1 0 R >>\nstartxref\n" - + xrefOffset + "\n%%EOF\n")); - return output.toByteArray(); + private String tenantNamesOf(List tenants) { + return namesOf(tenants.stream().map(tenant -> tenant.getFirstName() + " " + tenant.getLastName()).toList()); } - private String escape(String value) { - String ascii = Normalizer.normalize(String.valueOf(value), Normalizer.Form.NFD) - .replaceAll("\\p{M}", "").replaceAll("[^\\x20-\\x7E]", "?"); - return ascii.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)"); + private String namesOf(List names) { + return names.isEmpty() ? "Aucun" : String.join(", ", names); } - - private byte[] ascii(String value) { return value.getBytes(StandardCharsets.US_ASCII); } - private byte[] concat(byte[]... parts) { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - for (byte[] part : parts) write(output, part); - return output.toByteArray(); - } - private void write(ByteArrayOutputStream output, byte[] bytes) { output.writeBytes(bytes); } } diff --git a/backend/src/main/resources/templates/lease.html b/backend/src/main/resources/templates/lease.html new file mode 100644 index 0000000..1140321 --- /dev/null +++ b/backend/src/main/resources/templates/lease.html @@ -0,0 +1,31 @@ + + + + + + + +

BAIL D'HABITATION MEUBLÉE

+

Référence : lease-1

+ + + + + + + + + +
LogementStudio République
Adresse12 rue de la République
Locataire(s)Camille Martin
Période2026-09-01 au 2027-08-31
Loyer mensuel850.00 EUR
Charges mensuelles50.00 EUR
Dépôt de garantie850.00 EUR
Garant(s)Alex Durand
+
Bail généré par LMNP Organizer. À compléter et signer par les parties.
+ + diff --git a/backend/src/main/resources/templates/lease.txt b/backend/src/main/resources/templates/lease.txt deleted file mode 100644 index c5b9764..0000000 --- a/backend/src/main/resources/templates/lease.txt +++ /dev/null @@ -1,12 +0,0 @@ -BAIL D'HABITATION MEUBLÉE -Référence : {{leaseId}} -Logement : {{apartmentName}} -Adresse : {{apartmentAddress}} -Locataire(s) : {{tenantNames}} -Période : {{startDate}} au {{endDate}} -Loyer mensuel : {{monthlyRent}} EUR -Charges mensuelles : {{monthlyCharges}} EUR -Dépôt de garantie : {{securityDeposit}} EUR -Garant(s) : {{guarantorNames}} - -Bail généré par LMNP Organizer. À compléter et signer par les parties. diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java index f6fd09c..f607733 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java @@ -8,6 +8,9 @@ import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -144,9 +147,13 @@ class LeaseControllerTest { if (content.length < 100 || content[0] != '%' || content[1] != 'P') { throw new AssertionError("Expected a generated lease PDF"); } - String pdf = new String(content, java.nio.charset.StandardCharsets.US_ASCII); - if (!pdf.contains("Studio Republique") || !pdf.contains("Camille Martin")) { - throw new AssertionError("Expected rendered lease template values"); + try (PDDocument document = Loader.loadPDF(content)) { + String pdfText = new PDFTextStripper().getText(document); + if (!pdfText.contains("Studio République") || !pdfText.contains("Camille Martin")) { + throw new AssertionError("Expected rendered lease template values"); + } + } catch (java.io.IOException exception) { + throw new AssertionError("Expected a readable PDF", exception); } }); } diff --git a/frontend/angular.json b/frontend/angular.json index ef9add8..175e64c 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -3,7 +3,10 @@ "version": 1, "cli": { "packageManager": "bun", - "schematicCollections": ["angular-eslint"] + "schematicCollections": [ + "angular-eslint" + ], + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/frontend/bun.lock b/frontend/bun.lock index e976174..45b48b9 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -23,7 +23,6 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "22.1.0", "eslint": "^10.6.0", "jsdom": "^28.0.0", @@ -153,8 +152,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], @@ -561,8 +558,6 @@ "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], - "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], @@ -607,8 +602,6 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -835,8 +828,6 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], - "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -881,15 +872,9 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - - "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], @@ -953,10 +938,6 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - - "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1305,8 +1286,6 @@ "@angular/forms/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/frontend/package.json b/frontend/package.json index 3e5723a..d57ecfb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "22.1.0", "eslint": "^10.6.0", "jsdom": "^28.0.0", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index a27b931..b738fe0 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,15 +1,9 @@ -import { - ApplicationConfig, - provideBrowserGlobalErrorListeners, - provideAppInitializer, - inject, -} from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; import { MAT_NATIVE_DATE_FORMATS, MAT_DATE_LOCALE, provideNativeDateAdapter } from '@angular/material/core'; import { routes } from './app.routes'; -import { ConfigService } from './services/config.service'; const frenchDateFormats = { ...MAT_NATIVE_DATE_FORMATS, @@ -26,9 +20,5 @@ export const appConfig: ApplicationConfig = { provideHttpClient(), provideNativeDateAdapter(frenchDateFormats), { provide: MAT_DATE_LOCALE, useValue: 'fr-FR' }, - provideAppInitializer(() => { - const configService = inject(ConfigService); - return configService.loadConfig(); - }), ], }; diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index a2bd37b..2c77e23 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,13 +1,9 @@ -@if (!isLoggedIn) { +@if (!session.isLoggedIn()) { } @else {
@@ -21,53 +17,7 @@
- @if (activeTab() === 'dashboard') { - - } - @if (activeTab() === 'apartments') { - - } - @if (activeTab() === 'tenants') { - - } - @if (activeTab() === 'leases') { - - } - @if (activeTab() === 'settings') { - - } +
diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts new file mode 100644 index 0000000..f591d53 --- /dev/null +++ b/frontend/src/app/app.routes.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { routes } from './app.routes'; + +describe('routes', () => { + it('maps every workspace page to a URL', () => { + expect(routes.map((route) => route.path)).toEqual([ + 'dashboard', + 'apartments', + 'tenants', + 'leases', + 'settings', + '', + '**', + ]); + }); +}); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb..d764a52 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,16 @@ import { Routes } from '@angular/router'; +import { ApartmentsPage } from './components/apartments-page/apartments-page'; +import { DashboardPage } from './components/dashboard-page/dashboard-page'; +import { LeasesPage } from './components/leases-page/leases-page'; +import { SettingsPage } from './components/settings-page/settings-page'; +import { TenantsPage } from './components/tenants-page/tenants-page'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: 'dashboard', component: DashboardPage }, + { path: 'apartments', component: ApartmentsPage }, + { path: 'tenants', component: TenantsPage }, + { path: 'leases', component: LeasesPage }, + { path: 'settings', component: SettingsPage }, + { path: '', pathMatch: 'full', redirectTo: 'dashboard' }, + { path: '**', redirectTo: 'dashboard' }, +]; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts deleted file mode 100644 index db86c5e..0000000 --- a/frontend/src/app/app.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { App } from './app'; -import { HttpClient } from '@angular/common/http'; -import { ApartmentService } from './services/apartment.service'; -import { TenantService } from './services/tenant.service'; -import { LeaseService } from './services/lease.service'; -import { of, throwError } from 'rxjs'; -import { vi } from 'vitest'; - -describe('App', () => { - let mockHttpClient: any; - let mockApartmentService: any; - let mockTenantService: any; - let mockLeaseService: any; - let mockUserResponse: { authenticated: boolean; username: string; email: string }; - let originalLocation: any; - let locationMock: { href: string; origin: string }; - - beforeEach(async () => { - mockUserResponse = { authenticated: false, username: '', email: '' }; - - mockHttpClient = { - get: (url: string) => { - if (url === '/api/user') { - return of(mockUserResponse); - } - return of('Hello from mocked API!'); - }, - }; - - mockApartmentService = { - getApartments: () => - of([ - { - id: '1', - name: 'Apt 1', - address: 'Addr 1', - purchasePrice: 100000, - description: 'Desc 1', - }, - ]), - getApartment: (id: string) => - of({ id, name: 'Apt 1', address: 'Addr 1', purchasePrice: 100000, description: 'Desc 1' }), - createApartment: (apt: any) => of({ id: '2', ...apt }), - updateApartment: (id: string, apt: any) => of({ id, ...apt }), - deleteApartment: () => of(undefined), - }; - - mockTenantService = { - getTenants: vi.fn().mockReturnValue(of([])), - createTenant: vi.fn().mockImplementation((tenant) => of({ id: 'tenant-2', ...tenant })), - updateTenant: vi.fn().mockImplementation((id, tenant) => of({ id, ...tenant })), - deleteTenant: vi.fn().mockReturnValue(of(undefined)), - uploadAttachment: vi.fn().mockReturnValue(of({ id: 'attachment-1' })), - downloadAttachment: vi.fn().mockReturnValue(of(new Blob(['attachment']))), - }; - - mockLeaseService = { - getLeases: vi.fn().mockReturnValue(of([])), - createLease: vi.fn().mockImplementation((lease) => of({ id: 'lease-1', ...lease })), - updateLease: vi.fn().mockImplementation((id, lease) => of({ id, ...lease })), - deleteLease: vi.fn().mockReturnValue(of(undefined)), - downloadPdf: vi.fn().mockReturnValue(of(new Blob(['pdf']))), - }; - - originalLocation = window.location; - delete (window as any).location; - locationMock = { - href: '', - origin: 'http://localhost', - }; - (window as any).location = locationMock; - - await TestBed.configureTestingModule({ - imports: [App], - providers: [ - { provide: HttpClient, useValue: mockHttpClient }, - { provide: ApartmentService, useValue: mockApartmentService }, - { provide: TenantService, useValue: mockTenantService }, - { provide: LeaseService, useValue: mockLeaseService }, - ], - }).compileComponents(); - }); - - afterEach(() => { - (window as any).location = originalLocation; - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('LMNP Organizer'); - }); - - it('should return login status based on user endpoint response', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - // Initially unauthenticated - fixture.detectChanges(); - await fixture.whenStable(); - expect(app.isLoggedIn).toBe(false); - - // Activating authenticated response and calling ngOnInit manually - mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; - app.ngOnInit(); - expect(app.isLoggedIn).toBe(true); - expect(app.username).toBe('Alice'); - expect(app.email).toBe('alice@example.com'); - }); - - it('should invoke OIDC login and logout flows by redirecting the browser window', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - app.login(); - expect(locationMock.href).toBe('/oauth2/authorization/oidc'); - - app.logout(); - expect(locationMock.href).toBe('/logout'); - }); - - it('should call hello API and set message on success', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - app.callHelloApi(); - expect(app.helloMessage()).toBe('Hello from mocked API!'); - }); - - it('should call hello API and handle error', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - mockHttpClient.get = (url: string) => { - if (url === '/api/user') { - return of(mockUserResponse); - } - return throwError(() => ({ statusText: 'Unauthorized' })); - }; - - app.callHelloApi(); - expect(app.helloMessage()).toBe('Failed to connect to backend: Unauthorized'); - }); - - it('should render logged in state when user is authenticated', async () => { - mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; - const fixture = TestBed.createComponent(App); - - fixture.detectChanges(); - await fixture.whenStable(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.user-details h3')?.textContent).toContain('Alice'); - expect(compiled.querySelector('.user-email')?.textContent).toContain('alice@example.com'); - }); - - it('should render hello API response when API button is clicked', async () => { - mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - fixture.detectChanges(); - await fixture.whenStable(); - - // Click button - const compiled = fixture.nativeElement as HTMLElement; - const button = compiled.querySelector('.api-actions button') as HTMLButtonElement; - button.click(); - - fixture.detectChanges(); - await fixture.whenStable(); - - // Verify response message is shown - expect(app.helloMessage()).toBe('Hello from mocked API!'); - fixture.detectChanges(); - expect(compiled.querySelector('.api-response-code')?.textContent).toContain( - 'Hello from mocked API!', - ); - }); - - // Apartment CRUD tests - it('should load apartments on init if authenticated', async () => { - mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; - const loadSpy = vi.spyOn(mockApartmentService, 'getApartments'); - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - fixture.detectChanges(); - await fixture.whenStable(); - - app.ngOnInit(); - expect(loadSpy).toHaveBeenCalled(); - expect(app.apartments().length).toBe(1); - expect(app.apartments()[0].name).toBe('Apt 1'); - }); - - it('should navigate between tabs', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - fixture.detectChanges(); - await fixture.whenStable(); - - expect(app.activeTab()).toBe('dashboard'); - app.activeTab.set('apartments'); - - fixture.detectChanges(); - await fixture.whenStable(); - expect(app.activeTab()).toBe('apartments'); - }); - - it('should save a new apartment successfully', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - const createSpy = vi.spyOn(mockApartmentService, 'createApartment'); - - fixture.detectChanges(); - await fixture.whenStable(); - - app.saveApartment({ - apartment: { - name: 'New Apt', - address: 'New Addr', - purchasePrice: 200000, - description: 'New Desc', - }, - }); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(createSpy).toHaveBeenCalledWith({ - name: 'New Apt', - address: 'New Addr', - purchasePrice: 200000, - description: 'New Desc', - }); - }); - - it('should update an existing apartment successfully', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - const updateSpy = vi.spyOn(mockApartmentService, 'updateApartment'); - const apt = { - id: '1', - name: 'Old Apt', - address: 'Old Addr', - purchasePrice: 100000, - description: 'Old Desc', - }; - - fixture.detectChanges(); - await fixture.whenStable(); - - app.saveApartment({ - apartmentId: apt.id, - apartment: { - name: 'Updated Apt', - address: 'Updated Addr', - purchasePrice: 110000, - description: 'Updated Desc', - }, - }); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(updateSpy).toHaveBeenCalledWith('1', { - name: 'Updated Apt', - address: 'Updated Addr', - purchasePrice: 110000, - description: 'Updated Desc', - }); - }); - - it('should delete an apartment when confirmed', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment'); - vi.spyOn(window, 'confirm').mockImplementation(() => true); - - fixture.detectChanges(); - await fixture.whenStable(); - - app.deleteApartment('1'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(deleteSpy).toHaveBeenCalledWith('1'); - }); - - it('should not delete an apartment when not confirmed', async () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - const deleteSpy = vi.spyOn(mockApartmentService, 'deleteApartment'); - vi.spyOn(window, 'confirm').mockImplementation(() => false); - - fixture.detectChanges(); - await fixture.whenStable(); - - app.deleteApartment('1'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(deleteSpy).not.toHaveBeenCalled(); - }); - - it('should orchestrate tenant mutations and attachment upload', () => { - const app = TestBed.createComponent(App).componentInstance; - const tenant = { - firstName: 'Camille', - lastName: 'Martin', - email: 'camille@example.com', - phone: '', - address: '', - }; - const file = new File(['identity'], 'identite.pdf'); - - app.saveTenant({ tenant }); - app.saveTenant({ tenant, tenantId: 'tenant-1' }); - app.uploadTenantAttachment({ tenantId: 'tenant-1', file }); - - expect(mockTenantService.createTenant).toHaveBeenCalledWith(tenant); - expect(mockTenantService.updateTenant).toHaveBeenCalledWith('tenant-1', tenant); - expect(mockTenantService.uploadAttachment).toHaveBeenCalledWith('tenant-1', file); - expect(mockTenantService.getTenants).toHaveBeenCalledTimes(3); - }); - - it('should orchestrate lease creation and confirmed deletions', () => { - const app = TestBed.createComponent(App).componentInstance; - const lease = { - apartmentId: 'apartment-1', - tenantIds: ['tenant-1'], - guarantors: [], - startDate: '2026-09-01', - endDate: '2027-08-31', - monthlyRent: 850, - monthlyCharges: 50, - securityDeposit: 850, - status: 'ACTIVE' as const, - }; - vi.spyOn(window, 'confirm').mockReturnValue(true); - - app.saveLease(lease); - app.deleteLease('lease-1'); - app.deleteTenant('tenant-1'); - - expect(mockLeaseService.createLease).toHaveBeenCalledWith(lease); - expect(mockLeaseService.deleteLease).toHaveBeenCalledWith('lease-1'); - expect(mockTenantService.deleteTenant).toHaveBeenCalledWith('tenant-1'); - }); - - it('should download tenant attachments and lease PDFs', () => { - const app = TestBed.createComponent(App).componentInstance; - const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); - const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); - const click = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => undefined); - - app.downloadTenantAttachment({ - tenantId: 'tenant-1', - attachment: { id: 'attachment-1', fileName: 'identite.pdf' }, - }); - app.downloadLeasePdf({ - id: 'lease-1', - apartmentId: 'apartment-1', - tenantIds: ['tenant-1'], - guarantors: [], - startDate: '2026-09-01', - endDate: '2027-08-31', - monthlyRent: 850, - monthlyCharges: 50, - securityDeposit: 850, - status: 'ACTIVE', - }); - - expect(mockTenantService.downloadAttachment).toHaveBeenCalled(); - expect(mockLeaseService.downloadPdf).toHaveBeenCalledWith('lease-1'); - expect(createObjectUrl).toHaveBeenCalledTimes(2); - expect(click).toHaveBeenCalledTimes(2); - expect(revokeObjectUrl).toHaveBeenCalledWith('blob:test'); - }); -}); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 6616205..fb6d697 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,21 +1,9 @@ -import { Component, computed, signal, inject, OnInit, effect } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { ApartmentService, Apartment } from './services/apartment.service'; -import { ThemeService } from './services/theme.service'; +import { Component, computed, inject, OnInit } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; import { BrandMark } from './components/brand-mark'; import { SignInPage } from './components/sign-in-page/sign-in-page'; -import { - WorkspaceNavigation, - WorkspaceTab, -} from './components/workspace-navigation/workspace-navigation'; -import { DashboardPage } from './components/dashboard-page/dashboard-page'; -import { ApartmentsPage } from './components/apartments-page/apartments-page'; -import { ApartmentSaveRequest } from './components/apartments-page/apartments-page'; -import { SettingsPage } from './components/settings-page/settings-page'; -import { Tenant, TenantService } from './services/tenant.service'; -import { Lease, LeaseService } from './services/lease.service'; -import { TenantsPage, TenantSaveRequest } from './components/tenants-page/tenants-page'; -import { LeasesPage } from './components/leases-page/leases-page'; +import { WorkspaceNavigation } from './components/workspace-navigation/workspace-navigation'; +import { SessionService } from './services/session.service'; @Component({ selector: 'app-root', @@ -23,81 +11,17 @@ import { LeasesPage } from './components/leases-page/leases-page'; BrandMark, SignInPage, WorkspaceNavigation, - DashboardPage, - ApartmentsPage, - TenantsPage, - LeasesPage, - SettingsPage, + RouterOutlet, ], templateUrl: './app.html', styleUrl: './app.css', }) export class App implements OnInit { - protected readonly title = signal('LMNP Organizer'); - readonly helloMessage = signal(''); - - // Tab & CRUD States - readonly activeTab = signal('dashboard'); - readonly apartments = signal([]); - readonly tenants = signal([]); - readonly leases = signal([]); - readonly tenantAttachmentError = signal(''); - private readonly _isLoggedIn = signal(false); - private readonly _username = signal(''); - private readonly _email = signal(''); - readonly userInitial = computed(() => this._username().charAt(0).toUpperCase()); - - private readonly http = inject(HttpClient); - private readonly apartmentService = inject(ApartmentService); - private readonly tenantService = inject(TenantService); - private readonly leaseService = inject(LeaseService); - protected readonly themeService = inject(ThemeService); - readonly themePreference = this.themeService.preference; - - constructor() { - effect(() => { - if (this.isLoggedIn) { - this.loadApartments(); - this.loadTenants(); - this.loadLeases(); - } - }); - } + readonly session = inject(SessionService); + readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase()); ngOnInit() { - this.http - .get<{ authenticated: boolean; username?: string; email?: string }>('/api/user') - .subscribe({ - next: (user) => { - if (user.authenticated) { - this._isLoggedIn.set(true); - this._username.set(user.username || 'User'); - this._email.set(user.email || ''); - } else { - this._isLoggedIn.set(false); - this._username.set(''); - this._email.set(''); - } - }, - error: (err) => { - console.error('Error fetching user authentication status', err); - this._isLoggedIn.set(false); - this._username.set(''); - this._email.set(''); - }, - }); - } - - get isLoggedIn(): boolean { - return this._isLoggedIn(); - } - - get username(): string { - return this._username(); - } - - get email(): string { - return this._email(); + this.session.refresh(); } login() { @@ -108,146 +32,4 @@ export class App implements OnInit { window.location.href = '/logout'; } - callHelloApi() { - this.http.get('/api/hello', { responseType: 'text' }).subscribe({ - next: (msg) => this.helloMessage.set(msg), - error: (err) => { - console.error(err); - this.helloMessage.set('Failed to connect to backend: ' + err.statusText); - }, - }); - } - - // Apartment CRUD operations - loadApartments() { - this.apartmentService.getApartments().subscribe({ - next: (data) => this.apartments.set(data), - error: (err) => console.error('Error loading apartments', err), - }); - } - - saveApartment({ apartment, apartmentId }: ApartmentSaveRequest): void { - if (apartmentId) { - this.apartmentService.updateApartment(apartmentId, apartment).subscribe({ - next: () => { - this.loadApartments(); - }, - error: (err) => console.error('Error updating apartment', err), - }); - } else { - this.apartmentService.createApartment(apartment).subscribe({ - next: () => { - this.loadApartments(); - }, - error: (err) => console.error('Error creating apartment', err), - }); - } - } - - deleteApartment(id: string) { - if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) { - this.apartmentService.deleteApartment(id).subscribe({ - next: () => this.loadApartments(), - error: (err) => console.error('Error deleting apartment', err), - }); - } - } - - loadTenants(): void { - this.tenantService.getTenants().subscribe({ - next: (data) => this.tenants.set(data), - error: (err) => console.error('Error loading tenants', err), - }); - } - - saveTenant({ tenant, tenantId }: TenantSaveRequest): void { - const request = tenantId - ? this.tenantService.updateTenant(tenantId, tenant) - : this.tenantService.createTenant(tenant); - request.subscribe({ - next: () => this.loadTenants(), - error: (err) => console.error('Error saving tenant', err), - }); - } - - deleteTenant(id: string): void { - if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')) { - this.tenantService.deleteTenant(id).subscribe({ - next: () => this.loadTenants(), - error: (err) => console.error('Error deleting tenant', err), - }); - } - } - - uploadTenantAttachment(request: { tenantId: string; file: File }): void { - this.tenantAttachmentError.set(''); - this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({ - next: () => this.loadTenants(), - error: (error: unknown) => { - console.error('Error uploading tenant attachment', error); - this.tenantAttachmentError.set(this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.')); - }, - }); - } - - downloadTenantAttachment(request: { - tenantId: string; - attachment: { id: string; fileName: string }; - }): void { - this.tenantService.downloadAttachment(request.tenantId, request.attachment.id).subscribe({ - next: (blob) => this.saveBlob(blob, request.attachment.fileName), - error: (err) => console.error('Error downloading tenant attachment', err), - }); - } - - loadLeases(): void { - this.leaseService.getLeases().subscribe({ - next: (data) => this.leases.set(data), - error: (err) => console.error('Error loading leases', err), - }); - } - - saveLease(lease: Lease): void { - this.leaseService.createLease(lease).subscribe({ - next: () => this.loadLeases(), - error: (err) => console.error('Error saving lease', err), - }); - } - - deleteLease(id: string): void { - if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?')) { - this.leaseService.deleteLease(id).subscribe({ - next: () => this.loadLeases(), - error: (err) => console.error('Error deleting lease', err), - }); - } - } - - downloadLeasePdf(lease: Lease): void { - if (!lease.id) return; - this.leaseService.downloadPdf(lease.id).subscribe({ - next: (blob) => this.saveBlob(blob, `bail-${lease.id}.pdf`), - error: (err) => console.error('Error downloading lease PDF', err), - }); - } - - private saveBlob(blob: Blob, fileName: string): void { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - link.click(); - URL.revokeObjectURL(url); - } - - private errorMessage(error: unknown, fallback: string): string { - if (typeof error !== 'object' || error === null || !('error' in error)) { - return fallback; - } - const response = error.error; - if (typeof response !== 'object' || response === null || !('message' in response)) { - return fallback; - } - return typeof response.message === 'string' ? response.message : fallback; - } } diff --git a/frontend/src/app/components/apartments-page/apartments-page.html b/frontend/src/app/components/apartments-page/apartments-page.html index fd6e65e..437def6 100644 --- a/frontend/src/app/components/apartments-page/apartments-page.html +++ b/frontend/src/app/components/apartments-page/apartments-page.html @@ -1,10 +1,10 @@
@if (!showForm()) { } @else { { + const workspace = { + apartments: signal([]), + loadApartments: () => undefined, + saveApartment: () => undefined, + deleteApartment: () => undefined, + }; + + beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: workspace }] })); + it('opens the form for the apartment selected for editing', () => { const apartment = { id: 'apt-1', @@ -12,7 +23,6 @@ describe('ApartmentsPage', () => { description: '', }; const fixture = TestBed.createComponent(ApartmentsPage); - fixture.componentRef.setInput('apartments', [apartment]); fixture.detectChanges(); const page = fixture.componentInstance; @@ -22,7 +32,7 @@ describe('ApartmentsPage', () => { expect(page.editingApartment()).toEqual(apartment); }); - it('emits the edited apartment together with its id', () => { + it('saves the edited apartment together with its id', () => { const apartment = { id: 'apt-1', name: 'T2 Paris 15', @@ -31,19 +41,14 @@ describe('ApartmentsPage', () => { description: '', }; const fixture = TestBed.createComponent(ApartmentsPage); - fixture.componentRef.setInput('apartments', [apartment]); fixture.detectChanges(); const page = fixture.componentInstance; - const apartmentSaved = vi.fn(); - page.apartmentSaved.subscribe(apartmentSaved); + const saveApartment = vi.spyOn(workspace, 'saveApartment'); page.openEditForm(apartment); page.saveApartment({ ...apartment, name: 'T2 rénové' }); - expect(apartmentSaved).toHaveBeenCalledWith({ - apartment: { ...apartment, name: 'T2 rénové' }, - apartmentId: 'apt-1', - }); + expect(saveApartment).toHaveBeenCalledWith({ ...apartment, name: 'T2 rénové' }, 'apt-1'); expect(page.showForm()).toBe(false); }); }); diff --git a/frontend/src/app/components/apartments-page/apartments-page.ts b/frontend/src/app/components/apartments-page/apartments-page.ts index 693cbb8..2058c6e 100644 --- a/frontend/src/app/components/apartments-page/apartments-page.ts +++ b/frontend/src/app/components/apartments-page/apartments-page.ts @@ -1,26 +1,22 @@ -import { Component, input, output, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { Apartment } from '../../services/apartment.service'; +import { WorkspaceService } from '../../services/workspace.service'; import { ApartmentForm } from '../apartment-form/apartment-form'; import { ApartmentList } from '../apartment-list/apartment-list'; -export interface ApartmentSaveRequest { - apartment: Apartment; - apartmentId?: string; -} - @Component({ selector: 'app-apartments-page', imports: [ApartmentForm, ApartmentList], templateUrl: './apartments-page.html', }) export class ApartmentsPage { - readonly apartments = input.required(); - readonly apartmentSaved = output(); - readonly deleteRequested = output(); + readonly workspace = inject(WorkspaceService); readonly showForm = signal(false); readonly editingApartment = signal(null); + constructor() { this.workspace.loadApartments(); } + openAddForm(): void { this.editingApartment.set(null); this.showForm.set(true); @@ -37,7 +33,7 @@ export class ApartmentsPage { } saveApartment(apartment: Apartment): void { - this.apartmentSaved.emit({ apartment, apartmentId: this.editingApartment()?.id }); + this.workspace.saveApartment(apartment, this.editingApartment()?.id); this.closeForm(); } } diff --git a/frontend/src/app/components/dashboard-page/dashboard-page.html b/frontend/src/app/components/dashboard-page/dashboard-page.html index 6d5e8f9..02146ea 100644 --- a/frontend/src/app/components/dashboard-page/dashboard-page.html +++ b/frontend/src/app/components/dashboard-page/dashboard-page.html @@ -1,7 +1,7 @@
-

Bonjour {{ username() }}

+

Bonjour {{ session.username() }}

Tableau de bord

Retrouvez rapidement les actions utiles à votre gestion locative.

@@ -17,10 +17,10 @@

Votre portefeuille

-

{{ apartmentCount() }} appartement{{ apartmentCount() > 1 ? 's' : '' }}

+

{{ workspace.apartments().length }} appartement{{ workspace.apartments().length > 1 ? 's' : '' }}

Centralisez les informations essentielles de vos biens.

- @@ -28,8 +28,8 @@
{{ userInitial() }}

Compte actif

-

{{ username() }}

-

{{ email() }}

+

{{ session.username() }}

+

{{ session.email() }}

Bailleur connecté
@@ -40,7 +40,7 @@

Vérifiez que votre espace communique correctement avec le service sécurisé.

-
diff --git a/frontend/src/app/components/dashboard-page/dashboard-page.ts b/frontend/src/app/components/dashboard-page/dashboard-page.ts index 904eb56..d7b9793 100644 --- a/frontend/src/app/components/dashboard-page/dashboard-page.ts +++ b/frontend/src/app/components/dashboard-page/dashboard-page.ts @@ -1,5 +1,9 @@ -import { Component, input, output } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; +import { SessionService } from '../../services/session.service'; +import { WorkspaceService } from '../../services/workspace.service'; @Component({ selector: 'app-dashboard-page', @@ -8,11 +12,21 @@ import { MatButtonModule } from '@angular/material/button'; styleUrl: './dashboard-page.css', }) export class DashboardPage { - readonly username = input.required(); - readonly email = input.required(); - readonly userInitial = input.required(); - readonly apartmentCount = input.required(); - readonly helloMessage = input.required(); - readonly apartmentsRequested = output(); - readonly connectionCheckRequested = output(); + private readonly http = inject(HttpClient); + private readonly router = inject(Router); + readonly session = inject(SessionService); + readonly workspace = inject(WorkspaceService); + readonly userInitial = computed(() => this.session.username().charAt(0).toUpperCase()); + readonly helloMessage = signal(''); + + constructor() { this.workspace.loadApartments(); } + + checkConnection(): void { + this.http.get('/api/hello', { responseType: 'text' }).subscribe({ + next: (message) => this.helloMessage.set(message), + error: (error) => this.helloMessage.set(`Failed to connect to backend: ${error.statusText}`), + }); + } + + showApartments(): void { this.router.navigateByUrl('/apartments'); } } diff --git a/frontend/src/app/components/leases-page/leases-page.html b/frontend/src/app/components/leases-page/leases-page.html index 06205ba..fe0e3e5 100644 --- a/frontend/src/app/components/leases-page/leases-page.html +++ b/frontend/src/app/components/leases-page/leases-page.html @@ -10,7 +10,7 @@ Créer un bail
- @if (leases().length === 0) { + @if (workspace.leases().length === 0) {

Mise en location

Aucun bail enregistré

@@ -19,13 +19,13 @@
} @else {
- @for (lease of leases(); track lease.id) { + @for (lease of workspace.leases(); track lease.id) { }
@@ -33,9 +33,9 @@
} @else { } diff --git a/frontend/src/app/components/leases-page/leases-page.spec.ts b/frontend/src/app/components/leases-page/leases-page.spec.ts index 1cf076b..4276fce 100644 --- a/frontend/src/app/components/leases-page/leases-page.spec.ts +++ b/frontend/src/app/components/leases-page/leases-page.spec.ts @@ -1,12 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { LeasesPage } from './leases-page'; +import { WorkspaceService } from '../../services/workspace.service'; describe('LeasesPage', () => { + beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: { loadApartments: () => undefined, loadTenants: () => undefined, loadLeases: () => undefined } }] })); + it('opens and closes the focused lease form module', () => { const fixture = TestBed.createComponent(LeasesPage); - fixture.componentRef.setInput('leases', []); - fixture.componentRef.setInput('apartments', []); - fixture.componentRef.setInput('tenants', []); const component = fixture.componentInstance; component.openForm(); diff --git a/frontend/src/app/components/leases-page/leases-page.ts b/frontend/src/app/components/leases-page/leases-page.ts index 0837112..84bea62 100644 --- a/frontend/src/app/components/leases-page/leases-page.ts +++ b/frontend/src/app/components/leases-page/leases-page.ts @@ -1,8 +1,6 @@ -import { Component, input, output, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { Apartment } from '../../services/apartment.service'; -import { Lease } from '../../services/lease.service'; -import { Tenant } from '../../services/tenant.service'; +import { WorkspaceService } from '../../services/workspace.service'; import { LeaseCard } from '../lease-card/lease-card'; import { LeaseForm } from '../lease-form/lease-form'; @@ -13,14 +11,15 @@ import { LeaseForm } from '../lease-form/lease-form'; styleUrl: './leases-page.css', }) export class LeasesPage { - readonly leases = input.required(); - readonly apartments = input.required(); - readonly tenants = input.required(); - readonly leaseSaved = output(); - readonly deleteRequested = output(); - readonly pdfRequested = output(); + readonly workspace = inject(WorkspaceService); readonly showForm = signal(false); + constructor() { + this.workspace.loadApartments(); + this.workspace.loadTenants(); + this.workspace.loadLeases(); + } + openForm(): void { this.showForm.set(true); } diff --git a/frontend/src/app/components/settings-page/settings-page.html b/frontend/src/app/components/settings-page/settings-page.html index bfd34bf..6d7a21b 100644 --- a/frontend/src/app/components/settings-page/settings-page.html +++ b/frontend/src/app/components/settings-page/settings-page.html @@ -21,8 +21,8 @@
- {{ username() }}{{ email() }} + {{ session.username() }}{{ session.email() }}
- +
diff --git a/frontend/src/app/components/settings-page/settings-page.ts b/frontend/src/app/components/settings-page/settings-page.ts index db09a1e..53c78c0 100644 --- a/frontend/src/app/components/settings-page/settings-page.ts +++ b/frontend/src/app/components/settings-page/settings-page.ts @@ -1,7 +1,8 @@ -import { Component, input, output } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatRadioModule } from '@angular/material/radio'; -import { ThemePreference } from '../../services/theme.service'; +import { SessionService } from '../../services/session.service'; +import { ThemeService } from '../../services/theme.service'; @Component({ selector: 'app-settings-page', @@ -10,9 +11,8 @@ import { ThemePreference } from '../../services/theme.service'; styleUrl: './settings-page.css', }) export class SettingsPage { - readonly username = input.required(); - readonly email = input.required(); - readonly themePreference = input.required(); - readonly themePreferenceChanged = output(); - readonly logoutRequested = output(); + readonly session = inject(SessionService); + readonly theme = inject(ThemeService); + + logout(): void { window.location.href = '/logout'; } } diff --git a/frontend/src/app/components/tenants-page/tenants-page.html b/frontend/src/app/components/tenants-page/tenants-page.html index f23b768..4e2b56e 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.html +++ b/frontend/src/app/components/tenants-page/tenants-page.html @@ -10,7 +10,7 @@ Ajouter un locataire - @if (tenants().length === 0) { + @if (workspace.tenants().length === 0) {

Premier dossier

Aucun locataire enregistré

@@ -21,14 +21,14 @@
} @else {
- @for (tenant of tenants(); track tenant.id) { + @for (tenant of workspace.tenants(); track tenant.id) { }
@@ -38,6 +38,6 @@ } diff --git a/frontend/src/app/components/tenants-page/tenants-page.spec.ts b/frontend/src/app/components/tenants-page/tenants-page.spec.ts index ff6a238..6ab10f8 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.spec.ts +++ b/frontend/src/app/components/tenants-page/tenants-page.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { TenantsPage } from './tenants-page'; +import { WorkspaceService } from '../../services/workspace.service'; describe('TenantsPage', () => { + beforeEach(() => TestBed.configureTestingModule({ providers: [{ provide: WorkspaceService, useValue: { loadTenants: () => undefined } }] })); + it('opens and closes the focused tenant form module', () => { const fixture = TestBed.createComponent(TenantsPage); - fixture.componentRef.setInput('tenants', []); const component = fixture.componentInstance; component.openAddForm(); diff --git a/frontend/src/app/components/tenants-page/tenants-page.ts b/frontend/src/app/components/tenants-page/tenants-page.ts index 08f8a44..051e44b 100644 --- a/frontend/src/app/components/tenants-page/tenants-page.ts +++ b/frontend/src/app/components/tenants-page/tenants-page.ts @@ -1,10 +1,9 @@ -import { Component, input, output, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { Tenant } from '../../services/tenant.service'; -import { TenantCard, TenantAttachmentRequest } from '../tenant-card/tenant-card'; -import { TenantForm, TenantSaveRequest } from '../tenant-form/tenant-form'; - -export type { TenantAttachmentRequest, TenantSaveRequest }; +import { WorkspaceService } from '../../services/workspace.service'; +import { TenantCard } from '../tenant-card/tenant-card'; +import { TenantForm } from '../tenant-form/tenant-form'; @Component({ selector: 'app-tenants-page', @@ -13,16 +12,13 @@ export type { TenantAttachmentRequest, TenantSaveRequest }; styleUrl: './tenants-page.css', }) export class TenantsPage { - readonly tenants = input.required(); - readonly attachmentError = input(''); - readonly tenantSaved = output(); - readonly deleteRequested = output(); - readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>(); - readonly attachmentDownloadRequested = output(); + readonly workspace = inject(WorkspaceService); readonly showForm = signal(false); readonly editingTenant = signal(null); + constructor() { this.workspace.loadTenants(); } + openAddForm(): void { this.editingTenant.set(null); this.showForm.set(true); diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.html b/frontend/src/app/components/workspace-navigation/workspace-navigation.html index 3306b28..ffb0ab7 100644 --- a/frontend/src/app/components/workspace-navigation/workspace-navigation.html +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.html @@ -9,9 +9,8 @@ mat-button type="button" class="navigation-item" - [class.active]="activeTab() === 'dashboard'" - [attr.aria-current]="activeTab() === 'dashboard' ? 'page' : null" - (click)="tabSelected.emit('dashboard')" + routerLink="/dashboard" + routerLinkActive="active" >
{{ userInitial() }}
- {{ username() }}{{ email() }} + {{ session.username() }}{{ session.email() }}