diff --git a/timeless-api/pom.xml b/timeless-api/pom.xml index 11e4ba0..6af791a 100644 --- a/timeless-api/pom.xml +++ b/timeless-api/pom.xml @@ -120,6 +120,16 @@ quarkus-junit5 test + + io.quarkus + quarkus-test-security + test + + + io.quarkus + quarkus-test-security-oidc + test + io.rest-assured rest-assured diff --git a/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java b/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java index 54b6b5e..9112bef 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java +++ b/timeless-api/src/main/java/dev/matheuscruz/domain/Record.java @@ -1,5 +1,6 @@ package dev.matheuscruz.domain; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -9,6 +10,7 @@ import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.Instant; +import java.time.LocalDate; import java.util.Objects; import java.util.Optional; @@ -27,12 +29,13 @@ public class Record { @Enumerated(EnumType.STRING) private Categories category; private Instant createdAt; + private LocalDate transactionDate; protected Record() { } - private Record(String userId, BigDecimal amount, String description, Transactions transaction, - Categories category) { + private Record(String userId, BigDecimal amount, String description, Transactions transaction, Categories category, + LocalDate transactionDate) { this.userId = userId; this.amount = amount; this.description = description; @@ -40,6 +43,7 @@ private Record(String userId, BigDecimal amount, String description, Transaction this.category = category; this.createdAt = Instant.now(); this.category = Optional.ofNullable(category).orElse(Categories.NONE); + this.transactionDate = Optional.ofNullable(transactionDate).orElse(java.time.LocalDate.now()); } public Long getId() { @@ -70,12 +74,25 @@ public Instant getCreatedAt() { return createdAt; } + public LocalDate getTransactionDate() { + return transactionDate; + } + + public void update(UpdateRecordRequest request) { + this.amount = Objects.requireNonNull(request.amount()); + this.description = Objects.requireNonNull(request.description()); + this.transaction = Objects.requireNonNull(request.transaction()); + this.category = Objects.requireNonNull(request.category()); + this.transactionDate = Objects.requireNonNull(request.transactionDate()); + } + public static class Builder { private String userId; private BigDecimal amount; private String description; private Transactions transaction; private Categories category; + private LocalDate transactionDate; public Builder userId(String userId) { this.userId = userId; @@ -102,18 +119,25 @@ public Builder category(Categories category) { return this; } + public Builder transactionDate(LocalDate transactionDate) { + this.transactionDate = transactionDate; + return this; + } + public Record build() { Objects.requireNonNull(userId, "userId must not be null"); Objects.requireNonNull(amount, "amount must not be null"); Objects.requireNonNull(description, "description must not be null"); Objects.requireNonNull(transaction, "transaction must not be null"); + Objects.requireNonNull(transactionDate, "transactionDate must not be null"); if (transaction == Transactions.IN) { category = Categories.NONE; } category = Optional.ofNullable(category).orElse(Categories.GENERAL); - return new Record(userId, amount, description, transaction, category); + return new Record(userId, amount, description, transaction, category, transactionDate); } + } } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java index 58bc6ef..ebbf8c8 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/RecordResource.java @@ -9,6 +9,7 @@ import dev.matheuscruz.presentation.data.CreateRecordRequest; import dev.matheuscruz.presentation.data.PageRecord; import dev.matheuscruz.presentation.data.RecordItemResponse; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; import io.quarkus.narayana.jta.QuarkusTransaction; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Parameters; @@ -19,6 +20,7 @@ import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Response; @@ -58,6 +60,27 @@ public Response delete(@PathParam("id") Long id) { return Response.status(Response.Status.NO_CONTENT).build(); } + @PUT + @Path("/{id}") + public Response update(@PathParam("id") Long id, @Valid UpdateRecordRequest req) { + + QuarkusTransaction.requiringNew().run(() -> { + Record record = this.recordRepository.findById(id); + + if (record == null) { + throw new jakarta.ws.rs.NotFoundException(); + } + + if (!record.getUserId().equals(upn)) { + throw new jakarta.ws.rs.ForbiddenException(); + } + + record.update(req); + }); + + return Response.noContent().build(); + } + @POST public Response createRecord(@Valid CreateRecordRequest req) { @@ -85,10 +108,11 @@ public Response getRecords(@RestQuery("page") String p, @RestQuery("limit") Stri // pagination List output = recordRepository.find("userId = :userId", Parameters.with("userId", upn)) .page(Page.of(page, limit)).list().stream().map(record -> { - String format = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate() + String createdAt = record.getCreatedAt().atZone(ZoneId.of("America/Sao_Paulo")).toLocalDate() .format(formatter); + String transactionDate = record.getTransactionDate().format(formatter); return new RecordItemResponse(record.getId(), record.getAmount(), record.getDescription(), - record.getTransaction().name(), format, record.getCategory().name()); + record.getTransaction().name(), transactionDate, createdAt, record.getCategory().name()); }).toList(); // calculate total expenses and total in diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java index 7a929f4..4ed03d1 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/CreateRecordRequest.java @@ -6,7 +6,9 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import java.math.BigDecimal; +import java.time.LocalDate; public record CreateRecordRequest(@PositiveOrZero BigDecimal amount, @NotBlank String description, - @NotNull Transactions transaction, @NotBlank String from, @NotNull Categories category) { + @NotNull Transactions transaction, @NotBlank String from, @NotNull Categories category, + LocalDate transactionDate) { } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java index 931a453..5bc10a2 100644 --- a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/RecordItemResponse.java @@ -2,6 +2,6 @@ import java.math.BigDecimal; -public record RecordItemResponse(Long id, BigDecimal amount, String description, String transaction, String createdAt, - String category) { +public record RecordItemResponse(Long id, BigDecimal amount, String description, String transaction, String date, + String createdAt, String category) { } diff --git a/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java new file mode 100644 index 0000000..77a6a21 --- /dev/null +++ b/timeless-api/src/main/java/dev/matheuscruz/presentation/data/UpdateRecordRequest.java @@ -0,0 +1,10 @@ +package dev.matheuscruz.presentation.data; + +import dev.matheuscruz.domain.Categories; +import dev.matheuscruz.domain.Transactions; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record UpdateRecordRequest(BigDecimal amount, String description, Transactions transaction, Categories category, + LocalDate transactionDate) { +} diff --git a/timeless-api/src/main/resources/application.properties b/timeless-api/src/main/resources/application.properties index f7cc93b..f4d02d6 100644 --- a/timeless-api/src/main/resources/application.properties +++ b/timeless-api/src/main/resources/application.properties @@ -33,4 +33,12 @@ quarkus.quinoa.build-dir=dist/timeless/browser # jwt mp.jwt.verify.publickey=${JWT_PUBLIC_KEY} mp.jwt.verify.issuer=https://timelessapp.platformoon.com/issuer -smallrye.jwt.sign.key=${JWT_PRIVATE_KEY} \ No newline at end of file +smallrye.jwt.sign.key=${JWT_PRIVATE_KEY} +# TEST CONFIGURATION +%test.quarkus.langchain4j.openai.api-key=test-key +%test.quarkus.langchain4j.openai.gpt-4-turbo.api-key=test-key +%test.security.sensible.secret=YS0xNi1ieXRlLXNlY3JldA== +%test.mp.jwt.verify.publickey=test-public-key +%test.smallrye.jwt.sign.key=test-private-key +%test.whatsapp.incoming-message.queue-url=test-queue +%test.whatsapp.recognized-message.queue-url=test-queue diff --git a/timeless-api/src/main/webui/package-lock.json b/timeless-api/src/main/webui/package-lock.json index cae08e8..7020eed 100644 --- a/timeless-api/src/main/webui/package-lock.json +++ b/timeless-api/src/main/webui/package-lock.json @@ -345,7 +345,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.6.tgz", "integrity": "sha512-dSxhkh/ZlljdglZ0rriSy7GdC1Y3rGaagkx6oAzF5XqAoBbFmiVFEBZPxssSeQ+O0izmAw3GwsUnz3E/1JYsbA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -997,7 +996,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.6.tgz", "integrity": "sha512-Yd8PF0dR37FAzqEcBHAyVCiSGMJOezSJe6rV/4BC6AVLfaZ7oZLl8CNVxKsod2UHd6rKxt1hzx05QdVcVvYNeA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1014,7 +1012,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.0.6.tgz", "integrity": "sha512-rBMzG7WnQMouFfDST+daNSAOVYdtw560645PhlxyVeIeHMlCm0j1jjBgVPGTBNpVgKRdT/sqbi6W6JYkY9mERA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1028,7 +1025,6 @@ "integrity": "sha512-UcIUx+fbn0VLlCBCIYxntAzWG3zPRUo0K7wvuK0MC6ZFCWawgewx9SdLLZTqcaWe1g5FRQlQeVQcFgHAO5R2Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1061,7 +1057,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.6.tgz", "integrity": "sha512-SvWbOkkrsqprYJSBmzQEWkWjfZB/jkRYyFp2ClMJBPqOLxP1a+i3Om2rolcNQjZPz87bs9FszwgRlXUy7sw5cQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1087,7 +1082,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.6.tgz", "integrity": "sha512-aAkAAKuUrP8U7R4aH/HbmG/CXP90GlML77ECBI5b4qCSb+bvaTEYsaf85mCyTpr9jvGkia2LTe42hPcOuyzdsQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -1107,7 +1101,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz", "integrity": "sha512-tPk8rlUEBPXIUPRYq6Xu7QhJgKtnVr0dOHHuhyi70biKTupr5VikpZC5X9dy2Q3H3zYbK6MHC6384YMuwfU2kg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1148,7 +1141,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz", "integrity": "sha512-HOfomKq7jRSgxt/uUvpdbB8RNaYuGB/FJQ3BfQCFfGw1O9L3B72b7Hilk6AcjCruul6cfv/kmT4EB6Vqi3dQtA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1193,7 +1185,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2212,7 +2203,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -4709,7 +4699,6 @@ "integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5127,7 +5116,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5605,6 +5593,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-what": "^3.14.1" }, @@ -6047,6 +6036,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "prr": "~1.0.1" }, @@ -7098,6 +7088,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "image-size": "bin/image-size.js" }, @@ -7302,7 +7293,8 @@ "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/isbinaryfile": { "version": "4.0.10", @@ -7424,15 +7416,13 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.13.0.tgz", "integrity": "sha512-vsYjfh7lyqvZX5QgqKc4YH8phs7g96Z8bsdIFNEU3VqXhlHaq+vov/Fgn/sr6MiUczdZkyXRC3TX369Ll4Nzbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7530,7 +7520,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -7859,6 +7848,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -7887,6 +7877,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -7902,6 +7893,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -7916,6 +7908,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -7927,6 +7920,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7955,7 +7949,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -8609,6 +8602,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -8627,6 +8621,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -9099,6 +9094,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.10" } @@ -9262,6 +9258,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -9317,7 +9314,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9542,7 +9538,8 @@ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/punycode": { "version": "1.4.1", @@ -9890,7 +9887,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -9926,7 +9922,6 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9948,7 +9943,8 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "dev": true, "license": "BlueOak-1.0.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/semver": { "version": "7.7.3", @@ -10729,7 +10725,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10941,8 +10936,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -10979,7 +10973,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11162,7 +11155,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11466,7 +11458,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -11534,7 +11525,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -11553,8 +11543,7 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/timeless-api/src/main/webui/src/app/components/records/records.component.html b/timeless-api/src/main/webui/src/app/components/records/records.component.html index f0b5a77..2f4e1ac 100644 --- a/timeless-api/src/main/webui/src/app/components/records/records.component.html +++ b/timeless-api/src/main/webui/src/app/components/records/records.component.html @@ -1,3 +1,5 @@ + +

Overview

@@ -12,7 +14,9 @@

Overview

-

Saldo

+ +

Saldo

+
@if (eyes()) {

@@ -23,7 +27,9 @@

Overview

}
-

Entradas

+ +

Entradas

+
@if (eyes()) {

{{ totalIn() | currency: "BRL" }} @@ -33,7 +39,9 @@

Overview

}
-

Saídas

+ +

Saídas

+
@if (eyes()) {

{{ totalExpenses() | currency: "BRL" }} @@ -72,14 +80,24 @@

Overview

[severity]="record.transaction == 'IN' ? 'success' : 'danger'" > - {{ record.createdAt }} + {{ record.date }} - +
+ + +
@@ -91,3 +109,87 @@

Overview

[totalRecords]="totalRecords()" [rowsPerPageOptions]="[10, 20, 30]" /> + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/timeless-api/src/main/webui/src/app/components/records/records.component.ts b/timeless-api/src/main/webui/src/app/components/records/records.component.ts index 1b650e0..ba47a11 100644 --- a/timeless-api/src/main/webui/src/app/components/records/records.component.ts +++ b/timeless-api/src/main/webui/src/app/components/records/records.component.ts @@ -1,18 +1,48 @@ import { Component, inject, signal, HostListener } from '@angular/core'; import { TableModule } from 'primeng/table'; import { Tag } from 'primeng/tag'; -import { CurrencyPipe } from '@angular/common'; +import { CurrencyPipe, CommonModule } from '@angular/common'; import { RecordResponseItem, TimelessApiService, + UpdateRecord, } from '../../timeless-api.service'; import { Paginator, PaginatorState } from 'primeng/paginator'; import { Card } from 'primeng/card'; import { Button } from 'primeng/button'; +import { Dialog } from 'primeng/dialog'; +import { InputText } from 'primeng/inputtext'; +import { InputNumber } from 'primeng/inputnumber'; +import { Select } from 'primeng/select'; +import { DatePicker } from 'primeng/datepicker'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Toast } from 'primeng/toast'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'app-records', - imports: [TableModule, Tag, CurrencyPipe, Paginator, Card, Button], + imports: [ + TableModule, + Tag, + CurrencyPipe, + Paginator, + Card, + Button, + Dialog, + InputText, + InputNumber, + Select, + DatePicker, + ReactiveFormsModule, + CommonModule, + Toast, + ], + providers: [MessageService], templateUrl: './records.component.html', styleUrl: './records.component.scss', }) @@ -28,6 +58,34 @@ export class RecordsComponent { totalExpenses = signal(0); hideTag = signal(false); isMobile = signal(false); + editDialogVisible = signal(false); + selectedRecord = signal(null); + + private fb = inject(FormBuilder); + private messageService = inject(MessageService); + editForm: FormGroup = this.fb.group({ + amount: [0, [Validators.required, Validators.min(0)]], + description: ['', [Validators.required]], + transaction: ['OUT', [Validators.required]], + category: ['GENERAL', [Validators.required]], + date: [new Date(), [Validators.required]], + }); + + categories = [ + { label: 'Custos Fixos', value: 'FIXED_COSTS' }, + { label: 'Lazer', value: 'PLEASURES' }, + { label: 'Conhecimento', value: 'KNOWLEDGE' }, + { label: 'Metas', value: 'GOALS' }, + { label: 'Conforto', value: 'COMFORT' }, + { label: 'Liberdade Financeira', value: 'FINANCIAL_FREEDOM' }, + { label: 'Geral', value: 'GENERAL' }, + { label: 'Nenhum', value: 'NONE' }, + ]; + + transactions = [ + { label: 'Entrada', value: 'IN' }, + { label: 'Saída', value: 'OUT' }, + ]; constructor() { this.checkScreenSize(); @@ -66,6 +124,57 @@ export class RecordsComponent { }); } + showEditDialog(record: RecordResponseItem) { + this.selectedRecord.set(record); + const dateParts = record.date.split('/'); + const dateObj = new Date(+dateParts[2], +dateParts[1] - 1, +dateParts[0]); + + this.editForm.patchValue({ + amount: record.amount, + description: record.description, + transaction: record.transaction, + category: record.category, + date: dateObj, + }); + this.editDialogVisible.set(true); + } + + saveEdit() { + if (this.editForm.valid && this.selectedRecord()) { + const formValue = this.editForm.value; + const formattedDate = formValue.date.toISOString().split('T')[0]; + + const updateRequest: UpdateRecord = { + ...formValue, + transactionDate: formattedDate, + }; + + this.timelessApiService + .updateRecord(this.selectedRecord()!.id, updateRequest) + .subscribe({ + next: () => { + this.editDialogVisible.set(false); + this.populatePaginator(); + this.messageService.add({ + severity: 'success', + summary: 'Sucesso', + detail: 'Registro atualizado com sucesso!', + life: 3000, + }); + }, + error: (error) => { + console.error('Error updating record:', error); + this.messageService.add({ + severity: 'error', + summary: 'Erro', + detail: 'Falha ao atualizar registro.', + life: 3000, + }); + }, + }); + } + } + changeEyes() { this.eyes.update((value) => !value); } @@ -77,8 +186,25 @@ export class RecordsComponent { } deleteRecord(id: number) { - this.timelessApiService.deleteRecord(id).subscribe(() => { - this.populatePaginator(); + this.timelessApiService.deleteRecord(id).subscribe({ + next: () => { + this.populatePaginator(); + this.messageService.add({ + severity: 'success', + summary: 'Sucesso', + detail: 'Registro excluído com sucesso!', + life: 3000, + }); + }, + error: (error) => { + console.error('Error deleting record:', error); + this.messageService.add({ + severity: 'error', + summary: 'Erro', + detail: 'Falha ao excluir registro.', + life: 3000, + }); + }, }); } } diff --git a/timeless-api/src/main/webui/src/app/timeless-api.service.ts b/timeless-api/src/main/webui/src/app/timeless-api.service.ts index 35d4945..752c691 100644 --- a/timeless-api/src/main/webui/src/app/timeless-api.service.ts +++ b/timeless-api/src/main/webui/src/app/timeless-api.service.ts @@ -58,6 +58,10 @@ export class TimelessApiService { return this.httpClient.delete(`/api/records/${id}`); } + updateRecord(id: number, record: UpdateRecord) { + return this.httpClient.put(`/api/records/${id}`, record); + } + logout() { localStorage.removeItem(timelessLocalStorageKey); } @@ -75,10 +79,15 @@ export interface RecordPageResponse { } export interface RecordResponseItem { + id: number; amount: number; description: string; - transaction: string; + transaction: 'IN' | 'OUT'; + date: string; createdAt: string; + category: string; + tag?: string; + icon?: string; } export interface UpdateUser { @@ -88,3 +97,13 @@ export interface UpdateUser { email: string; phoneNumber: string; } + +export interface UpdateRecord { + id: number; + amount: number; + description: string; + transaction: 'IN' | 'OUT'; + date: string; + category: string; + tag?: string; +} diff --git a/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java new file mode 100644 index 0000000..1719591 --- /dev/null +++ b/timeless-api/src/test/java/dev/matheuscruz/presentation/RecordResourceTest.java @@ -0,0 +1,108 @@ +package dev.matheuscruz.presentation; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import dev.matheuscruz.domain.Categories; +import dev.matheuscruz.domain.Record; +import dev.matheuscruz.domain.RecordRepository; +import dev.matheuscruz.domain.Transactions; +import dev.matheuscruz.domain.User; +import dev.matheuscruz.domain.UserRepository; +import dev.matheuscruz.presentation.data.UpdateRecordRequest; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.oidc.Claim; +import io.quarkus.test.security.oidc.OidcSecurity; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class RecordResourceTest { + + @Inject + RecordRepository recordRepository; + + @Inject + UserRepository userRepository; + + @Inject + EntityManager em; + + public static LocalDate fixedDate = LocalDate.of(2026, 1, 1); + + @BeforeEach + @Transactional + void setUp() { + recordRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + @TestSecurity(user = "testUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "testUser") }) + void shouldUpdateRecord() { + // Given + Record record = new Record.Builder().userId("testUser").amount(new BigDecimal("100.00")) + .description("Original Description").transaction(Transactions.OUT).category(Categories.FIXED_COSTS) + .transactionDate(fixedDate).build(); + + saveRecord(record); + + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, fixedDate); + + // When + given().contentType("application/json").body(request).when().put("/api/records/" + record.getId()).then() + .statusCode(204); + + // Then + em.clear(); + Record updatedRecord = recordRepository.findById(record.getId()); + assert updatedRecord != null; + assert updatedRecord.getAmount().compareTo(new BigDecimal("150.00")) == 0; + assert updatedRecord.getDescription().equals("Updated Description"); + assert updatedRecord.getTransaction() == Transactions.IN; + assert updatedRecord.getCategory() == Categories.FINANCIAL_FREEDOM; + assert updatedRecord.getTransactionDate().equals(fixedDate); + } + + @Test + @TestSecurity(user = "otherUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "otherUser") }) + void shouldNotUpdateRecordOfAnotherUser() { + // Given + Record record = new Record.Builder().userId("testUser").amount(new BigDecimal("100.00")) + .description("Original Description").transaction(Transactions.OUT).category(Categories.FIXED_COSTS) + .transactionDate(fixedDate).build(); + + saveRecord(record); + + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, fixedDate); + + // When + given().contentType("application/json").body(request).when().put("/api/records/" + record.getId()).then() + .statusCode(403); + } + + @Test + @TestSecurity(user = "testUser", roles = "user") + @OidcSecurity(claims = { @Claim(key = "upn", value = "testUser") }) + void shouldReturnNotFoundWhenRecordDoesNotExist() { + UpdateRecordRequest request = new UpdateRecordRequest(new BigDecimal("150.00"), "Updated Description", + Transactions.IN, Categories.FINANCIAL_FREEDOM, LocalDate.now()); + + given().contentType("application/json").body(request).when().put("/api/records/999").then().statusCode(404); + } + + @Transactional + void saveRecord(Record record) { + recordRepository.persist(record); + } +}