L12 Zeiterfassung: Services für Nachtrag, Freigabe, Auto-Wechsel und Für heute beenden

time-entries.ts (manuelle Einträge, Korrekturvorschläge, Freigabe/Ablehnung mit
Team-Scope), Sessions in inTransaction mit switchFromOther, stopForToday,
Segmentwechsel und getMyActiveSession. Sync-Ops time.add_manual,
time.propose_correction, session.stop_day, session.segment inkl. optimistischer
Offline-Ansicht. Bericht, Zeiten-Tab, Dashboard und Lotse zählen nur freigegebene
Zeiten; Abrechnungsfreigabe blockiert bei offenen Zeiten. Empfänger und Links der
Zeit-Events. Notdienst-Start pausiert eine laufende Uhr automatisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:29:30 +02:00
co-authored by Claude Opus 5
parent 03a2ccab4f
commit 0e94eb0d9d
19 changed files with 1054 additions and 107 deletions
@@ -47,6 +47,8 @@ export type EventFacts = {
actorName?: string;
emergencyStart?: Date | null;
emergencyEnd?: Date | null;
/** L12: reason of a rejected time entry */
rejectionReason?: string;
};
export type RecipientPlan = {
@@ -89,6 +91,11 @@ export function backofficeWhere(): Prisma.UserWhereInput {
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
}
/** L12: back office approving time entries (all entries): work_order:read_all AND time:approve. */
export function timeApproverBackofficeWhere(): Prisma.UserWhereInput {
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("time:approve")] };
}
export function billingWhere(): Prisma.UserWhereInput {
return hasPermissionWhere("work_order:release_billing");
}
@@ -278,6 +285,38 @@ export async function resolveRecipients(
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
break;
}
case "time.approval_requested":
case "time.approved":
case "time.rejected": {
const entry = await ctx.db.timeEntry.findFirst({
where: { id: event.entityId },
select: { id: true, userId: true, workSession: { select: { workOrderId: true } } },
});
if (!entry) return null;
const wo = await loadWorkOrder(ctx, entry.workSession.workOrderId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
if (typeof event.data?.reason === "string") facts.rejectionReason = event.data.reason;
if (event.type === "time.approval_requested") {
// team leads of the order's team / the technician's teams (with time:approve), otherwise back office
const now = new Date();
const memberships = await ctx.db.teamMember.findMany({
where: { userId: entry.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } },
select: { team: { select: { leaderUserId: true } } },
});
const candidateIds = [...teamLeadIds(wo), ...memberships.map((m) => m.team.leaderUserId)].filter(
(id): id is string => !!id && id !== entry.userId && id !== ctx.userId,
);
const leads = candidateIds.length
? await ctx.db.user.findMany({ where: { AND: [{ id: { in: [...new Set(candidateIds)] } }, { status: "ACTIVE" }, hasPermissionWhere("time:approve")] }, select: { id: true } })
: [];
rule = leads.length ? { users: [], userIds: leads.map((l) => l.id) } : { users: [timeApproverBackofficeWhere()], userIds: [] };
} else {
// decision → the technician, in-app only
rule = { users: [], userIds: [entry.userId], mailToUsers: false };
}
break;
}
case "sync.failed": {
const op = await ctx.db.syncOperation.findFirst({
where: { id: event.entityId },