1436 lines
52 KiB
TypeScript
1436 lines
52 KiB
TypeScript
import { UserFacingError } from "@/lib/action-error";
|
||
import { randomUUID } from "node:crypto";
|
||
import { hashToken, newToken } from "@/lib/tokens";
|
||
import {
|
||
Prisma,
|
||
UserRole,
|
||
StaffLeaveKind,
|
||
StaffRequestStatus,
|
||
} from "@prisma/client";
|
||
import { z } from "zod";
|
||
import { prisma } from "@/lib/prisma";
|
||
import {
|
||
addDays,
|
||
calculateLeaveDays,
|
||
calculateWeek,
|
||
dateKey,
|
||
dateOnly,
|
||
dateSchema,
|
||
getTimeBalance,
|
||
scheduledMinutes,
|
||
todayKey,
|
||
vacationBalance,
|
||
weekStart,
|
||
} from "./calculations";
|
||
|
||
export type StaffActor = { id: string; kitaId: string; role: UserRole };
|
||
export type StaffNotice = {
|
||
profileId: string;
|
||
text: string;
|
||
toAdmins: boolean;
|
||
};
|
||
type CommandResult = {
|
||
educatorId?: string;
|
||
emailIds?: string[];
|
||
notice?: StaffNotice;
|
||
invite?: { email: string; name: string; token: string; kitaName: string };
|
||
};
|
||
const idSchema = z.string().min(1).max(100);
|
||
const educatorInput = z.object({
|
||
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
|
||
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
|
||
active: z.boolean().default(true),
|
||
email: z
|
||
.string()
|
||
.trim()
|
||
.max(254)
|
||
.email("Bitte eine gültige E-Mail-Adresse angeben.")
|
||
.toLowerCase(),
|
||
});
|
||
const onboardingDates = z
|
||
.object({
|
||
employmentStart: dateSchema,
|
||
trackingStart: dateSchema,
|
||
})
|
||
.refine((input) => input.trackingStart >= input.employmentStart, {
|
||
message:
|
||
"Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.",
|
||
});
|
||
|
||
async function inviteStaff(
|
||
tx: Prisma.TransactionClient,
|
||
actor: StaffActor,
|
||
profile: {
|
||
id: string;
|
||
kitaId: string;
|
||
userId: string | null;
|
||
user: { email: string; emailVerifiedAt: Date | null } | null;
|
||
educator: { firstName: string; lastName: string };
|
||
},
|
||
email: string,
|
||
): Promise<CommandResult> {
|
||
assertStaffAccess(actor, profile, true);
|
||
if (
|
||
profile.userId &&
|
||
(profile.user?.email !== email || profile.user.emailVerifiedAt)
|
||
)
|
||
throw new UserFacingError(
|
||
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
|
||
);
|
||
const existingUser = await tx.user.findUnique({ where: { email } });
|
||
if (
|
||
existingUser &&
|
||
(existingUser.kitaId !== actor.kitaId ||
|
||
!(
|
||
existingUser.role === UserRole.ERZIEHER ||
|
||
(existingUser.role === UserRole.ADMIN &&
|
||
existingUser.id === profile.userId)
|
||
) ||
|
||
(await tx.staffProfile.count({
|
||
where: { userId: existingUser.id, id: { not: profile.id } },
|
||
})))
|
||
)
|
||
throw new UserFacingError(
|
||
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
|
||
);
|
||
const user =
|
||
existingUser ??
|
||
(await tx.user.create({
|
||
data: {
|
||
email,
|
||
firstName: profile.educator.firstName,
|
||
lastName: profile.educator.lastName,
|
||
passwordHash: "",
|
||
role: UserRole.ERZIEHER,
|
||
kitaId: actor.kitaId,
|
||
},
|
||
}));
|
||
await tx.staffProfile.update({
|
||
where: { id: profile.id },
|
||
data: { userId: user.id },
|
||
});
|
||
await tx.staffHistory.create({
|
||
data: {
|
||
profileId: profile.id,
|
||
actorId: actor.id,
|
||
action: "Account verknüpft",
|
||
details: email,
|
||
},
|
||
});
|
||
if (user.emailVerifiedAt) return {};
|
||
const token = newToken();
|
||
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
|
||
await tx.verificationToken.create({
|
||
data: {
|
||
identifier: user.id,
|
||
token: hashToken(token),
|
||
expires: new Date(Date.now() + 7 * 86400000),
|
||
},
|
||
});
|
||
const kita = await tx.kita.findUniqueOrThrow({
|
||
where: { id: actor.kitaId },
|
||
select: { name: true },
|
||
});
|
||
const job = await tx.staffEmail.create({
|
||
data: {
|
||
profileId: profile.id,
|
||
userId: user.id,
|
||
subject: `Einladung zu ${kita.name} im Kita-Planer`,
|
||
message: "Account aktivieren",
|
||
path: `/invite/${token}`,
|
||
inviteToken: token,
|
||
},
|
||
});
|
||
return {
|
||
emailIds: [job.id],
|
||
invite: {
|
||
email,
|
||
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||
token,
|
||
kitaName: kita.name,
|
||
},
|
||
};
|
||
}
|
||
const reasonSchema = z
|
||
.string()
|
||
.trim()
|
||
.min(3, "Bitte eine Begründung angeben.")
|
||
.max(500);
|
||
const commandSchema = z.object({
|
||
command: z.string(),
|
||
data: z.record(z.string(), z.unknown()),
|
||
});
|
||
const activeLeaveStatuses: StaffRequestStatus[] = [
|
||
"PENDING",
|
||
"APPROVED",
|
||
"CANCEL_REQUESTED",
|
||
];
|
||
const profileInclude = {
|
||
educator: true,
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailVerifiedAt: true,
|
||
},
|
||
},
|
||
workPatterns: { orderBy: { effectiveFrom: "asc" as const } },
|
||
dayOverrides: true,
|
||
leaveAccounts: true,
|
||
leaves: { include: { days: true }, orderBy: { createdAt: "desc" as const } },
|
||
timeEntries: {
|
||
orderBy: [{ date: "asc" as const }, { startMinute: "asc" as const }],
|
||
},
|
||
weeks: { orderBy: { weekStart: "desc" as const } },
|
||
adjustments: true,
|
||
history: { orderBy: { createdAt: "desc" as const }, take: 30 },
|
||
} satisfies Prisma.StaffProfileInclude;
|
||
export type StaffProfileData = Prisma.StaffProfileGetPayload<{
|
||
include: typeof profileInclude;
|
||
}>;
|
||
|
||
function requireAdmin(actor: StaffActor) {
|
||
if (actor.role !== UserRole.ADMIN)
|
||
throw new UserFacingError("Nur Kita-Admins dürfen Personal verwalten.");
|
||
}
|
||
export function assertStaffAccess(
|
||
actor: StaffActor,
|
||
profile: { kitaId: string; userId: string | null },
|
||
adminOnly = false,
|
||
) {
|
||
if (
|
||
profile.kitaId !== actor.kitaId ||
|
||
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
|
||
actor.role as "ADMIN" | "ERZIEHER",
|
||
)
|
||
)
|
||
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
|
||
if (adminOnly) requireAdmin(actor);
|
||
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
|
||
throw new UserFacingError(
|
||
"Du kannst nur dein eigenes Personalprofil verwalten.",
|
||
);
|
||
}
|
||
|
||
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
|
||
if (
|
||
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
|
||
actor.role as "ADMIN" | "ERZIEHER",
|
||
)
|
||
)
|
||
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
|
||
const profile = await prisma.staffProfile.findFirst({
|
||
where: {
|
||
kitaId: actor.kitaId,
|
||
...(actor.role === UserRole.ADMIN && profileId
|
||
? { id: profileId }
|
||
: { userId: actor.id }),
|
||
},
|
||
include: profileInclude,
|
||
});
|
||
if (profile) assertStaffAccess(actor, profile);
|
||
return profile;
|
||
}
|
||
|
||
function ensureDayEditable(profile: StaffProfileData, key: string) {
|
||
const sheet = profile.weeks.find(
|
||
(w) => dateKey(w.weekStart) === weekStart(key),
|
||
);
|
||
if (sheet && sheet.status !== "DRAFT")
|
||
throw new UserFacingError(
|
||
"Diese Woche ist eingereicht oder genehmigt. Der Admin muss sie zuerst zur Korrektur zurückgeben.",
|
||
);
|
||
}
|
||
function ensureRangeEditable(
|
||
profile: StaffProfileData,
|
||
start: string,
|
||
end: string,
|
||
) {
|
||
if (
|
||
profile.weeks.some(
|
||
(w) =>
|
||
w.status !== "DRAFT" &&
|
||
dateKey(w.weekStart) <= end &&
|
||
addDays(dateKey(w.weekStart), 6) >= start,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Im Zeitraum liegt eine eingereichte oder genehmigte Woche. Bitte zuerst zur Korrektur zurückgeben.",
|
||
);
|
||
}
|
||
function ensureModelEditable(profile: StaffProfileData, from: string) {
|
||
if (
|
||
profile.weeks.some(
|
||
(w) => w.status !== "DRAFT" && addDays(dateKey(w.weekStart), 6) >= from,
|
||
) ||
|
||
profile.leaves.some(
|
||
(l) =>
|
||
["APPROVED", "CANCEL_REQUESTED"].includes(l.status) &&
|
||
dateKey(l.endDate) >= from,
|
||
)
|
||
) {
|
||
throw new UserFacingError(
|
||
"Ab diesem Datum bestehen bereits Freigaben. Bitte betroffene Wochen zurückgeben bzw. Abwesenheiten stornieren oder einen späteren Beginn wählen.",
|
||
);
|
||
}
|
||
}
|
||
function assertVacationFunds(
|
||
profile: StaffProfileData,
|
||
days: { date: string; vacationUnits: number }[],
|
||
excludeId?: string,
|
||
) {
|
||
for (const year of new Set(
|
||
days.filter((d) => d.vacationUnits).map((d) => Number(d.date.slice(0, 4))),
|
||
)) {
|
||
const account = profile.leaveAccounts.find((a) => a.year === year);
|
||
if (!account)
|
||
throw new UserFacingError(
|
||
`Für ${year} muss der Admin zuerst ein Urlaubskonto hinterlegen.`,
|
||
);
|
||
const balance = vacationBalance(
|
||
account,
|
||
profile.leaves.filter((l) => l.id !== excludeId),
|
||
);
|
||
const requested = days
|
||
.filter((d) => Number(d.date.slice(0, 4)) === year)
|
||
.reduce((sum, d) => sum + d.vacationUnits, 0);
|
||
if (balance.available - balance.pending < requested)
|
||
throw new UserFacingError(
|
||
`Für ${year} reichen die noch nicht verplanten Urlaubstage nicht aus.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Only called after authentication. Every command runs in a serializable transaction. */
|
||
export async function executeStaffCommand(
|
||
actor: StaffActor,
|
||
raw: unknown,
|
||
): Promise<CommandResult> {
|
||
const { command, data } = commandSchema.parse(raw);
|
||
if (
|
||
![UserRole.ADMIN, UserRole.ERZIEHER].includes(
|
||
actor.role as "ADMIN" | "ERZIEHER",
|
||
)
|
||
)
|
||
throw new UserFacingError("Kein Zugriff auf den Personalbereich.");
|
||
for (let attempt = 0; ; attempt++) {
|
||
try {
|
||
return await prisma.$transaction(
|
||
async (tx) => {
|
||
// Also validate the actor against the DB; a forged tenant or stale role cannot be used.
|
||
const dbActor = await tx.user.findFirst({
|
||
where: {
|
||
id: actor.id,
|
||
kitaId: actor.kitaId,
|
||
role: actor.role,
|
||
privacyPolicyAcceptedAt: { not: null },
|
||
emailVerifiedAt: { not: null },
|
||
disabledAt: null,
|
||
},
|
||
});
|
||
if (
|
||
!dbActor ||
|
||
(dbActor.lockedUntil && dbActor.lockedUntil > new Date())
|
||
)
|
||
throw new UserFacingError(
|
||
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
|
||
);
|
||
if (
|
||
["educatorCreate", "educatorUpdate", "educatorInvite"].includes(
|
||
command,
|
||
)
|
||
) {
|
||
requireAdmin(actor);
|
||
if (command === "educatorInvite") {
|
||
const educator = await tx.educator.findFirst({
|
||
where: {
|
||
id: idSchema.parse(data.educatorId),
|
||
kitaId: actor.kitaId,
|
||
},
|
||
include: {
|
||
staffProfile: { include: { user: true, educator: true } },
|
||
},
|
||
});
|
||
if (!educator?.staffProfile?.user)
|
||
throw new UserFacingError(
|
||
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
|
||
);
|
||
return inviteStaff(
|
||
tx,
|
||
actor,
|
||
educator.staffProfile,
|
||
educator.staffProfile.user.email,
|
||
);
|
||
}
|
||
const input = educatorInput.parse(data);
|
||
const existing =
|
||
command === "educatorUpdate"
|
||
? await tx.educator.findFirst({
|
||
where: {
|
||
id: idSchema.parse(data.educatorId),
|
||
kitaId: actor.kitaId,
|
||
},
|
||
include: { staffProfile: { include: { user: true } } },
|
||
})
|
||
: null;
|
||
if (command === "educatorUpdate" && !existing)
|
||
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||
if (
|
||
existing?.staffProfile?.user &&
|
||
existing.staffProfile.user.email !== input.email
|
||
)
|
||
throw new UserFacingError(
|
||
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
|
||
);
|
||
if (existing?.staffProfile)
|
||
assertStaffAccess(actor, existing.staffProfile, true);
|
||
// Dates are only supplied when creating the profile. Existing balances and dates stay intact.
|
||
const dates = !existing?.staffProfile
|
||
? onboardingDates.parse(data)
|
||
: null;
|
||
const values = {
|
||
firstName: input.firstName,
|
||
lastName: input.lastName,
|
||
active: input.active,
|
||
};
|
||
const educator = existing
|
||
? await tx.educator.update({
|
||
where: { id: existing.id },
|
||
data: values,
|
||
})
|
||
: await tx.educator.create({
|
||
data: { ...values, kitaId: actor.kitaId },
|
||
});
|
||
const profile =
|
||
existing?.staffProfile ??
|
||
(await tx.staffProfile.create({
|
||
data: {
|
||
kitaId: actor.kitaId,
|
||
educatorId: educator.id,
|
||
employmentStart: dateOnly(dates!.employmentStart),
|
||
trackingStart: dateOnly(dates!.trackingStart),
|
||
},
|
||
include: { user: true },
|
||
}));
|
||
await tx.staffHistory.create({
|
||
data: {
|
||
profileId: profile.id,
|
||
actorId: actor.id,
|
||
action: existing ? "Stammdaten geändert" : "Fachkraft angelegt",
|
||
details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`,
|
||
},
|
||
});
|
||
if (
|
||
profile.user &&
|
||
(profile.user.kitaId !== actor.kitaId ||
|
||
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
|
||
profile.user.role,
|
||
))
|
||
)
|
||
throw new UserFacingError(
|
||
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
|
||
);
|
||
if (profile.userId) {
|
||
await tx.user.update({
|
||
where: { id: profile.userId },
|
||
data: { firstName: input.firstName, lastName: input.lastName },
|
||
});
|
||
return { educatorId: educator.id };
|
||
}
|
||
const invitation = await inviteStaff(
|
||
tx,
|
||
actor,
|
||
{ ...profile, educator },
|
||
input.email,
|
||
);
|
||
return { ...invitation, educatorId: educator.id };
|
||
}
|
||
if (command === "emailPreference") {
|
||
if (
|
||
!(await tx.staffProfile.count({
|
||
where: { userId: actor.id, kitaId: actor.kitaId },
|
||
}))
|
||
)
|
||
throw new UserFacingError(
|
||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||
);
|
||
await tx.user.update({
|
||
where: { id: actor.id },
|
||
data: {
|
||
staffDecisionEmails: z.boolean().parse(data.enabled),
|
||
},
|
||
});
|
||
return {};
|
||
}
|
||
if (command === "emailRetry") {
|
||
const jobs = await tx.staffEmail.findMany({
|
||
where: {
|
||
profile: { kitaId: actor.kitaId },
|
||
...(actor.role === UserRole.ADMIN ? {} : { userId: actor.id }),
|
||
sentAt: null,
|
||
skippedAt: null,
|
||
},
|
||
select: { id: true },
|
||
orderBy: { createdAt: "asc" },
|
||
take: 50,
|
||
});
|
||
return { emailIds: jobs.map((job) => job.id) };
|
||
}
|
||
if (command === "holiday") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
date: dateSchema,
|
||
name: z.string().trim().min(2).max(100),
|
||
remove: z.boolean().optional(),
|
||
})
|
||
.parse(data);
|
||
const date = dateOnly(input.date);
|
||
const protectedWeek = await tx.staffWeek.count({
|
||
where: {
|
||
profile: { kitaId: actor.kitaId },
|
||
status: { not: "DRAFT" },
|
||
weekStart: dateOnly(weekStart(input.date)),
|
||
},
|
||
});
|
||
const protectedLeave = await tx.staffLeave.count({
|
||
where: {
|
||
profile: { kitaId: actor.kitaId },
|
||
status: { in: ["APPROVED", "CANCEL_REQUESTED"] },
|
||
startDate: { lte: date },
|
||
endDate: { gte: date },
|
||
},
|
||
});
|
||
if (protectedWeek || protectedLeave)
|
||
throw new UserFacingError(
|
||
"An diesem Tag bestehen bereits Freigaben. Bitte zuerst zurückgeben oder stornieren.",
|
||
);
|
||
if (input.remove)
|
||
await tx.staffHoliday.deleteMany({
|
||
where: { kitaId: actor.kitaId, date },
|
||
});
|
||
else
|
||
await tx.staffHoliday.upsert({
|
||
where: { kitaId_date: { kitaId: actor.kitaId, date } },
|
||
create: { kitaId: actor.kitaId, date, name: input.name },
|
||
update: { name: input.name },
|
||
});
|
||
await tx.auditLog.create({
|
||
data: {
|
||
actorUserId: actor.id,
|
||
actorEmail: dbActor.email,
|
||
action: "STAFF_HOLIDAY_CHANGE",
|
||
targetType: "KITA",
|
||
targetId: actor.kitaId,
|
||
targetLabel: `${input.date}: ${input.name}`,
|
||
targetKitaId: actor.kitaId,
|
||
},
|
||
});
|
||
return {};
|
||
}
|
||
if (command === "setup") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
educatorId: idSchema,
|
||
employmentStart: dateSchema,
|
||
employmentEnd: dateSchema.nullable(),
|
||
trackingStart: dateSchema,
|
||
openingMinutes: z.number().int().min(-200000).max(200000),
|
||
reason: reasonSchema,
|
||
})
|
||
.parse(data);
|
||
if (
|
||
input.employmentEnd &&
|
||
input.employmentEnd < input.employmentStart
|
||
)
|
||
throw new UserFacingError(
|
||
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||
);
|
||
if (
|
||
input.trackingStart < input.employmentStart ||
|
||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
|
||
)
|
||
throw new UserFacingError(
|
||
"Der Erfassungsbeginn muss in der Beschäftigung liegen.",
|
||
);
|
||
const educator = await tx.educator.findFirst({
|
||
where: { id: input.educatorId, kitaId: actor.kitaId },
|
||
});
|
||
if (!educator)
|
||
throw new UserFacingError("Fachkraft nicht gefunden.");
|
||
const existing = await tx.staffProfile.findFirst({
|
||
where: { educatorId: educator.id, kitaId: actor.kitaId },
|
||
include: profileInclude,
|
||
});
|
||
if (
|
||
existing &&
|
||
(existing.timeEntries.length ||
|
||
existing.leaves.length ||
|
||
existing.weeks.length)
|
||
)
|
||
throw new UserFacingError(
|
||
"Die Grunddaten sind nach Beginn der Erfassung geschützt. Verwende datierte Arbeitszeitmodelle und begründete Zeitkorrekturen.",
|
||
);
|
||
const values = {
|
||
employmentStart: dateOnly(input.employmentStart),
|
||
employmentEnd: input.employmentEnd
|
||
? dateOnly(input.employmentEnd)
|
||
: null,
|
||
trackingStart: dateOnly(input.trackingStart),
|
||
openingMinutes: input.openingMinutes,
|
||
};
|
||
const profile = await tx.staffProfile.upsert({
|
||
where: { educatorId: educator.id },
|
||
create: {
|
||
...values,
|
||
educatorId: educator.id,
|
||
kitaId: actor.kitaId,
|
||
},
|
||
update: values,
|
||
});
|
||
await tx.staffHistory.create({
|
||
data: {
|
||
profileId: profile.id,
|
||
actorId: actor.id,
|
||
action: "Beschäftigungsprofil",
|
||
details: `${input.reason} · Beginn ${input.employmentStart}, Erfassung ${input.trackingStart}, Anfangssaldo ${input.openingMinutes} Min.`,
|
||
},
|
||
});
|
||
return {};
|
||
}
|
||
const profileId = idSchema.parse(data.profileId);
|
||
const profile = await tx.staffProfile.findFirst({
|
||
where: { id: profileId, kitaId: actor.kitaId },
|
||
include: profileInclude,
|
||
});
|
||
if (!profile)
|
||
throw new UserFacingError("Personalprofil nicht gefunden.");
|
||
assertStaffAccess(actor, profile);
|
||
const holidays = new Set(
|
||
(
|
||
await tx.staffHoliday.findMany({
|
||
where: { kitaId: actor.kitaId },
|
||
})
|
||
).map((h) => dateKey(h.date)),
|
||
);
|
||
const history = async (action: string, details: string) => {
|
||
await tx.staffHistory.create({
|
||
data: { profileId, actorId: actor.id, action, details },
|
||
});
|
||
};
|
||
const notice = async (
|
||
text: string,
|
||
toAdmins: boolean,
|
||
email?: { message: string; query: string },
|
||
): Promise<CommandResult> => {
|
||
const recipients = await tx.user.findMany({
|
||
where: {
|
||
kitaId: actor.kitaId,
|
||
...(toAdmins
|
||
? { role: UserRole.ADMIN }
|
||
: { id: profile.userId ?? "" }),
|
||
},
|
||
select: { id: true, role: true, staffDecisionEmails: true },
|
||
});
|
||
await tx.staffNotification.createMany({
|
||
data: recipients.map((user) => ({
|
||
userId: user.id,
|
||
profileId,
|
||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`,
|
||
})),
|
||
});
|
||
const emailIds: string[] = [];
|
||
if (email) {
|
||
for (const user of recipients) {
|
||
if (
|
||
!toAdmins &&
|
||
(!(
|
||
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
|
||
).includes(user.role) ||
|
||
!user.staffDecisionEmails)
|
||
)
|
||
continue;
|
||
const job = await tx.staffEmail.create({
|
||
data: {
|
||
profileId,
|
||
userId: user.id,
|
||
toAdmins,
|
||
subject: "Kita-Planer: Hinweis im Personalbereich",
|
||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${email.message}`,
|
||
path: `/dashboard/personal?educator=${encodeURIComponent(profile.educatorId)}&${email.query}`,
|
||
},
|
||
});
|
||
emailIds.push(job.id);
|
||
}
|
||
}
|
||
return { notice: { profileId, text, toAdmins }, emailIds };
|
||
};
|
||
if (command === "noticeRead") {
|
||
await tx.staffNotification.updateMany({
|
||
where: {
|
||
id: idSchema.parse(data.id),
|
||
userId: actor.id,
|
||
profileId,
|
||
},
|
||
data: { readAt: new Date() },
|
||
});
|
||
return {};
|
||
}
|
||
|
||
if (command === "invite") {
|
||
requireAdmin(actor);
|
||
const email = z
|
||
.string()
|
||
.trim()
|
||
.email()
|
||
.toLowerCase()
|
||
.parse(data.email);
|
||
return inviteStaff(tx, actor, profile, email);
|
||
}
|
||
if (command === "pattern") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
effectiveFrom: dateSchema,
|
||
dailyMinutes: z
|
||
.array(z.number().int().min(0).max(960))
|
||
.length(7),
|
||
reason: reasonSchema,
|
||
})
|
||
.parse(data);
|
||
if (input.effectiveFrom < dateKey(profile.employmentStart))
|
||
throw new UserFacingError(
|
||
"Das Modell darf nicht vor Beschäftigungsbeginn gelten.",
|
||
);
|
||
ensureModelEditable(profile, input.effectiveFrom);
|
||
await tx.staffWorkPattern.upsert({
|
||
where: {
|
||
profileId_effectiveFrom: {
|
||
profileId,
|
||
effectiveFrom: dateOnly(input.effectiveFrom),
|
||
},
|
||
},
|
||
create: {
|
||
profileId,
|
||
effectiveFrom: dateOnly(input.effectiveFrom),
|
||
dailyMinutes: input.dailyMinutes,
|
||
},
|
||
update: { dailyMinutes: input.dailyMinutes },
|
||
});
|
||
await history(
|
||
"Wochenmodell",
|
||
`${input.effectiveFrom}: ${input.dailyMinutes.join(" / ")} Min. · ${input.reason}`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "employmentEnd") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
employmentEnd: dateSchema.nullable(),
|
||
reason: reasonSchema,
|
||
})
|
||
.parse(data);
|
||
if (
|
||
input.employmentEnd &&
|
||
input.employmentEnd < dateKey(profile.employmentStart)
|
||
)
|
||
throw new UserFacingError(
|
||
"Das Beschäftigungsende liegt vor dem Beginn.",
|
||
);
|
||
if (
|
||
input.employmentEnd &&
|
||
(input.employmentEnd < dateKey(profile.trackingStart) ||
|
||
profile.timeEntries.some(
|
||
(e) => dateKey(e.date) > input.employmentEnd!,
|
||
) ||
|
||
profile.leaves.some(
|
||
(l) =>
|
||
activeLeaveStatuses.includes(l.status) &&
|
||
dateKey(l.endDate) > input.employmentEnd!,
|
||
))
|
||
)
|
||
throw new UserFacingError(
|
||
"Nach diesem Ende bestehen Erfassungsbeginn, Arbeitszeiten oder aktive Anträge. Bitte zuerst korrigieren.",
|
||
);
|
||
const oldEnd = profile.employmentEnd
|
||
? dateKey(profile.employmentEnd)
|
||
: null;
|
||
if (oldEnd !== input.employmentEnd) {
|
||
const changedFrom = [oldEnd, input.employmentEnd]
|
||
.filter((d): d is string => d !== null)
|
||
.sort()[0];
|
||
if (changedFrom)
|
||
ensureModelEditable(profile, addDays(changedFrom, 1));
|
||
}
|
||
await tx.staffProfile.update({
|
||
where: { id: profileId },
|
||
data: {
|
||
employmentEnd: input.employmentEnd
|
||
? dateOnly(input.employmentEnd)
|
||
: null,
|
||
},
|
||
});
|
||
await history(
|
||
"Beschäftigungsende",
|
||
`${input.employmentEnd ?? "unbefristet"} · ${input.reason}`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "override") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
date: dateSchema,
|
||
minutes: z.number().int().min(0).max(960),
|
||
reason: reasonSchema,
|
||
remove: z.boolean().optional(),
|
||
})
|
||
.parse(data);
|
||
ensureDayEditable(profile, input.date);
|
||
if (
|
||
profile.leaves.some(
|
||
(l) =>
|
||
["APPROVED", "CANCEL_REQUESTED"].includes(l.status) &&
|
||
dateKey(l.startDate) <= input.date &&
|
||
dateKey(l.endDate) >= input.date,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte die genehmigte Abwesenheit zuerst stornieren.",
|
||
);
|
||
if (
|
||
input.date < dateKey(profile.employmentStart) ||
|
||
(profile.employmentEnd &&
|
||
input.date > dateKey(profile.employmentEnd))
|
||
)
|
||
throw new UserFacingError(
|
||
"Der Tag liegt außerhalb der Beschäftigung.",
|
||
);
|
||
if (input.remove)
|
||
await tx.staffDayOverride.deleteMany({
|
||
where: { profileId, date: dateOnly(input.date) },
|
||
});
|
||
else
|
||
await tx.staffDayOverride.upsert({
|
||
where: {
|
||
profileId_date: { profileId, date: dateOnly(input.date) },
|
||
},
|
||
create: {
|
||
profileId,
|
||
date: dateOnly(input.date),
|
||
minutes: input.minutes,
|
||
reason: input.reason,
|
||
},
|
||
update: { minutes: input.minutes, reason: input.reason },
|
||
});
|
||
await history(
|
||
"Tagesausnahme",
|
||
`${input.date}: ${input.remove ? "entfernt" : `${input.minutes} Min.`} · ${input.reason}`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "account") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
year: z.number().int().min(2000).max(2100),
|
||
entitlementUnits: z.number().int().min(0).max(800),
|
||
carryUnits: z.number().int().min(0).max(800),
|
||
reason: reasonSchema,
|
||
})
|
||
.parse(data);
|
||
const used = vacationBalance({ ...input }, profile.leaves);
|
||
if (used.available < 0 || used.available < used.pending)
|
||
throw new UserFacingError(
|
||
"Der Anspruch reicht nicht für bereits genehmigten oder beantragten Urlaub.",
|
||
);
|
||
await tx.staffLeaveAccount.upsert({
|
||
where: { profileId_year: { profileId, year: input.year } },
|
||
create: {
|
||
profileId,
|
||
year: input.year,
|
||
entitlementUnits: input.entitlementUnits,
|
||
carryUnits: input.carryUnits,
|
||
},
|
||
update: {
|
||
entitlementUnits: input.entitlementUnits,
|
||
carryUnits: input.carryUnits,
|
||
},
|
||
});
|
||
await history(
|
||
"Urlaubsanspruch",
|
||
`${input.year}: ${input.entitlementUnits / 2} Tage + ${input.carryUnits / 2} Übertrag · ${input.reason}`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "adjust") {
|
||
requireAdmin(actor);
|
||
const input = z
|
||
.object({
|
||
date: dateSchema,
|
||
minutes: z
|
||
.number()
|
||
.int()
|
||
.min(-200000)
|
||
.max(200000)
|
||
.refine((n) => n !== 0),
|
||
reason: reasonSchema,
|
||
})
|
||
.parse(data);
|
||
if (input.date < dateKey(profile.trackingStart))
|
||
throw new UserFacingError(
|
||
"Korrekturen müssen ab Erfassungsbeginn liegen.",
|
||
);
|
||
await tx.staffAdjustment.create({
|
||
data: {
|
||
profileId,
|
||
date: dateOnly(input.date),
|
||
minutes: input.minutes,
|
||
reason: input.reason,
|
||
actorId: actor.id,
|
||
},
|
||
});
|
||
await history(
|
||
"Zeitkorrektur",
|
||
`${input.date}: ${input.minutes} Min. · ${input.reason}`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "time" || command === "deleteTime") {
|
||
if (!profile.educator.active)
|
||
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
|
||
const existing = data.id
|
||
? await tx.staffTimeEntry.findFirst({
|
||
where: { id: idSchema.parse(data.id), profileId },
|
||
})
|
||
: null;
|
||
if (data.id && !existing)
|
||
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||
if (existing) ensureDayEditable(profile, dateKey(existing.date));
|
||
if (command === "deleteTime") {
|
||
if (!existing)
|
||
throw new UserFacingError("Zeiteintrag nicht gefunden.");
|
||
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
|
||
await history(
|
||
"Arbeitszeit gelöscht",
|
||
`${dateKey(existing.date)}: ${existing.startMinute}–${existing.endMinute} Min.`,
|
||
);
|
||
return {};
|
||
}
|
||
const input = z
|
||
.object({
|
||
date: dateSchema,
|
||
startMinute: z.number().int().min(0).max(1439),
|
||
endMinute: z.number().int().min(1).max(1440),
|
||
breakMinutes: z.number().int().min(0).max(1439),
|
||
note: z.string().trim().max(500).optional(),
|
||
})
|
||
.parse(data);
|
||
if (
|
||
input.date > todayKey() ||
|
||
input.date < dateKey(profile.trackingStart) ||
|
||
input.date < dateKey(profile.employmentStart) ||
|
||
(profile.employmentEnd &&
|
||
input.date > dateKey(profile.employmentEnd))
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte einen vergangenen oder heutigen Tag innerhalb des Erfassungszeitraums wählen.",
|
||
);
|
||
if (
|
||
!profile.workPatterns.some(
|
||
(p) => dateKey(p.effectiveFrom) <= input.date,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte zuerst ein Arbeitszeitmodell hinterlegen.",
|
||
);
|
||
if (
|
||
input.endMinute <= input.startMinute ||
|
||
input.breakMinutes >= input.endMinute - input.startMinute
|
||
)
|
||
throw new UserFacingError(
|
||
"Ende muss nach Beginn liegen; die Pause muss kürzer als der Arbeitsblock sein.",
|
||
);
|
||
ensureDayEditable(profile, input.date);
|
||
if (
|
||
profile.timeEntries.some(
|
||
(e) =>
|
||
e.id !== existing?.id &&
|
||
dateKey(e.date) === input.date &&
|
||
e.startMinute < input.endMinute &&
|
||
e.endMinute > input.startMinute,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Dieser Arbeitsblock überschneidet sich mit einem vorhandenen Eintrag.",
|
||
);
|
||
const absences = profile.leaves
|
||
.filter((l) =>
|
||
["APPROVED", "CANCEL_REQUESTED"].includes(l.status),
|
||
)
|
||
.flatMap((l) => l.days)
|
||
.filter((d) => dateKey(d.date) === input.date);
|
||
const reservedMinutes = absences.reduce(
|
||
(sum, d) => sum + d.creditMinutes,
|
||
0,
|
||
);
|
||
const worked = profile.timeEntries
|
||
.filter(
|
||
(e) => e.id !== existing?.id && dateKey(e.date) === input.date,
|
||
)
|
||
.reduce(
|
||
(sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes,
|
||
input.endMinute - input.startMinute - input.breakMinutes,
|
||
);
|
||
if (
|
||
reservedMinutes &&
|
||
worked + reservedMinutes >
|
||
scheduledMinutes(profile, input.date, holidays)
|
||
)
|
||
throw new UserFacingError(
|
||
"Arbeitszeit und genehmigte Abwesenheit überschreiten die Sollzeit. Bitte die Abwesenheit zuerst korrigieren.",
|
||
);
|
||
const values = {
|
||
profileId,
|
||
date: dateOnly(input.date),
|
||
startMinute: input.startMinute,
|
||
endMinute: input.endMinute,
|
||
breakMinutes: input.breakMinutes,
|
||
note: input.note || null,
|
||
};
|
||
if (existing)
|
||
await tx.staffTimeEntry.update({
|
||
where: { id: existing.id },
|
||
data: values,
|
||
});
|
||
else await tx.staffTimeEntry.create({ data: values });
|
||
await history(
|
||
"Arbeitszeit gespeichert",
|
||
`${input.date}: ${input.startMinute}–${input.endMinute}, Pause ${input.breakMinutes} Min.`,
|
||
);
|
||
return {};
|
||
}
|
||
if (command === "weekSubmit" || command === "weekReview") {
|
||
const start = weekStart(dateSchema.parse(data.weekStart));
|
||
const end = addDays(start, 6);
|
||
if (end < dateKey(profile.trackingStart) || start > todayKey())
|
||
throw new UserFacingError(
|
||
"Diese Woche liegt außerhalb der bisherigen Erfassung.",
|
||
);
|
||
const existing = profile.weeks.find(
|
||
(w) => dateKey(w.weekStart) === start,
|
||
);
|
||
if (command === "weekSubmit") {
|
||
if (existing && existing.status !== "DRAFT")
|
||
throw new UserFacingError(
|
||
"Die Woche ist bereits eingereicht oder genehmigt.",
|
||
);
|
||
if (
|
||
!profile.workPatterns.some(
|
||
(p) =>
|
||
dateKey(p.effectiveFrom) <=
|
||
(start < dateKey(profile.trackingStart)
|
||
? dateKey(profile.trackingStart)
|
||
: start),
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte zuerst ein Arbeitszeitmodell für die Woche hinterlegen.",
|
||
);
|
||
if (
|
||
profile.leaves.some(
|
||
(l) =>
|
||
["PENDING", "CANCEL_REQUESTED"].includes(l.status) &&
|
||
dateKey(l.startDate) <= end &&
|
||
dateKey(l.endDate) >= start,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte zuerst offene Abwesenheitsanträge dieser Woche klären.",
|
||
);
|
||
const days = calculateWeek(
|
||
profile,
|
||
holidays,
|
||
start,
|
||
profile.timeEntries,
|
||
profile.leaves,
|
||
);
|
||
if (days.some((d) => d.date > todayKey() && d.expected > 0))
|
||
throw new UserFacingError(
|
||
"Die Woche kann erst nach ihrem letzten vorgesehenen Arbeitstag eingereicht werden.",
|
||
);
|
||
await tx.staffWeek.upsert({
|
||
where: {
|
||
profileId_weekStart: {
|
||
profileId,
|
||
weekStart: dateOnly(start),
|
||
},
|
||
},
|
||
create: {
|
||
profileId,
|
||
weekStart: dateOnly(start),
|
||
status: "SUBMITTED",
|
||
submittedAt: new Date(),
|
||
},
|
||
update: {
|
||
status: "SUBMITTED",
|
||
submittedAt: new Date(),
|
||
dailySnapshot: Prisma.DbNull,
|
||
decisionNote: null,
|
||
},
|
||
});
|
||
await history("Woche eingereicht", start);
|
||
return notice(
|
||
`Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`,
|
||
true,
|
||
{
|
||
message: `Arbeitswoche ${start}–${end} wurde zur Prüfung eingereicht.`,
|
||
query: `tab=zeit&week=${start}`,
|
||
},
|
||
);
|
||
}
|
||
requireAdmin(actor);
|
||
if (profile.userId === actor.id)
|
||
throw new UserFacingError(
|
||
"Eigene Wochen dürfen nicht selbst freigegeben werden.",
|
||
);
|
||
const input = z
|
||
.object({ approve: z.boolean(), reason: reasonSchema })
|
||
.parse(data);
|
||
if (
|
||
!existing ||
|
||
(input.approve
|
||
? existing.status !== "SUBMITTED"
|
||
: existing.status === "DRAFT")
|
||
)
|
||
throw new UserFacingError(
|
||
"Diese Woche kann in ihrem aktuellen Status nicht bearbeitet werden.",
|
||
);
|
||
if (
|
||
input.approve &&
|
||
profile.leaves.some(
|
||
(l) =>
|
||
["PENDING", "CANCEL_REQUESTED"].includes(l.status) &&
|
||
dateKey(l.startDate) <= end &&
|
||
dateKey(l.endDate) >= start,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Bitte offene Abwesenheiten zuerst klären; gegebenenfalls die Woche zur Korrektur zurückgeben.",
|
||
);
|
||
const days = calculateWeek(
|
||
profile,
|
||
holidays,
|
||
start,
|
||
profile.timeEntries,
|
||
profile.leaves,
|
||
);
|
||
const snapshot = days.map((d) => ({ ...d }));
|
||
await tx.staffWeek.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
status: input.approve ? "APPROVED" : "DRAFT",
|
||
dailySnapshot: input.approve ? snapshot : Prisma.DbNull,
|
||
reviewedAt: new Date(),
|
||
reviewedById: actor.id,
|
||
decisionNote: input.reason,
|
||
},
|
||
});
|
||
await history(
|
||
input.approve ? "Woche genehmigt" : "Woche zur Korrektur",
|
||
`${start} · ${input.reason}`,
|
||
);
|
||
return notice(
|
||
`Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`,
|
||
false,
|
||
{
|
||
message: `Arbeitswoche ${start}–${end}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}.`,
|
||
query: `tab=zeit&week=${start}`,
|
||
},
|
||
);
|
||
}
|
||
if (
|
||
command === "leave" ||
|
||
command === "leaveReview" ||
|
||
command === "leaveCancel"
|
||
) {
|
||
const existing = data.id
|
||
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
|
||
: undefined;
|
||
if (data.id && !existing)
|
||
throw new UserFacingError("Antrag nicht gefunden.");
|
||
if (command === "leaveCancel") {
|
||
if (
|
||
!existing ||
|
||
!["PENDING", "APPROVED"].includes(existing.status)
|
||
)
|
||
throw new UserFacingError(
|
||
"Dieser Antrag kann nicht zurückgezogen werden.",
|
||
);
|
||
const status =
|
||
existing.status === "PENDING"
|
||
? "CANCELLED"
|
||
: "CANCEL_REQUESTED";
|
||
await tx.staffLeave.update({
|
||
where: { id: existing.id },
|
||
data: { status },
|
||
});
|
||
await history(
|
||
"Abwesenheit zurückgezogen",
|
||
`${existing.id}: ${status}`,
|
||
);
|
||
return notice(
|
||
status === "CANCELLED"
|
||
? "Ein offener Abwesenheitsantrag wurde zurückgezogen."
|
||
: "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.",
|
||
true,
|
||
existing.kind === "SICKNESS"
|
||
? undefined
|
||
: {
|
||
message: `Abwesenheitsantrag ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "zurückgezogen" : "Stornierung beantragt"}.`,
|
||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||
},
|
||
);
|
||
}
|
||
if (command === "leaveReview") {
|
||
requireAdmin(actor);
|
||
if (profile.userId === actor.id)
|
||
throw new UserFacingError(
|
||
"Eigene Anträge dürfen nicht selbst genehmigt werden.",
|
||
);
|
||
const input = z
|
||
.object({ approve: z.boolean(), reason: reasonSchema })
|
||
.parse(data);
|
||
if (
|
||
!existing ||
|
||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
|
||
)
|
||
throw new UserFacingError(
|
||
"Dieser Antrag ist nicht mehr offen.",
|
||
);
|
||
const cancelling = existing.status === "CANCEL_REQUESTED";
|
||
if (input.approve)
|
||
ensureRangeEditable(
|
||
profile,
|
||
dateKey(existing.startDate),
|
||
dateKey(existing.endDate),
|
||
);
|
||
if (input.approve && !cancelling) {
|
||
const days = calculateLeaveDays(profile, holidays, {
|
||
kind: existing.kind,
|
||
startDate: dateKey(existing.startDate),
|
||
endDate: dateKey(existing.endDate),
|
||
halfDay: existing.halfDay,
|
||
minutes: existing.minutes ?? undefined,
|
||
});
|
||
if (existing.kind === "VACATION")
|
||
assertVacationFunds(profile, days, existing.id);
|
||
if (existing.kind === "TIME_OFF") {
|
||
const balance = getTimeBalance(
|
||
profile,
|
||
profile.weeks,
|
||
profile.adjustments,
|
||
profile.leaves,
|
||
);
|
||
if (balance.available < (existing.minutes ?? 0))
|
||
throw new UserFacingError(
|
||
"Das bestätigte Zeitguthaben reicht nicht aus.",
|
||
);
|
||
}
|
||
for (const d of days) {
|
||
const worked = profile.timeEntries
|
||
.filter((e) => dateKey(e.date) === d.date)
|
||
.reduce(
|
||
(sum, e) =>
|
||
sum + e.endMinute - e.startMinute - e.breakMinutes,
|
||
0,
|
||
);
|
||
if (
|
||
worked + d.creditMinutes >
|
||
scheduledMinutes(profile, d.date, holidays)
|
||
)
|
||
throw new UserFacingError(
|
||
"An diesem Tag bestehen Arbeitszeiten. Bitte Zeitraum oder Arbeitszeit zuerst korrigieren.",
|
||
);
|
||
}
|
||
await tx.staffLeaveDay.deleteMany({
|
||
where: { leaveId: existing.id },
|
||
});
|
||
await tx.staffLeaveDay.createMany({
|
||
data: days.map((d) => ({
|
||
...d,
|
||
date: dateOnly(d.date),
|
||
leaveId: existing.id,
|
||
})),
|
||
});
|
||
}
|
||
const status: StaffRequestStatus = cancelling
|
||
? input.approve
|
||
? "CANCELLED"
|
||
: "APPROVED"
|
||
: input.approve
|
||
? "APPROVED"
|
||
: "REJECTED";
|
||
await tx.staffLeave.update({
|
||
where: { id: existing.id },
|
||
data: {
|
||
status,
|
||
reviewedById: actor.id,
|
||
reviewedAt: new Date(),
|
||
decisionNote: input.reason,
|
||
},
|
||
});
|
||
await history(
|
||
"Abwesenheit entschieden",
|
||
`${existing.id}: ${status} · ${input.reason}`,
|
||
);
|
||
return notice(
|
||
`${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`,
|
||
false,
|
||
existing.kind === "SICKNESS"
|
||
? undefined
|
||
: {
|
||
message: `${existing.kind === "VACATION" ? "Urlaub" : "Freizeitausgleich"} ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : existing.status === "CANCEL_REQUESTED" ? "Stornierung abgelehnt; Genehmigung bleibt bestehen" : "genehmigt"}.`,
|
||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||
},
|
||
);
|
||
}
|
||
if (!profile.educator.active)
|
||
throw new UserFacingError("Die Fachkraft ist nicht mehr aktiv.");
|
||
const input = z
|
||
.object({
|
||
kind: z.nativeEnum(StaffLeaveKind),
|
||
startDate: dateSchema,
|
||
endDate: dateSchema,
|
||
halfDay: z.boolean(),
|
||
minutes: z.number().int().min(1).max(960).optional(),
|
||
note: z.string().trim().max(500).optional(),
|
||
})
|
||
.parse(data);
|
||
if (input.kind === "SICKNESS") requireAdmin(actor);
|
||
if (existing && existing.status !== "PENDING")
|
||
throw new UserFacingError(
|
||
"Nur offene Anträge können bearbeitet werden.",
|
||
);
|
||
if (input.startDate < dateKey(profile.trackingStart))
|
||
throw new UserFacingError(
|
||
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
|
||
);
|
||
ensureRangeEditable(profile, input.startDate, input.endDate);
|
||
if (
|
||
profile.leaves.some(
|
||
(l) =>
|
||
l.id !== existing?.id &&
|
||
activeLeaveStatuses.includes(l.status) &&
|
||
dateKey(l.startDate) <= input.endDate &&
|
||
dateKey(l.endDate) >= input.startDate,
|
||
)
|
||
)
|
||
throw new UserFacingError(
|
||
"Der Zeitraum überschneidet sich mit einer offenen oder genehmigten Abwesenheit.",
|
||
);
|
||
const days = calculateLeaveDays(profile, holidays, input);
|
||
if (input.kind === "VACATION")
|
||
assertVacationFunds(profile, days, existing?.id);
|
||
if (input.kind === "TIME_OFF") {
|
||
const balance = getTimeBalance(
|
||
profile,
|
||
profile.weeks,
|
||
profile.adjustments,
|
||
profile.leaves,
|
||
);
|
||
const pending = profile.leaves
|
||
.filter(
|
||
(l) =>
|
||
l.id !== existing?.id &&
|
||
l.kind === "TIME_OFF" &&
|
||
l.status === "PENDING",
|
||
)
|
||
.reduce((sum, l) => sum + (l.minutes ?? 0), 0);
|
||
if (balance.available - pending < (input.minutes ?? 0))
|
||
throw new UserFacingError(
|
||
"Das noch nicht verplante bestätigte Zeitguthaben reicht nicht aus.",
|
||
);
|
||
}
|
||
if (input.kind === "SICKNESS") {
|
||
for (const day of days)
|
||
if (
|
||
profile.timeEntries.some((e) => dateKey(e.date) === day.date)
|
||
)
|
||
throw new UserFacingError(
|
||
"Für Krankheitstage mit Arbeitszeit bitte zuerst die Arbeitszeit korrigieren.",
|
||
);
|
||
}
|
||
const values = {
|
||
kind: input.kind,
|
||
startDate: dateOnly(input.startDate),
|
||
endDate: dateOnly(input.endDate),
|
||
halfDay: input.halfDay,
|
||
minutes: input.kind === "TIME_OFF" ? input.minutes : null,
|
||
note: input.note || null,
|
||
status:
|
||
input.kind === "SICKNESS"
|
||
? StaffRequestStatus.APPROVED
|
||
: StaffRequestStatus.PENDING,
|
||
...(input.kind === "SICKNESS"
|
||
? { reviewedById: actor.id, reviewedAt: new Date() }
|
||
: {}),
|
||
};
|
||
const leave = existing
|
||
? await tx.staffLeave.update({
|
||
where: { id: existing.id },
|
||
data: values,
|
||
})
|
||
: await tx.staffLeave.create({
|
||
data: {
|
||
...values,
|
||
profileId,
|
||
requestedById: actor.id,
|
||
},
|
||
});
|
||
await tx.staffLeaveDay.deleteMany({ where: { leaveId: leave.id } });
|
||
await tx.staffLeaveDay.createMany({
|
||
data: days.map((d) => ({
|
||
...d,
|
||
date: dateOnly(d.date),
|
||
leaveId: leave.id,
|
||
})),
|
||
});
|
||
await history(
|
||
"Abwesenheit erfasst",
|
||
`${input.kind}: ${input.startDate}–${input.endDate}`,
|
||
);
|
||
return notice(
|
||
`${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`,
|
||
input.kind !== "SICKNESS",
|
||
input.kind === "SICKNESS"
|
||
? undefined
|
||
: {
|
||
message: `${input.kind === "VACATION" ? "Urlaubsantrag" : "Antrag auf Freizeitausgleich"} ${input.startDate}–${input.endDate} wurde ${existing ? "geändert" : "eingereicht"}.`,
|
||
query: `tab=urlaub&year=${input.startDate.slice(0, 4)}`,
|
||
},
|
||
);
|
||
}
|
||
throw new UserFacingError("Unbekannte Aktion.");
|
||
},
|
||
{
|
||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||
timeout: 15000,
|
||
},
|
||
);
|
||
} catch (error) {
|
||
if (
|
||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||
error.code === "P2034" &&
|
||
attempt < 2
|
||
)
|
||
continue;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|