feat(#18): import de extratos OFX e CSV — parser, dedup, preview e confirmação
Parser OFX tolerante ao formato SGML de bancos brasileiros (sem closing tags, BOM, timezone suffix). Parser CSV com separador de campo/decimal configurável. Dedup por FITID (OFX) ou date+amount+desc normalizado. Fluxo: POST /preview → revisão na UI → POST /confirm salva e loga. Migration 002 adiciona external_id com unique index. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -52,6 +52,10 @@ func main() {
|
|||||||
categorySvc := service.NewCategoryService(categoryRepo)
|
categorySvc := service.NewCategoryService(categoryRepo)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
|
|
||||||
|
transactionRepo := repository.NewTransactionRepository(pool)
|
||||||
|
importSvc := service.NewImportService(transactionRepo)
|
||||||
|
importHandler := handler.NewImportHandler(importSvc)
|
||||||
|
|
||||||
r.Get("/health", handler.Health)
|
r.Get("/health", handler.Health)
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
@@ -59,6 +63,9 @@ func main() {
|
|||||||
r.Post("/categories", categoryHandler.Create)
|
r.Post("/categories", categoryHandler.Create)
|
||||||
r.Put("/categories/{id}", categoryHandler.Update)
|
r.Put("/categories/{id}", categoryHandler.Update)
|
||||||
r.Delete("/categories/{id}", categoryHandler.Delete)
|
r.Delete("/categories/{id}", categoryHandler.Delete)
|
||||||
|
|
||||||
|
r.Post("/imports/preview", importHandler.Preview)
|
||||||
|
r.Post("/imports/confirm", importHandler.Confirm)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serve Vue SPA — non-API routes fall through to index.html
|
// Serve Vue SPA — non-API routes fall through to index.html
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
|
type ImportHandler struct {
|
||||||
|
svc *service.ImportService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImportHandler(svc *service.ImportService) *ImportHandler {
|
||||||
|
return &ImportHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview parses the uploaded file and returns rows with duplicate flags.
|
||||||
|
// POST /api/imports/preview multipart: file + optional csv_mapping JSON field
|
||||||
|
func (h *ImportHandler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||||
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "file too large or invalid form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "field 'file' is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var csvMapping *model.CSVMapping
|
||||||
|
if raw := r.FormValue("csv_mapping"); raw != "" {
|
||||||
|
var m model.CSVMapping
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid csv_mapping JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csvMapping = &m
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, parseErrs, err := h.svc.Preview(r.Context(), header.Filename, file, csvMapping)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"rows": rows,
|
||||||
|
"parse_errors": parseErrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm saves the rows provided in the request body (after user review).
|
||||||
|
// POST /api/imports/confirm
|
||||||
|
func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Rows []model.ImportRow `json:"rows"`
|
||||||
|
ParseErrCount int `json:"parse_error_count"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Filename == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "filename is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.Confirm(r.Context(), body.Filename, body.Rows, body.ParseErrCount)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to save transactions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -9,11 +9,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
//go:embed sql/001_initial.sql
|
//go:embed sql/001_initial.sql
|
||||||
var initial string
|
var m001 string
|
||||||
|
|
||||||
|
//go:embed sql/002_add_external_id.sql
|
||||||
|
var m002 string
|
||||||
|
|
||||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
if _, err := pool.Exec(ctx, initial); err != nil {
|
for i, sql := range []struct {
|
||||||
return fmt.Errorf("001_initial: %w", err)
|
version int
|
||||||
|
sql string
|
||||||
|
}{
|
||||||
|
{1, m001},
|
||||||
|
{2, m002},
|
||||||
|
} {
|
||||||
|
if _, err := pool.Exec(ctx, sql.sql); err != nil {
|
||||||
|
return fmt.Errorf("migration %03d: %w", i+1, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version BIGINT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
color VARCHAR(7) NOT NULL DEFAULT '#6B7280',
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
type VARCHAR(10) NOT NULL CHECK (type IN ('income', 'expense')),
|
||||||
|
source VARCHAR(10) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'import')),
|
||||||
|
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
expected_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
|
||||||
|
category_id INTEGER REFERENCES categories (id) ON DELETE SET NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS import_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
format VARCHAR(10) NOT NULL CHECK (format IN ('ofx', 'csv')),
|
||||||
|
imported_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO categories (name, color, is_default)
|
||||||
|
VALUES
|
||||||
|
('Saúde / Insulina', '#EF4444', TRUE),
|
||||||
|
('Veleiro', '#3B82F6', TRUE),
|
||||||
|
('Jogos de Tabuleiro', '#8B5CF6', TRUE),
|
||||||
|
('Alimentação', '#F59E0B', TRUE),
|
||||||
|
('Lazer', '#10B981', TRUE),
|
||||||
|
('Freelance', '#6366F1', TRUE),
|
||||||
|
('Outros', '#6B7280', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO schema_migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS external_id TEXT;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS transactions_external_id_idx ON transactions (external_id) WHERE external_id IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO schema_migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Transaction struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"` // income | expense
|
||||||
|
Source string `json:"source"` // manual | import
|
||||||
|
CategoryID *int `json:"category_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportRow is a parsed-but-not-yet-saved transaction from a file.
|
||||||
|
type ImportRow struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"` // FITID from OFX
|
||||||
|
IsDuplicate bool `json:"is_duplicate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportResult is the response after confirming an import.
|
||||||
|
type ImportResult struct {
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Duplicates int `json:"duplicates"`
|
||||||
|
Errors int `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSVMapping tells the parser which CSV column index holds each field.
|
||||||
|
type CSVMapping struct {
|
||||||
|
DateColumn int `json:"date_column"`
|
||||||
|
AmountColumn int `json:"amount_column"`
|
||||||
|
DescriptionColumn int `json:"description_column"`
|
||||||
|
DateFormat string `json:"date_format"` // Go time format, e.g. "02/01/2006"
|
||||||
|
HasHeader bool `json:"has_header"`
|
||||||
|
DecimalSeparator string `json:"decimal_separator"` // "." or ","
|
||||||
|
FieldSeparator string `json:"field_separator"` // "," or ";"
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseCSV(r io.Reader, m model.CSVMapping) ([]model.ImportRow, []string) {
|
||||||
|
if m.DateFormat == "" {
|
||||||
|
m.DateFormat = "02/01/2006"
|
||||||
|
}
|
||||||
|
if m.DecimalSeparator == "" {
|
||||||
|
m.DecimalSeparator = ","
|
||||||
|
}
|
||||||
|
fieldSep := ','
|
||||||
|
if m.FieldSeparator == ";" {
|
||||||
|
fieldSep = ';'
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := csv.NewReader(r)
|
||||||
|
reader.Comma = fieldSep
|
||||||
|
reader.LazyQuotes = true
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
|
||||||
|
var rows []model.ImportRow
|
||||||
|
var errs []string
|
||||||
|
lineNum := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
record, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineNum++
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("linha %d: %v", lineNum, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lineNum == 1 && m.HasHeader {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCol := max3(m.DateColumn, m.AmountColumn, m.DescriptionColumn)
|
||||||
|
if len(record) <= maxCol {
|
||||||
|
errs = append(errs, fmt.Sprintf("linha %d: colunas insuficientes (%d)", lineNum, len(record)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dateStr := strings.TrimSpace(record[m.DateColumn])
|
||||||
|
amtStr := strings.TrimSpace(record[m.AmountColumn])
|
||||||
|
desc := cleanDescription(record[m.DescriptionColumn])
|
||||||
|
|
||||||
|
t, err := time.Parse(m.DateFormat, dateStr)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("linha %d: data inválida %q", lineNum, dateStr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.DecimalSeparator == "," {
|
||||||
|
amtStr = strings.ReplaceAll(amtStr, ".", "")
|
||||||
|
amtStr = strings.ReplaceAll(amtStr, ",", ".")
|
||||||
|
}
|
||||||
|
amt, err := strconv.ParseFloat(amtStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("linha %d: valor inválido %q", lineNum, amtStr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
txType := "expense"
|
||||||
|
if amt > 0 {
|
||||||
|
txType = "income"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, model.ImportRow{
|
||||||
|
Date: t.Format("2006-01-02"),
|
||||||
|
Amount: math.Abs(amt),
|
||||||
|
Description: desc,
|
||||||
|
Type: txType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func max3(a, b, c int) int {
|
||||||
|
if b > a {
|
||||||
|
a = b
|
||||||
|
}
|
||||||
|
if c > a {
|
||||||
|
a = c
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseOFX parses OFX SGML files as exported by Brazilian banks.
|
||||||
|
// It is tolerant of the non-strict SGML format (no closing tags, BOM, etc.).
|
||||||
|
func ParseOFX(r io.Reader) ([]model.ImportRow, error) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
|
||||||
|
var rows []model.ImportRow
|
||||||
|
var cur *model.ImportRow
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf") // strip UTF-8 BOM if present
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, value := splitOFXLine(line)
|
||||||
|
|
||||||
|
switch tag {
|
||||||
|
case "STMTTRN":
|
||||||
|
if cur != nil {
|
||||||
|
rows = append(rows, *cur)
|
||||||
|
}
|
||||||
|
cur = &model.ImportRow{}
|
||||||
|
case "/STMTTRN":
|
||||||
|
if cur != nil {
|
||||||
|
rows = append(rows, *cur)
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
case "DTPOSTED":
|
||||||
|
if cur != nil {
|
||||||
|
cur.Date = parseOFXDate(value)
|
||||||
|
}
|
||||||
|
case "TRNAMT":
|
||||||
|
if cur != nil {
|
||||||
|
amt, _ := strconv.ParseFloat(strings.ReplaceAll(value, ",", "."), 64)
|
||||||
|
cur.Amount = math.Abs(amt)
|
||||||
|
if amt < 0 {
|
||||||
|
cur.Type = "expense"
|
||||||
|
} else {
|
||||||
|
cur.Type = "income"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "FITID":
|
||||||
|
if cur != nil {
|
||||||
|
cur.ExternalID = value
|
||||||
|
}
|
||||||
|
case "MEMO":
|
||||||
|
if cur != nil && cur.Description == "" {
|
||||||
|
cur.Description = cleanDescription(value)
|
||||||
|
}
|
||||||
|
case "NAME":
|
||||||
|
if cur != nil {
|
||||||
|
cur.Description = cleanDescription(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
rows = append(rows, *cur)
|
||||||
|
}
|
||||||
|
return rows, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitOFXLine handles both "<TAG>value" and "TAG:value" formats.
|
||||||
|
func splitOFXLine(line string) (tag, value string) {
|
||||||
|
if strings.HasPrefix(line, "<") {
|
||||||
|
end := strings.Index(line, ">")
|
||||||
|
if end < 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
tag = strings.ToUpper(line[1:end])
|
||||||
|
value = strings.TrimSpace(line[end+1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// header lines like "OFXHEADER:100" — ignore
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOFXDate handles formats: 20060102, 20060102120000, 20060102120000[-3:BRT]
|
||||||
|
func parseOFXDate(s string) string {
|
||||||
|
s = strings.SplitN(s, "[", 2)[0] // strip timezone suffix
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
formats := []string{"20060102150405", "20060102"}
|
||||||
|
for _, f := range formats {
|
||||||
|
if len(s) >= len(f) {
|
||||||
|
t, err := time.Parse(f, s[:len(f)])
|
||||||
|
if err == nil {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanDescription(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
// remove duplicate whitespace
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package parser_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleOFX = `OFXHEADER:100
|
||||||
|
DATA:OFXSGML
|
||||||
|
VERSION:150
|
||||||
|
|
||||||
|
<OFX>
|
||||||
|
<BANKMSGSRSV1>
|
||||||
|
<STMTTRNRS>
|
||||||
|
<STMTRS>
|
||||||
|
<BANKTRANLIST>
|
||||||
|
<STMTTRN>
|
||||||
|
<TRNTYPE>DEBIT
|
||||||
|
<DTPOSTED>20240315120000[-3:BRT]
|
||||||
|
<TRNAMT>-150.90
|
||||||
|
<FITID>2024031500001
|
||||||
|
<MEMO>PAGTO BOLETO ENERGIA
|
||||||
|
</STMTTRN>
|
||||||
|
<STMTTRN>
|
||||||
|
<TRNTYPE>CREDIT
|
||||||
|
<DTPOSTED>20240320
|
||||||
|
<TRNAMT>5000.00
|
||||||
|
<FITID>2024032000001
|
||||||
|
<MEMO>SALARIO EMPRESA
|
||||||
|
</STMTTRN>
|
||||||
|
</BANKTRANLIST>
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseOFX_BasicFields(t *testing.T) {
|
||||||
|
rows, err := parser.ParseOFX(strings.NewReader(sampleOFX))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
debit := rows[0]
|
||||||
|
if debit.Date != "2024-03-15" {
|
||||||
|
t.Errorf("date: want 2024-03-15, got %q", debit.Date)
|
||||||
|
}
|
||||||
|
if debit.Amount != 150.90 {
|
||||||
|
t.Errorf("amount: want 150.90, got %v", debit.Amount)
|
||||||
|
}
|
||||||
|
if debit.Type != "expense" {
|
||||||
|
t.Errorf("type: want expense, got %q", debit.Type)
|
||||||
|
}
|
||||||
|
if debit.ExternalID != "2024031500001" {
|
||||||
|
t.Errorf("external_id: got %q", debit.ExternalID)
|
||||||
|
}
|
||||||
|
if debit.Description != "PAGTO BOLETO ENERGIA" {
|
||||||
|
t.Errorf("desc: got %q", debit.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
credit := rows[1]
|
||||||
|
if credit.Type != "income" {
|
||||||
|
t.Errorf("type: want income, got %q", credit.Type)
|
||||||
|
}
|
||||||
|
if credit.Amount != 5000.00 {
|
||||||
|
t.Errorf("amount: want 5000.00, got %v", credit.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOFX_EmptyFile(t *testing.T) {
|
||||||
|
rows, err := parser.ParseOFX(strings.NewReader(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("expected 0 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSV_Semicolon(t *testing.T) {
|
||||||
|
// Brazilian bank CSV: semicolon separator, comma as decimal
|
||||||
|
data := "15/03/2024;-150,90;SUPERMERCADO\n20/03/2024;5000,00;SALARIO\n"
|
||||||
|
m := model.CSVMapping{
|
||||||
|
DateColumn: 0,
|
||||||
|
AmountColumn: 1,
|
||||||
|
DescriptionColumn: 2,
|
||||||
|
DateFormat: "02/01/2006",
|
||||||
|
HasHeader: false,
|
||||||
|
DecimalSeparator: ",",
|
||||||
|
FieldSeparator: ";",
|
||||||
|
}
|
||||||
|
rows, errs := parser.ParseCSV(strings.NewReader(data), m)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Date != "2024-03-15" {
|
||||||
|
t.Errorf("date: got %q", rows[0].Date)
|
||||||
|
}
|
||||||
|
if rows[0].Amount != 150.90 {
|
||||||
|
t.Errorf("amount: want 150.90, got %v", rows[0].Amount)
|
||||||
|
}
|
||||||
|
if rows[0].Type != "expense" {
|
||||||
|
t.Errorf("type: got %q", rows[0].Type)
|
||||||
|
}
|
||||||
|
if rows[1].Type != "income" {
|
||||||
|
t.Errorf("income type: got %q", rows[1].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSV_WithHeader(t *testing.T) {
|
||||||
|
data := "Data;Valor;Descricao\n15/03/2024;-50,00;FARMACIA\n"
|
||||||
|
m := model.CSVMapping{
|
||||||
|
DateColumn: 0,
|
||||||
|
AmountColumn: 1,
|
||||||
|
DescriptionColumn: 2,
|
||||||
|
DateFormat: "02/01/2006",
|
||||||
|
HasHeader: true,
|
||||||
|
DecimalSeparator: ",",
|
||||||
|
FieldSeparator: ";",
|
||||||
|
}
|
||||||
|
rows, errs := parser.ParseCSV(strings.NewReader(data), m)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("errors: %v", errs)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 row (header skipped), got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Description != "FARMACIA" {
|
||||||
|
t.Errorf("desc: got %q", rows[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TransactionRepository interface {
|
||||||
|
IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error)
|
||||||
|
IsExternalIDKnown(ctx context.Context, externalID string) (bool, error)
|
||||||
|
BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error)
|
||||||
|
SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type transactionRepo struct{ db *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewTransactionRepository(db *pgxpool.Pool) TransactionRepository {
|
||||||
|
return &transactionRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeDesc lowercases and collapses whitespace for fuzzy dedup.
|
||||||
|
func normalizeDesc(s string) string {
|
||||||
|
return strings.ToLower(strings.Join(strings.Fields(s), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepo) IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM transactions
|
||||||
|
WHERE date = $1 AND amount = $2 AND LOWER(description) = $3`,
|
||||||
|
date, amount, normalizeDesc(description)).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepo) IsExternalIDKnown(ctx context.Context, externalID string) (bool, error) {
|
||||||
|
if externalID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM transactions WHERE external_id = $1`, externalID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error) {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.IsDuplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var extID *string
|
||||||
|
if row.ExternalID != "" {
|
||||||
|
extID = &row.ExternalID
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO transactions (date, amount, description, type, source, external_id)
|
||||||
|
VALUES ($1, $2, $3, $4, 'import', $5)`,
|
||||||
|
row.Date, row.Amount, row.Description, row.Type, extID)
|
||||||
|
if err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepo) SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error {
|
||||||
|
_, err := r.db.Exec(ctx, `
|
||||||
|
INSERT INTO import_logs (filename, format, imported_count, duplicate_count, error_count)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
filename, format, imported, duplicates, errors)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/parser"
|
||||||
|
"financeiro-carvalho/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportService struct {
|
||||||
|
repo repository.TransactionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImportService(repo repository.TransactionRepository) *ImportService {
|
||||||
|
return &ImportService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview parses the file and marks duplicates without saving anything.
|
||||||
|
func (s *ImportService) Preview(ctx context.Context, filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
|
||||||
|
rows, parseErrs, err := s.parse(filename, r, csvMapping)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
dup, err := s.isDuplicate(ctx, &rows[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("dedup check: %w", err)
|
||||||
|
}
|
||||||
|
rows[i].IsDuplicate = dup
|
||||||
|
}
|
||||||
|
return rows, parseErrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm saves the non-duplicate rows and logs the operation.
|
||||||
|
func (s *ImportService) Confirm(ctx context.Context, filename string, rows []model.ImportRow, parseErrCount int) (model.ImportResult, error) {
|
||||||
|
imported, err := s.repo.BulkInsert(ctx, rows)
|
||||||
|
if err != nil {
|
||||||
|
return model.ImportResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicates := 0
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.IsDuplicate {
|
||||||
|
duplicates++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
|
||||||
|
if format != "ofx" && format != "csv" {
|
||||||
|
format = "csv"
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.repo.SaveImportLog(ctx, filename, format, imported, duplicates, parseErrCount)
|
||||||
|
return model.ImportResult{
|
||||||
|
Imported: imported,
|
||||||
|
Duplicates: duplicates,
|
||||||
|
Errors: parseErrCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ImportService) parse(filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
switch ext {
|
||||||
|
case ".ofx":
|
||||||
|
rows, err := parser.ParseOFX(r)
|
||||||
|
return rows, nil, err
|
||||||
|
case ".csv":
|
||||||
|
m := model.CSVMapping{
|
||||||
|
DateColumn: 0,
|
||||||
|
AmountColumn: 1,
|
||||||
|
DescriptionColumn: 2,
|
||||||
|
DateFormat: "02/01/2006",
|
||||||
|
HasHeader: true,
|
||||||
|
DecimalSeparator: ",",
|
||||||
|
FieldSeparator: ";",
|
||||||
|
}
|
||||||
|
if csvMapping != nil {
|
||||||
|
m = *csvMapping
|
||||||
|
}
|
||||||
|
rows, errs := parser.ParseCSV(r, m)
|
||||||
|
return rows, errs, nil
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unsupported file format: %q (use .ofx or .csv)", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ImportService) isDuplicate(ctx context.Context, row *model.ImportRow) (bool, error) {
|
||||||
|
if row.ExternalID != "" {
|
||||||
|
return s.repo.IsExternalIDKnown(ctx, row.ExternalID)
|
||||||
|
}
|
||||||
|
return s.repo.IsDuplicate(ctx, row.Date, row.Description, row.Amount)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<RouterLink to="/">Início</RouterLink>
|
<RouterLink to="/">Início</RouterLink>
|
||||||
<RouterLink to="/categorias">Categorias</RouterLink>
|
<RouterLink to="/categorias">Categorias</RouterLink>
|
||||||
|
<RouterLink to="/importar">Importar</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const router = createRouter({
|
|||||||
name: 'categories',
|
name: 'categories',
|
||||||
component: () => import('../views/CategoriesView.vue'),
|
component: () => import('../views/CategoriesView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/importar',
|
||||||
|
name: 'import',
|
||||||
|
component: () => import('../views/ImportView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface ImportRow {
|
||||||
|
date: string
|
||||||
|
amount: number
|
||||||
|
description: string
|
||||||
|
type: string
|
||||||
|
external_id?: string
|
||||||
|
is_duplicate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number
|
||||||
|
duplicates: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CSVMapping {
|
||||||
|
date_column: number
|
||||||
|
amount_column: number
|
||||||
|
description_column: number
|
||||||
|
date_format: string
|
||||||
|
has_header: boolean
|
||||||
|
decimal_separator: string
|
||||||
|
field_separator: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImportStore = defineStore('import', () => {
|
||||||
|
const rows = ref<ImportRow[]>([])
|
||||||
|
const parseErrors = ref<string[]>([])
|
||||||
|
const filename = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const result = ref<ImportResult | null>(null)
|
||||||
|
|
||||||
|
async function preview(file: File, csvMapping?: CSVMapping) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
filename.value = file.name
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (csvMapping) {
|
||||||
|
form.append('csv_mapping', JSON.stringify(csvMapping))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/imports/preview', { method: 'POST', body: form })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? 'Erro no preview')
|
||||||
|
rows.value = data.rows ?? []
|
||||||
|
parseErrors.value = data.parse_errors ?? []
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/imports/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: filename.value,
|
||||||
|
rows: rows.value,
|
||||||
|
parse_error_count: parseErrors.value.length,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error ?? 'Erro ao confirmar')
|
||||||
|
result.value = data
|
||||||
|
rows.value = []
|
||||||
|
parseErrors.value = []
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
rows.value = []
|
||||||
|
parseErrors.value = []
|
||||||
|
filename.value = ''
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, parseErrors, filename, loading, error, result, preview, confirm, reset }
|
||||||
|
})
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useImportStore, type CSVMapping } from '@/stores/import'
|
||||||
|
|
||||||
|
const store = useImportStore()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const showCSVConfig = ref(false)
|
||||||
|
|
||||||
|
const csvMapping = ref<CSVMapping>({
|
||||||
|
date_column: 0,
|
||||||
|
amount_column: 1,
|
||||||
|
description_column: 2,
|
||||||
|
date_format: '02/01/2006',
|
||||||
|
has_header: true,
|
||||||
|
decimal_separator: ',',
|
||||||
|
field_separator: ';',
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
selectedFile.value = file
|
||||||
|
store.reset()
|
||||||
|
showCSVConfig.value = file.name.toLowerCase().endsWith('.csv')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPreview() {
|
||||||
|
if (!selectedFile.value) return
|
||||||
|
const mapping = showCSVConfig.value ? csvMapping.value : undefined
|
||||||
|
await store.preview(selectedFile.value, mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = computed(() => store.rows.filter((r) => !r.is_duplicate).length)
|
||||||
|
const dupCount = computed(() => store.rows.filter((r) => r.is_duplicate).length)
|
||||||
|
|
||||||
|
function fmt(amount: number) {
|
||||||
|
return amount.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<h1>Importar Extrato</h1>
|
||||||
|
|
||||||
|
<!-- Result banner -->
|
||||||
|
<div v-if="store.result" class="result-banner">
|
||||||
|
<strong>Import concluído!</strong>
|
||||||
|
{{ store.result.imported }} transações importadas ·
|
||||||
|
{{ store.result.duplicates }} duplicatas ignoradas ·
|
||||||
|
{{ store.result.errors }} erros
|
||||||
|
<button class="btn btn-ghost" style="margin-left:1rem" @click="store.reset(); selectedFile = null">
|
||||||
|
Importar outro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- File upload -->
|
||||||
|
<div class="upload-area" @click="fileInput?.click()" @dragover.prevent @drop.prevent="onFileChange">
|
||||||
|
<input ref="fileInput" type="file" accept=".ofx,.csv" style="display:none" @change="onFileChange" />
|
||||||
|
<span v-if="!selectedFile">Clique ou arraste um arquivo <strong>.ofx</strong> ou <strong>.csv</strong></span>
|
||||||
|
<span v-else>📄 {{ selectedFile.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV config -->
|
||||||
|
<div v-if="showCSVConfig" class="csv-config">
|
||||||
|
<h3>Configuração do CSV</h3>
|
||||||
|
<div class="config-grid">
|
||||||
|
<label>Separador de campo
|
||||||
|
<select v-model="csvMapping.field_separator">
|
||||||
|
<option value=";">Ponto e vírgula (;)</option>
|
||||||
|
<option value=",">, Vírgula (,)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Separador decimal
|
||||||
|
<select v-model="csvMapping.decimal_separator">
|
||||||
|
<option value=",">, Vírgula (1.234,56)</option>
|
||||||
|
<option value=".">. Ponto (1,234.56)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Formato de data
|
||||||
|
<input v-model="csvMapping.date_format" placeholder="02/01/2006" />
|
||||||
|
</label>
|
||||||
|
<label>Coluna da data (0-based)
|
||||||
|
<input type="number" v-model.number="csvMapping.date_column" min="0" />
|
||||||
|
</label>
|
||||||
|
<label>Coluna do valor
|
||||||
|
<input type="number" v-model.number="csvMapping.amount_column" min="0" />
|
||||||
|
</label>
|
||||||
|
<label>Coluna da descrição
|
||||||
|
<input type="number" v-model.number="csvMapping.description_column" min="0" />
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="csvMapping.has_header" /> Arquivo tem cabeçalho
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="selectedFile && store.rows.length === 0"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="store.loading"
|
||||||
|
@click="doPreview"
|
||||||
|
>
|
||||||
|
{{ store.loading ? 'Processando…' : 'Analisar arquivo' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="store.error" class="form-error">{{ store.error }}</p>
|
||||||
|
|
||||||
|
<!-- Preview table -->
|
||||||
|
<template v-if="store.rows.length > 0">
|
||||||
|
<div class="preview-summary">
|
||||||
|
<span class="badge-new">{{ newCount }} novas</span>
|
||||||
|
<span class="badge-dup">{{ dupCount }} duplicatas</span>
|
||||||
|
<span v-if="store.parseErrors.length" class="badge-err">{{ store.parseErrors.length }} erros de parse</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.parseErrors.length" class="error-list">
|
||||||
|
<p v-for="e in store.parseErrors" :key="e" class="form-error">{{ e }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="preview-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Descrição</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(row, i) in store.rows"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'row-dup': row.is_duplicate }"
|
||||||
|
>
|
||||||
|
<td>{{ row.date }}</td>
|
||||||
|
<td>{{ row.description }}</td>
|
||||||
|
<td :class="row.type === 'income' ? 'amt-income' : 'amt-expense'">
|
||||||
|
{{ fmt(row.amount) }}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.type === 'income' ? 'Receita' : 'Gasto' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.is_duplicate" class="badge badge-dup-sm">duplicata</span>
|
||||||
|
<span v-else class="badge badge-new-sm">novo</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-bar">
|
||||||
|
<button class="btn btn-primary" :disabled="store.loading || newCount === 0" @click="store.confirm()">
|
||||||
|
{{ store.loading ? 'Salvando…' : `Confirmar ${newCount} transações` }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" @click="store.reset(); selectedFile = null">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 1.25rem; }
|
||||||
|
h3 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; }
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.upload-area:hover { border-color: #4f46e5; }
|
||||||
|
|
||||||
|
.csv-config { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
.config-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
|
||||||
|
.config-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #374151; }
|
||||||
|
.config-grid input, .config-grid select { padding: 0.4rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.875rem; }
|
||||||
|
.checkbox-label { flex-direction: row !important; align-items: center; gap: 0.5rem !important; }
|
||||||
|
|
||||||
|
.btn { padding: 0.5rem 1.25rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font-size: 0.875rem; background: #fff; }
|
||||||
|
.btn-primary { background: #4f46e5; color: #fff; border-color: #4f46e5; }
|
||||||
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.btn-ghost { background: transparent; }
|
||||||
|
.form-error { color: #dc2626; font-size: 0.875rem; margin: 0.5rem 0; }
|
||||||
|
|
||||||
|
.preview-summary { display: flex; gap: 0.75rem; margin: 1rem 0 0.5rem; flex-wrap: wrap; }
|
||||||
|
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
|
||||||
|
.badge-new { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-dup { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-err { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-new-sm { background: #d1fae5; color: #065f46; font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 3px; }
|
||||||
|
.badge-dup-sm { background: #fef3c7; color: #92400e; font-size: 0.7rem; padding: 0.1rem 0.35rem; border-radius: 3px; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; margin: 0.5rem 0; }
|
||||||
|
.preview-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||||
|
.preview-table th { text-align: left; padding: 0.5rem 0.75rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.preview-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.row-dup { opacity: 0.45; }
|
||||||
|
.amt-income { color: #059669; font-weight: 500; }
|
||||||
|
.amt-expense { color: #dc2626; font-weight: 500; }
|
||||||
|
|
||||||
|
.confirm-bar { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
|
||||||
|
|
||||||
|
.result-banner {
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #6ee7b7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
color: #065f46;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-list { margin-bottom: 0.5rem; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user