From 4d42a172a585f9bdc3d0518ff11b31c4f50242a1 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 29 Sep 2026 16:15:28 +0200 Subject: [PATCH] added document uploading --- app/admin/clients/[id]/storage/page.tsx | 20 + app/admin/dashboard/page.tsx | 11 +- app/admin/settings/page.tsx | 12 + app/client/storage/page.tsx | 20 + .../20260930140000_documents_hardening.sql | 12 + modules/admin/actions/manage-client.ts | 90 ++++ modules/admin/actions/settings.ts | 155 ++++++ modules/admin/lib/constants.ts | 1 + modules/admin/queries/dashboard.ts | 227 +++++++++ modules/admin/queries/settings.ts | 31 ++ .../ui/components/client-actions-menu.tsx | 169 +++++++ modules/admin/ui/components/clients-table.tsx | 24 +- .../admin/ui/components/settings-forms.tsx | 286 +++++++++++ modules/admin/ui/views/dashboard-view.tsx | 202 ++++++++ modules/admin/ui/views/settings-view.tsx | 38 ++ modules/shared/actions/documents.ts | 225 +++++++++ modules/shared/queries/documents.ts | 74 +++ modules/shared/schemas/documents.ts | 79 +++ .../ui/components/documents-browser.tsx | 459 ++++++++++++++++++ modules/shared/ui/components/file-icon.tsx | 31 ++ .../shared/ui/components/folder-dialogs.tsx | 124 +++++ modules/shared/ui/views/documents-panel.tsx | 96 ++++ 22 files changed, 2369 insertions(+), 17 deletions(-) create mode 100644 app/admin/clients/[id]/storage/page.tsx create mode 100644 app/admin/settings/page.tsx create mode 100644 app/client/storage/page.tsx create mode 100644 lib/supabase/migrations/20260930140000_documents_hardening.sql create mode 100644 modules/admin/actions/manage-client.ts create mode 100644 modules/admin/actions/settings.ts create mode 100644 modules/admin/lib/constants.ts create mode 100644 modules/admin/queries/dashboard.ts create mode 100644 modules/admin/queries/settings.ts create mode 100644 modules/admin/ui/components/client-actions-menu.tsx create mode 100644 modules/admin/ui/components/settings-forms.tsx create mode 100644 modules/admin/ui/views/dashboard-view.tsx create mode 100644 modules/admin/ui/views/settings-view.tsx create mode 100644 modules/shared/actions/documents.ts create mode 100644 modules/shared/queries/documents.ts create mode 100644 modules/shared/schemas/documents.ts create mode 100644 modules/shared/ui/components/documents-browser.tsx create mode 100644 modules/shared/ui/components/file-icon.tsx create mode 100644 modules/shared/ui/components/folder-dialogs.tsx create mode 100644 modules/shared/ui/views/documents-panel.tsx diff --git a/app/admin/clients/[id]/storage/page.tsx b/app/admin/clients/[id]/storage/page.tsx new file mode 100644 index 0000000..a68515f --- /dev/null +++ b/app/admin/clients/[id]/storage/page.tsx @@ -0,0 +1,20 @@ +import { Params } from "@/modules/shared/types/user" +import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel" + +type SearchParams = Promise<{ folder?: string | string[] }> + +const StorageAdminPage = async ({ params, searchParams }: { params: Params; searchParams: SearchParams }) => { + const { id } = await params + const { folder } = await searchParams + + return ( + + ) +} + +export default StorageAdminPage diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 73a140f..cb05ed9 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -1,15 +1,12 @@ -import { verifySession } from "@/lib/supabase/dal" import { redirect } from "next/navigation"; +import { verifySession } from "@/lib/supabase/dal" +import DashboardView from "@/modules/admin/ui/views/dashboard-view"; const AdminDashboard = async () => { const session = await verifySession(); if (!session.isAdmin) redirect("/client/dashboard"); - return ( -
-

{session.email}

