From 88461a4aeacfae297e11ee15e35b4c9002689022 Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Wed, 27 May 2026 16:41:26 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20excluir=20transa=C3=A7=C3=B5es=20de=20e?= =?UTF-8?q?xtrato=20+=20delete=20em=20lote=20por=20m=C3=AAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove restrição que bloqueava delete de transações importadas. Adiciona DELETE /transactions?month=YYYY-MM para exclusão em lote e botão "EXCLUIR MÊS" na view de transações. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/cmd/server/main.go | 1 + apps/api/internal/handler/transaction.go | 16 +++++++++++++++- .../internal/repository/transaction_manual.go | 11 ++++++++++- apps/api/internal/service/transaction.go | 7 +++++-- apps/web/src/stores/transactions.ts | 9 ++++++++- apps/web/src/views/TransactionsView.vue | 11 +++++++++++ 6 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 08ba623..24af262 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -101,6 +101,7 @@ func main() { r.Post("/transactions", txHandler.Create) r.Put("/transactions/{id}", txHandler.Update) r.Delete("/transactions/{id}", txHandler.Delete) + r.Delete("/transactions", txHandler.DeleteByMonth) r.Get("/recurring", recurringHandler.List) r.Post("/recurring", recurringHandler.Create) diff --git a/apps/api/internal/handler/transaction.go b/apps/api/internal/handler/transaction.go index e03f1e4..ace5419 100644 --- a/apps/api/internal/handler/transaction.go +++ b/apps/api/internal/handler/transaction.go @@ -60,7 +60,7 @@ func (h *TransactionHandler) Update(w http.ResponseWriter, r *http.Request) { } t.ID = id out, err := h.svc.Update(r.Context(), t) - if errors.Is(err, repository.ErrNotFound) || errors.Is(err, service.ErrCannotEditImported) { + if errors.Is(err, repository.ErrNotFound) { respondError(w, http.StatusNotFound, err.Error()) return } @@ -83,3 +83,17 @@ func (h *TransactionHandler) Delete(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +func (h *TransactionHandler) DeleteByMonth(w http.ResponseWriter, r *http.Request) { + month := r.URL.Query().Get("month") + if len(month) != 7 { + respondError(w, http.StatusBadRequest, "month required (YYYY-MM)") + return + } + count, err := h.svc.DeleteByMonth(r.Context(), month) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]int{"deleted": count}) +} diff --git a/apps/api/internal/repository/transaction_manual.go b/apps/api/internal/repository/transaction_manual.go index 0f54150..afa8b37 100644 --- a/apps/api/internal/repository/transaction_manual.go +++ b/apps/api/internal/repository/transaction_manual.go @@ -16,6 +16,7 @@ type ManualTransactionRepository interface { Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) Delete(ctx context.Context, id int) error + DeleteByMonth(ctx context.Context, month string) (int, error) // HasMatchingTransaction checks if a category has a transaction of the given type in the given month. HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) } @@ -93,7 +94,7 @@ func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model. } func (r *manualTxRepo) Delete(ctx context.Context, id int) error { - tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1 AND source = 'manual'`, id) + tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1`, id) if err != nil { return err } @@ -103,6 +104,14 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error { return nil } +func (r *manualTxRepo) DeleteByMonth(ctx context.Context, month string) (int, error) { + tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE TO_CHAR(date, 'YYYY-MM') = $1`, month) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil +} + func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) { if categoryID == nil { // No category set — cannot auto-match, always report as uncovered diff --git a/apps/api/internal/service/transaction.go b/apps/api/internal/service/transaction.go index 6b56561..5a0624e 100644 --- a/apps/api/internal/service/transaction.go +++ b/apps/api/internal/service/transaction.go @@ -11,7 +11,6 @@ import ( var ( ErrInvalidTransactionType = errors.New("type must be 'income' or 'expense'") - ErrCannotEditImported = errors.New("imported transactions cannot be edited or deleted") ErrTransactionEmptyDesc = errors.New("description is required") ErrTransactionInvalidAmount = errors.New("amount must be greater than zero") ) @@ -55,11 +54,15 @@ func (s *TransactionService) Update(ctx context.Context, t model.Transaction) (* func (s *TransactionService) Delete(ctx context.Context, id int) error { err := s.repo.Delete(ctx, id) if errors.Is(err, repository.ErrNotFound) { - return ErrCannotEditImported + return errors.New("transaction not found") } return err } +func (s *TransactionService) DeleteByMonth(ctx context.Context, month string) (int, error) { + return s.repo.DeleteByMonth(ctx, month) +} + func validateTransaction(t model.Transaction) error { if strings.TrimSpace(t.Description) == "" { return ErrTransactionEmptyDesc diff --git a/apps/web/src/stores/transactions.ts b/apps/web/src/stores/transactions.ts index cdc853d..91c7af2 100644 --- a/apps/web/src/stores/transactions.ts +++ b/apps/web/src/stores/transactions.ts @@ -60,5 +60,12 @@ export const useTransactionsStore = defineStore('transactions', () => { transactions.value = transactions.value.filter((x) => x.id !== id) } - return { transactions, loading, error, fetchAll, create, update, remove } + async function removeByMonth(month: string) { + await api.delete(`/transactions?month=${month}`) + transactions.value = transactions.value.filter( + (x) => x.date.slice(0, 7) !== month, + ) + } + + return { transactions, loading, error, fetchAll, create, update, remove, removeByMonth } }) diff --git a/apps/web/src/views/TransactionsView.vue b/apps/web/src/views/TransactionsView.vue index b430fb8..7ad745c 100644 --- a/apps/web/src/views/TransactionsView.vue +++ b/apps/web/src/views/TransactionsView.vue @@ -73,6 +73,12 @@ async function remove(id: number) { try { await store.remove(id) } catch (e: any) { alert(e.message) } } +async function removeMonth() { + const label = monthLabel(currentMonth.value) + if (!confirm(`Excluir TODAS as transações de ${label}?`)) return + try { await store.removeByMonth(currentMonth.value) } catch (e: any) { alert(e.message) } +} + function fmt(v: number) { return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) } @@ -98,6 +104,11 @@ function monthLabel(ym: string) { @prev="changeMonth(-1)" @next="changeMonth(1)" /> +