Merge pull request #8 from Since-3/feature/added-documents-uploading

added document uploading
This commit is contained in:
Rajpreet0 2026-09-29 16:39:52 +02:00 • committed by GitHub
commit ad3c3c59d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2437 additions and 23 deletions

View file

@ -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 (
<DocumentsPanel
profileId={id}
isAdmin
basePath={`/admin/clients/${id}/storage`}
folderId={typeof folder === "string" ? folder : undefined}
/>
)
}
export default StorageAdminPage

View file

@ -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 (
<div>
<p>{session.email}</p>
</div>
)
return <DashboardView name={session.name} />
}
export default AdminDashboard
export default AdminDashboard

View file

@ -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 <SettingsView name={session.name} email={session.email} />
}
export default AdminSettings

View file

@ -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 (
<DocumentsPanel
profileId={session.userId}
isAdmin={false}
basePath="/client/storage"
folderId={typeof folder === "string" ? folder : undefined}
/>
)
}
export default StoragePage

View file

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

View file

@ -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<ResetPasswordState> {
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<UpdateClientState> {
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 };
}

View file

@ -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";
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
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<SettingsState> {
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." };
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." };
}
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<SettingsState> {
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) {
return parseDecimalInput(String(value ?? ""));
}
export async function saveProductTemplate(_prev: SettingsState, formData: FormData): Promise<SettingsState> {
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<SettingsState> {
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<SettingsState> {
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 };
}

View file

@ -0,0 +1 @@
export const MIN_PASSWORD_LENGTH = 10;

View file

@ -0,0 +1,229 @@
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<Decimal>((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<DashboardData> {
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")
// !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),
])
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<string, { id: string; name: string }>()
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<string>()
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<AttentionItem["kind"], number> = { 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<string, UploadGroup>()
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),
})),
}
}

View file

@ -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<AdminProductTemplate[]> {
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,
}))
}

View file

