217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { BellRing, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
checkAndSendDutyReminders,
|
|
upsertDutyAssignment,
|
|
} from "./actions";
|
|
|
|
const UNASSIGNED = "__unassigned__";
|
|
|
|
export type DutyPlanWeekDto = {
|
|
key: string;
|
|
label: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
};
|
|
|
|
export type DutyPlanDutyTypeDto = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
};
|
|
|
|
export type DutyPlanFamilyDto = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
export type DutyPlanAssignmentDto = {
|
|
id: string;
|
|
dutyTypeId: string;
|
|
familyId: string;
|
|
startDate: string;
|
|
};
|
|
|
|
export function DutyPlanner({
|
|
weeks,
|
|
dutyTypes,
|
|
families,
|
|
assignments,
|
|
}: {
|
|
weeks: DutyPlanWeekDto[];
|
|
dutyTypes: DutyPlanDutyTypeDto[];
|
|
families: DutyPlanFamilyDto[];
|
|
assignments: DutyPlanAssignmentDto[];
|
|
}) {
|
|
const [isPending, startTransition] = useTransition();
|
|
const [pendingCell, setPendingCell] = useState<string | null>(null);
|
|
|
|
const assignmentByCell = new Map(
|
|
assignments.map((assignment) => [
|
|
`${assignment.startDate}:${assignment.dutyTypeId}`,
|
|
assignment,
|
|
]),
|
|
);
|
|
|
|
function handleAssignmentChange(
|
|
week: DutyPlanWeekDto,
|
|
dutyTypeId: string,
|
|
value: string,
|
|
) {
|
|
const cellKey = `${week.startDate}:${dutyTypeId}`;
|
|
setPendingCell(cellKey);
|
|
|
|
startTransition(async () => {
|
|
const result = await upsertDutyAssignment({
|
|
dutyTypeId,
|
|
familyId: value === UNASSIGNED ? null : value,
|
|
startDate: week.startDate,
|
|
endDate: week.endDate,
|
|
});
|
|
|
|
if (result.error) {
|
|
toast.error(result.error);
|
|
} else {
|
|
toast.success(
|
|
value === UNASSIGNED
|
|
? "Zuweisung entfernt"
|
|
: "Dienstplan aktualisiert",
|
|
);
|
|
}
|
|
setPendingCell(null);
|
|
});
|
|
}
|
|
|
|
function sendReminders() {
|
|
startTransition(async () => {
|
|
const result = await checkAndSendDutyReminders();
|
|
if (result.error) {
|
|
toast.error(result.error);
|
|
return;
|
|
}
|
|
|
|
toast.success(
|
|
`Erinnerungen versendet: ${result.sent ?? 0}. Ohne Empfaenger: ${
|
|
result.skipped ?? 0
|
|
}.`,
|
|
);
|
|
|
|
if (result.errors?.length) {
|
|
toast.warning(`${result.errors.length} Erinnerung(en) fehlgeschlagen.`);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (dutyTypes.length === 0) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-8 text-center">
|
|
<p className="text-sm font-medium text-[var(--ink)]">
|
|
Noch keine Diensttypen
|
|
</p>
|
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
|
Lege zuerst einen Diensttyp an, danach erscheint hier das Planungsgrid.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendReminders}
|
|
disabled={isPending}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
|
) : (
|
|
<BellRing className="h-4 w-4 [stroke-width:1.7]" />
|
|
)}
|
|
Reminder diese Woche senden
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto rounded-lg border border-[var(--hairline)]">
|
|
<table className="w-full min-w-[760px] border-collapse text-[13.5px] text-[var(--ink)]">
|
|
<thead>
|
|
<tr className="border-b border-[var(--hairline-soft)] bg-[var(--surface-2)]">
|
|
<th className="sticky left-0 z-10 w-56 bg-[var(--surface-2)] px-4 py-3 text-left text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
|
Woche
|
|
</th>
|
|
{dutyTypes.map((dutyType) => (
|
|
<th
|
|
key={dutyType.id}
|
|
className="min-w-56 px-4 py-3 text-left font-medium text-[var(--ink)]"
|
|
>
|
|
<div>{dutyType.name}</div>
|
|
{dutyType.description && (
|
|
<div className="mt-1 line-clamp-1 text-xs font-normal text-[var(--ink-muted)]">
|
|
{dutyType.description}
|
|
</div>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{weeks.map((week) => (
|
|
<tr
|
|
key={week.key}
|
|
className="border-b border-[var(--hairline-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
|
|
>
|
|
<td className="sticky left-0 z-10 bg-[var(--surface)] px-4 py-3 font-medium">
|
|
{week.label}
|
|
</td>
|
|
{dutyTypes.map((dutyType) => {
|
|
const cellKey = `${week.startDate}:${dutyType.id}`;
|
|
const currentAssignment = assignmentByCell.get(cellKey);
|
|
return (
|
|
<td key={cellKey} className="px-4 py-3">
|
|
<Select
|
|
value={currentAssignment?.familyId ?? UNASSIGNED}
|
|
onValueChange={(value) =>
|
|
handleAssignmentChange(week, dutyType.id, value)
|
|
}
|
|
disabled={isPending && pendingCell === cellKey}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="Familie wählen" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={UNASSIGNED}>
|
|
Nicht zugewiesen
|
|
</SelectItem>
|
|
{families.map((family) => (
|
|
<SelectItem key={family.id} value={family.id}>
|
|
{family.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|