mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
292 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|