since3-portal/modules/admin/ui/components/client-actions-menu.tsx

227 lines
11 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { Info, KeyRoundIcon, MoreHorizontalIcon, PencilIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
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, updateClientStatus } from "@/modules/admin/actions/manage-client";
import { CLIENT_STATUSES, CLIENT_STATUS_CONFIG, type ClientStatus } from "@/modules/admin/lib/client-status";
import { MIN_PASSWORD_LENGTH } from "@/modules/admin/lib/constants";
import { CopyField } from "./copy-field";
type Client = { id: string; name: string; email: string; status: ClientStatus };
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 gap-2 sm:grid-cols-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>
</>
);
}
function StatusClientContent({ client, onDone }: { client: Client; onDone: () => void }) {
const [status, setStatus] = useState<ClientStatus>(client.status);
async function run(prev: Parameters<typeof updateClientStatus>[0], formData: FormData) {
const result = await updateClientStatus(prev, formData);
if (result && "success" in result) onDone();
return result;
}
const [state, formAction, pending] = useActionState(run, undefined);
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>Status ändern</DialogTitle>
<DialogDescription>
Projektstatus von <span className="font-medium text-foreground">{client.name}</span>.
</DialogDescription>
</DialogHeader>
<form action={formAction}>
<input type="hidden" name="profileId" value={client.id} />
<FieldGroup>
<div role="radiogroup" aria-label="Status" className="grid gap-2">
{CLIENT_STATUSES.map((value) => (
<label
key={value}
className="flex cursor-pointer items-center gap-3 rounded-md border border-border px-3 py-2.5 text-sm has-checked:border-primary has-checked:bg-surface"
>
<input
type="radio"
name="status"
value={value}
checked={status === value}
onChange={() => setStatus(value)}
className="accent-primary"
/>
<Badge variant={CLIENT_STATUS_CONFIG[value].variant}>{CLIENT_STATUS_CONFIG[value].label}</Badge>
</label>
))}
</div>
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending || status === client.status}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</>
);
}
export function ClientActionsMenu({ client }: { client: Client }) {
const [dialog, setDialog] = useState<"password" | "edit" | "status" | 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("status")}>
<Info/> Status ändern
</DropdownMenuItem>
<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} />}
{dialog === "status" && <StatusClientContent key={instanceKey} client={client} onDone={close}/> }
</DialogContent>
</Dialog>
</>
);
}