@ -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 (
<>
<DialogHeader className="mb-4">
<DialogTitle>Passwort geändert</DialogTitle>
<DialogDescription>
Gib das neue Passwort an {client.name} weiter. Es wird nur jetzt angezeigt.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<CopyField label="Email" value={client.email} />
<CopyField label="Neues Passwort" value={state.password} />
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" onClick={onDone}>Fertig</Button>
</DialogFooter>
</>
);
}
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>Passwort zurücksetzen</DialogTitle>
<DialogDescription>
Setzt das Passwort von <span className="font-medium text-foreground">{client.name}</span> neu.
Das alte Passwort funktioniert danach nicht mehr.
</DialogDescription>
</DialogHeader>
<form action={formAction}>
<input type="hidden" name="profileId" value={client.id} />
<input type="hidden" name="mode" value={mode} />
<FieldGroup>
<div role="radiogroup" aria-label="Art des neuen Passworts" className="grid grid-cols-2 gap-2">
{(["generate", "manual"] as const).map((value) => (
<label
key={value}
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm has-checked:border-primary has-checked:bg-surface"
>
<input
type="radio"
name="mode-choice"
value={value}
checked={mode === value}
onChange={() => setMode(value)}
className="accent-primary"
/>
{value === "generate" ? "Automatisch generieren" : "Selbst festlegen"}
</label>
))}
</div>
{mode === "manual" && (
<Field>
<FieldLabel htmlFor="new-password">Neues Passwort</FieldLabel>
<Input
id="new-password"
name="password"
type="text"
autoComplete="new-password"
minLength={MIN_PASSWORD_LENGTH}
required
className="font-mono focus-visible:ring-0"
/>
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
</Field>
)}
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird geändert..." : "Passwort zurücksetzen"}
</Button>
</DialogFooter>
</form>
</>
);
}
function EditClientContent({ client, onDone }: { client: Client; onDone: () => void }) {
async function run(prev: Parameters<typeof updateClientAccount>[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 (
<>
<DialogHeader className="mb-4">
<DialogTitle>Client bearbeiten</DialogTitle>
</DialogHeader>
<form action={formAction}>
<input type="hidden" name="profileId" value={client.id} />
<FieldGroup>
<Field>
<FieldLabel htmlFor="edit-client-name">Name</FieldLabel>
<Input id="edit-client-name" name="name" defaultValue={client.name} required className="focus-visible:ring-0" />
</Field>
<Field>
<FieldLabel htmlFor="edit-client-email">Email</FieldLabel>
<Input id="edit-client-email" name="email" type="email" defaultValue={client.email} required className="focus-visible:ring-0" />
<FieldDescription>Das ist auch die Login-Email des Clients.</FieldDescription>
</Field>
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</>
);
}
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Aktionen für ${client.name}`} />}>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={() => setDialog("edit")}>
<PencilIcon /> Bearbeiten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog("password")}>
<KeyRoundIcon /> Passwort zurücksetzen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={dialog !== null} onOpenChange={(open) => !open && close()}>
<DialogContent>
{dialog === "password" && <ResetPasswordContent key={instanceKey} client={client} onDone={close} />}
{dialog === "edit" && <EditClientContent key={instanceKey} client={client} onDone={close} />}
</DialogContent>
</Dialog>
</>
);
}

View file

@ -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[] }) {
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-16" />
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
@ -102,15 +103,18 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
<Badge variant={status.variant}>{status.label}</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
nativeButton={false}
render={<Link href={`/admin/clients/${client.id}`} />}
aria-label={`${client.name} öffnen`}
>
<ArrowRightIcon />
</Button>
<div className="flex justify-end gap-1">
<ClientActionsMenu client={client} />
<Button
variant="ghost"
size="icon-sm"
nativeButton={false}
render={<Link href={`/admin/clients/${client.id}`} />}
aria-label={`${client.name} öffnen`}
>
<ArrowRightIcon />
</Button>
</div>
</TableCell>
</TableRow>
);

View file

@ -0,0 +1,292 @@
"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 <p className="text-sm text-destructive">{state.error}</p>;
return state.message ? <p className="text-sm text-signal-success" aria-live="polite">{state.message}</p> : null;
}
export function ProfileForm({ name, email }: { name: string; email: string }) {
const [state, formAction, pending] = useActionState(updateOwnProfile, undefined);
return (
<form action={formAction} className="flex flex-col gap-4">
<FieldGroup>
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="settings-name">Name</FieldLabel>
<Input id="settings-name" name="name" defaultValue={name} required className="focus-visible:ring-0" />
</Field>
<Field>
<FieldLabel htmlFor="settings-email">Email</FieldLabel>
<Input id="settings-email" value={email} disabled readOnly />
<FieldDescription>Die Login-Email kann nur in Supabase geändert werden.</FieldDescription>
</Field>
</div>
<StateMessage state={state} />
</FieldGroup>
<div>
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
</div>
</form>
);
}
function PasswordInput({ id, name, label, autoComplete }: { id: string; name: string; label: string; autoComplete: string }) {
const [visible, setVisible] = useState(false);
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<div className="relative">
<Input
id={id}
name={name}
type={visible ? "text" : "password"}
autoComplete={autoComplete}
required
className="pr-10 focus-visible:ring-0"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute top-1 right-1"
onClick={() => setVisible((value) => !value)}
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
>
{visible ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</Button>
</div>
</Field>
);
}
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 (
<form key={formKey} action={formAction} className="flex flex-col gap-4">
<FieldGroup>
<div className="grid gap-4 md:grid-cols-3">
<PasswordInput id="current-password" name="current" label="Aktuelles Passwort" autoComplete="current-password" />
<PasswordInput id="next-password" name="next" label="Neues Passwort" autoComplete="new-password" />
<PasswordInput id="confirm-password" name="confirm" label="Neues Passwort wiederholen" autoComplete="new-password" />
</div>
<FieldDescription>Mindestens {MIN_PASSWORD_LENGTH} Zeichen.</FieldDescription>
<StateMessage state={state} />
</FieldGroup>
<div>
<Button type="submit" disabled={pending}>{pending ? "Wird geändert..." : "Passwort ändern"}</Button>
</div>
</form>
);
}
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 (
<form action={formAction}>
<input type="hidden" name="id" value={template?.id ?? ""} />
<FieldGroup>
<Field>
<FieldLabel htmlFor="template-name">Bezeichnung <span className="text-destructive">*</span></FieldLabel>
<Input id="template-name" name="name" defaultValue={template?.name ?? ""} placeholder="z. B. Website Wartung (monatlich)" required />
<FieldDescription>Erscheint so als Position auf der Rechnung.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="template-description">Interne Beschreibung</FieldLabel>
<Input id="template-description" name="description" defaultValue={template?.description ?? ""} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="template-price">Preis netto (€) <span className="text-destructive">*</span></FieldLabel>
<Input id="template-price" name="unitPrice" inputMode="decimal" defaultValue={template ? toInput(template.unitPrice) : ""} placeholder="50,00" required />
</Field>
<Field>
<FieldLabel htmlFor="template-tax">MwSt (%)</FieldLabel>
<Input id="template-tax" name="taxRate" inputMode="decimal" defaultValue={template ? toInput(template.taxRate) : "0"} required />
</Field>
</div>
<StateMessage state={state} />
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>{pending ? "Wird gespeichert..." : "Speichern"}</Button>
</DialogFooter>
</form>
);
}
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={
template ? (
<Button variant="ghost" size="icon-sm" aria-label={`${template.name} bearbeiten`} />
) : (
<Button variant="outline" size="sm" />
)
}
>
{template ? <PencilIcon className="size-4" /> : <><PlusIcon className="size-4" /> Neue Vorlage</>}
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>{template ? "Vorlage bearbeiten" : "Neue Vorlage"}</DialogTitle>
</DialogHeader>
<TemplateForm key={instanceKey} template={template} onDone={() => handleOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
function DeleteTemplateDialog({ template }: { template: AdminProductTemplate }) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`${template.name} löschen`} />}>
<Trash2Icon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Vorlage löschen</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{template.name}</span> wird gelöscht. Bereits erstellte
Rechnungen bleiben unverändert. Zum Ausblenden reicht auch „Deaktivieren“.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>Abbrechen</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ActiveToggle({ template }: { template: AdminProductTemplate }) {
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 (
<span className="flex items-center gap-2">
{error && <span className="text-small text-destructive" role="alert">{error}</span>}
<Button type="button" variant="ghost" size="sm" disabled={pending} onClick={handleToggle}>
{template.isActive ? "Deaktivieren" : "Aktivieren"}
</Button>
</span>
);
}
export function ProductTemplatesSection({ templates }: { templates: AdminProductTemplate[] }) {
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<TemplateDialog />
</div>
{templates.length === 0 ? (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
Noch keine Vorlagen. Vorlagen lassen sich beim Erstellen einer Rechnung als Position auswählen.
</p>
) : (
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
{templates.map((template) => (
<li key={template.id} className={cn("flex flex-wrap items-center gap-3 px-4 py-3", !template.isActive && "opacity-60")}>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 font-medium">
{template.name}
{!template.isActive && <Badge>Inaktiv</Badge>}
</span>
{template.description && <span className="text-small text-muted-foreground">{template.description}</span>}
</div>
<span className="tabular-nums">{formatCurrency(template.unitPrice)}</span>
<span className="w-20 text-right text-small text-muted-foreground tabular-nums">
{template.taxRate.toLocaleString("de-DE")} % MwSt
</span>
<div className="flex items-center gap-1">
<ActiveToggle template={template} />
<TemplateDialog template={template} />
<DeleteTemplateDialog template={template} />
</div>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -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<AttentionItem["kind"], { icon: LucideIcon; className: string }> = {
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 = (
<>
<span className="text-small text-muted-foreground">{label}</span>
<span
className={cn(
"text-2xl font-semibold tabular-nums",
tone === "danger" && "text-signal-error",
tone === "success" && "text-signal-success"
)}
>
{value}
</span>
{detail && <span className="text-small text-muted-foreground">{detail}</span>}
</>
)
const className = "flex flex-col gap-1 rounded-lg border border-border p-4"
return href ? (
<Link href={href} className={cn(className, "transition-colors hover:bg-surface")}>{content}</Link>
) : (
<div className={className}>{content}</div>
)
}
function Section({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-xl font-semibold">
{title}
{count !== undefined && count > 0 && <Badge>{count}</Badge>}
</h2>
{children}
</section>
)
}
function EmptyHint({ icon: Icon = CheckCircle2Icon, children }: { icon?: LucideIcon; children: React.ReactNode }) {
return (
<p className="flex items-center gap-2 rounded-lg border border-border bg-surface/50 p-4 text-muted-foreground">
<Icon className="size-4 shrink-0" /> {children}
</p>
)
}
function LinkRow({ href, children }: { href: string; children: React.ReactNode }) {
return (
<li>
<Link href={href} className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface">
{children}
<ArrowRightIcon className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
</li>
)
}
const DashboardView = async ({ name }: { name: string }) => {
const data = await getDashboardData()
const today = berlinToday()
return (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold">Hallo {name.split(" ")[0]}</h1>
<p className="text-accent-400">Alles Wichtige über alle Clients auf einen Blick.</p>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Offene Rechnungen" value={formatCurrency(data.openTotal)} detail={`${data.openCount} ${data.openCount === 1 ? "Rechnung" : "Rechnungen"}`} />
<StatCard
label="Davon überfällig"
value={formatCurrency(data.overdueTotal)}
detail={`${data.overdueCount} ${data.overdueCount === 1 ? "Rechnung" : "Rechnungen"}`}
tone={data.overdueCount > 0 ? "danger" : "default"}
/>
<StatCard label="Bezahlt diesen Monat" value={formatCurrency(data.paidThisMonthTotal)} tone={data.paidThisMonthTotal > 0 ? "success" : "default"} />
<StatCard
label="Clients"
value={String(data.clientCount)}
detail={data.clientsWithIssues > 0 ? `${data.clientsWithIssues} mit Störung` : "Keine Störungen"}
tone={data.clientsWithIssues > 0 ? "danger" : "default"}
href="/admin/clients"
/>
</div>
<Section title="Braucht Aufmerksamkeit" count={data.attention.length}>
{data.attention.length === 0 ? (
<EmptyHint>Alles im grünen Bereich.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.attention.map((item) => {
const style = ATTENTION_STYLES[item.kind]
return (
<LinkRow key={item.key} href={item.href}>
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", style.className)}>
<style.icon className="size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{item.title}</span>
{item.detail && <span className="truncate text-small text-muted-foreground">{item.detail}</span>}
</span>
</LinkRow>
)
})}
</ul>
)}
</Section>
<div className="grid gap-10 lg:grid-cols-2">
<Section title="Neueste Uploads von Clients">
{data.uploads.length === 0 ? (
<EmptyHint icon={UploadIcon}>Noch keine Uploads von Clients.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.uploads.map((upload) => (
<LinkRow key={upload.key} href={upload.href}>
<UploadIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">
<span className="font-medium">{upload.clientName}</span> hat {upload.count}{" "}
{upload.count === 1 ? "Datei" : "Dateien"} in „{upload.folderName}“ hochgeladen
</span>
<span className="truncate text-small text-muted-foreground">
{upload.uploaderName} · zuletzt {formatDateTime(upload.latestAt)}
</span>
</span>
</LinkRow>
))}
</ul>
)}
</Section>
<Section title="Laufende Vorgänge">
{data.tasks.length === 0 ? (
<EmptyHint icon={ClipboardListIcon}>Keine offenen Vorgänge.</EmptyHint>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{data.tasks.map((task) => (
<LinkRow key={task.id} href={task.href}>
<Badge variant={task.status === "in_progress" ? "warning" : "neutral"} className="w-20">
{TASK_STATUS_LABELS[task.status]}
</Badge>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{task.name}</span>
<span className="truncate text-small text-muted-foreground">{task.clientName}</span>
</span>
<span className="shrink-0 text-small text-muted-foreground">
{formatTaskTiming(task.status, task.since, today)}
</span>
</LinkRow>
))}
</ul>
)}
</Section>
</div>
</div>
)
}
export default DashboardView

View file

@ -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 (
<section className="flex flex-col gap-4 rounded-lg border border-border p-4 md:p-6">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-small text-muted-foreground">{description}</p>
</div>
{children}
</section>
)
}
const SettingsView = async ({ name, email }: { name: string; email: string }) => {
const templates = await getAllProductTemplates()
return (
<div className="flex flex-col gap-8 w-full max-w-4xl">
<h1 className="text-3xl font-semibold">Einstellungen</h1>
<Section title="Profil" description="So wirst du im Portal angezeigt, z. B. bei „zuletzt bearbeitet von“.">
<ProfileForm name={name} email={email} />
</Section>
<Section title="Passwort" description="Ändere das Passwort für deinen Admin-Zugang.">
<PasswordForm />
</Section>
<Section title="Produktvorlagen" description="Vorlagen für Rechnungspositionen. Inaktive Vorlagen erscheinen nicht mehr im Rechnungsdialog.">
<ProductTemplatesSection templates={templates} />
</Section>
</div>
)
}
export default SettingsView

View file

@ -0,0 +1,249 @@
"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<Access | { error: string }> {
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<DocumentActionState> {
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<DocumentActionState> {
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);
// 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,
folderId: string,
uploads: { documentId: string; path: string; name: string; size: number; type: string }[]
): Promise<DocumentActionState> {
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();
// 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(
verified.map((entry) => ({
id: entry.upload.documentId,
client_id: access.clientId,
folder_id: folderId,
name: entry.upload.name,
storage_path: entry.upload.path,
mime_type: entry.contentType,
size_bytes: entry.size,
uploaded_by: session.userId,
}))
);
if (error) {
await removeOrphanedObjects(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<DocumentActionState> {
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." };
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 };
}

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

@ -0,0 +1,71 @@
import "server-only"
import { createClient } from "@/lib/supabase/server"
import { DOCUMENTS_BUCKET, previewKind, type DocumentItem, type Folder } from "@/modules/shared/schemas/documents"
const THUMBNAIL_TTL_SECONDS = 60 * 60
export async function getFolders(clientId: string): Promise<Folder[]> {
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<Folder | null> {
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<DocumentItem[]> {
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) => previewKind(row.mime_type) === "image")
.map((row) => row.storage_path as string)
const thumbnails = new Map<string, string>()
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,
}))
}

View file

@ -0,0 +1,101 @@
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<string, FileKind> = {
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";
}
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<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) {
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." });

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." });
// 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(),

View file

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

View file

@ -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,
fileKind,
previewKind,
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<string>) {
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<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
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 = previewKind(doc.mimeType);
const [text, setText] = useState<string | null>(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 <img src={url} alt={doc.name} className="mx-auto max-h-[70vh] w-auto rounded-md object-contain" />;
case "pdf":
return <iframe src={url} title={doc.name} className="h-[70vh] w-full rounded-md border border-border" />;
case "video":
return <video src={url} controls className="max-h-[70vh] w-full rounded-md" />;
case "audio":
return <audio src={url} controls className="w-full" />;
case "text":
return (
<pre className="max-h-[70vh] overflow-auto rounded-md border border-border bg-surface p-4 text-small whitespace-pre-wrap break-words">
{text ?? "Wird geladen..."}
</pre>
);
default:
return null;
}
}
function PreviewDialog({
doc,
profileId,
isAdmin,
onClose,
onDownload,
}: {
doc: DocumentItem | null;
profileId: string;
isAdmin: boolean;
onClose: () => void;
onDownload: (ids: string[]) => void;
}) {
const [url, setUrl] = useState<{ id: string; value: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!doc) return;
let cancelled = false;
getDocumentUrls(profileId, [doc.id], "preview").then((result) => {
if (cancelled) return;
if ("error" in result) setError(result.error);
else setUrl({ id: doc.id, value: result.files[0].url });
});
return () => {
cancelled = true;
};
}, [doc, profileId]);
function handleOpenChange(open: boolean) {
if (open) return;
setUrl(null);
setError(null);
setConfirmDelete(false);
onClose();
}
async function handleDelete() {
if (!doc) return;
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setDeleting(true);
const result = await deleteDocument(profileId, doc.id);
setDeleting(false);
if (result && "error" in result) {
setError(result.error);
return;
}
handleOpenChange(false);
}
const currentUrl = doc && url?.id === doc.id ? url.value : null;
return (
<Dialog open={doc !== null} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[95vh] overflow-y-auto sm:max-w-4xl">
{doc && (
<>
<DialogHeader>
<DialogTitle className="truncate pr-8">{doc.name}</DialogTitle>
<p className="text-small text-muted-foreground">{formatFileSize(doc.sizeBytes)}</p>
</DialogHeader>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : currentUrl ? (
<PreviewContent doc={doc} url={currentUrl} />
) : (
<div className="flex h-60 items-center justify-center text-muted-foreground">
<LoaderCircleIcon className="size-6 animate-spin" />
</div>
)}
<DialogFooter className="gap-2">
{isAdmin && (
<Button type="button" variant={confirmDelete ? "destructive" : "outline"} onClick={handleDelete} disabled={deleting}>
<Trash2Icon className="size-4" />
{deleting ? "Wird gelöscht..." : confirmDelete ? "Wirklich löschen?" : "Löschen"}
</Button>
)}
<Button type="button" onClick={() => onDownload([doc.id])}>
<DownloadIcon className="size-4" /> Herunterladen
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
export function DocumentsBrowser({
profileId,
folderId,
isAdmin,
documents,
}: {
profileId: string;
folderId: string;
isAdmin: boolean;
documents: DocumentItem[];
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<{ type: "error" | "info"; text: string } | null>(null);
const [dragActive, setDragActive] = useState(false);
const [preview, setPreview] = useState<DocumentItem | null>(null);
const visibleSelection = documents.filter((doc) => selected.has(doc.id)).map((doc) => doc.id);
const busy = upload !== null || downloading;
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleFiles(fileList: FileList | File[]) {
const files = Array.from(fileList);
if (files.length === 0 || busy) return;
setMessage(null);
if (files.length > MAX_FILES_PER_UPLOAD) {
setMessage({ type: "error", text: `Bitte höchstens ${MAX_FILES_PER_UPLOAD} Dateien auf einmal hochladen.` });
return;
}
const tooLarge = files.find((file) => file.size > MAX_FILE_BYTES);
if (tooLarge) {
setMessage({ type: "error", text: `${tooLarge.name} ist größer als ${formatFileSize(MAX_FILE_BYTES)}.` });
return;
}
const prepared = await prepareUpload(
profileId,
folderId,
files.map((file) => ({ name: file.name, size: file.size, type: file.type }))
);
if ("error" in prepared) {
setMessage({ type: "error", text: prepared.error });
return;
}
const supabase = createClient();
const jobs = prepared.uploads.map((target, index) => ({ target, file: files[index] }));
const succeeded: typeof jobs = [];
const failed: string[] = [];
setUpload({ done: 0, total: jobs.length });
await runLimited(jobs, PARALLEL_UPLOADS, async (job) => {
const { error } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.upload(job.target.path, job.file, { contentType: job.file.type || undefined, upsert: false });
if (error) failed.push(job.file.name);
else succeeded.push(job);
setUpload((current) => (current ? { ...current, done: current.done + 1 } : current));
});
if (succeeded.length > 0) {
const result = await finalizeUpload(
profileId,
folderId,
succeeded.map(({ target, file }) => ({
documentId: target.documentId,
path: target.path,
name: file.name,
size: file.size,
type: file.type,
}))
);
if (result && "error" in result) failed.push(...succeeded.map((job) => job.file.name));
}
setUpload(null);
if (inputRef.current) inputRef.current.value = "";
setMessage(
failed.length > 0
? { type: "error", text: `Nicht hochgeladen: ${failed.join(", ")}` }
: { type: "info", text: `${succeeded.length} ${succeeded.length === 1 ? "Datei" : "Dateien"} hochgeladen.` }
);
}
async function handleDownload(ids: string[]) {
if (ids.length === 0 || downloading) return;
setDownloading(true);
setMessage(null);
try {
if (ids.length === 1) {
const result = await getDocumentUrls(profileId, ids, "download");
if ("error" in result) throw new Error(result.error);
triggerDownload(result.files[0].url);
return;
}
const result = await getDocumentUrls(profileId, ids, "preview");
if ("error" in result) throw new Error(result.error);
const used = new Set<string>();
const entries: Record<string, Uint8Array> = {};
for (const file of result.files) {
const response = await fetch(file.url);
if (!response.ok) throw new Error(`${file.name} konnte nicht geladen werden.`);
entries[uniqueName(file.name, used)] = new Uint8Array(await response.arrayBuffer());
}
const blobUrl = URL.createObjectURL(new Blob([zipSync(entries, { level: 0 })], { type: "application/zip" }));
triggerDownload(blobUrl, "Dokumente.zip");
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} catch (error) {
setMessage({ type: "error", text: error instanceof Error ? error.message : "Download fehlgeschlagen." });
} finally {
setDownloading(false);
}
}
function openDocument(doc: DocumentItem) {
if (previewKind(doc.mimeType)) setPreview(doc);
else handleDownload([doc.id]);
}
return (
<div
className="relative flex flex-col gap-4"
onDragOver={(event) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
setDragActive(true);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragActive(false);
handleFiles(event.dataTransfer.files);
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-small text-muted-foreground">
{documents.length > 0 && (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="size-4 cursor-pointer accent-primary"
checked={visibleSelection.length === documents.length}
onChange={() =>
setSelected(visibleSelection.length === documents.length ? new Set() : new Set(documents.map((doc) => doc.id)))
}
/>
{visibleSelection.length > 0 ? `${visibleSelection.length} ausgewählt` : "Alle auswählen"}
</label>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => handleDownload(visibleSelection)} disabled={visibleSelection.length === 0 || busy}>
{downloading ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{downloading ? "Wird vorbereitet..." : visibleSelection.length > 1 ? `Download (${visibleSelection.length})` : "Download"}
</Button>
<Button onClick={() => inputRef.current?.click()} disabled={busy}>
{upload ? <LoaderCircleIcon className="size-4 animate-spin" /> : <UploadIcon className="size-4" />}
{upload ? `Hochladen ${upload.done}/${upload.total}` : "Hochladen"}
</Button>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(event) => event.target.files && handleFiles(event.target.files)}
/>
</div>
</div>
{message && (
<p className={cn("text-sm", message.type === "error" ? "text-destructive" : "text-muted-foreground")} aria-live="polite">
{message.text}
</p>
)}
{documents.length === 0 ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-surface/50 p-12 text-center text-muted-foreground transition-colors hover:bg-surface"
>
<UploadIcon className="size-8" />
<span>Noch keine Dateien. Hierher ziehen oder klicken zum Hochladen.</span>
</button>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{documents.map((doc) => {
const kind = fileKind(doc.name, doc.mimeType);
const isSelected = selected.has(doc.id);
return (
<li
key={doc.id}
className={cn(
"group relative flex flex-col overflow-hidden rounded-lg border bg-background transition-colors",
isSelected ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-ink-300"
)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggle(doc.id)}
aria-label={`${doc.name} auswählen`}
className={cn(
"absolute top-2 left-2 z-10 size-4 cursor-pointer accent-primary transition-opacity",
isSelected ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
)}
/>
<button
type="button"
onClick={() => openDocument(doc)}
className="flex flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={doc.name}
>
<div className="flex aspect-[4/3] items-center justify-center bg-surface">
{doc.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- signierte, kurzlebige Storage-URL
<img src={doc.thumbnailUrl} alt="" loading="lazy" className="size-full object-cover" />
) : (
<FileKindIcon kind={kind} className="size-12 stroke-[1.25]" />
)}
</div>
<div className="flex flex-col gap-0.5 p-2.5">
<span className="truncate text-small font-medium">{doc.name}</span>
<span className="text-label text-muted-foreground">{formatFileSize(doc.sizeBytes)}</span>
</div>
</button>
</li>
);
})}
</ul>
)}
{dragActive && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-lg border-2 border-dashed border-primary bg-background/80 font-medium">
Dateien hier ablegen
</div>
)}
<PreviewDialog
doc={preview}
profileId={profileId}
isAdmin={isAdmin}
onClose={() => setPreview(null)}
onDownload={handleDownload}
/>
</div>
);
}

View file

@ -0,0 +1,31 @@
import {
FileArchiveIcon,
FileAudioIcon,
FileIcon,
FileImageIcon,
FileSpreadsheetIcon,
FileTextIcon,
FileVideoIcon,
PresentationIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "cn";
import type { FileKind } from "@/modules/shared/schemas/documents";
const ICONS: Record<FileKind, { icon: LucideIcon; className: string }> = {
image: { icon: FileImageIcon, className: "text-violet-500" },
pdf: { icon: FileTextIcon, className: "text-red-500" },
text: { icon: FileTextIcon, className: "text-ink-500" },
document: { icon: FileTextIcon, className: "text-blue-500" },
spreadsheet: { icon: FileSpreadsheetIcon, className: "text-emerald-600" },
presentation: { icon: PresentationIcon, className: "text-orange-500" },
archive: { icon: FileArchiveIcon, className: "text-amber-600" },
video: { icon: FileVideoIcon, className: "text-pink-500" },
audio: { icon: FileAudioIcon, className: "text-cyan-600" },
other: { icon: FileIcon, className: "text-ink-500" },
};
export function FileKindIcon({ kind, className }: { kind: FileKind; className?: string }) {
const { icon: Icon, className: color } = ICONS[kind];
return <Icon className={cn(color, className)} aria-hidden />;
}

View file

@ -0,0 +1,124 @@
"use client";
import { useActionState, useState } from "react";
import { useRouter } from "next/navigation";
import { FolderPlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { createFolder, deleteFolder, type DocumentActionState } from "@/modules/shared/actions/documents";
function CreateFolderForm({ profileId, onDone }: { profileId: string; onDone: () => void }) {
async function run(prev: DocumentActionState, formData: FormData) {
const result = await createFolder(prev, formData);
if (result && "success" in result) onDone();
return result;
}
const [state, formAction, pending] = useActionState(run, undefined);
return (
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="profileId" value={profileId} />
<Field>
<FieldLabel htmlFor="folder-name">Name</FieldLabel>
<Input id="folder-name" name="name" placeholder="z. B. Verträge" autoFocus required />
</Field>
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
<DialogFooter>
<Button type="submit" disabled={pending}>
{pending ? "Wird erstellt..." : "Erstellen"}
</Button>
</DialogFooter>
</form>
);
}
export function CreateFolderDialog({ profileId }: { profileId: string }) {
const [open, setOpen] = useState(false);
const [instanceKey, setInstanceKey] = useState(0);
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) setInstanceKey((key) => key + 1);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button />}>
<FolderPlusIcon className="size-4" /> Neuer Ordner
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-2">
<DialogTitle>Neuer Ordner</DialogTitle>
</DialogHeader>
<CreateFolderForm key={instanceKey} profileId={profileId} onDone={() => handleOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
export function DeleteFolderDialog({
profileId,
folderId,
folderName,
redirectTo,
}: {
profileId: string;
folderId: string;
folderName: string;
redirectTo: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDelete() {
setPending(true);
setError(null);
const result = await deleteFolder(profileId, folderId);
setPending(false);
if (result && "error" in result) {
setError(result.error);
return;
}
setOpen(false);
router.push(redirectTo);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" />}>
<Trash2Icon className="size-4" /> Ordner löschen
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Ordner löschen</DialogTitle>
<DialogDescription>
Möchtest du den Ordner <span className="font-medium text-foreground">{folderName}</span> löschen?
Das geht nur, wenn er leer ist.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,96 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { ChevronRightIcon, FolderIcon } from "lucide-react"
import { z } from "zod"
import { getClientId } from "@/modules/shared/queries/profile"
import { getDocuments, getFolder, getFolders } from "@/modules/shared/queries/documents"
import { CreateFolderDialog, DeleteFolderDialog } from "@/modules/shared/ui/components/folder-dialogs"
import { DocumentsBrowser } from "@/modules/shared/ui/components/documents-browser"
function EmptyState({ children }: { children: React.ReactNode }) {
return <p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">{children}</p>
}
export async function DocumentsPanel({
profileId,
isAdmin,
basePath,
folderId,
}: {
profileId: string
isAdmin: boolean
basePath: string
folderId?: string
}) {
const clientId = await getClientId(profileId)
if (!clientId) {
return (
<div className="flex flex-col gap-8 w-full">
<h1 className="text-3xl font-semibold">Dokumente</h1>
<EmptyState>
{isAdmin
? "Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen."
: "Sobald eure Stammdaten hinterlegt sind, könnt ihr hier Dokumente ablegen."}
</EmptyState>
</div>
)
}
if (folderId) {
if (!z.string().uuid().safeParse(folderId).success) notFound()
const folder = await getFolder(clientId, folderId)
if (!folder) notFound()
const documents = await getDocuments(clientId, folder.id)
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-wrap items-center justify-between gap-3">
<nav aria-label="Pfad" className="flex min-w-0 items-center gap-1.5 text-3xl font-semibold">
<Link href={basePath} className="text-muted-foreground hover:text-foreground">Dokumente</Link>
<ChevronRightIcon className="size-6 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate">{folder.name}</span>
</nav>
{isAdmin && documents.length === 0 && (
<DeleteFolderDialog profileId={profileId} folderId={folder.id} folderName={folder.name} redirectTo={basePath} />
)}
</div>
<DocumentsBrowser profileId={profileId} folderId={folder.id} isAdmin={isAdmin} documents={documents} />
</div>
)
}
const folders = await getFolders(clientId)
return (
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-3xl font-semibold">Dokumente</h1>
{isAdmin && <CreateFolderDialog profileId={profileId} />}
</div>
{folders.length === 0 ? (
<EmptyState>
{isAdmin ? "Noch keine Ordner. Lege einen Ordner an, damit Dateien hochgeladen werden können." : "Noch keine Ordner vorhanden."}
</EmptyState>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{folders.map((folder) => (
<li key={folder.id}>
<Link
href={`${basePath}?folder=${folder.id}`}
className="flex flex-col items-center gap-2 rounded-lg border border-border bg-background p-5 text-center transition-colors hover:border-ink-300 hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring outline-none"
>
<FolderIcon className="size-12 fill-accent-400/20 stroke-[1.25] text-accent-500" aria-hidden />
<span className="w-full truncate font-medium">{folder.name}</span>
<span className="text-label text-muted-foreground">
{folder.documentCount} {folder.documentCount === 1 ? "Datei" : "Dateien"}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
)
}