since3-portal/modules/admin/ui/components/settings-forms.tsx
2026-09-29 22:56:37 +02:00

292 lines
13 KiB
TypeScript

"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 sm: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 basis-full flex-col sm:basis-auto">
<span className="flex flex-wrap items-center gap-2 font-medium break-words">
{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="ml-auto flex items-center gap-1">
<ActiveToggle template={template} />
<TemplateDialog template={template} />
<DeleteTemplateDialog template={template} />
</div>
</li>
))}
</ul>
)}
</div>
);
}