-
- ) + return } -export default AdminDashboard \ No newline at end of file +export default AdminDashboard diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..8146ac2 --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from "next/navigation" +import { verifySession } from "@/lib/supabase/dal" +import SettingsView from "@/modules/admin/ui/views/settings-view" + +const AdminSettings = async () => { + const session = await verifySession() + if (!session.isAdmin) redirect("/client/dashboard") + + return +} + +export default AdminSettings diff --git a/app/client/storage/page.tsx b/app/client/storage/page.tsx new file mode 100644 index 0000000..fac6de5 --- /dev/null +++ b/app/client/storage/page.tsx @@ -0,0 +1,20 @@ +import { verifySession } from "@/lib/supabase/dal" +import { DocumentsPanel } from "@/modules/shared/ui/views/documents-panel" + +type SearchParams = Promise<{ folder?: string | string[] }> + +const StoragePage = async ({ searchParams }: { searchParams: SearchParams }) => { + const session = await verifySession() + const { folder } = await searchParams + + return ( + + ) +} + +export default StoragePage diff --git a/lib/supabase/migrations/20260930140000_documents_hardening.sql b/lib/supabase/migrations/20260930140000_documents_hardening.sql new file mode 100644 index 0000000..94a5415 --- /dev/null +++ b/lib/supabase/migrations/20260930140000_documents_hardening.sql @@ -0,0 +1,12 @@ +-- Dokumente: Upload-Grenzen und Absicherung des Storage-Pfads + +-- Ein Dokument darf nur auf eine Datei im Storage-Ordner des eigenen Clients zeigen. +-- Sonst könnte ein Client einen Datensatz auf den Pfad eines anderen Clients anlegen. +alter table public.documents + add constraint documents_storage_path_matches_client + check (split_part(storage_path, '/', 1) = client_id::text); + +-- Max. 50 MB pro Datei (Supabase-Standardlimit im Free-Plan) +update storage.buckets + set file_size_limit = 52428800 + where id = 'client-documents'; diff --git a/modules/admin/actions/manage-client.ts b/modules/admin/actions/manage-client.ts new file mode 100644 index 0000000..bfa7bda --- /dev/null +++ b/modules/admin/actions/manage-client.ts @@ -0,0 +1,90 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { createClient } from "@/lib/supabase/server"; +import { verifySession } from "@/lib/supabase/dal"; +import { generatePassword } from "@/lib/generate-password"; +import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants"; + +export type ResetPasswordState = { error: string } | { success: true; password: string } | undefined; +export type UpdateClientState = { error: string } | { success: true } | undefined; + +const IdSchema = z.string().uuid(); + +// Nur Admins, und nur Konten mit Rolle "client" – Admin-Konten lassen sich hierüber nicht ändern. +async function assertClientTarget(profileId: string) { + const session = await verifySession(); + if (!session.isAdmin) return { error: "Keine Berechtigung." }; + if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." }; + + const supabase = await createClient(); + const { data } = await supabase.from("profiles").select("role").eq("id", profileId).maybeSingle(); + if (data?.role !== "client") return { error: "Client nicht gefunden." }; + + return null; +} + +const ResetSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("generate") }), + z.object({ + mode: z.literal("manual"), + password: z.string().min(MIN_PASSWORD_LENGTH, { error: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72), + }), +]); + +export async function resetClientPassword(_prev: ResetPasswordState, formData: FormData): Promise { + const profileId = String(formData.get("profileId") ?? ""); + const denied = await assertClientTarget(profileId); + if (denied) return denied; + + const parsed = ResetSchema.safeParse({ + mode: formData.get("mode"), + password: formData.get("password") ?? undefined, + }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + + const password = parsed.data.mode === "generate" ? generatePassword() : parsed.data.password; + + const admin = createAdminClient(); + const { error } = await admin.auth.admin.updateUserById(profileId, { password }); + if (error) return { error: "Passwort konnte nicht geändert werden." }; + + return { success: true, password }; +} + +const UpdateSchema = z.object({ + name: z.string().trim().min(3, { error: "Mindestens drei Zeichen eingeben." }), + email: z.email({ error: "Bitte eine gültige Email eingeben." }), +}); + +export async function updateClientAccount(_prev: UpdateClientState, formData: FormData): Promise { + const profileId = String(formData.get("profileId") ?? ""); + const denied = await assertClientTarget(profileId); + if (denied) return denied; + + const parsed = UpdateSchema.safeParse({ name: formData.get("name"), email: formData.get("email") }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + + const email = parsed.data.email.toLowerCase(); + + // Login-Email lebt in auth.users; ohne email_confirm würde Supabase erst eine Bestätigungsmail verlangen. + const admin = createAdminClient(); + const { error: authError } = await admin.auth.admin.updateUserById(profileId, { + email, + email_confirm: true, + user_metadata: { name: parsed.data.name }, + }); + if (authError) { + return { error: authError.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht aktualisiert werden." }; + } + + const supabase = await createClient(); + const { error } = await supabase.from("profiles").update({ name: parsed.data.name, email }).eq("id", profileId); + if (error) return { error: "Login-Email wurde geändert, das Profil aber nicht. Bitte erneut speichern." }; + + revalidatePath("/admin/clients"); + revalidatePath(`/admin/clients/${profileId}`, "layout"); + return { success: true }; +} diff --git a/modules/admin/actions/settings.ts b/modules/admin/actions/settings.ts new file mode 100644 index 0000000..96d800c --- /dev/null +++ b/modules/admin/actions/settings.ts @@ -0,0 +1,155 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +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"; + +export type SettingsState = { error: string } | { success: true; message?: string } | undefined; + +const IdSchema = z.string().uuid(); + +async function requireAdmin() { + const session = await verifySession(); + return session.isAdmin ? session : null; +} + +function firstIssue(error: z.ZodError) { + return error.issues[0]?.message ?? "Ungültige Eingabe."; +} + +export async function updateOwnProfile(_prev: SettingsState, formData: FormData): Promise { + const session = await requireAdmin(); + if (!session) return { error: "Keine Berechtigung." }; + + const name = z.string().trim().min(2, { error: "Mindestens zwei Zeichen eingeben." }).max(100).safeParse(formData.get("name")); + if (!name.success) return { error: firstIssue(name.error) }; + + const supabase = await createClient(); + 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 } }); + + revalidatePath("/admin", "layout"); + return { success: true, message: "Name gespeichert." }; +} + +const PasswordSchema = z + .object({ + current: z.string().min(1, { error: "Bitte das aktuelle Passwort eingeben." }), + next: z.string().min(MIN_PASSWORD_LENGTH, { error: `Das neue Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.` }).max(72), + confirm: z.string(), + }) + .refine((data) => data.next === data.confirm, { path: ["confirm"], error: "Die Passwörter stimmen nicht überein." }) + .refine((data) => data.next !== data.current, { path: ["next"], error: "Das neue Passwort muss sich vom alten unterscheiden." }); + +export async function changeOwnPassword(_prev: SettingsState, formData: FormData): Promise { + const session = await requireAdmin(); + if (!session) return { error: "Keine Berechtigung." }; + + const parsed = PasswordSchema.safeParse({ + current: formData.get("current"), + next: formData.get("next"), + confirm: formData.get("confirm"), + }); + if (!parsed.success) return { error: firstIssue(parsed.error) }; + + // Aktuelles Passwort mit einem separaten Client prüfen, damit die Session des Admins unberührt bleibt. + const verifier = createSupabaseClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + { auth: { persistSession: false, autoRefreshToken: false } } + ); + const { error: signInError } = await verifier.auth.signInWithPassword({ email: session.email, password: parsed.data.current }); + if (signInError) return { error: "Das aktuelle Passwort ist falsch." }; + await verifier.auth.signOut({ scope: "local" }); + + const supabase = await createClient(); + const { error } = await supabase.auth.updateUser({ password: parsed.data.next }); + if (error) return { error: "Passwort konnte nicht geändert werden." }; + + return { success: true, message: "Passwort geändert." }; +} + +const TemplateSchema = z.object({ + name: z.string().trim().min(1, { error: "Name ist erforderlich." }).max(200), + description: z + .string() + .trim() + .max(500) + .transform((value) => (value === "" ? null : value)), + unitPrice: z.number({ error: "Bitte einen gültigen Preis eingeben." }).min(0).lt(1e10).multipleOf(0.01, { error: "Preis: maximal 2 Nachkommastellen." }), + taxRate: z.number({ error: "Bitte eine gültige MwSt eingeben." }).min(0).max(100).multipleOf(0.01), +}); + +function parseDecimal(value: FormDataEntryValue | null) { + const trimmed = String(value ?? "").trim(); + if (trimmed === "") return Number.NaN; + return Number(trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed); +} + +export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." }; + + const parsed = TemplateSchema.safeParse({ + name: formData.get("name") ?? "", + description: formData.get("description") ?? "", + unitPrice: parseDecimal(formData.get("unitPrice")), + taxRate: parseDecimal(formData.get("taxRate")), + }); + if (!parsed.success) return { error: firstIssue(parsed.error) }; + + const values = { + name: parsed.data.name, + description: parsed.data.description, + unit_price: parsed.data.unitPrice, + tax_rate: parsed.data.taxRate, + }; + + const id = String(formData.get("id") ?? ""); + const supabase = await createClient(); + + if (id === "") { + const { error } = await supabase.from("product_templates").insert(values); + if (error) return { error: "Vorlage konnte nicht gespeichert werden." }; + } else { + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." }; + const { data, error } = await supabase.from("product_templates").update(values).eq("id", id).select("id"); + if (error || !data?.length) return { error: "Vorlage konnte nicht gespeichert werden." }; + } + + revalidatePath("/admin/settings"); + return { success: true }; +} + +export async function setProductTemplateActive(id: string, isActive: boolean): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." }; + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("product_templates") + .update({ is_active: Boolean(isActive) }) + .eq("id", id) + .select("id"); + if (error || !data?.length) return { error: "Vorlage konnte nicht geändert werden." }; + + revalidatePath("/admin/settings"); + return { success: true }; +} + +// Bestehende Rechnungen bleiben unverändert: invoice_items speichern Bezeichnung und Preis als Snapshot. +export async function deleteProductTemplate(id: string): Promise { + if (!(await requireAdmin())) return { error: "Keine Berechtigung." }; + if (!IdSchema.safeParse(id).success) return { error: "Ungültige Vorlage." }; + + const supabase = await createClient(); + const { data, error } = await supabase.from("product_templates").delete().eq("id", id).select("id"); + if (error || !data?.length) return { error: "Vorlage konnte nicht gelöscht werden." }; + + revalidatePath("/admin/settings"); + return { success: true }; +} diff --git a/modules/admin/lib/constants.ts b/modules/admin/lib/constants.ts new file mode 100644 index 0000000..2d2d61c --- /dev/null +++ b/modules/admin/lib/constants.ts @@ -0,0 +1 @@ +export const MIN_PASSWORD_LENGTH = 10; diff --git a/modules/admin/queries/dashboard.ts b/modules/admin/queries/dashboard.ts new file mode 100644 index 0000000..1ebeeb0 --- /dev/null +++ b/modules/admin/queries/dashboard.ts @@ -0,0 +1,227 @@ +import "server-only" +import Decimal from "decimal.js" +import { createClient } from "@/lib/supabase/server" +import { berlinToday } from "@/modules/shared/lib/info-format" +import { formatCurrency } from "@/modules/shared/lib/invoice-math" +import { PRODUCT_STATUS_LABELS, type ProductStatus } from "@/modules/shared/schemas/info" + +const RECENT_UPLOAD_ROWS = 100 +const MAX_UPLOAD_GROUPS = 8 +const MAX_TASKS = 8 + +export type AttentionItem = { + key: string + kind: "overdue" | "outage" | "maintenance" | "check" | "stammdaten" | "terms" + title: string + detail: string + href: string +} + +export type UploadGroup = { + key: string + clientName: string + uploaderName: string + folderName: string + count: number + latestAt: string + href: string +} + +export type DashboardTask = { + id: string + name: string + clientName: string + since: string + status: "in_progress" | "planned" + href: string +} + +export type DashboardData = { + openCount: number + openTotal: number + overdueCount: number + overdueTotal: number + paidThisMonthTotal: number + clientCount: number + clientsWithIssues: number + attention: AttentionItem[] + uploads: UploadGroup[] + tasks: DashboardTask[] +} + +function daysBetween(fromIso: string, toIso: string) { + return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +function sum(values: (number | string | null)[]) { + return values.reduce((total, value) => total.plus(new Decimal(value ?? 0)), new Decimal(0)).toNumber() +} + +// Admins lesen per RLS (is_admin) alle Clients – daher reicht der normale Server-Client. +export async function getDashboardData(): Promise { + const supabase = await createClient() + const today = berlinToday() + const currentMonth = today.slice(0, 7) + + const [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes] = await Promise.all([ + supabase.from("profiles").select("id, name, client_id, terms_accepted_at").eq("role", "client").order("name"), + supabase.from("clients").select("id, company_name"), + supabase + .from("invoices_overview") + .select("id, client_id, invoice_number, status, display_status, gross_total, due_date, paid_at") + .neq("status", "cancelled"), + supabase.from("client_products").select("id, client_id, name, status, status_message"), + supabase.from("product_check_summary").select("product_id, ok, error"), + supabase + .from("product_tasks") + .select("id, client_id, name, status, since") + .in("status", ["in_progress", "planned"]) + .order("status") + .order("since") + .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)") + .order("created_at", { ascending: false }) + .limit(RECENT_UPLOAD_ROWS), + ]) + + for (const result of [profilesRes, companiesRes, invoicesRes, productsRes, checksRes, tasksRes, documentsRes]) { + if (result.error) throw new Error("Dashboard konnte nicht geladen werden.") + } + + const profiles = profilesRes.data! + const companyById = new Map(companiesRes.data!.map((row) => [row.id as string, row.company_name as string])) + const profileByClient = new Map() + for (const profile of profiles) { + if (profile.client_id && !profileByClient.has(profile.client_id)) { + profileByClient.set(profile.client_id, { id: profile.id, name: profile.name }) + } + } + + const clientLabel = (clientId: string) => + companyById.get(clientId) ?? profileByClient.get(clientId)?.name ?? "Unbekannter Client" + const clientBase = (clientId: string) => { + const profile = profileByClient.get(clientId) + return profile ? `/admin/clients/${profile.id}` : "/admin/clients" + } + + const attention: AttentionItem[] = [] + + // Rechnungen + const invoices = invoicesRes.data! + const open = invoices.filter((invoice) => invoice.status === "pending") + const overdue = open + .filter((invoice) => invoice.display_status === "overdue") + .sort((a, b) => a.due_date.localeCompare(b.due_date)) + const paidThisMonth = invoices.filter((invoice) => invoice.status === "paid" && invoice.paid_at?.startsWith(currentMonth)) + + for (const invoice of overdue) { + const days = daysBetween(invoice.due_date, today) + attention.push({ + key: `invoice-${invoice.id}`, + kind: "overdue", + title: `${invoice.invoice_number} ist überfällig`, + detail: `${clientLabel(invoice.client_id)} · ${formatCurrency(Number(invoice.gross_total))} · seit ${days} ${days === 1 ? "Tag" : "Tagen"}`, + href: `${clientBase(invoice.client_id)}/billing`, + }) + } + + // Systeme + const lastCheckByProduct = new Map(checksRes.data!.map((row) => [row.product_id as string, row])) + const clientsWithIssues = new Set() + + for (const product of productsRes.data!) { + const status = product.status as ProductStatus + const lastCheck = lastCheckByProduct.get(product.id) + const failedCheck = lastCheck?.ok === false + + if (status === "offline" || failedCheck) clientsWithIssues.add(product.client_id) + if (status === "online" && !failedCheck) continue + + const kind: AttentionItem["kind"] = status === "offline" ? "outage" : status === "maintenance" ? "maintenance" : "check" + attention.push({ + key: `product-${product.id}`, + kind, + title: + kind === "check" + ? `${product.name}: letzte Prüfung fehlgeschlagen` + : `${product.name}: ${PRODUCT_STATUS_LABELS[status]}`, + detail: [clientLabel(product.client_id), kind === "check" ? lastCheck?.error : product.status_message] + .filter(Boolean) + .join(" · "), + href: clientBase(product.client_id), + }) + } + + // Onboarding + for (const profile of profiles) { + if (!profile.client_id) { + attention.push({ + key: `stammdaten-${profile.id}`, + kind: "stammdaten", + title: `${profile.name}: Stammdaten fehlen`, + detail: "Ohne Stammdaten sind Rechnungen, Dokumente und Status nicht nutzbar.", + href: `/admin/clients/${profile.id}/data`, + }) + } + if (!profile.terms_accepted_at) { + attention.push({ + key: `terms-${profile.id}`, + kind: "terms", + title: `${profile.name}: AGB noch nicht akzeptiert`, + detail: "Wird beim nächsten Login des Clients abgefragt.", + href: `/admin/clients/${profile.id}`, + }) + } + } + + const priority: Record = { outage: 0, overdue: 1, check: 2, maintenance: 3, stammdaten: 4, terms: 5 } + attention.sort((a, b) => priority[a.kind] - priority[b.kind]) + + // Uploads von Clients, zusammengefasst pro Person + Ordner + const groups = new Map() + for (const doc of documentsRes.data!) { + const uploader = doc.uploader as unknown as { id: string; name: string; role: string } | null + if (!uploader || uploader.role !== "client") continue + + const key = `${uploader.id}-${doc.folder_id}` + const existing = groups.get(key) + if (existing) { + existing.count++ + continue + } + if (groups.size >= MAX_UPLOAD_GROUPS) continue + + const folder = doc.folders as unknown as { name: string } | null + groups.set(key, { + key, + clientName: clientLabel(doc.client_id), + uploaderName: uploader.name, + folderName: folder?.name ?? "Ohne Ordner", + count: 1, + latestAt: doc.created_at, + href: `/admin/clients/${uploader.id}/storage${doc.folder_id ? `?folder=${doc.folder_id}` : ""}`, + }) + } + + return { + openCount: open.length, + openTotal: sum(open.map((invoice) => invoice.gross_total)), + overdueCount: overdue.length, + overdueTotal: sum(overdue.map((invoice) => invoice.gross_total)), + paidThisMonthTotal: sum(paidThisMonth.map((invoice) => invoice.gross_total)), + clientCount: profiles.length, + clientsWithIssues: clientsWithIssues.size, + attention, + uploads: [...groups.values()], + tasks: tasksRes.data!.map((task) => ({ + id: task.id, + name: task.name, + clientName: clientLabel(task.client_id), + since: task.since, + status: task.status, + href: clientBase(task.client_id), + })), + } +} diff --git a/modules/admin/queries/settings.ts b/modules/admin/queries/settings.ts new file mode 100644 index 0000000..8b2c455 --- /dev/null +++ b/modules/admin/queries/settings.ts @@ -0,0 +1,31 @@ +import "server-only" +import { createClient } from "@/lib/supabase/server" + +export type AdminProductTemplate = { + id: string + name: string + description: string | null + unitPrice: number + taxRate: number + isActive: boolean +} + +export async function getAllProductTemplates(): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("product_templates") + .select("id, name, description, unit_price, tax_rate, is_active") + .order("is_active", { ascending: false }) + .order("name") + + if (error) throw new Error("Produktvorlagen konnten nicht geladen werden.") + + return data.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + unitPrice: Number(row.unit_price), + taxRate: Number(row.tax_rate), + isActive: row.is_active, + })) +} diff --git a/modules/admin/ui/components/client-actions-menu.tsx b/modules/admin/ui/components/client-actions-menu.tsx new file mode 100644 index 0000000..18f96ce --- /dev/null +++ b/modules/admin/ui/components/client-actions-menu.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { KeyRoundIcon, MoreHorizontalIcon, PencilIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { resetClientPassword, updateClientAccount } from "@/modules/admin/actions/manage-client"; +import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants"; +import { CopyField } from "./copy-field"; + +type Client = { id: string; name: string; email: string }; + +function ResetPasswordContent({ client, onDone }: { client: Client; onDone: () => void }) { + const [mode, setMode] = useState<"generate" | "manual">("generate"); + const [state, formAction, pending] = useActionState(resetClientPassword, undefined); + + if (state && "success" in state) { + return ( + <> + + Passwort geändert + + Gib das neue Passwort an {client.name} weiter. Es wird nur jetzt angezeigt. + + + + + + + + + + + ); + } + + return ( + <> + + Passwort zurücksetzen + + Setzt das Passwort von {client.name} neu. + Das alte Passwort funktioniert danach nicht mehr. + + +
+ + + +
+ {(["generate", "manual"] as const).map((value) => ( + + ))} +
+ {mode === "manual" && ( + + Neues Passwort + + Mindestens {MIN_PASSWORD_LENGTH} Zeichen. + + )} + {state && "error" in state &&

{state.error}

} +
+ + + +
+ + ); +} + +function EditClientContent({ client, onDone }: { client: Client; onDone: () => void }) { + async function run(prev: Parameters[0], formData: FormData) { + const result = await updateClientAccount(prev, formData); + if (result && "success" in result) onDone(); + return result; + } + + const [state, formAction, pending] = useActionState(run, undefined); + + return ( + <> + + Client bearbeiten + +
+ + + + Name + + + + Email + + Das ist auch die Login-Email des Clients. + + {state && "error" in state &&

{state.error}

} +
+ + + +
+ + ); +} + +export function ClientActionsMenu({ client }: { client: Client }) { + const [dialog, setDialog] = useState<"password" | "edit" | null>(null); + const [instanceKey, setInstanceKey] = useState(0); + + function close() { + setDialog(null); + setInstanceKey((key) => key + 1); + } + + return ( + <> + + }> + + + + setDialog("edit")}> + Bearbeiten + + setDialog("password")}> + Passwort zurücksetzen + + + + + !open && close()}> + + {dialog === "password" && } + {dialog === "edit" && } + + + + ); +} diff --git a/modules/admin/ui/components/clients-table.tsx b/modules/admin/ui/components/clients-table.tsx index 27c57b5..9eb0578 100644 --- a/modules/admin/ui/components/clients-table.tsx +++ b/modules/admin/ui/components/clients-table.tsx @@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { AddClientDialog } from "./add-client-dialog"; +import { ClientActionsMenu } from "./client-actions-menu"; type Client = { id: string; @@ -75,7 +76,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) { Client Status - + @@ -102,15 +103,18 @@ export function ClientsTable({ clients }: { clients: Client[] }) { {status.label} - +
+ + +
); diff --git a/modules/admin/ui/components/settings-forms.tsx b/modules/admin/ui/components/settings-forms.tsx new file mode 100644 index 0000000..f89202c --- /dev/null +++ b/modules/admin/ui/components/settings-forms.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useActionState, useState, useTransition } from "react"; +import { EyeIcon, EyeOffIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { cn } from "cn"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + changeOwnPassword, + deleteProductTemplate, + saveProductTemplate, + setProductTemplateActive, + updateOwnProfile, + type SettingsState, +} from "@/modules/admin/actions/settings"; +import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants"; +import type { AdminProductTemplate } from "@/modules/admin/queries/settings"; +import { formatCurrency } from "@/modules/shared/lib/invoice-math"; + +function StateMessage({ state }: { state: SettingsState }) { + if (!state) return null; + if ("error" in state) return

