see comments

This commit is contained in:
Rajpreet0 2026-09-29 16:37:48 +02:00
parent 4d42a172a5
commit bc9b0b925e
10 changed files with 102 additions and 40 deletions

View file

@ -6,6 +6,7 @@ import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { verifySession } from "@/lib/supabase/dal"; import { verifySession } from "@/lib/supabase/dal";
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants"; 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; 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); const { error } = await supabase.from("profiles").update({ name: name.data }).eq("id", session.userId);
if (error) return { error: "Name konnte nicht gespeichert werden." }; 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"); revalidatePath("/admin", "layout");
if (authError) return { error: "Name konnte nicht vollständig gespeichert werden. Bitte erneut speichern." };
return { success: true, message: "Name gespeichert." }; return { success: true, message: "Name gespeichert." };
} }
@ -86,9 +88,7 @@ const TemplateSchema = z.object({
}); });
function parseDecimal(value: FormDataEntryValue | null) { function parseDecimal(value: FormDataEntryValue | null) {
const trimmed = String(value ?? "").trim(); return parseDecimalInput(String(value ?? ""));
if (trimmed === "") return Number.NaN;
return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed);
} }
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> { export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {

View file

@ -81,7 +81,9 @@ export async function getDashboardData(): Promise<DashboardData> {
.limit(MAX_TASKS), .limit(MAX_TASKS),
supabase supabase
.from("documents") .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 }) .order("created_at", { ascending: false })
.limit(RECENT_UPLOAD_ROWS), .limit(RECENT_UPLOAD_ROWS),
]) ])

View file

@ -233,17 +233,23 @@ function DeleteTemplateDialog({ template }: { template: AdminProductTemplate })
function ActiveToggle({ template }: { template: AdminProductTemplate }) { function ActiveToggle({ template }: { template: AdminProductTemplate }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleToggle() {
setError(null);
startTransition(async () => {
const result = await setProductTemplateActive(template.id, !template.isActive);
if (result && "error" in result) setError(result.error);
});
}
return ( return (
<Button <span className="flex items-center gap-2">
type="button" {error && <span className="text-small text-destructive" role="alert">{error}</span>}
variant="ghost" <Button type="button" variant="ghost" size="sm" disabled={pending} onClick={handleToggle}>
size="sm" {template.isActive ? "Deaktivieren" : "Aktivieren"}
disabled={pending} </Button>
onClick={() => startTransition(async () => { await setProductTemplateActive(template.id, !template.isActive); })} </span>
>
{template.isActive ? "Deaktivieren" : "Aktivieren"}
</Button>
); );
} }

View file

@ -128,6 +128,18 @@ const FinalizeSchema = z
.min(1) .min(1)
.max(MAX_FILES_PER_UPLOAD); .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. // Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen.
export async function finalizeUpload( export async function finalizeUpload(
profileId: string, profileId: string,
@ -148,21 +160,32 @@ export async function finalizeUpload(
const session = await verifySession(); const session = await verifySession();
const supabase = await createClient(); 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<typeof entry> => 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( const { error } = await supabase.from("documents").insert(
parsed.data.map((upload) => ({ verified.map((entry) => ({
id: upload.documentId, id: entry.upload.documentId,
client_id: access.clientId, client_id: access.clientId,
folder_id: folderId, folder_id: folderId,
name: upload.name, name: entry.upload.name,
storage_path: upload.path, storage_path: entry.upload.path,
mime_type: upload.type || null, mime_type: entry.contentType,
size_bytes: upload.size, size_bytes: entry.size,
uploaded_by: session.userId, uploaded_by: session.userId,
})) }))
); );
if (error) { 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." }; 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." }; 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); revalidateDocuments(profileId);
if (storageError) return { error: "Eintrag gelöscht, die Datei selbst konnte aber nicht entfernt werden." };
return { success: true }; return { success: true };
} }

View file

@ -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;
}

View file

@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { createClient } from "@/lib/supabase/server" 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 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.") if (error) throw new Error("Dokumente konnten nicht geladen werden.")
const imagePaths = data const imagePaths = data
.filter((row) => { .filter((row) => previewKind(row.mime_type) === "image")
const kind = fileKind(row.name, row.mime_type)
return kind === "image" && canPreview(kind, row.name)
})
.map((row) => row.storage_path as string) .map((row) => row.storage_path as string)
const thumbnails = new Map<string, string>() const thumbnails = new Map<string, string>()

View file

@ -44,10 +44,32 @@ export function fileKind(name: string, mimeType: string | null): FileKind {
return "other"; return "other";
} }
// SVG wird bewusst nicht inline angezeigt (kann Skripte enthalten), nur heruntergeladen. export type PreviewKind = "image" | "pdf" | "text" | "video" | "audio";
export function canPreview(kind: FileKind, name: string) {
if (kind === "image") return !name.toLowerCase().endsWith(".svg"); // Vorschau richtet sich nach dem Content-Type, mit dem der Storage die Datei ausliefert – nicht nach der Endung.
return kind === "pdf" || kind === "text" || kind === "video" || kind === "audio"; // So landet z. B. ein als ".pdf" benanntes HTML nie im Iframe. SVG (kann Skripte enthalten) nur als Download.
const PREVIEW_TYPES: Record<string, PreviewKind> = {
"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) { export function formatFileSize(bytes: number | null) {

View file

@ -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." }); 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) // 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({ export const InvoiceItemSchema = z.object({
productTemplateId: z.string().uuid().nullable(), productTemplateId: z.string().uuid().nullable(),

View file

@ -14,6 +14,7 @@ import {
type ProductTemplate, type ProductTemplate,
} from "@/modules/shared/schemas/invoice"; } from "@/modules/shared/schemas/invoice";
import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math"; import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math";
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
export type BillingPrefill = { export type BillingPrefill = {
companyName: string; companyName: string;
@ -43,11 +44,7 @@ function emptyRow(): ItemRow {
return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" }; return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" };
} }
function parseDecimal(value: string) { const parseDecimal = parseDecimalInput;
const trimmed = value.trim();
const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed;
return normalized === "" ? Number.NaN : Number(normalized);
}
function formatInputNumber(value: number) { function formatInputNumber(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false }); return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });

View file

@ -12,8 +12,8 @@ import {
DOCUMENTS_BUCKET, DOCUMENTS_BUCKET,
MAX_FILES_PER_UPLOAD, MAX_FILES_PER_UPLOAD,
MAX_FILE_BYTES, MAX_FILE_BYTES,
canPreview,
fileKind, fileKind,
previewKind,
formatFileSize, formatFileSize,
type DocumentItem, type DocumentItem,
} from "@/modules/shared/schemas/documents"; } from "@/modules/shared/schemas/documents";
@ -58,7 +58,7 @@ async function runLimited<T>(items: T[], limit: number, worker: (item: T) => Pro
} }
function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) { function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) {
const kind = fileKind(doc.name, doc.mimeType); const kind = previewKind(doc.mimeType);
const [text, setText] = useState<string | null>(null); const [text, setText] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -321,7 +321,7 @@ export function DocumentsBrowser({
} }
function openDocument(doc: DocumentItem) { 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]); else handleDownload([doc.id]);
} }