feat: show planning availability and manage Kita access roles
This commit is contained in:
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
import { serializableTransaction } from "@/lib/transaction";
|
||||
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
|
||||
import {
|
||||
queueNotdienstPublication,
|
||||
queueNotdienstReminders,
|
||||
deliverDueNotdienstEmails,
|
||||
} from "@/lib/notdienst-notifications";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
targetMonth = data.targetMonth;
|
||||
}
|
||||
|
||||
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!validPlanMonth(targetYear, targetMonth))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
|
||||
try {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const existingPlan = await tx.notdienstPlan.findUnique({
|
||||
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
});
|
||||
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
|
||||
if (existingPlan)
|
||||
throw new UserFacingError(
|
||||
"Ein Plan für diesen Monat existiert bereits.",
|
||||
);
|
||||
|
||||
const [availabilities, workingDays] = await Promise.all([
|
||||
tx.notdienstAvailability.findMany({
|
||||
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
const familyUsageCount = new Map<string, number>();
|
||||
const assignments: { childId: string; date: Date }[] = [];
|
||||
for (const day of workingDays) {
|
||||
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
|
||||
const candidates = availabilities.filter(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
if (!candidates.length) continue;
|
||||
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
|
||||
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
|
||||
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||
const minUsage = Math.min(
|
||||
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
|
||||
);
|
||||
const leastUsed = candidates.filter(
|
||||
(a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage,
|
||||
);
|
||||
const selected =
|
||||
leastUsed[Math.floor(Math.random() * leastUsed.length)];
|
||||
assignments.push({ childId: selected.childId, date: day });
|
||||
familyUsageCount.set(selected.child.familyId, minUsage + 1);
|
||||
}
|
||||
const plan = await tx.notdienstPlan.create({
|
||||
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
|
||||
data: {
|
||||
kitaId,
|
||||
year: targetYear,
|
||||
month: targetMonth,
|
||||
status: NotdienstPlanStatus.DRAFT,
|
||||
createdById: session.user.id,
|
||||
},
|
||||
});
|
||||
if (assignments.length) {
|
||||
await tx.notdienstAssignment.createMany({
|
||||
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
|
||||
return {
|
||||
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) {
|
||||
await serializableTransaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
include: { assignments: { include: { child: { select: { active: true } } } } },
|
||||
include: {
|
||||
assignments: { include: { child: { select: { active: true } } } },
|
||||
},
|
||||
});
|
||||
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
|
||||
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT)
|
||||
throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
const validDates = new Set(days.map((day) => day.getTime()));
|
||||
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
|
||||
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
|
||||
if (
|
||||
plan.assignments.some(
|
||||
(a) => !validDates.has(a.date.getTime()) || !a.child.active,
|
||||
)
|
||||
) {
|
||||
throw new UserFacingError(
|
||||
"Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.",
|
||||
);
|
||||
}
|
||||
await tx.notdienstPlan.update({
|
||||
where: { id: planId, kitaId },
|
||||
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
|
||||
data: {
|
||||
status: NotdienstPlanStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
|
||||
await queueNotdienstPublication(
|
||||
tx,
|
||||
kitaId,
|
||||
planId,
|
||||
plan.year,
|
||||
plan.month,
|
||||
);
|
||||
});
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
|
||||
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
|
||||
);
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Der Plan konnte nicht veröffentlicht werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +188,8 @@ export async function updateAssignmentAction(
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
|
||||
if (!dateOnlySchema.safeParse(date).success)
|
||||
return { error: "Ungültiges Datum." };
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
@@ -161,14 +212,27 @@ export async function updateAssignmentAction(
|
||||
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
|
||||
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
|
||||
if (
|
||||
newChildId &&
|
||||
(parsedDate.getUTCFullYear() !== plan.year ||
|
||||
parsedDate.getUTCMonth() + 1 !== plan.month)
|
||||
) {
|
||||
throw new UserFacingError(
|
||||
"Das Datum gehört nicht zum Planungsmonat.",
|
||||
);
|
||||
}
|
||||
// Clearing obsolete entries remains possible after a new closure.
|
||||
if (newChildId) {
|
||||
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
|
||||
const days = await loadNotdienstDays(
|
||||
tx,
|
||||
kitaId,
|
||||
plan.year,
|
||||
plan.month,
|
||||
);
|
||||
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
|
||||
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
|
||||
throw new UserFacingError(
|
||||
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +253,18 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: {
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
],
|
||||
},
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
|
||||
where: {
|
||||
role: {
|
||||
in: [
|
||||
UserRole.ELTERN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.KOORDINATOR,
|
||||
],
|
||||
},
|
||||
emailVerifiedAt: { not: null },
|
||||
disabledAt: null,
|
||||
privacyPolicyAcceptedAt: { not: null },
|
||||
},
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
|
||||
if (
|
||||
!Number.isInteger(minPerChild) ||
|
||||
minPerChild < 1 ||
|
||||
minPerChild > 31 ||
|
||||
!Number.isInteger(reminderDays) ||
|
||||
reminderDays < 0 ||
|
||||
reminderDays > 31
|
||||
)
|
||||
return { error: "Ungültige Einstellungen." };
|
||||
|
||||
try {
|
||||
await prisma.kita.update({
|
||||
@@ -411,33 +505,61 @@ export async function updateKitaNotdienstSettingsAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotdienstRemindersAction(year: number, month: number) {
|
||||
export async function sendNotdienstRemindersAction(
|
||||
year: number,
|
||||
month: number,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||
if (!validPlanMonth(year, month))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
|
||||
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
|
||||
try {
|
||||
const queued = await queueNotdienstReminders(kitaId, year, month);
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
const delivery = await deliverDueNotdienstEmails({
|
||||
kitaId,
|
||||
year,
|
||||
month,
|
||||
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, queued, count: delivery.sent, ...delivery };
|
||||
} catch (error) {
|
||||
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
|
||||
return {
|
||||
error: publicActionError(
|
||||
error,
|
||||
"Die Erinnerungen konnten nicht vorbereitet werden.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryNotdienstEmailsAction(year: number, month: number) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||
if (!validPlanMonth(year, month))
|
||||
return { error: "Ungültiger Planungsmonat." };
|
||||
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
|
||||
return { error: "Bitte warte vor einem weiteren Versandversuch." };
|
||||
try {
|
||||
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
|
||||
await prisma.notdienstEmail.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
year,
|
||||
month,
|
||||
failed: true,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
data: { nextAttemptAt: new Date() },
|
||||
});
|
||||
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
return { success: true, ...delivery };
|
||||
} catch {
|
||||
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
|
||||
return {
|
||||
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
|
||||
import {
|
||||
formatUtcDateKey,
|
||||
getWorkingDaysOfMonth,
|
||||
getTargetMonthData,
|
||||
} from "@/lib/date-utils";
|
||||
import { loadNotdienstDays } from "@/lib/notdienst-planning";
|
||||
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
|
||||
import { EmailStatusCard } from "./_components/email-status-card";
|
||||
@@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({
|
||||
]);
|
||||
|
||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// aber für MVP reichen alle aktiven Kinder.
|
||||
const allChildrenRaw = await prisma.child.findMany({
|
||||
where: { kitaId, active: true },
|
||||
select: {
|
||||
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
|
||||
});
|
||||
|
||||
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
|
||||
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
|
||||
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||
const availabilities = rawAvailabilities.filter((a) =>
|
||||
validDateKeys.has(formatUtcDateKey(a.date)),
|
||||
);
|
||||
const closureCount =
|
||||
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
|
||||
|
||||
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
||||
availabilities.forEach((availability) => {
|
||||
@@ -186,37 +191,52 @@ export default async function PlanungsZentralePage({
|
||||
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
// Keep obsolete assignments visible so the coordinator can remove them.
|
||||
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
|
||||
const displayedDays = new Map(
|
||||
workingDays.map((day) => [formatUtcDateKey(day), day]),
|
||||
);
|
||||
for (const assignment of plan?.assignments ?? []) {
|
||||
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
|
||||
}
|
||||
const activeChildIds = new Set(allChildren.map((child) => child.id));
|
||||
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
const isOriginallyUnassigned =
|
||||
!assignment ||
|
||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||
const daysWithAssignments = Array.from(displayedDays.values())
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
const isOriginallyUnassigned =
|
||||
!assignment ||
|
||||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||
|
||||
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
|
||||
return {
|
||||
date: day.toISOString(),
|
||||
isInvalid,
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment && !isInvalid &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
const isInvalid =
|
||||
!validDateKeys.has(formatUtcDateKey(day)) ||
|
||||
(!!assignment && !activeChildIds.has(assignment.childId));
|
||||
return {
|
||||
date: day.toISOString(),
|
||||
isInvalid,
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment &&
|
||||
!isInvalid &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
|
||||
const mailStatus = await getNotdienstEmailStatus(
|
||||
kitaId,
|
||||
targetYear,
|
||||
targetMonth,
|
||||
);
|
||||
const target = getTargetMonthData(now);
|
||||
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
|
||||
const remindersOpen =
|
||||
!target.isLocked &&
|
||||
target.targetYear === targetYear &&
|
||||
target.targetMonth === targetMonth &&
|
||||
plan?.status !== "PUBLISHED";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
|
||||
<EmailStatusCard
|
||||
status={mailStatus}
|
||||
year={targetYear}
|
||||
month={targetMonth}
|
||||
remindersOpen={remindersOpen}
|
||||
daysBefore={kita.notdienstReminderDaysBefore}
|
||||
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
|
||||
/>
|
||||
|
||||
<PlanView
|
||||
planId={plan?.id}
|
||||
|
||||
@@ -121,8 +121,14 @@ export function PlanView({
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
|
||||
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
|
||||
if (result.failed || ("deferred" in result && result.deferred))
|
||||
toast.warning(
|
||||
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
|
||||
);
|
||||
else
|
||||
toast.success(
|
||||
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -133,8 +139,14 @@ export function PlanView({
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else if ("success" in result && result.success) {
|
||||
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
|
||||
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
|
||||
if (result.failed || ("deferred" in result && result.deferred))
|
||||
toast.warning(
|
||||
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
|
||||
);
|
||||
else
|
||||
toast.success(
|
||||
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -157,7 +169,11 @@ export function PlanView({
|
||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
|
||||
const result = await updateAssignmentAction(
|
||||
planId,
|
||||
date.slice(0, 10),
|
||||
valueToSet,
|
||||
);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
@@ -248,7 +264,10 @@ export function PlanView({
|
||||
{family.childCount === 1 ? "" : "er"}
|
||||
</p>
|
||||
</div>
|
||||
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
|
||||
<FamilyStatusPill
|
||||
selectedDays={family.selectedDays.length}
|
||||
requiredDays={family.requiredDays}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
|
||||
{family.selectedDays.length > 0
|
||||
? family.selectedDays.map((day) => day.label).join(" · ")
|
||||
@@ -318,8 +337,8 @@ export function PlanView({
|
||||
Plan automatisch generieren
|
||||
</h3>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||||
Verteilt {requiredDays} Notdiensttage fair auf{" "}
|
||||
die verfügbaren Familien.
|
||||
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
|
||||
Familien.
|
||||
</p>
|
||||
<details className="mt-3 text-xs text-[var(--ink-soft)]">
|
||||
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
|
||||
@@ -412,6 +431,7 @@ export function PlanView({
|
||||
<ManualAssignmentTable
|
||||
days={days}
|
||||
allChildren={allChildren}
|
||||
familyStatuses={familyStatuses}
|
||||
isDraft={isDraft}
|
||||
isPending={isPending}
|
||||
onAssignmentChange={handleAssignmentChange}
|
||||
@@ -451,7 +471,13 @@ export function PlanView({
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
|
||||
function FamilyStatusPill({
|
||||
selectedDays,
|
||||
requiredDays,
|
||||
}: {
|
||||
selectedDays: number;
|
||||
requiredDays: number;
|
||||
}) {
|
||||
if (selectedDays === 0) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
|
||||
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
|
||||
<span
|
||||
className={
|
||||
selectedDays >= requiredDays
|
||||
? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]"
|
||||
: "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"
|
||||
}
|
||||
>
|
||||
{selectedDays} / {requiredDays} Tage
|
||||
</span>
|
||||
);
|
||||
@@ -552,7 +584,11 @@ function PlanPreviewTable({
|
||||
{format(dateObj, "EEEE", { locale: de })}
|
||||
</div>
|
||||
<div className="text-[var(--ink)]">
|
||||
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
|
||||
{assignedChild
|
||||
? assignedChild.parentName
|
||||
: day.childId
|
||||
? "Inaktives Kind"
|
||||
: "Unbesetzt"}
|
||||
{assignedChild && (
|
||||
<span className="ml-1 text-[var(--ink-muted)]">
|
||||
({assignedChild.name})
|
||||
@@ -576,8 +612,20 @@ function PlanPreviewTable({
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
|
||||
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
|
||||
<Badge
|
||||
variant={
|
||||
day.isInvalid
|
||||
? "warning"
|
||||
: assignedChild
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{day.isInvalid
|
||||
? "Bitte korrigieren"
|
||||
: assignedChild
|
||||
? "Besetzt"
|
||||
: "Offen"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,17 +636,29 @@ function PlanPreviewTable({
|
||||
);
|
||||
}
|
||||
|
||||
function ManualAssignmentTable({
|
||||
export function ManualAssignmentTable({
|
||||
days,
|
||||
allChildren,
|
||||
familyStatuses,
|
||||
isDraft,
|
||||
isPending,
|
||||
onAssignmentChange,
|
||||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
||||
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
|
||||
isDraft: boolean;
|
||||
isPending: boolean;
|
||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||
}) {
|
||||
const availabilityByFamily = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
familyStatuses.map((family) => [
|
||||
family.id,
|
||||
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
|
||||
]),
|
||||
),
|
||||
[familyStatuses],
|
||||
);
|
||||
|
||||
// Map childId to familyId for quick lookup
|
||||
const childFamilyMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
|
||||
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
|
||||
gilt für den gesamten Haushalt.
|
||||
</div>
|
||||
<div className="hidden sm:grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||
<div>Datum</div>
|
||||
<div>Eingeteilte Familie</div>
|
||||
</div>
|
||||
@@ -634,42 +699,82 @@ function ManualAssignmentTable({
|
||||
{days.map((day) => {
|
||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||
const dateObj = new Date(yr, mo - 1, dy);
|
||||
const dateKey = day.date.slice(0, 10);
|
||||
const available = allChildren.filter((child) =>
|
||||
availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||
);
|
||||
const unavailable = allChildren.filter(
|
||||
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
|
||||
);
|
||||
const availableFamilies = new Set(
|
||||
available.map((child) => child.familyId),
|
||||
).size;
|
||||
const selectedChild = allChildren.find(
|
||||
(child) => child.id === day.childId,
|
||||
);
|
||||
const selectedAvailable =
|
||||
!!selectedChild &&
|
||||
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
|
||||
const option = (child: PlanViewProps["allChildren"][number]) => (
|
||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||
{child.parentName} ({child.name}) —{" "}
|
||||
{familyAssignmentCounts.get(child.familyId) ?? 0}x
|
||||
</option>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="font-medium text-[var(--ink)]">
|
||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={day.childId || "empty"}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
onChange={(event) =>
|
||||
onAssignmentChange(day.date, event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
|
||||
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
|
||||
) : null}
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id} disabled={day.isInvalid}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<select
|
||||
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
|
||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||
value={day.childId || "empty"}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
onChange={(event) =>
|
||||
onAssignmentChange(day.date, event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{day.childId &&
|
||||
!allChildren.some((child) => child.id === day.childId) ? (
|
||||
<option value={day.childId} disabled>
|
||||
Inaktives Kind – bitte Einteilung entfernen
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : null}
|
||||
{available.length > 0 ? (
|
||||
<optgroup label="Verfügbar an diesem Tag">
|
||||
{available.map(option)}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{unavailable.length > 0 ? (
|
||||
<optgroup label="Keine Verfügbarkeit für diesen Tag">
|
||||
{unavailable.map(option)}
|
||||
</optgroup>
|
||||
) : null}
|
||||
</select>
|
||||
{!day.isInvalid ? (
|
||||
<p
|
||||
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
|
||||
>
|
||||
{selectedChild
|
||||
? selectedAvailable
|
||||
? "Eingeteilte Familie ist verfügbar."
|
||||
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
|
||||
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{day.isInvalid ? (
|
||||
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
|
||||
<span className="text-xs text-[var(--danger)]">
|
||||
Einteilung nicht mehr gültig – bitte entfernen.
|
||||
</span>
|
||||
) : null}
|
||||
{!isDraft && (
|
||||
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user