feat: #44 fatura de cartão com data futura fica pendente até confirmação

- Nova tabela pending_bill_imports (migration 016)
- ImportHandler.Confirm: quando is_credit_card=true e payment_date > hoje,
  salva como pending em vez de inserir transações
- Novos endpoints: GET /pending-bills, POST /pending-bills/:id/confirm,
  DELETE /pending-bills/:id
- Dashboard inclui pending_bill_imports no payload
- Frontend: resultado "fatura salva como pendente" no ImportView
- AccountsView exibe widget de faturas pendentes com ações de confirmar/descartar
- dashboard_test: mock de PendingBillRepo + TransactionRepository

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-28 22:26:42 -03:00
co-authored by Claude Sonnet 4.6
parent 9742ae7469
commit 2b8524dcde
16 changed files with 453 additions and 32 deletions
+9 -2
View File
@@ -65,7 +65,10 @@ func main() {
transactionRepo := repository.NewTransactionRepository(pool) transactionRepo := repository.NewTransactionRepository(pool)
importSvc := service.NewImportService(transactionRepo) importSvc := service.NewImportService(transactionRepo)
importHandler := handler.NewImportHandler(importSvc, gameSvc) pendingBillRepo := repository.NewPendingBillRepository(pool)
pendingBillSvc := service.NewPendingBillService(pendingBillRepo, transactionRepo)
importHandler := handler.NewImportHandler(importSvc, gameSvc, pendingBillSvc)
pendingBillHandler := handler.NewPendingBillHandler(pendingBillSvc, gameSvc)
manualTxRepo := repository.NewManualTransactionRepository(pool) manualTxRepo := repository.NewManualTransactionRepository(pool)
txSvc := service.NewTransactionService(manualTxRepo) txSvc := service.NewTransactionService(manualTxRepo)
@@ -84,7 +87,7 @@ func main() {
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc) creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
dashboardRepo := repository.NewDashboardRepository(pool) dashboardRepo := repository.NewDashboardRepository(pool)
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc) dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
dashboardHandler := handler.NewDashboardHandler(dashboardSvc) dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
r.Get("/health", handler.Health) r.Get("/health", handler.Health)
@@ -108,6 +111,10 @@ func main() {
r.Post("/imports/preview", importHandler.Preview) r.Post("/imports/preview", importHandler.Preview)
r.Post("/imports/confirm", importHandler.Confirm) r.Post("/imports/confirm", importHandler.Confirm)
r.Get("/pending-bills", pendingBillHandler.List)
r.Post("/pending-bills/{id}/confirm", pendingBillHandler.Confirm)
r.Delete("/pending-bills/{id}", pendingBillHandler.Discard)
r.Get("/transactions", txHandler.List) r.Get("/transactions", txHandler.List)
r.Post("/transactions", txHandler.Create) r.Post("/transactions", txHandler.Create)
r.Put("/transactions/{id}", txHandler.Update) r.Put("/transactions/{id}", txHandler.Update)
+24 -2
View File
@@ -13,10 +13,11 @@ const maxUploadSize = 10 << 20 // 10 MB
type ImportHandler struct { type ImportHandler struct {
svc *service.ImportService svc *service.ImportService
gameSvc *service.GameService gameSvc *service.GameService
pendingSvc *service.PendingBillService
} }
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler { func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService, pendingSvc *service.PendingBillService) *ImportHandler {
return &ImportHandler{svc: svc, gameSvc: gameSvc} return &ImportHandler{svc: svc, gameSvc: gameSvc, pendingSvc: pendingSvc}
} }
// Preview parses the uploaded file and returns rows with duplicate flags. // Preview parses the uploaded file and returns rows with duplicate flags.
@@ -59,11 +60,14 @@ func (h *ImportHandler) Preview(w http.ResponseWriter, r *http.Request) {
// Confirm saves the rows provided in the request body (after user review). // Confirm saves the rows provided in the request body (after user review).
// POST /api/imports/confirm // POST /api/imports/confirm
// When is_credit_card=true and payment_date is in the future, saves as pending instead of transactions.
func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) { func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Rows []model.ImportRow `json:"rows"` Rows []model.ImportRow `json:"rows"`
ParseErrCount int `json:"parse_error_count"` ParseErrCount int `json:"parse_error_count"`
IsCreditCard bool `json:"is_credit_card"`
PaymentDate string `json:"payment_date"` // YYYY-MM-DD
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON") respondError(w, http.StatusBadRequest, "invalid JSON")
@@ -74,6 +78,24 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
return return
} }
if body.IsCreditCard && body.PaymentDate != "" {
saved, pending, err := h.pendingSvc.MaybeSaveAsPending(r.Context(), body.Filename, body.PaymentDate, body.Rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save pending bill")
return
}
if saved {
respondJSON(w, http.StatusOK, map[string]any{
"pending": true,
"pending_bill": pending,
"imported": 0,
"duplicates": len(body.Rows) - len(pending.Rows),
"errors": body.ParseErrCount,
})
return
}
}
result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount) result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save transactions") respondError(w, http.StatusInternalServerError, "failed to save transactions")
+59
View File
@@ -0,0 +1,59 @@
package handler
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"financeiro-carvalho/internal/service"
)
type PendingBillHandler struct {
svc *service.PendingBillService
gameSvc *service.GameService
}
func NewPendingBillHandler(svc *service.PendingBillService, gameSvc *service.GameService) *PendingBillHandler {
return &PendingBillHandler{svc: svc, gameSvc: gameSvc}
}
func (h *PendingBillHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list pending bills")
return
}
respondJSON(w, http.StatusOK, items)
}
func (h *PendingBillHandler) Confirm(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.Confirm(r.Context(), id); err != nil {
if err == service.ErrPendingBillNotFound {
respondError(w, http.StatusNotFound, "pending bill not found")
return
}
respondError(w, http.StatusInternalServerError, "failed to confirm pending bill")
return
}
h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": 1})
w.WriteHeader(http.StatusNoContent)
}
func (h *PendingBillHandler) Discard(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.Discard(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to discard pending bill")
return
}
w.WriteHeader(http.StatusNoContent)
}
+4 -1
View File
@@ -53,6 +53,9 @@ var m014 string
//go:embed sql/015_accounts_cdi_percentage.sql //go:embed sql/015_accounts_cdi_percentage.sql
var m015 string var m015 string
//go:embed sql/016_pending_bill_imports.sql
var m016 string
func Run(ctx context.Context, pool *pgxpool.Pool) error { func Run(ctx context.Context, pool *pgxpool.Pool) error {
// Bootstrap: ensure schema_migrations table exists before checking versions. // Bootstrap: ensure schema_migrations table exists before checking versions.
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
@@ -64,7 +67,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
return fmt.Errorf("bootstrap schema_migrations: %w", err) return fmt.Errorf("bootstrap schema_migrations: %w", err)
} }
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015} migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016}
for i, sql := range migrations { for i, sql := range migrations {
version := i + 1 version := i + 1
var applied bool var applied bool
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS pending_bill_imports (
id SERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
payment_date DATE NOT NULL,
total NUMERIC(12,2) NOT NULL DEFAULT 0,
rows JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+1
View File
@@ -35,4 +35,5 @@ type DashboardData struct {
PendingRecurring int `json:"pending_recurring"` PendingRecurring int `json:"pending_recurring"`
PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"` PendingIncomeRecurrings []PendingIncome `json:"pending_income_recurrings"`
CurrentBills []CreditBill `json:"current_bills"` CurrentBills []CreditBill `json:"current_bills"`
PendingBillImports []PendingBillImport `json:"pending_bill_imports"`
} }
+10
View File
@@ -0,0 +1,10 @@
package model
type PendingBillImport struct {
ID int `json:"id"`
Filename string `json:"filename"`
PaymentDate string `json:"payment_date"`
Total float64 `json:"total"`
Rows []ImportRow `json:"rows"`
CreatedAt string `json:"created_at"`
}
@@ -0,0 +1,93 @@
package repository
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
type PendingBillRepository interface {
List(ctx context.Context) ([]model.PendingBillImport, error)
Create(ctx context.Context, filename, paymentDate string, total float64, rows []model.ImportRow) (*model.PendingBillImport, error)
GetByID(ctx context.Context, id int) (*model.PendingBillImport, error)
Delete(ctx context.Context, id int) error
}
type pendingBillRepo struct{ pool *pgxpool.Pool }
func NewPendingBillRepository(pool *pgxpool.Pool) PendingBillRepository {
return &pendingBillRepo{pool: pool}
}
func (r *pendingBillRepo) List(ctx context.Context) ([]model.PendingBillImport, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT id, filename, payment_date::text, total, rows, created_at::text
FROM pending_bill_imports
WHERE profile_id = $1
ORDER BY payment_date
`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.PendingBillImport
for rows.Next() {
var p model.PendingBillImport
var rowsJSON []byte
if err := rows.Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsJSON, &p.CreatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal(rowsJSON, &p.Rows)
out = append(out, p)
}
return out, rows.Err()
}
func (r *pendingBillRepo) Create(ctx context.Context, filename, paymentDate string, total float64, rows []model.ImportRow) (*model.PendingBillImport, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rowsJSON, err := json.Marshal(rows)
if err != nil {
return nil, err
}
var p model.PendingBillImport
var rowsBack []byte
err = r.pool.QueryRow(ctx, `
INSERT INTO pending_bill_imports (profile_id, filename, payment_date, total, rows)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, filename, payment_date::text, total, rows, created_at::text
`, pid, filename, paymentDate, total, rowsJSON).
Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsBack, &p.CreatedAt)
if err != nil {
return nil, err
}
_ = json.Unmarshal(rowsBack, &p.Rows)
return &p, nil
}
func (r *pendingBillRepo) GetByID(ctx context.Context, id int) (*model.PendingBillImport, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var p model.PendingBillImport
var rowsJSON []byte
err := r.pool.QueryRow(ctx, `
SELECT id, filename, payment_date::text, total, rows, created_at::text
FROM pending_bill_imports
WHERE id = $1 AND profile_id = $2
`, id, pid).Scan(&p.ID, &p.Filename, &p.PaymentDate, &p.Total, &rowsJSON, &p.CreatedAt)
if err != nil {
return nil, err
}
_ = json.Unmarshal(rowsJSON, &p.Rows)
return &p, nil
}
func (r *pendingBillRepo) Delete(ctx context.Context, id int) error {
pid := middleware.ProfileIDFromCtx(ctx)
_, err := r.pool.Exec(ctx, `DELETE FROM pending_bill_imports WHERE id = $1 AND profile_id = $2`, id, pid)
return err
}
+9 -2
View File
@@ -23,10 +23,11 @@ type DashboardService struct {
recurrSvc *RecurringService recurrSvc *RecurringService
patrimony PatrimonySource patrimony PatrimonySource
billSvc *CreditBillService billSvc *CreditBillService
pendingBillSvc *PendingBillService
} }
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService) *DashboardService { func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc} return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
} }
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) { func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
@@ -109,6 +110,11 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
currentBills = []model.CreditBill{} currentBills = []model.CreditBill{}
} }
pendingBillImports, _ := s.pendingBillSvc.List(ctx)
if pendingBillImports == nil {
pendingBillImports = []model.PendingBillImport{}
}
return &model.DashboardData{ return &model.DashboardData{
Month: month, Month: month,
TotalIncome: income, TotalIncome: income,
@@ -121,5 +127,6 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
PendingRecurring: pending, PendingRecurring: pending,
PendingIncomeRecurrings: pendingIncome, PendingIncomeRecurrings: pendingIncome,
CurrentBills: currentBills, CurrentBills: currentBills,
PendingBillImports: pendingBillImports,
}, nil }, nil
} }
+30 -1
View File
@@ -8,6 +8,21 @@ import (
"financeiro-carvalho/internal/service" "financeiro-carvalho/internal/service"
) )
type mockImportTxRepo struct{}
func (m *mockImportTxRepo) IsDuplicate(_ context.Context, _, _ string, _ float64) (bool, error) {
return false, nil
}
func (m *mockImportTxRepo) IsExternalIDKnown(_ context.Context, _ string) (bool, error) {
return false, nil
}
func (m *mockImportTxRepo) BulkInsert(_ context.Context, rows []model.ImportRow) (int, error) {
return len(rows), nil
}
func (m *mockImportTxRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error {
return nil
}
type mockDashboardRepo struct { type mockDashboardRepo struct {
income float64 income float64
expenses float64 expenses float64
@@ -33,11 +48,25 @@ type mockPatrimony struct{}
func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil } func (m *mockPatrimony) TotalPatrimony(_ context.Context) (float64, error) { return 0, nil }
type mockPendingBillRepo struct{}
func (m *mockPendingBillRepo) List(_ context.Context) ([]model.PendingBillImport, error) {
return nil, nil
}
func (m *mockPendingBillRepo) Create(_ context.Context, _, _ string, _ float64, _ []model.ImportRow) (*model.PendingBillImport, error) {
return &model.PendingBillImport{}, nil
}
func (m *mockPendingBillRepo) GetByID(_ context.Context, _ int) (*model.PendingBillImport, error) {
return nil, nil
}
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
func newDashboardSvc(income, expenses float64) *service.DashboardService { func newDashboardSvc(income, expenses float64) *service.DashboardService {
repo := &mockAccountRepo{} repo := &mockAccountRepo{}
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{}) recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo) billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc) pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc)
} }
func TestDashboard_SavingsPct_40(t *testing.T) { func TestDashboard_SavingsPct_40(t *testing.T) {
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"context"
"errors"
"time"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
)
var ErrPendingBillNotFound = errors.New("pending bill not found")
type PendingBillService struct {
repo repository.PendingBillRepository
txRepo repository.TransactionRepository
}
func NewPendingBillService(repo repository.PendingBillRepository, txRepo repository.TransactionRepository) *PendingBillService {
return &PendingBillService{repo: repo, txRepo: txRepo}
}
func (s *PendingBillService) List(ctx context.Context) ([]model.PendingBillImport, error) {
items, err := s.repo.List(ctx)
if items == nil {
return []model.PendingBillImport{}, err
}
return items, err
}
// Save stores a credit-card import as pending when payment_date is in the future.
// Returns (true, pendingBill, nil) if saved as pending; (false, nil, nil) if not applicable.
func (s *PendingBillService) MaybeSaveAsPending(ctx context.Context, filename, paymentDate string, rows []model.ImportRow) (bool, *model.PendingBillImport, error) {
if paymentDate == "" {
return false, nil, nil
}
t, err := time.Parse("2006-01-02", paymentDate)
if err != nil || !t.After(time.Now().Truncate(24*time.Hour)) {
return false, nil, nil
}
var total float64
newRows := make([]model.ImportRow, 0, len(rows))
for _, r := range rows {
if !r.IsDuplicate {
total += r.Amount
newRows = append(newRows, r)
}
}
p, err := s.repo.Create(ctx, filename, paymentDate, total, newRows)
if err != nil {
return true, nil, err
}
return true, p, nil
}
// Confirm inserts the pending bill's rows as actual transactions and removes the pending record.
func (s *PendingBillService) Confirm(ctx context.Context, id int) error {
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return ErrPendingBillNotFound
}
if _, err := s.txRepo.BulkInsert(ctx, p.Rows); err != nil {
return err
}
return s.repo.Delete(ctx, id)
}
// Discard removes a pending bill import without creating transactions.
func (s *PendingBillService) Discard(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}
+9
View File
@@ -34,6 +34,14 @@ export interface PendingIncome {
late: boolean late: boolean
} }
export interface PendingBillImport {
id: number
filename: string
payment_date: string
total: number
created_at: string
}
export interface DashboardData { export interface DashboardData {
month: string month: string
total_income: number total_income: number
@@ -46,6 +54,7 @@ export interface DashboardData {
pending_recurring: number pending_recurring: number
pending_income_recurrings: PendingIncome[] pending_income_recurrings: PendingIncome[]
current_bills: CreditBill[] current_bills: CreditBill[]
pending_bill_imports: PendingBillImport[]
} }
export const useDashboardStore = defineStore('dashboard', () => { export const useDashboardStore = defineStore('dashboard', () => {
+5 -1
View File
@@ -15,6 +15,8 @@ export interface ImportResult {
imported: number imported: number
duplicates: number duplicates: number
errors: number errors: number
pending?: boolean
pending_bill?: { id: number; payment_date: string; total: number }
} }
export interface CSVMapping { export interface CSVMapping {
@@ -63,7 +65,7 @@ export const useImportStore = defineStore('import', () => {
} }
} }
async function confirm() { async function confirm(isCreditCard?: boolean, paymentDate?: string) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -74,6 +76,8 @@ export const useImportStore = defineStore('import', () => {
filename: filename.value, filename: filename.value,
rows: rows.value, rows: rows.value,
parse_error_count: parseErrors.value.length, parse_error_count: parseErrors.value.length,
is_credit_card: isCreditCard ?? false,
payment_date: paymentDate ?? '',
}), }),
}) })
const data = await res.json() const data = await res.json()
+30
View File
@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
import type { PendingBillImport } from './dashboard'
export const usePendingBillsStore = defineStore('pending_bills', () => {
const items = ref<PendingBillImport[]>([])
const loading = ref(false)
async function fetchAll() {
loading.value = true
try {
items.value = await api.get<PendingBillImport[]>('/pending-bills')
} finally {
loading.value = false
}
}
async function confirm(id: number) {
await api.post(`/pending-bills/${id}/confirm`, {})
items.value = items.value.filter((p) => p.id !== id)
}
async function discard(id: number) {
await api.delete(`/pending-bills/${id}`)
items.value = items.value.filter((p) => p.id !== id)
}
return { items, loading, fetchAll, confirm, discard }
})
+48 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAccountsStore, type AccountInput } from '@/stores/accounts' import { useAccountsStore, type AccountInput } from '@/stores/accounts'
import { usePendingBillsStore } from '@/stores/pending_bills'
import NeonPanel from '@/components/NeonPanel.vue' import NeonPanel from '@/components/NeonPanel.vue'
import CurrencyInput from '@/components/CurrencyInput.vue' import CurrencyInput from '@/components/CurrencyInput.vue'
const store = useAccountsStore() const store = useAccountsStore()
onMounted(() => store.fetchAll()) const pendingStore = usePendingBillsStore()
onMounted(() => { store.fetchAll(); pendingStore.fetchAll() })
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
checking: 'Conta Corrente', checking: 'Conta Corrente',
@@ -173,6 +175,34 @@ function totalBalance() {
</ul> </ul>
</NeonPanel> </NeonPanel>
<!-- Pending bill imports -->
<template v-if="pendingStore.items.length > 0">
<NeonPanel title="FATURAS PENDENTES DE CONFIRMAÇÃO" variant="danger">
<div class="fc-pending-list">
<div
v-for="p in pendingStore.items"
:key="p.id"
class="fc-pending-item"
>
<div class="fc-pending-item__info">
<span class="fc-body fc-pending-item__name">{{ p.filename }}</span>
<span class="fc-mono fc-pending-item__meta">
Vencimento: {{ p.payment_date }} · {{ fmt(p.total) }}
</span>
</div>
<div class="fc-pending-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="pendingStore.confirm(p.id)">
CONFIRMAR PAGAMENTO
</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="pendingStore.discard(p.id)">
DESCARTAR
</button>
</div>
</div>
</div>
</NeonPanel>
</template>
<!-- Current bills for credit accounts --> <!-- Current bills for credit accounts -->
<template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id"> <template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id">
<NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'"> <NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'">
@@ -327,4 +357,21 @@ function totalBalance() {
.fc-acc-item__balance { font-size: 16px; font-weight: 700; } .fc-acc-item__balance { font-size: 16px; font-weight: 700; }
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); } .fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
/* Pending bill imports */
.fc-pending-list { display: flex; flex-direction: column; gap: var(--fc-space-3); }
.fc-pending-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 10px var(--fc-space-3);
border: 1px solid rgba(255,59,107,.3);
border-radius: var(--fc-radius);
flex-wrap: wrap;
}
.fc-pending-item__info { display: flex; flex-direction: column; gap: 4px; }
.fc-pending-item__name { font-size: 13px; }
.fc-pending-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-pending-item__actions { display: flex; gap: var(--fc-space-2); }
</style> </style>
+21 -3
View File
@@ -76,8 +76,26 @@ function fmt(amount: number) {
<div class="fc-view"> <div class="fc-view">
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span> <span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
<!-- Result banner --> <!-- Result banner: pending bill -->
<NeonPanel v-if="store.result" variant="success" title="CONCLUÍDO"> <NeonPanel v-if="store.result?.pending" variant="success" title="FATURA SALVA COMO PENDENTE">
<div class="fc-import-result">
<span class="fc-mono fc-import-result__stat">
Vencimento: <span class="fc-text-gold">{{ store.result.pending_bill?.payment_date }}</span>
</span>
<span class="fc-mono fc-import-result__stat fc-text-green">
{{ fmt(store.result.pending_bill?.total ?? 0) }}
</span>
<span class="fc-mono fc-import-result__stat" style="color:var(--fc-text-dim);font-size:11px">
Confirme o pagamento em Contas quando pagar a fatura
</span>
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">
IMPORTAR OUTRO
</button>
</div>
</NeonPanel>
<!-- Result banner: imported -->
<NeonPanel v-else-if="store.result" variant="success" title="CONCLUÍDO">
<div class="fc-import-result"> <div class="fc-import-result">
<span class="fc-mono fc-import-result__stat"> <span class="fc-mono fc-import-result__stat">
<span class="fc-text-green">{{ store.result.imported }}</span> importadas <span class="fc-text-green">{{ store.result.imported }}</span> importadas
@@ -243,7 +261,7 @@ function fmt(amount: number) {
<button <button
class="fc-btn fc-btn--primary" class="fc-btn fc-btn--primary"
:disabled="store.loading || newCount === 0" :disabled="store.loading || newCount === 0"
@click="store.confirm()" @click="store.confirm(csvMapping.all_expenses, csvMapping.all_expenses ? csvMapping.payment_date : '')"
> >
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }} {{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
</button> </button>