From 75fb18a0a498ef2c2582989d985fb92a1cff67fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:06:08 +0000 Subject: [PATCH 1/2] Implement transaction attachments & unified add/edit UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreate the FinTrack Transactions design handoff in the real stack: Frontend - Redesigned Transactions table as a glass card with a grid layout, inline filter bar, and a new Receipt column (πŸ“Ž count). - Rows are clickable and open a unified add/edit dialog (shared Dialog component with overlay/Esc/click-out close). - Dialog supports editing the date of historical transactions, an attachments section (image thumbnails / PDF icon), and a full-size attachment viewer (image + PDF iframe). - Attachments are uploaded to the backend on save; removals and new files are reconciled. Auth-protected files are fetched as blobs. Backend - Add Attachment Prisma model + migration (FK to Transaction, cascade). - New routes: POST /transactions/:id/attachments (multipart upload), GET /attachments/:id (inline stream), DELETE /attachments/:id. - Include attachment metadata in transaction list responses. https://claude.ai/code/session_01HEUSQ8Ntz9nzGMcstCtKeh --- .../20240110_attachments/migration.sql | 12 + backend/prisma/schema.prisma | 14 + backend/src/index.js | 2 + backend/src/routes/attachments.js | 80 +++ backend/src/routes/transactions.js | 10 +- frontend/src/api/client.js | 10 + frontend/src/components/Dialog.jsx | 39 ++ frontend/src/pages/Transactions.jsx | 588 +++++++++++------- 8 files changed, 544 insertions(+), 211 deletions(-) create mode 100644 backend/prisma/migrations/20240110_attachments/migration.sql create mode 100644 backend/src/routes/attachments.js create mode 100644 frontend/src/components/Dialog.jsx diff --git a/backend/prisma/migrations/20240110_attachments/migration.sql b/backend/prisma/migrations/20240110_attachments/migration.sql new file mode 100644 index 0000000..b02f186 --- /dev/null +++ b/backend/prisma/migrations/20240110_attachments/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "Attachment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "transactionId" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Attachment_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "Attachment_transactionId_idx" ON "Attachment"("transactionId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46ce456..062632f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -69,12 +69,26 @@ model Transaction { recurring RecurringTransaction? @relation(fields: [recurringId], references: [id]) importedFrom String? externalId String? + attachments Attachment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([externalId, accountId]) } +model Attachment { + id String @id @default(cuid()) + transactionId String + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + filename String + mimeType String + size Int + storagePath String + createdAt DateTime @default(now()) + + @@index([transactionId]) +} + model User { id String @id @default(cuid()) username String @unique diff --git a/backend/src/index.js b/backend/src/index.js index bfecc31..05acab9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -10,6 +10,7 @@ const usersRouter = require("./routes/users"); const configRouter = require("./routes/config"); const accountsRouter = require("./routes/accounts"); const transactionsRouter = require("./routes/transactions"); +const attachmentsRouter = require("./routes/attachments"); const categoriesRouter = require("./routes/categories"); const recurringRouter = require("./routes/recurring"); const importRouter = require("./routes/import"); @@ -38,6 +39,7 @@ app.use("/users", requireAdmin, usersRouter); app.use("/config", configRouter); app.use("/accounts", accountsRouter); app.use("/transactions", transactionsRouter); +app.use(attachmentsRouter); // /transactions/:id/attachments, /attachments/:id app.use("/categories", categoriesRouter); app.use("/recurring", recurringRouter); app.use("/import", importRouter); diff --git a/backend/src/routes/attachments.js b/backend/src/routes/attachments.js new file mode 100644 index 0000000..94496ae --- /dev/null +++ b/backend/src/routes/attachments.js @@ -0,0 +1,80 @@ +const express = require("express"); +const multer = require("multer"); +const fs = require("fs"); +const path = require("path"); +const { PrismaClient } = require("@prisma/client"); + +const router = express.Router(); +const prisma = new PrismaClient(); + +const UPLOAD_DIR = path.join(__dirname, "../../uploads/attachments"); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const ALLOWED = new Set([ + "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/heic", + "application/pdf", +]); + +const upload = multer({ + storage: multer.diskStorage({ + destination: UPLOAD_DIR, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 10)}${ext}`); + }, + }), + limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB per file + fileFilter: (_req, file, cb) => cb(null, ALLOWED.has(file.mimetype)), +}); + +// POST /transactions/:id/attachments β€” upload one or more receipts/invoices +router.post("/transactions/:id/attachments", upload.array("files"), async (req, res) => { + const transaction = await prisma.transaction.findUnique({ where: { id: req.params.id } }); + if (!transaction) { + (req.files || []).forEach((f) => fs.unlink(f.path, () => {})); + return res.status(404).json({ error: "Transaction not found" }); + } + + const created = await Promise.all( + (req.files || []).map((f) => + prisma.attachment.create({ + data: { + transactionId: transaction.id, + filename: f.originalname, + mimeType: f.mimetype, + size: f.size, + storagePath: path.basename(f.path), + }, + select: { id: true, filename: true, mimeType: true, size: true, createdAt: true }, + }) + ) + ); + + res.status(201).json(created); +}); + +// GET /attachments/:id β€” stream the stored file for inline viewing +router.get("/attachments/:id", async (req, res) => { + const att = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!att) return res.status(404).json({ error: "Attachment not found" }); + + const filePath = path.join(UPLOAD_DIR, att.storagePath); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" }); + + res.setHeader("Content-Type", att.mimeType); + res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(att.filename)}"`); + fs.createReadStream(filePath).pipe(res); +}); + +// DELETE /attachments/:id β€” remove an attachment and its file +router.delete("/attachments/:id", async (req, res) => { + const att = await prisma.attachment.findUnique({ where: { id: req.params.id } }); + if (!att) return res.status(404).json({ error: "Attachment not found" }); + + await prisma.attachment.delete({ where: { id: att.id } }); + fs.unlink(path.join(UPLOAD_DIR, att.storagePath), () => {}); + + res.status(204).end(); +}); + +module.exports = router; diff --git a/backend/src/routes/transactions.js b/backend/src/routes/transactions.js index cb93c48..326638d 100644 --- a/backend/src/routes/transactions.js +++ b/backend/src/routes/transactions.js @@ -4,7 +4,15 @@ const { PrismaClient } = require("@prisma/client"); const router = express.Router(); const prisma = new PrismaClient(); -const INCLUDE = { account: true, toAccount: true, category: true }; +const INCLUDE = { + account: true, + toAccount: true, + category: true, + attachments: { + select: { id: true, filename: true, mimeType: true, size: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }, +}; // Balance delta for a transaction relative to its accountId function delta(type, amount) { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 88699f4..da05e71 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,6 +38,16 @@ export const transactions = { remove: (id) => api.delete(`/transactions/${id}`), }; +export const attachments = { + upload: (transactionId, files) => { + const form = new FormData(); + files.forEach((f) => form.append("files", f)); + return api.post(`/transactions/${transactionId}/attachments`, form).then((r) => r.data); + }, + blob: (id) => api.get(`/attachments/${id}`, { responseType: "blob" }).then((r) => r.data), + remove: (id) => api.delete(`/attachments/${id}`), +}; + export const categories = { list: () => api.get("/categories").then((r) => r.data), create: (data) => api.post("/categories", data).then((r) => r.data), diff --git a/frontend/src/components/Dialog.jsx b/frontend/src/components/Dialog.jsx new file mode 100644 index 0000000..9375cd2 --- /dev/null +++ b/frontend/src/components/Dialog.jsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; + +/** + * FinTrack dialog β€” a centered glass-strong panel over a dimmed, blurred + * overlay. Click-outside and Esc close it. + */ +export default function Dialog({ open = true, onClose, title, children, width = 460, style = {} }) { + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === "Escape" && onClose) onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
e.target === e.currentTarget && onClose?.()}> +
+ {title && ( +
+

{title}

+ +
+ )} + {children} +
+
+ ); +} diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx index 25d234f..9700312 100644 --- a/frontend/src/pages/Transactions.jsx +++ b/frontend/src/pages/Transactions.jsx @@ -1,32 +1,43 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useSearchParams } from "react-router-dom"; -import { transactions as txApi, accounts as accountsApi, categories as catsApi } from "../api/client"; +import { + transactions as txApi, + accounts as accountsApi, + categories as catsApi, + attachments as attachmentsApi, +} from "../api/client"; import GlassCard from "../components/GlassCard"; +import Dialog from "../components/Dialog"; import { format } from "date-fns"; const fmt = (n) => new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }).format(n); +const fmtDate = (d) => + new Date(d).toLocaleDateString("nl-NL", { day: "numeric", month: "short", year: "numeric" }); -const emptyForm = { - accountId: "", toAccountId: "", categoryId: "", - amount: "", description: "", - date: format(new Date(), "yyyy-MM-dd"), - type: "EXPENSE", notes: "", -}; +const GRID = "1.2fr 1fr 0.9fr 92px 96px 130px"; -const fieldStyle = { padding: "10px 14px", width: "100%", boxSizing: "border-box", display: "block" }; -const labelStyle = { fontSize: 12, color: "rgba(255,255,255,0.5)", fontWeight: 500, display: "block", marginBottom: 16 }; +const TXN_TYPES = [ + { id: "INCOME", label: "Income", color: "#34d399" }, + { id: "EXPENSE", label: "Expense", color: "#f87171" }, + { id: "TRANSFER", label: "Transfer", color: "#94a3b8" }, +]; + +const fieldStyle = { padding: "10px 14px", width: "100%", boxSizing: "border-box", display: "block", marginTop: 6 }; +const labelStyle = { fontSize: 12, color: "rgba(255,255,255,0.5)", fontWeight: 500, display: "block" }; export default function Transactions() { const [data, setData] = useState({ transactions: [], total: 0 }); const [accounts, setAccounts] = useState([]); const [categories, setCategories] = useState([]); - const [modal, setModal] = useState(false); - const [form, setForm] = useState(emptyForm); - const [editing, setEditing] = useState(null); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(""); + const [modal, setModal] = useState(null); // null | { mode: 'add' } | { mode: 'edit', txn } const [searchParams] = useSearchParams(); - const [filters, setFilters] = useState({ search: "", accountId: searchParams.get("accountId") || "", categoryId: "", type: "", page: 1 }); + const [filters, setFilters] = useState({ + search: "", + accountId: searchParams.get("accountId") || "", + categoryId: "", + type: "", + page: 1, + }); const load = useCallback(() => { txApi.list({ ...filters, limit: 50 }).then(setData); @@ -38,52 +49,7 @@ export default function Transactions() { catsApi.list().then(setCategories); }, []); - const open = (item = null) => { - setEditing(item?.id || null); - setSaveError(""); - setForm(item ? { - accountId: item.accountId, - toAccountId: item.toAccountId || "", - categoryId: item.categoryId || "", - amount: item.amount, - description: item.description, - date: format(new Date(item.date), "yyyy-MM-dd"), - type: item.type, - notes: item.notes || "", - } : { ...emptyForm, accountId: accounts[0]?.id || "" }); - setModal(true); - }; - - const save = async () => { - if (!form.description.trim()) { setSaveError("Description is required"); return; } - if (!form.amount || Number(form.amount) <= 0) { setSaveError("Enter a valid amount"); return; } - if (!form.accountId) { setSaveError("Select an account"); return; } - if (form.type === "TRANSFER" && !form.toAccountId) { setSaveError("Select a destination account"); return; } - if (form.type === "TRANSFER" && form.toAccountId === form.accountId) { setSaveError("From and To accounts must be different"); return; } - - setSaveError(""); - setSaving(true); - try { - const payload = { ...form, amount: Number(form.amount), categoryId: form.categoryId || null }; - if (editing) await txApi.update(editing, payload); - else await txApi.create(payload); - setModal(false); - load(); - } catch (err) { - setSaveError(err.response?.data?.error || err.message || "Failed to save"); - } finally { - setSaving(false); - } - }; - - const remove = async (id) => { - if (!confirm("Delete this transaction?")) return; - await txApi.remove(id); - load(); - }; - const allCategories = categories.flatMap((c) => [c, ...(c.children || [])]); - const isTransfer = form.type === "TRANSFER"; return (
@@ -92,74 +58,84 @@ export default function Transactions() {

Transactions

{data.total} transactions

- + - {/* Filters */} - -
- setFilters({ ...filters, search: e.target.value, page: 1 })} /> - - setFilters({ ...filters, search: e.target.value, page: 1 })} + /> + - setFilters({ ...filters, accountId: e.target.value, page: 1 })}> + + {accounts.map((a) => )} + +
-
- {/* Table */} - - - - - {["Date", "Description", "Account", "Category", "Type", "Amount", ""].map((h) => ( - - ))} - - - - {data.transactions.map((t) => ( - - - - - - - - - - ))} - {data.transactions.length === 0 && ( - - )} - -
{h}
{format(new Date(t.date), "dd MMM yyyy")}{t.description} - {t.type === "TRANSFER" && t.toAccount - ? {t.account?.name} β†’ {t.toAccount.name} - : t.account?.name} - - {t.category && ( - - {t.category.name} - - )} - - {t.type} - - {t.type === "EXPENSE" ? "-" : t.type === "INCOME" ? "+" : ""}{fmt(Number(t.amount))} - - - -
No transactions found
+ {/* Table head */} +
+ DescriptionAccountCategoryTypeReceipt + Amount +
+ + {/* Rows */} + {data.transactions.map((t, i) => { + const sign = t.type === "INCOME" ? "+" : t.type === "EXPENSE" ? "βˆ’" : ""; + const color = t.type === "INCOME" ? "#34d399" : t.type === "EXPENSE" ? "#f87171" : "#94a3b8"; + const files = t.attachments || []; + return ( +
setModal({ mode: "edit", txn: t })} + style={{ display: "grid", gridTemplateColumns: GRID, gap: 12, padding: "14px 18px", alignItems: "center", borderTop: i > 0 ? "1px solid rgba(255,255,255,0.08)" : "none", fontSize: 13, cursor: "pointer", transition: "background 0.15s ease" }} + onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.04)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > +
+
{t.description}
+
{fmtDate(t.date)}
+
+
+ {t.type === "TRANSFER" && t.toAccount + ? {t.account?.name} β†’ {t.toAccount.name} + : t.account?.name} +
+
+ {t.category + ? {t.category.name} + : β€”} +
+
{t.type}
+
+ {files.length > 0 + ? 1 ? "s" : ""}`} style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 12, color: "#c4b5fd" }}>πŸ“Ž {files.length} + : β€”} +
+
{sign}{fmt(Number(t.amount))}
+
+ ); + })} + + {data.transactions.length === 0 && ( +
No transactions yet
+ )}
{/* Pagination */} @@ -171,107 +147,299 @@ export default function Transactions() { )} - {/* Modal */} {modal && ( -
e.target === e.currentTarget && setModal(false)}> -
-

{editing ? "Edit" : "Add"} Transaction

- -
- {/* Type selector */} -
- {["EXPENSE", "INCOME", "TRANSFER"].map((t) => ( - - ))} -
+ setModal(null)} + onSaved={() => { setModal(null); load(); }} + /> + )} +
+ ); +} - - -
- - -
+function TransactionModal({ mode, txn, accounts, categories, onClose, onSaved }) { + const isEdit = mode === "edit"; + const [type, setType] = useState(txn?.type || "EXPENSE"); + const [desc, setDesc] = useState(txn?.description || ""); + const [amount, setAmount] = useState(txn ? String(txn.amount) : ""); + const [date, setDate] = useState(txn ? format(new Date(txn.date), "yyyy-MM-dd") : format(new Date(), "yyyy-MM-dd")); + const [accountId, setAccountId] = useState(txn?.accountId || accounts[0]?.id || ""); + const [toAccountId, setToAccountId] = useState(txn?.toAccountId || ""); + const [categoryId, setCategoryId] = useState(txn?.categoryId || ""); + const [notes, setNotes] = useState(txn?.notes || ""); - {isTransfer ? ( -
- -
β†’
- -
- ) : ( - - )} - - {!isTransfer && ( - - )} - -