diff --git a/.env.example b/.env.example index 97f9b97..8c71a45 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ JWT_SECRET= # URLs — used for OIDC callback and frontend redirect APP_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 + +# AI receipt tagging (optional) — can also be set in Settings → AI. +# Fallback Anthropic API key used when none is configured in the app. +ANTHROPIC_API_KEY= diff --git a/backend/package-lock.json b/backend/package-lock.json index 97141be..da830fb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "@prisma/client": "^5.14.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -32,6 +33,35 @@ "prisma": "^5.14.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@panva/asn1.js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", @@ -1037,6 +1067,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1549,6 +1580,19 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2720,6 +2764,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/backend/package.json b/backend/package.json index 821a798..a2c7361 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "db:studio": "prisma studio" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "@prisma/client": "^5.14.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", 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/migrations/20240111_ai_tagging/migration.sql b/backend/prisma/migrations/20240111_ai_tagging/migration.sql new file mode 100644 index 0000000..c0fab71 --- /dev/null +++ b/backend/prisma/migrations/20240111_ai_tagging/migration.sql @@ -0,0 +1,10 @@ +-- Attachment tags (AI-generated, JSON-encoded array) +ALTER TABLE "Attachment" ADD COLUMN "tags" TEXT; + +-- AI tagging configuration on the Settings singleton +ALTER TABLE "Settings" ADD COLUMN "aiTaggingEnabled" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Settings" ADD COLUMN "aiProvider" TEXT NOT NULL DEFAULT 'claude'; +ALTER TABLE "Settings" ADD COLUMN "aiModel" TEXT; +ALTER TABLE "Settings" ADD COLUMN "anthropicApiKey" TEXT; +ALTER TABLE "Settings" ADD COLUMN "odysseusBaseUrl" TEXT; +ALTER TABLE "Settings" ADD COLUMN "odysseusApiKey" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46ce456..08b9212 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -69,12 +69,27 @@ 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 + tags String? // JSON-encoded array of AI-generated tags + createdAt DateTime @default(now()) + + @@index([transactionId]) +} + model User { id String @id @default(cuid()) username String @unique @@ -105,6 +120,12 @@ model Settings { googleOidcEnabled Boolean @default(false) googleClientId String? googleClientSecret String? + aiTaggingEnabled Boolean @default(false) + aiProvider String @default("claude") // "claude" | "odysseus" + aiModel String? // Claude model id, or Odysseus model name + anthropicApiKey String? + odysseusBaseUrl String? + odysseusApiKey String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 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..88232bf --- /dev/null +++ b/backend/src/routes/attachments.js @@ -0,0 +1,116 @@ +const express = require("express"); +const multer = require("multer"); +const fs = require("fs"); +const path = require("path"); +const { PrismaClient } = require("@prisma/client"); +const { loadAiConfig, tagStoredAttachment } = require("../services/aiTagging"); + +const router = express.Router(); +const prisma = new PrismaClient(); + +const ATT_SELECT = { id: true, filename: true, mimeType: true, size: true, tags: true, createdAt: true }; + +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: { ...ATT_SELECT, storagePath: true }, + }) + ) + ); + + // Best-effort AI tagging — never fails the upload. + const aiConfig = await loadAiConfig().catch(() => null); + if (aiConfig) { + await Promise.all( + created.map(async (att) => { + try { + att.tags = JSON.stringify(await tagStoredAttachment(att, aiConfig)); + } catch (e) { + console.error(`AI tagging failed for attachment ${att.id}:`, e.message); + } + }) + ); + } + + res.status(201).json(created.map(({ storagePath, ...rest }) => rest)); +}); + +// POST /transactions/:id/attachments/retag — re-run AI tagging on all of a transaction's attachments +router.post("/transactions/:id/attachments/retag", async (req, res) => { + const aiConfig = await loadAiConfig(); + if (!aiConfig) return res.status(400).json({ error: "AI tagging is not enabled" }); + + const attachments = await prisma.attachment.findMany({ where: { transactionId: req.params.id } }); + const results = await Promise.all( + attachments.map(async (att) => { + try { + const tags = await tagStoredAttachment(att, aiConfig); + return { id: att.id, filename: att.filename, mimeType: att.mimeType, size: att.size, tags: JSON.stringify(tags), createdAt: att.createdAt }; + } catch (e) { + return { id: att.id, filename: att.filename, mimeType: att.mimeType, size: att.size, tags: att.tags, createdAt: att.createdAt, error: e.message }; + } + }) + ); + res.json(results); +}); + +// 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/config.js b/backend/src/routes/config.js index 05ebe8f..3d636f1 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -27,6 +27,12 @@ router.get("/", async (req, res) => { googleOidcEnabled: s.googleOidcEnabled, googleClientId: s.googleClientId ?? "", hasGoogleClientSecret: !!s.googleClientSecret, + aiTaggingEnabled: s.aiTaggingEnabled, + aiProvider: s.aiProvider ?? "claude", + aiModel: s.aiModel ?? "", + hasAnthropicApiKey: !!(s.anthropicApiKey || process.env.ANTHROPIC_API_KEY), + odysseusBaseUrl: s.odysseusBaseUrl ?? "", + hasOdysseusApiKey: !!s.odysseusApiKey, }); }); @@ -36,6 +42,7 @@ router.put("/", async (req, res) => { appName, defaultCurrency, appPort, jwtSecret, oidcEnabled, oidcTenantId, oidcClientId, oidcClientSecret, googleOidcEnabled, googleClientId, googleClientSecret, + aiTaggingEnabled, aiProvider, aiModel, anthropicApiKey, odysseusBaseUrl, odysseusApiKey, } = req.body; const data = {}; if (appName !== undefined) data.appName = String(appName).trim() || "FinTrack"; @@ -49,6 +56,12 @@ router.put("/", async (req, res) => { if (googleOidcEnabled !== undefined) data.googleOidcEnabled = Boolean(googleOidcEnabled); if (googleClientId !== undefined) data.googleClientId = googleClientId || null; if (googleClientSecret !== undefined) data.googleClientSecret = googleClientSecret || null; + if (aiTaggingEnabled !== undefined) data.aiTaggingEnabled = Boolean(aiTaggingEnabled); + if (aiProvider !== undefined) data.aiProvider = ["claude", "odysseus"].includes(aiProvider) ? aiProvider : "claude"; + if (aiModel !== undefined) data.aiModel = aiModel || null; + if (anthropicApiKey !== undefined) data.anthropicApiKey = anthropicApiKey || null; + if (odysseusBaseUrl !== undefined) data.odysseusBaseUrl = odysseusBaseUrl || null; + if (odysseusApiKey !== undefined) data.odysseusApiKey = odysseusApiKey || null; await prisma.settings.update({ where: { id: "singleton" }, data }); res.json({ ok: true, note: "Port and JWT secret changes take effect after restart" }); diff --git a/backend/src/routes/transactions.js b/backend/src/routes/transactions.js index cb93c48..93137df 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, tags: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }, +}; // Balance delta for a transaction relative to its accountId function delta(type, amount) { @@ -15,7 +23,8 @@ router.get("/", async (req, res) => { const { accountId, categoryId, type, from, to, search, page = 1, limit = 50 } = req.query; const where = {}; - if (accountId) where.OR = [{ accountId }, { toAccountId: accountId }]; + const and = []; + if (accountId) and.push({ OR: [{ accountId }, { toAccountId: accountId }] }); if (categoryId) where.categoryId = categoryId; if (type) where.type = type; if (from || to) { @@ -23,7 +32,16 @@ router.get("/", async (req, res) => { if (from) where.date.gte = new Date(from); if (to) where.date.lte = new Date(to); } - if (search) where.description = { contains: search }; + // Search matches the description OR any AI-generated attachment tag. + if (search) { + and.push({ + OR: [ + { description: { contains: search } }, + { attachments: { some: { tags: { contains: search } } } }, + ], + }); + } + if (and.length) where.AND = and; const [transactions, total] = await Promise.all([ prisma.transaction.findMany({ diff --git a/backend/src/services/aiTagging.js b/backend/src/services/aiTagging.js new file mode 100644 index 0000000..c0e8d17 --- /dev/null +++ b/backend/src/services/aiTagging.js @@ -0,0 +1,137 @@ +const fs = require("fs"); +const path = require("path"); +const Anthropic = require("@anthropic-ai/sdk"); +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +const UPLOAD_DIR = path.join(__dirname, "../../uploads/attachments"); + +// Image mime types Claude's vision accepts. Anything else (incl. PDF) is handled separately. +const CLAUDE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]); + +const PROMPT = + "You are a finance assistant tagging a receipt or invoice so the user can search for it later. " + + "Read the document and produce 3 to 8 short, lowercase search tags: the merchant or store name, " + + "the spending category (e.g. groceries, fuel, software, restaurant), and a few notable line items or keywords. " + + 'Respond with ONLY a JSON array of strings and nothing else, e.g. ["albert heijn","groceries","coffee"].'; + +// Load and normalize AI config from the Settings singleton (env var is a fallback for the Anthropic key). +async function loadAiConfig() { + const s = await prisma.settings.findUnique({ where: { id: "singleton" } }); + if (!s || !s.aiTaggingEnabled) return null; + return { + enabled: true, + provider: s.aiProvider || "claude", + model: s.aiModel || null, + anthropicApiKey: s.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null, + odysseusBaseUrl: (s.odysseusBaseUrl || "http://localhost:7000").replace(/\/+$/, ""), + odysseusApiKey: s.odysseusApiKey || null, + }; +} + +// Turn arbitrary model output into a clean, deduped tag list. +function parseTags(text) { + if (!text) return []; + let raw = []; + const match = text.match(/\[[\s\S]*\]/); + if (match) { + try { raw = JSON.parse(match[0]); } catch { /* fall through */ } + } + if (!Array.isArray(raw) || raw.length === 0) { + raw = text.split(/[\n,]+/); + } + const seen = new Set(); + const tags = []; + for (const item of raw) { + const t = String(item).trim().replace(/^["'\-•*\d.\s]+/, "").replace(/["']+$/, "").toLowerCase().trim(); + if (t && t.length <= 40 && !seen.has(t)) { + seen.add(t); + tags.push(t); + } + if (tags.length >= 10) break; + } + return tags; +} + +async function tagWithClaude(buffer, mimeType, cfg) { + if (!cfg.anthropicApiKey) throw new Error("Anthropic API key is not configured"); + const client = new Anthropic({ apiKey: cfg.anthropicApiKey }); + const data = buffer.toString("base64"); + + let fileBlock; + if (CLAUDE_IMAGE_TYPES.has(mimeType)) { + fileBlock = { type: "image", source: { type: "base64", media_type: mimeType === "image/jpg" ? "image/jpeg" : mimeType, data } }; + } else if (mimeType === "application/pdf") { + fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data } }; + } else { + return []; // unsupported type for vision — nothing to read + } + + const response = await client.messages.create({ + model: cfg.model || "claude-haiku-4-5", + max_tokens: 300, + messages: [{ role: "user", content: [fileBlock, { type: "text", text: PROMPT }] }], + }); + + const text = (response.content || []) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + return parseTags(text); +} + +async function tagWithOdysseus(buffer, mimeType, filename, cfg) { + if (!cfg.odysseusApiKey) throw new Error("Odysseus API token is not configured"); + const auth = { Authorization: `Bearer ${cfg.odysseusApiKey}` }; + + // 1. Upload the file → get an attachment id + const form = new FormData(); + form.append("files", new Blob([buffer], { type: mimeType }), filename); + const upRes = await fetch(`${cfg.odysseusBaseUrl}/api/upload`, { method: "POST", headers: auth, body: form }); + if (!upRes.ok) throw new Error(`Odysseus upload failed (${upRes.status})`); + const upJson = await upRes.json(); + const fileId = upJson?.files?.[0]?.id; + if (!fileId) throw new Error("Odysseus upload returned no file id"); + + // 2. Create a chat session + const sessForm = new FormData(); + sessForm.append("name", "FinTrack tagging"); + if (cfg.model) sessForm.append("model", cfg.model); + const sessRes = await fetch(`${cfg.odysseusBaseUrl}/api/session`, { method: "POST", headers: auth, body: sessForm }); + if (!sessRes.ok) throw new Error(`Odysseus session failed (${sessRes.status})`); + const sessJson = await sessRes.json(); + const sessionId = sessJson?.id; + if (!sessionId) throw new Error("Odysseus session returned no id"); + + // 3. Ask for tags with the file attached + const chatRes = await fetch(`${cfg.odysseusBaseUrl}/api/chat`, { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ message: PROMPT, session: sessionId, attachments: [fileId] }), + }); + if (!chatRes.ok) throw new Error(`Odysseus chat failed (${chatRes.status})`); + const chatJson = await chatRes.json(); + return parseTags(chatJson?.response || ""); +} + +// Generate tags for a single file buffer. Returns string[] (possibly empty); never throws to the caller +// unless `config` is missing — callers should treat failures as "no tags". +async function generateTags({ buffer, mimeType, filename }, config) { + const cfg = config || (await loadAiConfig()); + if (!cfg) return []; + if (cfg.provider === "odysseus") return tagWithOdysseus(buffer, mimeType, filename, cfg); + return tagWithClaude(buffer, mimeType, cfg); +} + +// Best-effort tagging for a stored attachment row; writes tags back to the DB. Returns the tags written. +async function tagStoredAttachment(attachment, config) { + const filePath = path.join(UPLOAD_DIR, attachment.storagePath); + if (!fs.existsSync(filePath)) return []; + const buffer = fs.readFileSync(filePath); + const tags = await generateTags({ buffer, mimeType: attachment.mimeType, filename: attachment.filename }, config); + await prisma.attachment.update({ where: { id: attachment.id }, data: { tags: JSON.stringify(tags) } }); + return tags; +} + +module.exports = { loadAiConfig, generateTags, tagStoredAttachment, parseTags }; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 88699f4..a7bc358 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,6 +38,17 @@ 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}`), + retag: (transactionId) => api.post(`/transactions/${transactionId}/attachments/retag`).then((r) => r.data), +}; + 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 ( +
Loading…
; + + return ( + + ); +} + // ── Main Settings page ─────────────────────────────────────── const NAV_TABS = [ { id: "appearance", label: "Appearance", icon: "🎨" }, @@ -725,6 +843,7 @@ const NAV_TABS = [ { id: "users", label: "Users", icon: "👥", adminOnly: true }, { id: "server", label: "Server", icon: "⚙", adminOnly: true }, { id: "sso", label: "SSO", icon: "🔑", adminOnly: true }, + { id: "ai", label: "AI", icon: "🤖", adminOnly: true }, { id: "backups", label: "Backups", icon: "💾", adminOnly: true }, ]; @@ -825,6 +944,13 @@ export default function Settings() { > )} + {isAdmin && activeTab === "ai" && ( + <> +{data.total} transactions