diff --git a/modules/admin/actions/settings.ts b/modules/admin/actions/settings.ts index 96d800c..d526f5f 100644 --- a/modules/admin/actions/settings.ts +++ b/modules/admin/actions/settings.ts @@ -6,6 +6,7 @@ import { createClient as createSupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/server"; import { verifySession } from "@/lib/supabase/dal"; import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants"; +import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal"; export type SettingsState = { error: string } | { success: true; message?: string } | undefined; @@ -31,9 +32,10 @@ export async function updateOwnProfile(_prev: SettingsState, formData: FormData) const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId); if (error) return { error: "Name konnte nicht gespeichert werden." }; - await supabase.auth.updateUser({ data: { name: name.data } }); + const { error: authError } = await supabase.auth.updateUser({ data: { name: name.data } }); revalidatePath("/admin", "layout"); + if (authError) return { error: "Name konnte nicht vollständig gespeichert werden. Bitte erneut speichern." }; return { success: true, message: "Name gespeichert." }; } @@ -86,9 +88,7 @@ const TemplateSchema = z.object({ }); function parseDecimal(value: FormDataEntryValue | null) { - const trimmed = String(value ?? "").trim(); - if (trimmed === "") return Number.NaN; - return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed); + return parseDecimalInput(String(value ?? "")); } export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise { diff --git a/modules/admin/queries/dashboard.ts b/modules/admin/queries/dashboard.ts index 1ebeeb0..10e8732 100644 --- a/modules/admin/queries/dashboard.ts +++ b/modules/admin/queries/dashboard.ts @@ -81,7 +81,9 @@ export async function getDashboardData(): Promise { .limit(MAX_TASKS), supabase .from("documents") - .select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey(id, name, role)") + // !inner + Filter auf der Relation: nur Client-Uploads, bevor das Limit greift + .select("id, client_id, folder_id, created_at, folders(name), uploader:profiles!documents_uploaded_by_fkey!inner(id, name, role)") + .eq("uploader.role", "client") .order("created_at", { ascending: false }) .limit(RECENT_UPLOAD_ROWS), ]) diff --git a/modules/admin/ui/components/settings-forms.tsx b/modules/admin/ui/components/settings-forms.tsx index f89202c..26b056e 100644 --- a/modules/admin/ui/components/settings-forms.tsx +++ b/modules/admin/ui/components/settings-forms.tsx @@ -233,17 +233,23 @@ function DeleteTemplateDialog({ template }: { template: AdminProductTemplate }) function ActiveToggle({ template }: { template: AdminProductTemplate }) { const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function handleToggle() { + setError(null); + startTransition(async () => { + const result = await setProductTemplateActive(template.id, !template.isActive); + if (result && "error" in result) setError(result.error); + }); + } return ( - + + {error && {error}} + + ); } diff --git a/modules/shared/actions/documents.ts b/modules/shared/actions/documents.ts index a7c4496..a57ba61 100644 --- a/modules/shared/actions/documents.ts +++ b/modules/shared/actions/documents.ts @@ -128,6 +128,18 @@ const FinalizeSchema = z .min(1) .max(MAX_FILES_PER_UPLOAD); +// Nur Dateien ohne Dokument-Datensatz entfernen – sonst könnte ein Aufruf mit dem Pfad einer +// bestehenden Datei (Konflikt beim Insert) genau diese Datei löschen. Ohne Gewissheit: nichts löschen. +async function removeOrphanedObjects(paths: string[]) { + const supabase = await createClient(); + const { data: existing, error } = await supabase.from("documents").select("storage_path").in("storage_path", paths); + if (error) return; + + const referenced = new Set((existing ?? []).map((row) => row.storage_path as string)); + const orphaned = paths.filter((path) => !referenced.has(path)); + if (orphaned.length > 0) await supabase.storage.from(DOCUMENTS_BUCKET).remove(orphaned); +} + // Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen. export async function finalizeUpload( profileId: string, @@ -148,21 +160,32 @@ export async function finalizeUpload( const session = await verifySession(); const supabase = await createClient(); + + // Datei muss wirklich im Storage liegen; Größe und Typ kommen von dort, nicht aus dem Request. + const stored = await Promise.all( + parsed.data.map(async (upload) => { + const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).info(upload.path); + return error || !data ? null : { upload, size: data.size ?? null, contentType: data.contentType ?? null }; + }) + ); + const verified = stored.filter((entry): entry is NonNullable => entry !== null); + if (verified.length !== parsed.data.length) return { error: "Mindestens eine Datei wurde nicht vollständig hochgeladen." }; + const { error } = await supabase.from("documents").insert( - parsed.data.map((upload) => ({ - id: upload.documentId, + verified.map((entry) => ({ + id: entry.upload.documentId, client_id: access.clientId, folder_id: folderId, - name: upload.name, - storage_path: upload.path, - mime_type: upload.type || null, - size_bytes: upload.size, + name: entry.upload.name, + storage_path: entry.upload.path, + mime_type: entry.contentType, + size_bytes: entry.size, uploaded_by: session.userId, })) ); if (error) { - await supabase.storage.from(DOCUMENTS_BUCKET).remove(parsed.data.map((upload) => upload.path)); + await removeOrphanedObjects(parsed.data.map((upload) => upload.path)); return { error: "Dateien konnten nicht gespeichert werden." }; } @@ -218,8 +241,9 @@ export async function deleteDocument(profileId: string, documentId: string): Pro if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." }; - await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]); + const { error: storageError } = await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]); revalidateDocuments(profileId); + if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." }; return { success: true }; } diff --git a/modules/shared/lib/parse-decimal.ts b/modules/shared/lib/parse-decimal.ts new file mode 100644 index 0000000..71d7205 --- /dev/null +++ b/modules/shared/lib/parse-decimal.ts @@ -0,0 +1,11 @@ +// Deutsches Format mit Tausenderpunkten ("1.000", "1.234,50") oder schlicht ("12,5", "12.50"). +// Mehrdeutiges wie "1.5,00" oder mehr als 2 Nachkommastellen ergibt NaN statt still falsch zu rechnen. +const GROUPED = /^\d{1,3}(\.\d{3})*(,\d{1,2})?$/; +const PLAIN = /^\d+([.,]\d{1,2})?$/; + +export function parseDecimalInput(value: string) { + const trimmed = value.trim(); + if (GROUPED.test(trimmed)) return Number(trimmed.replace(/\./g, "").replace(",", ".")); + if (PLAIN.test(trimmed)) return Number(trimmed.replace(",", ".")); + return Number.NaN; +} diff --git a/modules/shared/queries/documents.ts b/modules/shared/queries/documents.ts index c354f2c..910a627 100644 --- a/modules/shared/queries/documents.ts +++ b/modules/shared/queries/documents.ts @@ -1,6 +1,6 @@ import "server-only" import { createClient } from "@/lib/supabase/server" -import { DOCUMENTS_BUCKET, canPreview, fileKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" +import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" const THUMBNAIL_TTL_SECONDS = 60 * 60 @@ -47,10 +47,7 @@ export async function getDocuments(clientId: string, folderId: string): Promise< if (error) throw new Error("Dokumente konnten nicht geladen werden.") const imagePaths = data - .filter((row) => { - const kind = fileKind(row.name, row.mime_type) - return kind === "image" && canPreview(kind, row.name) - }) + .filter((row) => previewKind(row.mime_type) === "image") .map((row) => row.storage_path as string) const thumbnails = new Map() diff --git a/modules/shared/schemas/documents.ts b/modules/shared/schemas/documents.ts index ddc167d..63f4746 100644 --- a/modules/shared/schemas/documents.ts +++ b/modules/shared/schemas/documents.ts @@ -44,10 +44,32 @@ export function fileKind(name: string, mimeType: string | null): FileKind { return "other"; } -// SVG wird bewusst nicht inline angezeigt (kann Skripte enthalten), nur heruntergeladen. -export function canPreview(kind: FileKind, name: string) { - if (kind === "image") return !name.toLowerCase().endsWith(".svg"); - return kind === "pdf" || kind === "text" || kind === "video" || kind === "audio"; +export type PreviewKind = "image" | "pdf" | "text" | "video" | "audio"; + +// Vorschau richtet sich nach dem Content-Type, mit dem der Storage die Datei ausliefert – nicht nach der Endung. +// So landet z. B. ein als ".pdf" benanntes HTML nie im Iframe. SVG (kann Skripte enthalten) nur als Download. +const PREVIEW_TYPES: Record = { + "image/png": "image", + "image/jpeg": "image", + "image/gif": "image", + "image/webp": "image", + "application/pdf": "pdf", + "text/plain": "text", + "text/markdown": "text", + "text/csv": "text", + "application/json": "text", + "video/mp4": "video", + "video/webm": "video", + "video/quicktime": "video", + "audio/mpeg": "audio", + "audio/wav": "audio", + "audio/mp4": "audio", + "audio/x-m4a": "audio", +}; + +export function previewKind(mimeType: string | null): PreviewKind | null { + if (!mimeType) return null; + return PREVIEW_TYPES[mimeType.split(";")[0].trim().toLowerCase()] ?? null; } export function formatFileSize(bytes: number | null) { diff --git a/modules/shared/schemas/invoice.ts b/modules/shared/schemas/invoice.ts index c3e21f3..08e2b16 100644 --- a/modules/shared/schemas/invoice.ts +++ b/modules/shared/schemas/invoice.ts @@ -6,7 +6,10 @@ export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]; const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Ungültiges Datum." }); // Grenzen und Nachkommastellen passend zu invoice_items: quantity numeric(10,2), unit_price numeric(12,2), tax_rate numeric(5,2) -const twoDecimals = (label: string) => z.number().multipleOf(0.01, { error: `${label}: maximal 2 Nachkommastellen.` }); +const twoDecimals = (label: string) => + z + .number({ error: `${label}: bitte eine gültige Zahl eingeben (z. B. 1.250,00).` }) + .multipleOf(0.01, { error: `${label}: maximal 2 Nachkommastellen.` }); export const InvoiceItemSchema = z.object({ productTemplateId: z.string().uuid().nullable(), diff --git a/modules/shared/ui/components/billing-dialog.tsx b/modules/shared/ui/components/billing-dialog.tsx index 208207e..fc5af82 100644 --- a/modules/shared/ui/components/billing-dialog.tsx +++ b/modules/shared/ui/components/billing-dialog.tsx @@ -14,6 +14,7 @@ import { type ProductTemplate, } from "@/modules/shared/schemas/invoice"; import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math"; +import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal"; export type BillingPrefill = { companyName: string; @@ -43,11 +44,7 @@ function emptyRow(): ItemRow { return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" }; } -function parseDecimal(value: string) { - const trimmed = value.trim(); - const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed; - return normalized === "" ? Number.NaN : Number(normalized); -} +const parseDecimal = parseDecimalInput; function formatInputNumber(value: number) { return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false }); diff --git a/modules/shared/ui/components/documents-browser.tsx b/modules/shared/ui/components/documents-browser.tsx index 50ee27b..dba1b8c 100644 --- a/modules/shared/ui/components/documents-browser.tsx +++ b/modules/shared/ui/components/documents-browser.tsx @@ -12,8 +12,8 @@ import { DOCUMENTS_BUCKET, MAX_FILES_PER_UPLOAD, MAX_FILE_BYTES, - canPreview, fileKind, + previewKind, formatFileSize, type DocumentItem, } from "@/modules/shared/schemas/documents"; @@ -58,7 +58,7 @@ async function runLimited(items: T[], limit: number, worker: (item: T) => Pro } function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) { - const kind = fileKind(doc.name, doc.mimeType); + const kind = previewKind(doc.mimeType); const [text, setText] = useState(null); useEffect(() => { @@ -321,7 +321,7 @@ export function DocumentsBrowser({ } function openDocument(doc: DocumentItem) { - if (canPreview(fileKind(doc.name, doc.mimeType), doc.name)) setPreview(doc); + if (previewKind(doc.mimeType)) setPreview(doc); else handleDownload([doc.id]); }