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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user