diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8e37bba --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,130 @@ +# Copilot Instructions — Annas Rechnungsmanager + +German accounting & invoice management system for tax consultants (Steuerberater), built with React Router v7 (SSR), Prisma, MariaDB, and Tailwind CSS v4. + +--- + +## Commands + +```bash +npm run dev # Dev server at http://localhost:5173 +npm run build # Production build +npm run typecheck # react-router typegen + tsc (run before every commit) +npm run lint # ESLint + +npm run db:migrate # prisma migrate dev (create + apply migration) +npm run db:seed # Seed database with sample data +npm run db:studio # Prisma Studio GUI + +npm run setup-admin # Create initial admin user +npm run reset-password +``` + +**Required `.env` variables:** +```env +DATABASE_URL="mysql://root:password@localhost:3306/annas_rechnungsmanager" +AUTH_SECRET="" # NOT SESSION_SECRET +NODE_ENV="development" +``` + +--- + +## Architecture + +### Route Layout + +Routes are **explicitly configured** in `app/routes.ts` (not auto-discovered by file name). Two layout wrappers exist: +- `dashboard-layout.tsx` — wraps all authenticated company/user routes +- `admin-layout.tsx` — wraps `/admin/*` (requires ADMIN role) + +**UI routes** (`app/routes/companies.*.tsx`, etc.) export `loader` + default component. +**API routes** (`app/routes/api.*.ts`) export only `action` (no default export, no loader). + +### Path Alias + +`@/` resolves to `app/`. Use it everywhere: `import prisma from "@/lib/prisma.server"`. + +### Data Flow Pattern + +``` +UI Route (loader/action) + → app/lib/ (business logic + Prisma queries) + → prisma.server.ts (single Prisma Client instance) + → AuditLog (mandatory on every write) +``` + +Auth helpers live in `app/session.server.ts`: +- `requireUser(request)` — throws redirect to `/login` if not authenticated +- `requireAdmin(request)` — throws redirect to `/` if not ADMIN +- `getApiUser(request)` — returns user or null (for API routes) + +--- + +## Key Conventions + +### Input Validation + +All Zod schemas live in `app/lib/schemas.ts`. Reusable validators (`currencySchema`, `taxRateSchema`, `ibanSchema`, `vatIdSchema`) are defined there — import and extend them, don't redefine. + +API routes must `safeParse` the request body before any DB access: +```ts +const parsed = mySchema.safeParse(await request.json()); +if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); +``` + +### Tax Calculations (Never Trust the Client) + +Always **recalculate amounts server-side** using `app/lib/tax.ts`: +- `calcItemAmounts(quantity, unitPrice, taxRate)` — standard MwSt. +- `calcItemAmountsKleinunternehmer(quantity, unitPrice)` — §19 UStG, no VAT +- `calcInvoiceTotals(items)` — sums net/tax/gross + +Valid tax rates: `0`, `7`, `19` (enforced by `taxRateSchema`). + +The `Company.kleinunternehmer` flag controls which calculation is used — always read it from the DB, not from the request body. + +### Audit Logging (Mandatory on Every Write) + +Every mutation must call `log()` from `app/lib/logger.server.ts`. The `action` parameter must use the `LogAction` union type defined in that file: +```ts +await log({ userId: user.id, action: "CREATE_INVOICE", entity: "Invoice", entityId: invoice.id, metadata: {...}, request }); +``` +Logging failures are swallowed intentionally — never let them break the main operation. + +### Database Rules + +- All Prisma queries belong in `app/lib/`, not in routes or components. +- Use `prisma.$transaction()` for multi-step operations (e.g., creating an invoice + a Buchung entry). +- Invoice amounts are stored as both net/tax/gross (`Decimal(10,2)`) — always persist all three. +- Soft-delete pattern: invoices use `deletedAt` field; companies use `archived`/`archivedAt`. +- When an invoice is marked PAID, a linked `Buchung` record is created automatically. + +### Domain Vocabulary (German ↔ Code) + +| German | Code / Table | +|--------|--------------| +| Mandant | `Company` model | +| Buchung | `Buchung` model (transaction/ledger entry) | +| Anlagegut | `Anlagegut` model (fixed asset) | +| Ausgabe | expense (type `ENTNAHME` in `Buchung`) | +| Einnahme | revenue (type `EINLAGE` in `Buchung`) | +| Kleinunternehmer | §19 UStG — no VAT on invoices | +| AFA | Absetzung für Abnutzung — depreciation, computed in `app/lib/afa.ts` | + +### Sessions + +Session cookie name: `__session`, expires after **4 hours**, stored via `createCookieSessionStorage`. Keys stored in session: `userId`, `userName`, `userRole`. + +--- + +## High-Impact Files + +Changes to these files have wide blast radius — check dependents carefully: + +| File | Why it matters | +|------|----------------| +| `app/session.server.ts` | Auth used by every protected route | +| `app/lib/tax.ts` | Tax calculations used in invoices, reports, exports | +| `app/lib/afa.ts` | Depreciation logic used in reports and asset management | +| `prisma/schema.prisma` | Any change requires a migration | +| `app/routes.ts` | Route config — adding routes here is required, not automatic | diff --git a/.github/copilot/mcp.json b/.github/copilot/mcp.json new file mode 100644 index 0000000..514a668 --- /dev/null +++ b/.github/copilot/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--browser", "chromium"] + } + } +} diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..652a4c8 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,33 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index 8292551..854ac21 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ next-env.d.ts /db/data /graphify-out + +# Uploaded Belege (persistent volume in production) +data/documents/ diff --git a/app/lib/logger.server.ts b/app/lib/logger.server.ts index 32fbf24..60bcd51 100644 --- a/app/lib/logger.server.ts +++ b/app/lib/logger.server.ts @@ -21,7 +21,9 @@ export type LogAction = | "DELETE_CUSTOMER" | "CREATE_SERVICE" | "UPDATE_SERVICE" - | "DELETE_SERVICE"; + | "DELETE_SERVICE" + | "UPLOAD_BELEG" + | "DELETE_BELEG"; export async function log({ userId, diff --git a/app/routes.ts b/app/routes.ts index 172ff2f..05fb5ad 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -59,6 +59,8 @@ export default [ route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"), route("api/einnahmen", "routes/api.einnahmen.ts"), route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"), + route("api/einnahmen/:id/upload", "routes/api.einnahmen.$id.upload.ts"), + route("api/beleg/:userId/:filename", "routes/api.beleg.$userId.$filename.ts"), route("api/companies/:id/buchungkategorien", "routes/api.companies.$id.buchungkategorien.ts"), route("api/companies/:id/buchungkategorien/:katId", "routes/api.companies.$id.buchungkategorien.$katId.ts"), route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"), diff --git a/app/routes/api.beleg.$userId.$filename.ts b/app/routes/api.beleg.$userId.$filename.ts new file mode 100644 index 0000000..7ccf1e1 --- /dev/null +++ b/app/routes/api.beleg.$userId.$filename.ts @@ -0,0 +1,57 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve, extname } from "node:path"; +import { requireUser } from "@/session.server"; + +const MIME: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".pdf": "application/pdf", +}; + +function storageRoot(): string { + return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents"); +} + +export async function loader({ + request, + params, +}: { + request: Request; + params: { userId: string; filename: string }; +}) { + const user = await requireUser(request); + + // Users may only access their own documents + if (params.userId !== user.id) { + throw new Response("Forbidden", { status: 403 }); + } + + // Prevent path traversal + const root = storageRoot(); + const filePath = join(root, params.userId, params.filename); + if (!filePath.startsWith(root)) { + throw new Response("Forbidden", { status: 403 }); + } + + let data: Buffer; + try { + data = await readFile(filePath); + } catch { + throw new Response("Not Found", { status: 404 }); + } + + const ext = extname(params.filename).toLowerCase(); + const contentType = MIME[ext] ?? "application/octet-stream"; + const disposition = contentType === "application/pdf" ? "inline" : "inline"; + + return new Response(new Uint8Array(data), { + headers: { + "Content-Type": contentType, + "Content-Disposition": `${disposition}; filename="${params.filename}"`, + "Cache-Control": "private, max-age=3600", + }, + }); +} diff --git a/app/routes/api.einnahmen.$id.ts b/app/routes/api.einnahmen.$id.ts index 05d03d0..e598831 100644 --- a/app/routes/api.einnahmen.$id.ts +++ b/app/routes/api.einnahmen.$id.ts @@ -9,6 +9,7 @@ const updateSchema = z.object({ zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), datum: z.string().min(1), beschreibung: z.string().optional(), + belegUrl: z.string().optional(), }); /** @@ -48,6 +49,7 @@ export async function action({ request, params }: { request: Request; params: { account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK", date: new Date(parsed.data.datum), description: parsed.data.beschreibung, + belegUrl: parsed.data.belegUrl || null, }, }); diff --git a/app/routes/api.einnahmen.$id.upload.ts b/app/routes/api.einnahmen.$id.upload.ts new file mode 100644 index 0000000..bef30a8 --- /dev/null +++ b/app/routes/api.einnahmen.$id.upload.ts @@ -0,0 +1,118 @@ +import { getApiUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { log } from "@/lib/logger.server"; +import { writeFile, mkdir, unlink } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB +const ALLOWED_MIME: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "application/pdf": "pdf", +}; + +/** Absolute path to the document storage root (configurable via env). */ +function storageRoot(): string { + return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents"); +} + +/** belegUrl format stored in DB: "beleg:{userId}/{filename}" */ +function parseBelegPath(belegUrl: string | null): string | null { + if (!belegUrl?.startsWith("beleg:")) return null; + return belegUrl.slice("beleg:".length); // "{userId}/{filename}" +} + +async function removeUploadedFile(belegUrl: string | null) { + const rel = parseBelegPath(belegUrl); + if (!rel) return; + try { + await unlink(join(storageRoot(), rel)); + } catch { + // File may already be gone + } +} + +export async function action({ + request, + params, +}: { + request: Request; + params: { id: string }; +}) { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const buchung = await prisma.buchung.findFirst({ + where: { id: params.id, company: { userId: user.id }, isBusinessRecord: true }, + select: { id: true, companyId: true, belegUrl: true }, + }); + if (!buchung) return Response.json({ error: "Not found" }, { status: 404 }); + + // DELETE — remove beleg + if (request.method === "DELETE") { + await removeUploadedFile(buchung.belegUrl); + await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl: null } }); + await log({ + userId: user.id, + action: "DELETE_BELEG", + entity: "Buchung", + entityId: params.id, + request, + }); + return Response.json({ ok: true }); + } + + if (request.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + // POST — upload new beleg + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return Response.json({ error: "Ungültige Formulardaten" }, { status: 400 }); + } + + const file = formData.get("file"); + if (!(file instanceof File) || file.size === 0) { + return Response.json({ error: "Keine Datei angegeben" }, { status: 400 }); + } + if (file.size > MAX_SIZE_BYTES) { + return Response.json({ error: "Datei zu groß (max. 10 MB)" }, { status: 413 }); + } + const ext = ALLOWED_MIME[file.type]; + if (!ext) { + return Response.json( + { error: "Dateityp nicht erlaubt (erlaubt: PDF, JPG, PNG, WebP, GIF)" }, + { status: 415 } + ); + } + + // Replace old file if present + await removeUploadedFile(buchung.belegUrl); + + const safeName = `${buchung.id}-${Date.now()}.${ext}`; + const userDir = join(storageRoot(), user.id); + await mkdir(userDir, { recursive: true }); + await writeFile(join(userDir, safeName), Buffer.from(await file.arrayBuffer())); + + // Store as "beleg:{userId}/{storedName}|{originalName}" + // storedName is used for serving; originalName is preserved for display + const originalName = file.name; + const belegUrl = `beleg:${user.id}/${safeName}|${originalName}`; + await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl } }); + + await log({ + userId: user.id, + action: "UPLOAD_BELEG", + entity: "Buchung", + entityId: params.id, + metadata: { filename: file.name, size: file.size, type: file.type }, + request, + }); + + return Response.json({ belegUrl, originalName: file.name, size: file.size }); +} diff --git a/app/routes/api.einnahmen.ts b/app/routes/api.einnahmen.ts index 4d5cddf..c5fc10d 100644 --- a/app/routes/api.einnahmen.ts +++ b/app/routes/api.einnahmen.ts @@ -10,6 +10,7 @@ const createSchema = z.object({ zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), datum: z.string().min(1), beschreibung: z.string().optional(), + belegUrl: z.string().optional(), }); /** @@ -105,6 +106,7 @@ export async function action({ request }: { request: Request }) { steuersatz: parsed.data.steuersatz, zahlungsart: parsed.data.zahlungsart, isBusinessRecord: true, + belegUrl: parsed.data.belegUrl || null, }, }); diff --git a/app/routes/companies.$id.buchhaltung.einnahmen.tsx b/app/routes/companies.$id.buchhaltung.einnahmen.tsx index 51371f6..7e1a6b2 100644 --- a/app/routes/companies.$id.buchhaltung.einnahmen.tsx +++ b/app/routes/companies.$id.buchhaltung.einnahmen.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useRef, useCallback } from "react"; import { Link, useLoaderData, useRevalidator } from "react-router"; import { requireUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; @@ -6,10 +6,32 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react"; +import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark, List, LayoutGrid, Paperclip, Upload, X, FileText } from "lucide-react"; import { formatCurrency } from "@/lib/tax"; import { DEFAULT_EINNAHME_KATEGORIEN } from "@/lib/kategorie-defaults"; +/** Converts stored belegUrl ("beleg:{userId}/{storedName}|{originalName}") to a viewable href. */ +function belegHref(belegUrl: string | null): string | null { + if (!belegUrl) return null; + if (belegUrl.startsWith("beleg:")) { + const rel = belegUrl.slice("beleg:".length); // "userId/storedName|originalName" + const [userId, rest] = rel.split("/"); + const storedName = rest?.split("|")[0]; // strip "|originalName" if present + return `/api/beleg/${userId}/${storedName}`; + } + return belegUrl; // fallback for legacy http(s) URLs +} + +/** Extracts a human-readable display name from a stored belegUrl. */ +function belegDisplayName(belegUrl: string): string { + if (belegUrl.startsWith("beleg:")) { + const rest = belegUrl.split("/").pop() ?? "Beleg"; // "storedName|originalName" or just "storedName" + const parts = rest.split("|"); + return parts.length > 1 ? parts.slice(1).join("|") : parts[0]; // prefer originalName + } + return belegUrl.split("/").pop() ?? "Beleg"; +} + export const handle = { breadcrumbs: (data: { companyId: string; companyName: string }) => [ { label: "Mandanten", href: "/companies" }, @@ -35,6 +57,7 @@ interface Einnahme { zahlungsart: "KASSE" | "BANK"; datum: string; beschreibung: string | null; + belegUrl: string | null; } const emptyForm = { @@ -44,6 +67,7 @@ const emptyForm = { zahlungsart: "BANK" as "KASSE" | "BANK", datum: new Date().toISOString().slice(0, 10), beschreibung: "", + belegUrl: "", }; export async function loader({ request, params }: { request: Request; params: { id: string } }) { @@ -99,6 +123,7 @@ export async function loader({ request, params }: { request: Request; params: { zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK", datum: e.date.toISOString(), beschreibung: e.description, + belegUrl: e.belegUrl ?? null, })), }; } @@ -113,6 +138,47 @@ export default function EinnahmenPage() { const [loadingYear, setLoadingYear] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(null); + const [view, setView] = useState<"pivot" | "liste">("liste"); + + // File upload state + const [pendingFile, setPendingFile] = useState(null); + const [uploadingBeleg, setUploadingBeleg] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + // Quick-upload from list view (without opening dialog) + const [quickUploadId, setQuickUploadId] = useState(null); + + const handleFileDrop = useCallback((file: File) => { + const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif", "application/pdf"]; + if (!allowed.includes(file.type)) { + setUploadError("Nur PDF, JPG, PNG, WebP oder GIF erlaubt."); + return; + } + if (file.size > 10 * 1024 * 1024) { + setUploadError("Datei zu groß (max. 10 MB)."); + return; + } + setUploadError(null); + setPendingFile(file); + }, []); + + async function handleQuickUpload(buchungId: string, file: File) { + const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif", "application/pdf"]; + if (!allowed.includes(file.type) || file.size > 10 * 1024 * 1024) return; + setQuickUploadId(buchungId); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`/api/einnahmen/${buchungId}/upload`, { method: "POST", body: fd }); + if (res.ok) { + await loadYear(year); + revalidate(); + } + } finally { + setQuickUploadId(null); + } + } const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); @@ -134,18 +200,23 @@ export default function EinnahmenPage() { zahlungsart: ((e.zahlungsart as string) || "BANK") as "KASSE" | "BANK", datum: e.date as string, beschreibung: (e.description as string | null) ?? null, + belegUrl: (e.belegUrl as string | null) ?? null, }))); setLoadingYear(false); } function openCreate() { setEditingId(null); + setPendingFile(null); + setUploadError(null); setForm({ ...emptyForm, datum: `${year}-01-01`, kategorie: kategorien[0] ?? "" }); setDialogOpen(true); } function openEdit(e: Einnahme) { setEditingId(e.id); + setPendingFile(null); + setUploadError(null); setForm({ kategorie: e.kategorie, betrag: String(e.betrag), @@ -153,12 +224,14 @@ export default function EinnahmenPage() { zahlungsart: e.zahlungsart, datum: e.datum.slice(0, 10), beschreibung: e.beschreibung ?? "", + belegUrl: e.belegUrl ?? "", }); setDialogOpen(true); } async function handleSave() { setSaving(true); + setUploadError(null); const payload = { kategorie: form.kategorie, betrag: parseFloat(form.betrag), @@ -166,29 +239,59 @@ export default function EinnahmenPage() { zahlungsart: form.zahlungsart, datum: form.datum, beschreibung: form.beschreibung || undefined, + belegUrl: form.belegUrl || undefined, }; try { + let savedId: string; if (editingId) { - await fetch(`/api/einnahmen/${editingId}`, { + const res = await fetch(`/api/einnahmen/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (!res.ok) throw new Error("Speichern fehlgeschlagen."); + savedId = editingId; } else { - await fetch("/api/einnahmen", { + const res = await fetch("/api/einnahmen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, companyId }), }); + if (!res.ok) throw new Error("Erstellen fehlgeschlagen."); + const created = await res.json() as { id?: string }; + savedId = created.id ?? ""; } + + // Upload pending file after entry is saved + if (pendingFile && savedId) { + setUploadingBeleg(true); + const fd = new FormData(); + fd.append("file", pendingFile); + const upRes = await fetch(`/api/einnahmen/${savedId}/upload`, { method: "POST", body: fd }); + if (!upRes.ok) { + const err = await upRes.json().catch(() => ({ error: "Upload fehlgeschlagen." })) as { error?: string }; + throw new Error(err.error ?? "Upload fehlgeschlagen."); + } + setPendingFile(null); + } + setDialogOpen(false); await loadYear(year); revalidate(); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Unbekannter Fehler."); } finally { setSaving(false); + setUploadingBeleg(false); } } + async function handleDeleteBeleg(id: string) { + await fetch(`/api/einnahmen/${id}/upload`, { method: "DELETE" }); + await loadYear(year); + revalidate(); + } + async function handleDelete(id: string) { if (!confirm("Eintrag wirklich löschen?")) return; setDeleting(id); @@ -243,6 +346,33 @@ export default function EinnahmenPage() {