{state.error}

; + return state.message ?

{state.message}

: null; +} + +export function ProfileForm({ name, email }: { name: string; email: string }) { + const [state, formAction, pending] = useActionState(updateOwnProfile, undefined); + + return ( +
+ +
+ + Name + + + + Email + + Die Login-Email kann nur in Supabase geändert werden. + +
+ +
+
+ +
+
+ ); +} + +function PasswordInput({ id, name, label, autoComplete }: { id: string; name: string; label: string; autoComplete: string }) { + const [visible, setVisible] = useState(false); + + return ( + + {label} +
+ + +
+
+ ); +} + +export function PasswordForm() { + const [formKey, setFormKey] = useState(0); + + async function run(prev: SettingsState, formData: FormData) { + const result = await changeOwnPassword(prev, formData); + if (result && "success" in result) setFormKey((key) => key + 1); + return result; + } + + const [state, formAction, pending] = useActionState(run, undefined); + + return ( +
+ +
+ + + +
+ Mindestens {MIN_PASSWORD_LENGTH} Zeichen. + +
+
+ +
+
+ ); +} + +function TemplateForm({ template, onDone }: { template?: AdminProductTemplate; onDone: () => void }) { + async function run(prev: SettingsState, formData: FormData) { + const result = await saveProductTemplate(prev, formData); + if (result && "success" in result) onDone(); + return result; + } + + const [state, formAction, pending] = useActionState(run, undefined); + const toInput = (value: number) => value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false }); + + return ( +
+ + + + Bezeichnung * + + Erscheint so als Position auf der Rechnung. + + + Interne Beschreibung + + +
+ + Preis netto (€) * + + + + MwSt (%) + + +
+ +
+ + + +
+ ); +} + +function TemplateDialog({ template }: { template?: AdminProductTemplate }) { + const [open, setOpen] = useState(false); + const [instanceKey, setInstanceKey] = useState(0); + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (!nextOpen) setInstanceKey((key) => key + 1); + } + + return ( + + + ) : ( + + ); +} + +function DeleteTemplateDialog({ template }: { template: AdminProductTemplate }) { + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function handleDelete() { + startTransition(async () => { + const result = await deleteProductTemplate(template.id); + if (result && "error" in result) setError(result.error); + else setOpen(false); + }); + } + + return ( + + }> + + + + + Vorlage löschen + + {template.name} wird gelöscht. Bereits erstellte + Rechnungen bleiben unverändert. Zum Ausblenden reicht auch „Deaktivieren“. + + + {error &&

{error}

} + + + + +
+
+ ); +} + +function ActiveToggle({ template }: { template: AdminProductTemplate }) { + const [pending, startTransition] = useTransition(); + + return ( + + ); +} + +export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) { + return ( +
+
+ +
+ {templates.length === 0 ? ( +

+ Noch keine Vorlagen. Vorlagen lassen sich beim Erstellen einer Rechnung als Position auswählen. +

+ ) : ( +
    + {templates.map((template) => ( +
  • +
    + + {template.name} + {!template.isActive && Inaktiv} + + {template.description && {template.description}} +
    + {formatCurrency(template.unitPrice)} + + {template.taxRate.toLocaleString("de-DE")} % MwSt + +
    + + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/modules/admin/ui/views/dashboard-view.tsx b/modules/admin/ui/views/dashboard-view.tsx new file mode 100644 index 0000000..59d2381 --- /dev/null +++ b/modules/admin/ui/views/dashboard-view.tsx @@ -0,0 +1,202 @@ +import Link from "next/link" +import { + AlertTriangleIcon, + ArrowRightIcon, + CheckCircle2Icon, + ClipboardListIcon, + FileWarningIcon, + ReceiptIcon, + ScaleIcon, + ServerCrashIcon, + UploadIcon, + WrenchIcon, + type LucideIcon, +} from "lucide-react" +import { cn } from "cn" +import { Badge } from "@/components/ui/badge" +import { getDashboardData, type AttentionItem } from "@/modules/admin/queries/dashboard" +import { formatCurrency } from "@/modules/shared/lib/invoice-math" +import { berlinToday, formatDateTime, formatTaskTiming } from "@/modules/shared/lib/info-format" +import { TASK_STATUS_LABELS } from "@/modules/shared/schemas/info" + +const ATTENTION_STYLES: Record = { + outage: { icon: ServerCrashIcon, className: "text-signal-error bg-signal-error/10" }, + overdue: { icon: ReceiptIcon, className: "text-signal-error bg-signal-error/10" }, + check: { icon: AlertTriangleIcon, className: "text-signal-warning bg-signal-warning/10" }, + maintenance: { icon: WrenchIcon, className: "text-signal-warning bg-signal-warning/10" }, + stammdaten: { icon: FileWarningIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" }, + terms: { icon: ScaleIcon, className: "text-ink-600 bg-ink-100 dark:text-ink-300 dark:bg-ink-800" }, +} + +function StatCard({ + label, + value, + detail, + tone = "default", + href, +}: { + label: string + value: string + detail?: string + tone?: "default" | "danger" | "success" + href?: string +}) { + const content = ( + <> + {label} + + {value} + + {detail && {detail}} + + ) + + const className = "flex flex-col gap-1 rounded-lg border border-border p-4" + return href ? ( + {content} + ) : ( +
{content}
+ ) +} + +function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) { + return ( +
+

+ {title} + {count !== undefined && count > 0 && {count}} +

+ {children} +
+ ) +} + +function EmptyHint({ icon: Icon = CheckCircle2Icon, children }: { icon?: LucideIcon; children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function LinkRow({ href, children }: { href: string; children: React.ReactNode }) { + return ( +
  • + + {children} + + +
  • + ) +} + +const DashboardView = async ({ name }: { name: string }) => { + const data = await getDashboardData() + const today = berlinToday() + + return ( +
    +
    +

    Hallo {name.split(" ")[0]}

    +

    Alles Wichtige über alle Clients auf einen Blick.

    +
    + +
    + + 0 ? "danger" : "default"} + /> + 0 ? "success" : "default"} /> + 0 ? `${data.clientsWithIssues} mit Störung` : "Keine Störungen"} + tone={data.clientsWithIssues > 0 ? "danger" : "default"} + href="/admin/clients" + /> +
    + +
    + {data.attention.length === 0 ? ( + Alles im grünen Bereich. + ) : ( +
      + {data.attention.map((item) => { + const style = ATTENTION_STYLES[item.kind] + return ( + + + + + + {item.title} + {item.detail && {item.detail}} + + + ) + })} +
    + )} +
    + +
    +
    + {data.uploads.length === 0 ? ( + Noch keine Uploads von Clients. + ) : ( +
      + {data.uploads.map((upload) => ( + + + + + {upload.clientName} hat {upload.count}{" "} + {upload.count === 1 ? "Datei" : "Dateien"} in „{upload.folderName}“ hochgeladen + + + {upload.uploaderName} · zuletzt {formatDateTime(upload.latestAt)} + + + + ))} +
    + )} +
    + +
    + {data.tasks.length === 0 ? ( + Keine offenen Vorgänge. + ) : ( +
      + {data.tasks.map((task) => ( + + + {TASK_STATUS_LABELS[task.status]} + + + {task.name} + {task.clientName} + + + {formatTaskTiming(task.status, task.since, today)} + + + ))} +
    + )} +
    +
    +
    + ) +} + +export default DashboardView diff --git a/modules/admin/ui/views/settings-view.tsx b/modules/admin/ui/views/settings-view.tsx new file mode 100644 index 0000000..00cf12a --- /dev/null +++ b/modules/admin/ui/views/settings-view.tsx @@ -0,0 +1,38 @@ +import { getAllProductTemplates } from "@/modules/admin/queries/settings" +import { PasswordForm, ProductTemplatesSection, ProfileForm } from "@/modules/admin/ui/components/settings-forms" + +function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    + {children} +
    + ) +} + +const SettingsView = async ({ name, email }: { name: string; email: string }) => { + const templates = await getAllProductTemplates() + + return ( +
    +

    Einstellungen

    + +
    + +
    + +
    + +
    + +
    + +
    +
    + ) +} + +export default SettingsView diff --git a/modules/shared/actions/documents.ts b/modules/shared/actions/documents.ts new file mode 100644 index 0000000..a7c4496 --- /dev/null +++ b/modules/shared/actions/documents.ts @@ -0,0 +1,225 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { verifySession } from "@/lib/supabase/dal"; +import { getClientId } from "@/modules/shared/queries/profile"; +import { + DOCUMENTS_BUCKET, + FolderNameSchema, + MAX_FILES_PER_UPLOAD, + UploadFileSchema, +} from "@/modules/shared/schemas/documents"; + +const IdSchema = z.string().uuid(); +const URL_TTL_SECONDS = 5 * 60; + +export type DocumentActionState = { error: string } | { success: true } | undefined; + +type Access = { clientId: string; isAdmin: boolean }; + +// Admin: jeder Client. Client: nur das eigene Profil. Storage und Tabellen prüfen zusätzlich per RLS. +async function resolveAccess(profileId: string, adminOnly = false): Promise { + const session = await verifySession(); + if (!IdSchema.safeParse(profileId).success) return { error: "Ungültiger Client." }; + if (adminOnly && !session.isAdmin) return { error: "Keine Berechtigung." }; + if (!session.isAdmin && session.userId !== profileId) return { error: "Keine Berechtigung." }; + + const clientId = await getClientId(profileId); + if (!clientId) return { error: "Für diesen Client sind noch keine Stammdaten hinterlegt." }; + + return { clientId, isAdmin: session.isAdmin }; +} + +function revalidateDocuments(profileId: string) { + revalidatePath("/client/storage"); + revalidatePath(`/admin/clients/${profileId}/storage`); +} + +function safeFileName(name: string) { + const cleaned = name + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^\w.-]+/g, "_") + .replace(/_+/g, "_") + .slice(-120); + return cleaned === "" || cleaned === "." ? "datei" : cleaned; +} + +export async function createFolder(_prev: DocumentActionState, formData: FormData): Promise { + const profileId = String(formData.get("profileId") ?? ""); + const access = await resolveAccess(profileId, true); + if ("error" in access) return access; + + const name = FolderNameSchema.safeParse(formData.get("name") ?? ""); + if (!name.success) return { error: name.error.issues[0]?.message ?? "Ungültiger Name." }; + + const supabase = await createClient(); + const { error } = await supabase.from("folders").insert({ client_id: access.clientId, name: name.data }); + + if (error) { + return { error: error.code === "23505" ? "Ein Ordner mit diesem Namen existiert bereits." : "Ordner konnte nicht erstellt werden." }; + } + + revalidateDocuments(profileId); + return { success: true }; +} + +export async function deleteFolder(profileId: string, folderId: string): Promise { + const access = await resolveAccess(profileId, true); + if ("error" in access) return access; + if (!IdSchema.safeParse(folderId).success) return { error: "Ungültiger Ordner." }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("folders") + .delete() + .eq("id", folderId) + .eq("client_id", access.clientId) + .select("id"); + + if (error) { + return { error: error.code === "23503" ? "Der Ordner ist nicht leer. Bitte zuerst alle Dateien löschen." : "Ordner konnte nicht gelöscht werden." }; + } + if (!data?.length) return { error: "Ordner nicht gefunden." }; + + revalidateDocuments(profileId); + return { success: true }; +} + +async function assertFolder(clientId: string, folderId: string) { + if (!IdSchema.safeParse(folderId).success) return false; + const supabase = await createClient(); + const { data } = await supabase.from("folders").select("id").eq("id", folderId).eq("client_id", clientId).maybeSingle(); + return Boolean(data); +} + +export type PreparedUpload = { documentId: string; path: string }; + +// Schritt 1: Pfade vergeben. Die Dateien lädt der Browser direkt in den Storage (Vercel-Limit ~4,5 MB pro Request). +export async function prepareUpload( + profileId: string, + folderId: string, + files: { name: string; size: number; type: string }[] +): Promise<{ error: string } | { uploads: PreparedUpload[] }> { + const access = await resolveAccess(profileId); + if ("error" in access) return access; + + if (files.length === 0 || files.length > MAX_FILES_PER_UPLOAD) { + return { error: `Bitte 1 bis ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` }; + } + for (const file of files) { + const parsed = UploadFileSchema.safeParse(file); + if (!parsed.success) return { error: `${file.name}: ${parsed.error.issues[0]?.message ?? "Ungültige Datei."}` }; + } + if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." }; + + return { + uploads: files.map((file) => { + const documentId = crypto.randomUUID(); + return { documentId, path: `${access.clientId}/${documentId}/${safeFileName(file.name)}` }; + }), + }; +} + +const FinalizeSchema = z + .array(UploadFileSchema.extend({ documentId: z.string().uuid(), path: z.string().min(1).max(400) })) + .min(1) + .max(MAX_FILES_PER_UPLOAD); + +// Schritt 2: Datensätze anlegen – nur für Pfade im eigenen Client-Ordner, die zur vergebenen ID passen. +export async function finalizeUpload( + profileId: string, + folderId: string, + uploads: { documentId: string; path: string; name: string; size: number; type: string }[] +): Promise { + const access = await resolveAccess(profileId); + if ("error" in access) return access; + + const parsed = FinalizeSchema.safeParse(uploads); + if (!parsed.success) return { error: "Ungültige Upload-Daten." }; + if (!(await assertFolder(access.clientId, folderId))) return { error: "Ordner nicht gefunden." }; + + const invalidPath = parsed.data.some( + (upload) => upload.path !== `${access.clientId}/${upload.documentId}/${safeFileName(upload.name)}` + ); + if (invalidPath) return { error: "Ungültiger Speicherpfad." }; + + const session = await verifySession(); + const supabase = await createClient(); + const { error } = await supabase.from("documents").insert( + parsed.data.map((upload) => ({ + id: 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, + uploaded_by: session.userId, + })) + ); + + if (error) { + await supabase.storage.from(DOCUMENTS_BUCKET).remove(parsed.data.map((upload) => upload.path)); + return { error: "Dateien konnten nicht gespeichert werden." }; + } + + revalidateDocuments(profileId); + return { success: true }; +} + +export async function getDocumentUrls( + profileId: string, + documentIds: string[], + mode: "preview" | "download" +): Promise<{ error: string } | { files: { id: string; name: string; url: string }[] }> { + const access = await resolveAccess(profileId); + if ("error" in access) return access; + + const ids = z.array(z.string().uuid()).min(1).max(100).safeParse(documentIds); + if (!ids.success) return { error: "Ungültige Auswahl." }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("documents") + .select("id, name, storage_path") + .eq("client_id", access.clientId) + .in("id", ids.data); + + if (error || !data || data.length !== new Set(ids.data).size) return { error: "Nicht alle Dateien gefunden." }; + + const signed = await Promise.all( + data.map(async (row) => { + const { data: url } = await supabase.storage + .from(DOCUMENTS_BUCKET) + .createSignedUrl(row.storage_path, URL_TTL_SECONDS, mode === "download" ? { download: row.name } : undefined); + return url?.signedUrl ? { id: row.id as string, name: row.name as string, url: url.signedUrl } : null; + }) + ); + + if (signed.some((entry) => entry === null)) return { error: "Download-Link konnte nicht erstellt werden." }; + return { files: signed as { id: string; name: string; url: string }[] }; +} + +export async function deleteDocument(profileId: string, documentId: string): Promise { + const access = await resolveAccess(profileId, true); + if ("error" in access) return access; + if (!IdSchema.safeParse(documentId).success) return { error: "Ungültige Datei." }; + + const supabase = await createClient(); + const { data, error } = await supabase + .from("documents") + .delete() + .eq("id", documentId) + .eq("client_id", access.clientId) + .select("storage_path"); + + if (error || !data?.length) return { error: "Datei konnte nicht gelöscht werden." }; + + await supabase.storage.from(DOCUMENTS_BUCKET).remove([data[0].storage_path]); + + revalidateDocuments(profileId); + return { success: true }; +} diff --git a/modules/shared/queries/documents.ts b/modules/shared/queries/documents.ts new file mode 100644 index 0000000..c354f2c --- /dev/null +++ b/modules/shared/queries/documents.ts @@ -0,0 +1,74 @@ +import "server-only" +import { createClient } from "@/lib/supabase/server" +import { DOCUMENTS_BUCKET, canPreview, fileKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents" + +const THUMBNAIL_TTL_SECONDS = 60 * 60 + +export async function getFolders(clientId: string): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("folders") + .select("id, name, documents(count)") + .eq("client_id", clientId) + .is("parent_id", null) + .order("name") + + if (error) throw new Error("Ordner konnten nicht geladen werden.") + + return data.map((row) => ({ + id: row.id, + name: row.name, + documentCount: (row.documents as unknown as { count: number }[])[0]?.count ?? 0, + })) +} + +export async function getFolder(clientId: string, folderId: string): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("folders") + .select("id, name") + .eq("client_id", clientId) + .eq("id", folderId) + .maybeSingle() + + if (error) throw new Error("Ordner konnte nicht geladen werden.") + return data ? { id: data.id, name: data.name, documentCount: 0 } : null +} + +export async function getDocuments(clientId: string, folderId: string): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from("documents") + .select("id, name, mime_type, size_bytes, created_at, storage_path") + .eq("client_id", clientId) + .eq("folder_id", folderId) + .order("created_at", { ascending: false }) + + 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) + }) + .map((row) => row.storage_path as string) + + const thumbnails = new Map() + if (imagePaths.length > 0) { + const { data: signed } = await supabase.storage + .from(DOCUMENTS_BUCKET) + .createSignedUrls(imagePaths, THUMBNAIL_TTL_SECONDS) + for (const entry of signed ?? []) { + if (entry.path && entry.signedUrl) thumbnails.set(entry.path, entry.signedUrl) + } + } + + return data.map((row) => ({ + id: row.id, + name: row.name, + mimeType: row.mime_type, + sizeBytes: row.size_bytes === null ? null : Number(row.size_bytes), + createdAt: row.created_at, + thumbnailUrl: thumbnails.get(row.storage_path) ?? null, + })) +} diff --git a/modules/shared/schemas/documents.ts b/modules/shared/schemas/documents.ts new file mode 100644 index 0000000..ddc167d --- /dev/null +++ b/modules/shared/schemas/documents.ts @@ -0,0 +1,79 @@ +import * as z from "zod"; + +export const MAX_FILE_BYTES = 50 * 1024 * 1024; +export const MAX_FILES_PER_UPLOAD = 20; +export const DOCUMENTS_BUCKET = "client-documents"; + +export type Folder = { + id: string; + name: string; + documentCount: number; +}; + +export type DocumentItem = { + id: string; + name: string; + mimeType: string | null; + sizeBytes: number | null; + createdAt: string; + thumbnailUrl: string | null; +}; + +export type FileKind = "image" | "pdf" | "text" | "spreadsheet" | "presentation" | "document" | "archive" | "video" | "audio" | "other"; + +const EXTENSION_KINDS: Record = { + png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", + pdf: "pdf", + txt: "text", md: "text", csv: "spreadsheet", json: "text", + xls: "spreadsheet", xlsx: "spreadsheet", ods: "spreadsheet", + ppt: "presentation", pptx: "presentation", odp: "presentation", + doc: "document", docx: "document", odt: "document", rtf: "document", + zip: "archive", rar: "archive", "7z": "archive", gz: "archive", + mp4: "video", mov: "video", webm: "video", + mp3: "audio", wav: "audio", m4a: "audio", +}; + +export function fileKind(name: string, mimeType: string | null): FileKind { + const extension = name.split(".").pop()?.toLowerCase() ?? ""; + if (EXTENSION_KINDS[extension]) return EXTENSION_KINDS[extension]; + if (mimeType?.startsWith("image/")) return "image"; + if (mimeType?.startsWith("video/")) return "video"; + if (mimeType?.startsWith("audio/")) return "audio"; + if (mimeType === "application/pdf") return "pdf"; + if (mimeType?.startsWith("text/")) return "text"; + 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 function formatFileSize(bytes: number | null) { + if (bytes === null) return ""; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toLocaleString("de-DE", { maximumFractionDigits: value < 10 ? 1 : 0 })} ${units[unit]}`; +} + +export const UploadFileSchema = z.object({ + name: z.string().trim().min(1).max(255), + size: z.number().int().positive({ error: "Leere Dateien können nicht hochgeladen werden." }).max(MAX_FILE_BYTES, { + error: "Dateien dürfen maximal 50 MB groß sein.", + }), + type: z.string().max(255), +}); + +export const FolderNameSchema = z + .string() + .trim() + .min(1, { error: "Name ist erforderlich." }) + .max(100, { error: "Name ist zu lang." }) + .refine((value) => !/[\\/]/.test(value), { error: "Name darf keine Schrägstriche enthalten." }); diff --git a/modules/shared/ui/components/documents-browser.tsx b/modules/shared/ui/components/documents-browser.tsx new file mode 100644 index 0000000..50ee27b --- /dev/null +++ b/modules/shared/ui/components/documents-browser.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { zipSync } from "fflate"; +import { DownloadIcon, LoaderCircleIcon, Trash2Icon, UploadIcon } from "lucide-react"; +import { cn } from "cn"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { createClient } from "@/lib/supabase/client"; +import { deleteDocument, finalizeUpload, getDocumentUrls, prepareUpload } from "@/modules/shared/actions/documents"; +import { + DOCUMENTS_BUCKET, + MAX_FILES_PER_UPLOAD, + MAX_FILE_BYTES, + canPreview, + fileKind, + formatFileSize, + type DocumentItem, +} from "@/modules/shared/schemas/documents"; +import { FileKindIcon } from "./file-icon"; + +const PARALLEL_UPLOADS = 3; +const TEXT_PREVIEW_LIMIT = 200_000; + +function triggerDownload(href: string, fileName?: string) { + const link = document.createElement("a"); + link.href = href; + if (fileName) link.download = fileName; + link.rel = "noopener"; + link.click(); +} + +function uniqueName(name: string, used: Set) { + if (!used.has(name)) { + used.add(name); + return name; + } + const dot = name.lastIndexOf("."); + const base = dot > 0 ? name.slice(0, dot) : name; + const extension = dot > 0 ? name.slice(dot) : ""; + let counter = 2; + while (used.has(`${base} (${counter})${extension}`)) counter++; + const next = `${base} (${counter})${extension}`; + used.add(next); + return next; +} + +async function runLimited(items: T[], limit: number, worker: (item: T) => Promise) { + let index = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (index < items.length) { + const item = items[index++]; + await worker(item); + } + }) + ); +} + +function PreviewContent({ doc, url }: { doc: DocumentItem; url: string }) { + const kind = fileKind(doc.name, doc.mimeType); + const [text, setText] = useState(null); + + useEffect(() => { + if (kind !== "text") return; + let cancelled = false; + fetch(url) + .then((response) => response.text()) + .then((content) => { + if (!cancelled) { + setText(content.length > TEXT_PREVIEW_LIMIT ? `${content.slice(0, TEXT_PREVIEW_LIMIT)}\n…` : content); + } + }) + .catch(() => !cancelled && setText("Vorschau konnte nicht geladen werden.")); + return () => { + cancelled = true; + }; + }, [kind, url]); + + switch (kind) { + case "image": + // eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL + return {doc.name}; + case "pdf": + return