{companyName} · {year}

+ {/* View toggle */} +
+ + +
{ + const file = ev.target.files?.[0]; + if (file) handleQuickUpload(e.id, file); + ev.target.value = ""; + }} + /> + + )} + + +
+ + +
+ + + ); + })} + + + + + Gesamt ({einnahmen.length} Einträge) + + + {formatCurrency(gesamt)} + + + + {formatCurrency(gesamt - ustGesamt)} + + + + + +
+ ) : (
@@ -600,17 +871,110 @@ export default function EinnahmenPage() { className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" />
+ +
+ + + {/* Pending file preview */} + {pendingFile ? ( +
+ +
+

{pendingFile.name}

+

{(pendingFile.size / 1024).toFixed(0)} KB

+
+ +
+ ) : form.belegUrl ? ( + /* Existing uploaded beleg */ +
+ +
+ + {belegDisplayName(form.belegUrl)} + +

Vorhandener Beleg · neuen hochladen zum Ersetzen

+
+ +
+ ) : null} + + {/* Drag & drop zone — always shown so user can replace/add */} + {!pendingFile && ( + + )} + + {uploadError && ( +

{uploadError}

+ )} + + { + const file = e.target.files?.[0]; + if (file) handleFileDrop(file); + e.target.value = ""; + }} + /> +
diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebc117e..9f92b61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,6 +115,7 @@ model Buchung { steuersatz Int? // Tax rate: 0, 7, 19 (nullable for non-business records) zahlungsart Zahlungsart? // KASSE or BANK isBusinessRecord Boolean @default(false) // True if this is from Einnahme/Ausgabe + belegUrl String? @db.Text // Optional receipt/document reference URL linkedBuchungId String? linkedBuchung Buchung? @relation("BuchungLink", fields: [linkedBuchungId], references: [id], onDelete: SetNull) linkedFrom Buchung[] @relation("BuchungLink")