From 0e94eb0d9da4b56c03a1531f6d971e0d8786ca71 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:30 +0200 Subject: [PATCH] =?UTF-8?q?L12=20Zeiterfassung:=20Services=20f=C3=BCr=20Na?= =?UTF-8?q?chtrag,=20Freigabe,=20Auto-Wechsel=20und=20F=C3=BCr=20heute=20b?= =?UTF-8?q?eenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/field/time-rules.ts | 38 ++ src/lib/offline/bundle-core.ts | 25 +- src/lib/offline/outbox-core.ts | 8 +- src/lib/reports/content.ts | 2 + src/lib/sync/envelope.ts | 5 + src/lib/sync/ops.ts | 52 +- src/server/services/emergency/create.ts | 2 + src/server/services/field/queries.ts | 26 +- src/server/services/field/sessions.ts | 290 ++++++--- src/server/services/field/time-correction.ts | 9 +- src/server/services/field/time-entries.ts | 589 ++++++++++++++++++ src/server/services/lotse/completeness.ts | 2 +- .../services/notifications/handle-event.ts | 6 +- .../services/notifications/recipients.ts | 39 ++ src/server/services/reports/build-content.ts | 12 +- src/server/services/sync/apply.ts | 22 +- src/server/services/work-orders/completion.ts | 12 +- src/server/services/work-orders/dashboard.ts | 8 +- src/server/services/work-orders/detail.ts | 14 +- 19 files changed, 1054 insertions(+), 107 deletions(-) create mode 100644 src/lib/field/time-rules.ts create mode 100644 src/server/services/field/time-entries.ts diff --git a/src/lib/field/time-rules.ts b/src/lib/field/time-rules.ts new file mode 100644 index 0000000..de8db48 --- /dev/null +++ b/src/lib/field/time-rules.ts @@ -0,0 +1,38 @@ +import type { SyncOpResult } from "@/lib/sync/envelope"; + +/** + * Client-safe rules of the L12 time recording (shared by the mobile forms and the services). + */ + +/** Own time may be recorded/corrected for today and this many previous local days. */ +export const MAX_BACKDATE_DAYS = 7; +/** Maximum length of a single manual entry. */ +export const MAX_ENTRY_MINUTES = 16 * 60; +/** Quick picks of the duration mode. */ +export const DURATION_QUICK_PICKS = [15, 30, 60] as const; +/** Entry types offered in „Zeit nachtragen" (chips). */ +export const MANUAL_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement", "break"] as const; +/** Reason suggestions (i18n keys field.myTime.reasons.*). */ +export const REASON_SUGGESTIONS = ["forgotStart", "noNetwork", "recordedLater"] as const; + +/** Error messages of the time services that have a plain-language text (field.myTime.errors.*). */ +export const TIME_ERROR_KEYS = ["overlap", "too_old", "in_future", "too_long", "end_before_start", "work_order_status", "not_own_entry", "own_entry", "entry_running"] as const; +export type TimeErrorKey = (typeof TIME_ERROR_KEYS)[number]; + +export function timeErrorKey(result: Pick | { message?: string | null }): TimeErrorKey | null { + const m = result.message ?? ""; + return (TIME_ERROR_KEYS as readonly string[]).find((k) => m === k || m.startsWith(`${k}:`)) as TimeErrorKey | undefined ?? null; +} + +export type OtherSession = { workOrderId: string; number: string; title?: string }; + +/** `session.start`/`session.resume` rejected because another order is on the clock (auto switch dialog). */ +export function otherSessionOf(result: Pick): OtherSession | null { + if (result.errorCode !== "conflict" || !result.message?.startsWith("other_session_running")) return null; + try { + const details = JSON.parse(result.message.slice("other_session_running:".length)) as OtherSession; + return typeof details?.number === "string" ? details : null; + } catch { + return { workOrderId: "", number: "" }; + } +} diff --git a/src/lib/offline/bundle-core.ts b/src/lib/offline/bundle-core.ts index 9917b24..c5f1c18 100644 --- a/src/lib/offline/bundle-core.ts +++ b/src/lib/offline/bundle-core.ts @@ -46,6 +46,8 @@ export function toBundleRecords(ctxKey: string, orders: BundleOrderData[], synce export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean }; export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null }; export type SessionState = "en_route" | "running" | "paused" | null; +/** L12: manual time entry recorded on this device, not yet confirmed by the server snapshot. */ +export type LocalManualTime = { id: string; type: string; startedAt: string; endedAt: string | null; durationMinutes: number | null; reason: string; pending: boolean }; export type OrderView = BundleOrderData & { local: { @@ -53,6 +55,7 @@ export type OrderView = BundleOrderData & { photos: LocalPhoto[]; voiceNotes: number; session: SessionState; + manualTimes: LocalManualTime[]; pendingOps: number; conflict: boolean; rejected: boolean; @@ -64,7 +67,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null); /** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */ export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView { const data: BundleOrderData = structuredCloneSafe(record.data); - const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } }; + const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), manualTimes: [], pendingOps: 0, conflict: false, rejected: false } }; const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq); for (const op of mine) { @@ -116,6 +119,26 @@ function applyOp(view: OrderView, op: OutboxEntry) { case "session.end": view.local.session = null; break; + case "session.stop_day": + // L12 „Für heute beenden": session ends, the order stays open (paused) + view.local.session = null; + if (view.status === "in_progress") view.status = "paused"; + break; + case "session.segment": + if (view.status === "paused") view.status = "in_progress"; + view.local.session = "running"; + break; + case "time.add_manual": + view.local.manualTimes.push({ + id: str(p.clientId) ?? op.clientOpId, + type: str(p.type) ?? "work", + startedAt: str(p.startedAt) ?? op.clientCreatedAt, + endedAt: str(p.endedAt), + durationMinutes: typeof p.durationMinutes === "number" ? p.durationMinutes : null, + reason: str(p.reason) ?? "", + pending: isPending(op), + }); + break; case "work_order.transition": if (typeof p.to === "string") view.status = p.to; break; diff --git a/src/lib/offline/outbox-core.ts b/src/lib/offline/outbox-core.ts index affe083..168a45e 100644 --- a/src/lib/offline/outbox-core.ts +++ b/src/lib/offline/outbox-core.ts @@ -21,7 +21,7 @@ export const BACKOFF_BASE_MS = 2_000; export const BACKOFF_MAX_MS = 5 * 60_000; /** Ops that change WorkOrder.version on the server (status changes). */ -export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "report.submit"]; +export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "session.stop_day", "session.segment", "work_order.transition", "report.submit"]; const TRANSIENT_CODES = new Set(["internal", "network"]); @@ -337,7 +337,13 @@ export function problemKey(e: Pick; export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; diff --git a/src/server/services/emergency/create.ts b/src/server/services/emergency/create.ts index 24e42dc..dbba55a 100644 --- a/src/server/services/emergency/create.ts +++ b/src/server/services/emergency/create.ts @@ -246,6 +246,8 @@ export async function createEmergencyOrder( at: startedAt.toISOString(), offline: input.offline, deviceInfo: input.deviceInfo, + // L12: an emergency call-out pauses a running session on another order (one clock per user) + switchFromOther: true, }); return { diff --git a/src/server/services/field/queries.ts b/src/server/services/field/queries.ts index 48d07c6..9534c80 100644 --- a/src/server/services/field/queries.ts +++ b/src/server/services/field/queries.ts @@ -68,6 +68,8 @@ export type OrderCard = { plannedStart: Date | null; plannedEnd: Date | null; version: number; + /** L12: own active session on this order (card start/stop button) */ + mySession: "en_route" | "running" | "paused" | null; }; const CARD_SELECT = { @@ -119,9 +121,23 @@ function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>): plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, version: wo.version, + mySession: null, }; } +/** L12: attach the caller's own active session per card (one query). */ +async function withMySessions(ctx: ServiceCtx, cards: OrderCard[]): Promise { + if (!cards.length) return cards; + const sessions = await ctx.db.workSession.findMany({ + where: { workOrderId: { in: cards.map((c) => c.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false }, + orderBy: { startedAt: "desc" }, + select: { workOrderId: true, status: true }, + }); + const byOrder = new Map(); + for (const s of sessions) if (!byOrder.has(s.workOrderId)) byOrder.set(s.workOrderId, s.status as OrderCard["mySession"]); + return cards.map((c) => ({ ...c, mySession: byOrder.get(c.id) ?? null })); +} + export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise { const scope = await workOrderScope(ctx); const rows = await ctx.db.workOrder.findMany({ @@ -130,7 +146,7 @@ export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise): Promise { - const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); - const at = opTime(input.at); - - if (input.clientId) { - const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } }); - if (replay) { - if (replay.workOrderId !== wo.id || replay.userId !== ctx.userId) throw new ServiceError("invalid", "clientId already used"); - return { sessionId: replay.id, status: replay.status, workOrderStatus: wo.status, workOrderVersion: wo.version }; - } +/** + * L12 auto switch: a running/en-route session of the user on ANOTHER order. Without + * `switchFromOther` → conflict `other_session_running` (details: workOrderId, number); with it the + * other session is paused inside the caller's transaction. + */ +async function handleOtherRunning(ctx: ServiceCtx, workOrderId: string, switchFromOther: boolean | undefined, at: Date) { + const other = await ctx.db.workSession.findFirst({ + where: { userId: ctx.userId, workOrderId: { not: workOrderId }, status: { in: CLOCK_STATUSES }, workOrder: { deletedAt: null } }, + orderBy: { startedAt: "desc" }, + include: { workOrder: { select: { id: true, number: true, title: true, status: true, version: true, siteId: true, customerId: true, assignedTeamId: true } } }, + }); + if (!other) return; + if (!switchFromOther) { + throw new ServiceError("conflict", "other_session_running", { workOrderId: other.workOrder.id, number: other.workOrder.number, title: other.workOrder.title }); } + await pauseLoaded(ctx, other, other.workOrder as FieldOrder, at); +} - const existing = await activeSession(ctx, wo.id); +/** Pause a loaded running/en-route session (break segment) and move its order to `paused` when nobody else works on it. */ +async function pauseLoaded(ctx: ServiceCtx, session: WorkSession, wo: FieldOrder, at: Date) { + await closeOpenEntries(ctx, session.id, at); + await openEntry(ctx, session.id, "break", at); + const updated = await ctx.db.workSession.update({ where: { id: session.id }, data: { status: "paused" } }); + const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version }; + await audit(ctx, "update", "work_session", session.id, { status: session.status }, { status: "paused", at }); + return { session: updated, moved }; +} - if (input.mode === "travel") { - if (existing) throw new ServiceError("conflict", "a session is already active for this work order"); - const moved = await moveOrder(ctx, wo, "en_route"); +export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.start">): Promise { + const at = opTime(input.at); + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + + if (input.clientId) { + const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.workOrderId !== wo.id || replay.userId !== ctx.userId) throw new ServiceError("invalid", "clientId already used"); + return { sessionId: replay.id, status: replay.status, workOrderStatus: wo.status, workOrderVersion: wo.version }; + } + } + + const existing = await activeSession(ctx, wo.id); + if (input.mode === "travel" && existing) throw new ServiceError("conflict", "a session is already active for this work order"); + if (input.mode === "work" && existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order"); + + await handleOtherRunning(ctx, wo.id, input.switchFromOther, at); + + if (input.mode === "travel") { + const moved = await moveOrder(ctx, wo, "en_route"); + const session = await ctx.db.workSession.create({ + data: { + tenantId: ctx.tenantId, + workOrderId: wo.id, + userId: ctx.userId, + teamId: wo.assignedTeamId, + status: "en_route", + startedAt: at, + startLat: input.latitude ?? null, + startLng: input.longitude ?? null, + startedOffline: input.offline, + deviceInfo: input.deviceInfo ?? null, + clientId: input.clientId ?? null, + }, + }); + await openEntry(ctx, session.id, "travel", at); + await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "en_route", startedAt: at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + } + + // mode "work" + const moved = await moveOrder(ctx, wo, "in_progress"); + if (existing) { + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, "work", at); + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); + await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "running", at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + } const session = await ctx.db.workSession.create({ data: { tenantId: ctx.tenantId, workOrderId: wo.id, userId: ctx.userId, teamId: wo.assignedTeamId, - status: "en_route", + status: "running", startedAt: at, startLat: input.latitude ?? null, startLng: input.longitude ?? null, @@ -90,72 +158,40 @@ export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"sess clientId: input.clientId ?? null, }, }); - await openEntry(ctx, session.id, "travel", at); - await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "en_route", startedAt: at }); + await openEntry(ctx, session.id, "work", at); + await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at }); return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; - } - - // mode "work" - if (existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order"); - const moved = await moveOrder(ctx, wo, "in_progress"); - if (existing) { - await closeOpenEntries(ctx, existing.id, at); - await openEntry(ctx, existing.id, "work", at); - const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); - await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "running", at }); - return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; - } - const session = await ctx.db.workSession.create({ - data: { - tenantId: ctx.tenantId, - workOrderId: wo.id, - userId: ctx.userId, - teamId: wo.assignedTeamId, - status: "running", - startedAt: at, - startLat: input.latitude ?? null, - startLng: input.longitude ?? null, - startedOffline: input.offline, - deviceInfo: input.deviceInfo ?? null, - clientId: input.clientId ?? null, - }, }); - await openEntry(ctx, session.id, "work", at); - await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at }); - return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; } export async function pauseSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.pause">): Promise { - const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); const at = opTime(input.at); - const existing = await activeSession(ctx, wo.id); - if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause"); - await closeOpenEntries(ctx, existing.id, at); - await openEntry(ctx, existing.id, "break", at); - const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "paused" } }); - const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version }; - await audit(ctx, "update", "work_session", session.id, { status: "running" }, { status: "paused", at }); - return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const existing = await activeSession(ctx, wo.id); + if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause"); + const { session, moved } = await pauseLoaded(ctx, existing, wo, at); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + }); } export async function resumeSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.resume">): Promise { - const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); const at = opTime(input.at); - const existing = await activeSession(ctx, wo.id); - if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume"); - await closeOpenEntries(ctx, existing.id, at); - await openEntry(ctx, existing.id, "work", at); - const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); - const moved = await moveOrder(ctx, wo, "in_progress"); - await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at }); - return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const existing = await activeSession(ctx, wo.id); + if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume"); + await handleOtherRunning(ctx, wo.id, input.switchFromOther, at); + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, "work", at); + const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); + const moved = await moveOrder(ctx, wo, "in_progress"); + await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at }); + return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version }; + }); } -export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise { - const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); - const at = opTime(input.at); - const existing = await activeSession(ctx, wo.id); - if (!existing) throw new ServiceError("invalid", "no active session to end"); +async function finishSession(ctx: ServiceCtx, wo: FieldOrder, existing: WorkSession, at: Date, extra: Record = {}) { await closeOpenEntries(ctx, existing.id, at); const endedAt = at < existing.startedAt ? existing.startedAt : at; const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } }); @@ -165,15 +201,107 @@ export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"sessio const workSeconds = sum(["work", "material_procurement", "interruption"]); const breakSeconds = sum(["break"]); const travelSeconds = sum(["travel", "return_travel"]); - await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds }); + await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds, ...extra }); return { - sessionId: session.id, - status: session.status, - workOrderStatus: wo.status, - workOrderVersion: wo.version, + session, workSeconds, breakSeconds, travelSeconds, totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000), }; } + +export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise { + const at = opTime(input.at); + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const existing = await activeSession(ctx, wo.id); + if (!existing) throw new ServiceError("invalid", "no active session to end"); + const r = await finishSession(ctx, wo, existing, at); + return { sessionId: r.session.id, status: r.session.status, workOrderStatus: wo.status, workOrderVersion: wo.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds }; + }); +} + +/** + * „Für heute beenden" (L12): ends the own session, the order stays open. When nobody else is on + * the clock for the order it is moved to `paused` (only where the status machine allows it). + * No completion, no completion blockers. + */ +export async function stopForToday(ctx: ServiceCtx, input: ParsedOpPayload<"session.stop_day">): Promise { + const at = opTime(input.at); + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const existing = await activeSession(ctx, wo.id); + if (!existing) throw new ServiceError("invalid", "no active session to stop"); + const r = await finishSession(ctx, wo, existing, at, { stoppedForToday: true }); + const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version }; + return { sessionId: r.session.id, status: r.session.status, workOrderStatus: moved.status, workOrderVersion: moved.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds }; + }); +} + +/** Segment switch inside the own active session (L12): „Rückfahrt starten", „Material holen", back to work. */ +export async function switchSegment(ctx: ServiceCtx, input: ParsedOpPayload<"session.segment">): Promise { + const at = opTime(input.at); + return inTransaction(ctx, async (ctx) => { + const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true }); + const existing = await activeSession(ctx, wo.id); + if (!existing || existing.status === "en_route") throw new ServiceError("invalid", "no running or paused session"); + const open = await ctx.db.timeEntry.findFirst({ where: { workSessionId: existing.id, endedAt: null }, orderBy: { startedAt: "desc" } }); + if (open?.type === input.type) throw new ServiceError("invalid", "segment already active"); + if (existing.status === "paused") await handleOtherRunning(ctx, wo.id, true, at); + await closeOpenEntries(ctx, existing.id, at); + await openEntry(ctx, existing.id, input.type, at); + const session = existing.status === "running" ? existing : await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } }); + const moved = existing.status === "paused" ? await moveOrder(ctx, wo, "in_progress") : { status: wo.status, version: wo.version }; + await audit(ctx, "update", "work_session", session.id, { status: existing.status, segment: open?.type ?? null }, { status: "running", segment: input.type, at }); + return { sessionId: session.id, status: "running", workOrderStatus: moved.status, workOrderVersion: moved.version }; + }); +} + +export type MyActiveSession = { + sessionId: string; + status: "en_route" | "running" | "paused"; + workOrderId: string; + number: string; + title: string; + workOrderStatus: WorkOrderStatus; + workOrderVersion: number; + startedAt: string; + /** type + start of the open segment (null when none is open) */ + segmentType: TimeEntryType | null; + segmentStartedAt: string | null; + /** sum of closed non-break segments of the session (seconds); the client adds the running segment */ + closedSeconds: number; +}; + +/** The user's current session for the clock bar: running/en-route first, otherwise the newest paused one. */ +export async function getMyActiveSession(ctx: ServiceCtx): Promise { + const sessions = await ctx.db.workSession.findMany({ + where: { userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false, workOrder: { deletedAt: null } }, + orderBy: { startedAt: "desc" }, + take: 10, + include: { + workOrder: { select: { id: true, number: true, title: true, status: true, version: true } }, + entries: { orderBy: { startedAt: "asc" }, select: { type: true, startedAt: true, endedAt: true } }, + }, + }); + const s = sessions.find((x) => CLOCK_STATUSES.includes(x.status)) ?? sessions[0]; + if (!s) return null; + const open = [...s.entries].reverse().find((e) => !e.endedAt) ?? null; + const closedSeconds = Math.round( + s.entries.filter((e) => e.endedAt && NON_BREAK.includes(e.type)).reduce((acc, e) => acc + (e.endedAt!.getTime() - e.startedAt.getTime()), 0) / 1000, + ); + return { + sessionId: s.id, + status: s.status as MyActiveSession["status"], + workOrderId: s.workOrder.id, + number: s.workOrder.number, + title: s.workOrder.title, + workOrderStatus: s.workOrder.status, + workOrderVersion: s.workOrder.version, + startedAt: s.startedAt.toISOString(), + segmentType: open?.type ?? null, + segmentStartedAt: open?.startedAt.toISOString() ?? null, + closedSeconds, + }; +} diff --git a/src/server/services/field/time-correction.ts b/src/server/services/field/time-correction.ts index ec62c6a..2303011 100644 --- a/src/server/services/field/time-correction.ts +++ b/src/server/services/field/time-correction.ts @@ -1,9 +1,14 @@ +import { Prisma } from "@prisma/client"; import { z } from "zod"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; import { audit } from "./common"; -/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */ +/** + * Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. + * L12: own entries cannot be corrected directly (nobody approves their own time) → proposeTimeCorrection. + * A direct correction supersedes an open correction proposal of the entry. + */ export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const; @@ -31,6 +36,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput }); if (!entry) throw new ServiceError("not_found", "time entry not found"); await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true }); + if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry"); if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future"); const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason }; @@ -43,6 +49,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput corrected: true, correctionReason: input.reason, correctedById: ctx.userId, + pendingChange: Prisma.DbNull, }, }); await audit(ctx, "update", "time_entry", entry.id, before, { diff --git a/src/server/services/field/time-entries.ts b/src/server/services/field/time-entries.ts new file mode 100644 index 0000000..648766e --- /dev/null +++ b/src/server/services/field/time-entries.ts @@ -0,0 +1,589 @@ +import { Prisma, type TimeEntryType } from "@prisma/client"; +import { z } from "zod"; +import { emitEvent } from "@/server/events"; +import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { dayWindow, localDateKey } from "@/lib/reports/dates"; +import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { TIME_TYPES } from "@/lib/sync/ops"; +import { MAX_BACKDATE_DAYS, MAX_ENTRY_MINUTES } from "@/lib/field/time-rules"; +import { audit } from "./common"; + +/** + * L12 Zeiterfassung — manual time entries, correction proposals and their approval. + * + * Rules (decision of the product owner): + * - Technicians record/correct only their OWN time of the last 7 days, reason mandatory, marked + * `source = manual`. Own entries are `pending`; proposals for existing entries are stored in + * `pendingChange` while the entry keeps its old (counted) values. + * - Team leads (entries of their teams / orders of their teams) and back office (all) approve with + * `time:approve`. Nobody approves their own entries. + * - `field:correct_time` records entries for team members directly (approved, source manual). + * - Only `approvalStatus = approved` counts for reports, totals and billing; billing release is + * blocked while pending entries or proposals exist on the order. + * + * Manual entries live in a "manual" WorkSession (flag `manual`, status `ended`) per user + order + + * local day, so every existing read path (sessions → entries → order) keeps working unchanged. + */ + +/** Order statuses in which time may still be recorded afterwards (field statuses + in review). */ +export const TIME_RECORDABLE: readonly WorkOrderStatus[] = [...FIELD_EDITABLE, "in_review"]; + +const NON_BREAK: TimeEntryType[] = ["travel", "work", "material_procurement", "return_travel", "interruption"]; +const idSchema = z.string().min(1).max(64); + +export type PendingChange = { startedAt: string; endedAt: string; type: TimeEntryType; reason: string; requestedAt: string; requestedById: string }; + +export function parsePendingChange(v: unknown): PendingChange | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const o = v as Record; + if (typeof o.startedAt !== "string" || typeof o.endedAt !== "string" || typeof o.reason !== "string") return null; + return { + startedAt: o.startedAt, + endedAt: o.endedAt, + type: (typeof o.type === "string" && (TIME_TYPES as readonly string[]).includes(o.type) ? o.type : "work") as TimeEntryType, + reason: o.reason, + requestedAt: typeof o.requestedAt === "string" ? o.requestedAt : "", + requestedById: typeof o.requestedById === "string" ? o.requestedById : "", + }; +} + +const minutesBetween = (a: Date, b: Date) => Math.max(0, Math.round((b.getTime() - a.getTime()) / 60_000)); + +// ---------------------------------------------------------------- validation helpers + +async function validateWindow(ctx: ServiceCtx, start: Date, end: Date, now = new Date()) { + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new ServiceError("invalid", "invalid_timestamp"); + if (end.getTime() <= start.getTime()) throw new ServiceError("invalid", "end_before_start"); + if (end.getTime() - start.getTime() > MAX_ENTRY_MINUTES * 60_000) throw new ServiceError("invalid", "too_long"); + if (end.getTime() > now.getTime() + 60_000) throw new ServiceError("invalid", "in_future"); + if (start.getTime() < (await earliestStart(ctx, now)).getTime()) throw new ServiceError("invalid", "too_old"); +} + +/** Start of the local day MAX_BACKDATE_DAYS ago (tenant time zone). */ +export async function earliestStart(ctx: ServiceCtx, now = new Date()): Promise { + const tz = await tenantTimezone(ctx); + return dayWindow(localDateKey(new Date(now.getTime() - MAX_BACKDATE_DAYS * 86_400_000), tz), tz).start; +} + +/** Own entries (tracked or manual, not rejected) overlapping [start, end). */ +async function assertNoOverlap(ctx: ServiceCtx, userId: string, start: Date, end: Date, excludeId?: string) { + const hit = await ctx.db.timeEntry.findFirst({ + where: { + userId, + approvalStatus: { not: "rejected" }, + ...(excludeId ? { id: { not: excludeId } } : {}), + startedAt: { lt: end }, + OR: [{ endedAt: null }, { endedAt: { gt: start } }], + }, + select: { id: true, startedAt: true, endedAt: true, type: true, workSession: { select: { workOrder: { select: { number: true } } } } }, + }); + if (hit) { + throw new ServiceError("invalid", "overlap", { + reason: "overlap", + timeEntryId: hit.id, + number: hit.workSession.workOrder.number, + startedAt: hit.startedAt.toISOString(), + endedAt: hit.endedAt?.toISOString() ?? null, + }); + } +} + +// ---------------------------------------------------------------- approver scope + +type ApproverScope = { all: true } | { all: false; teamIds: string[]; memberIds: string[] }; + +/** Back office (work_order:read_all) → all; team lead → teams led by the user and their active members. */ +async function approverScope(ctx: ServiceCtx): Promise { + if (can(ctx, "work_order:read_all")) return { all: true }; + const now = new Date(); + const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } }); + const teamIds = led.map((t) => t.id); + const members = teamIds.length + ? await ctx.db.teamMember.findMany({ + where: { teamId: { in: teamIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] }, + select: { userId: true }, + }) + : []; + return { all: false, teamIds, memberIds: [...new Set(members.map((m) => m.userId))] }; +} + +function scopeWhere(scope: ApproverScope, userId: string): Prisma.TimeEntryWhereInput { + const base: Prisma.TimeEntryWhereInput = { workSession: { workOrder: { deletedAt: null } } }; + if (scope.all) return base; + return { + AND: [ + base, + { + OR: [ + ...(scope.teamIds.length ? [{ workSession: { workOrder: { assignedTeamId: { in: scope.teamIds } } } }] : []), + { workSession: { workOrder: { teamLeadUserId: userId } } }, + ...(scope.memberIds.length ? [{ userId: { in: scope.memberIds } }] : []), + ], + }, + ], + }; +} + +/** Direct recording for another person: back office → anyone, team lead → active members of the teams they lead. */ +function mayRecordFor(scope: ApproverScope, targetUserId: string): boolean { + return scope.all || scope.memberIds.includes(targetUserId); +} + +const PENDING_WHERE: Prisma.TimeEntryWhereInput = { OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] }; + +// ---------------------------------------------------------------- add manual entry + +export const manualTimeSchema = z + .object({ + workOrderId: idSchema, + type: z.enum(TIME_TYPES).default("work"), + startedAt: z.coerce.date(), + endedAt: z.coerce.date().nullish(), + durationMinutes: z.number().int().min(1).max(MAX_ENTRY_MINUTES).nullish(), + reason: z.string().trim().min(3).max(500), + note: z.string().trim().max(2000).nullish(), + forUserId: idSchema.nullish(), + clientId: z.string().uuid().nullish(), + }) + .refine((v) => !!v.endedAt || !!v.durationMinutes, { message: "endedAt or durationMinutes required", path: ["endedAt"] }); + +export type ManualTimeInput = z.input; + +export async function addManualTimeEntry(ctx: ServiceCtx, raw: ManualTimeInput) { + const parsed = manualTimeSchema.safeParse(raw); + if (!parsed.success) throw new ServiceError("invalid", "invalid_manual_time", parsed.error.issues); + const input = parsed.data; + const targetUserId = input.forUserId && input.forUserId !== ctx.userId ? input.forUserId : ctx.userId; + const own = targetUserId === ctx.userId; + if (own) { + if (!can(ctx, "field:record_own_time") && !can(ctx, "field:correct_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time"); + } else { + assertCan(ctx, "field:correct_time"); + } + + const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, assignedTeamId: true, teamLeadUserId: true }); + if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status }); + + if (!own) { + const target = await ctx.db.user.findFirst({ where: { id: targetUserId, status: "ACTIVE" }, select: { id: true } }); + if (!target) throw new ServiceError("not_found", "user not found"); + if (!mayRecordFor(await approverScope(ctx), targetUserId)) throw new ServiceError("forbidden", "user_not_in_team"); + } + + if (input.clientId) { + const replay = await ctx.db.timeEntry.findFirst({ where: { clientId: input.clientId } }); + if (replay) { + if (replay.userId !== targetUserId) throw new ServiceError("invalid", "clientId already used"); + return replay; + } + } + + const start = input.startedAt; + const end = input.endedAt ?? new Date(start.getTime() + (input.durationMinutes ?? 0) * 60_000); + await validateWindow(ctx, start, end); + const tz = await tenantTimezone(ctx); + + const entry = await inTransaction(ctx, async (tx) => { + await assertNoOverlap(tx, targetUserId, start, end); + const day = dayWindow(localDateKey(start, tz), tz); + const existing = await tx.db.workSession.findFirst({ + where: { workOrderId: wo.id, userId: targetUserId, manual: true, startedAt: { gte: day.start, lt: day.end } }, + }); + const session = existing + ? await tx.db.workSession.update({ + where: { id: existing.id }, + data: { + startedAt: start < existing.startedAt ? start : existing.startedAt, + endedAt: !existing.endedAt || end > existing.endedAt ? end : existing.endedAt, + }, + }) + : await tx.db.workSession.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, userId: targetUserId, teamId: wo.assignedTeamId, status: "ended", manual: true, startedAt: start, endedAt: end }, + }); + const now = new Date(); + const created = await tx.db.timeEntry.create({ + data: { + tenantId: ctx.tenantId, + workSessionId: session.id, + userId: targetUserId, + type: input.type, + startedAt: start, + endedAt: end, + source: "manual", + approvalStatus: own ? "pending" : "approved", + approvedById: own ? null : ctx.userId, + approvedAt: own ? null : now, + // the mandatory reason of a manual entry is stored like a correction reason + correctionReason: input.reason, + note: input.note ?? null, + clientId: input.clientId ?? null, + }, + }); + await audit(tx, "create", "time_entry", created.id, null, { + workOrderId: wo.id, + userId: targetUserId, + type: created.type, + startedAt: start, + endedAt: end, + source: "manual", + approvalStatus: created.approvalStatus, + reason: input.reason, + }); + return created; + }); + + if (entry.approvalStatus === "pending") { + await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: "manual" } }); + } + return entry; +} + +// ---------------------------------------------------------------- correction proposal + +export const proposeCorrectionSchema = z.object({ + timeEntryId: idSchema, + startedAt: z.coerce.date(), + endedAt: z.coerce.date(), + type: z.enum(TIME_TYPES).nullish(), + reason: z.string().trim().min(3).max(500), +}); + +export async function proposeTimeCorrection(ctx: ServiceCtx, raw: z.input) { + if (!can(ctx, "field:record_own_time") && !can(ctx, "field:correct_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time"); + const parsed = proposeCorrectionSchema.safeParse(raw); + if (!parsed.success) throw new ServiceError("invalid", "invalid_correction", parsed.error.issues); + const input = parsed.data; + + const entry = await ctx.db.timeEntry.findFirst({ where: { id: input.timeEntryId }, include: { workSession: { select: { workOrderId: true } } } }); + if (!entry) throw new ServiceError("not_found", "time entry not found"); + const wo = await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true, number: true, status: true }); + if (entry.userId !== ctx.userId) throw new ServiceError("forbidden", "not_own_entry"); + if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status }); + if (entry.approvalStatus === "rejected") throw new ServiceError("invalid", "entry_rejected"); + if (!entry.endedAt) throw new ServiceError("invalid", "entry_running"); + if (entry.startedAt.getTime() < (await earliestStart(ctx)).getTime()) throw new ServiceError("invalid", "too_old"); + await validateWindow(ctx, input.startedAt, input.endedAt); + const type = input.type ?? entry.type; + + const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange }; + const updated = await inTransaction(ctx, async (tx) => { + await assertNoOverlap(tx, ctx.userId, input.startedAt, input.endedAt, entry.id); + if (entry.approvalStatus === "pending") { + // not yet approved: the pending values themselves are changed (nothing counts yet) + const u = await tx.db.timeEntry.update({ + where: { id: entry.id }, + data: { startedAt: input.startedAt, endedAt: input.endedAt, type, correctionReason: input.reason, rejectionReason: null }, + }); + await audit(tx, "update", "time_entry", entry.id, before, { type, startedAt: input.startedAt, endedAt: input.endedAt, approvalStatus: "pending", reason: input.reason }); + return u; + } + const change: PendingChange = { + startedAt: input.startedAt.toISOString(), + endedAt: input.endedAt.toISOString(), + type, + reason: input.reason, + requestedAt: new Date().toISOString(), + requestedById: ctx.userId, + }; + const u = await tx.db.timeEntry.update({ where: { id: entry.id }, data: { pendingChange: change, rejectionReason: null } }); + await audit(tx, "update", "time_entry", entry.id, before, { pendingChange: change }); + return u; + }); + await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: entry.approvalStatus === "pending" ? "manual" : "correction" } }); + return updated; +} + +// ---------------------------------------------------------------- approval + +async function loadForDecision(ctx: ServiceCtx, timeEntryId: string) { + assertCan(ctx, "time:approve"); + const id = idSchema.safeParse(timeEntryId); + if (!id.success) throw new ServiceError("not_found", "time entry not found"); + const scope = await approverScope(ctx); + const entry = await ctx.db.timeEntry.findFirst({ + where: { AND: [{ id: id.data }, scopeWhere(scope, ctx.userId)] }, + include: { workSession: { select: { workOrder: { select: { id: true, number: true } } } } }, + }); + if (!entry) throw new ServiceError("not_found", "time entry not found"); + if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry"); + return entry; +} + +export async function approveTimeEntry(ctx: ServiceCtx, timeEntryId: string) { + const entry = await loadForDecision(ctx, timeEntryId); + const change = parsePendingChange(entry.pendingChange); + if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending"); + const now = new Date(); + const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange }; + + const updated = await inTransaction(ctx, async (tx) => { + if (change) { + const start = new Date(change.startedAt); + const end = new Date(change.endedAt); + await assertNoOverlap(tx, entry.userId, start, end, entry.id); + const u = await tx.db.timeEntry.update({ + where: { id: entry.id }, + data: { + startedAt: start, + endedAt: end, + type: change.type, + corrected: true, + correctionReason: change.reason, + correctedById: ctx.userId, + pendingChange: Prisma.DbNull, + rejectionReason: null, + approvedById: ctx.userId, + approvedAt: now, + }, + }); + await audit(tx, "update", "time_entry", entry.id, before, { type: u.type, startedAt: start, endedAt: end, corrected: true, correctionReason: change.reason, approvalStatus: u.approvalStatus, decision: "approved" }); + return u; + } + if (entry.endedAt) await assertNoOverlap(tx, entry.userId, entry.startedAt, entry.endedAt, entry.id); + const res = await tx.db.timeEntry.updateMany({ where: { id: entry.id, approvalStatus: "pending" }, data: { approvalStatus: "approved", approvedById: ctx.userId, approvedAt: now, rejectionReason: null } }); + if (res.count !== 1) throw new ServiceError("conflict", "entry changed concurrently"); + await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: "approved", approvedById: ctx.userId, decision: "approved" }); + return tx.db.timeEntry.findFirstOrThrow({ where: { id: entry.id } }); + }); + await emitEvent(ctx, { type: "time.approved", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, kind: change ? "correction" : "manual" } }); + return updated; +} + +export async function rejectTimeEntry(ctx: ServiceCtx, timeEntryId: string, rawReason: string) { + const entry = await loadForDecision(ctx, timeEntryId); + const reason = z.string().trim().min(3).max(500).safeParse(rawReason); + if (!reason.success) throw new ServiceError("invalid", "reason_required"); + const change = parsePendingChange(entry.pendingChange); + if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending"); + const now = new Date(); + const before = { approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange }; + + const updated = await inTransaction(ctx, async (tx) => { + // proposal rejected → entry keeps its approved values; new manual entry rejected → never counts + const u = await tx.db.timeEntry.update({ + where: { id: entry.id }, + data: change + ? { pendingChange: Prisma.DbNull, rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now } + : { approvalStatus: "rejected", rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now }, + }); + await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: u.approvalStatus, pendingChange: null, rejectionReason: reason.data, decision: "rejected" }); + return u; + }); + await emitEvent(ctx, { type: "time.rejected", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, reason: reason.data, kind: change ? "correction" : "manual" } }); + return updated; +} + +/** Bulk approval (back office list): every id is decided independently. */ +export async function approveTimeEntries(ctx: ServiceCtx, ids: string[]): Promise> { + assertCan(ctx, "time:approve"); + const results: Array<{ id: string; ok: boolean; code?: string }> = []; + for (const id of [...new Set(ids)].slice(0, 200)) { + try { + await approveTimeEntry(ctx, id); + results.push({ id, ok: true }); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + results.push({ id, ok: false, code: err.code }); + } + } + return results; +} + +// ---------------------------------------------------------------- read models + +export type PendingTimeEntry = { + id: string; + kind: "manual" | "correction"; + userId: string; + userName: string; + workOrderId: string; + number: string; + title: string; + type: TimeEntryType; + source: "tracked" | "manual"; + startedAt: Date; + endedAt: Date | null; + minutes: number; + reason: string | null; + note: string | null; + proposed: { startedAt: Date; endedAt: Date; type: TimeEntryType; minutes: number } | null; + requestedAt: Date; +}; + +export async function listPendingTimeEntries(ctx: ServiceCtx, filter: { workOrderId?: string; userId?: string; limit?: number } = {}): Promise { + assertCan(ctx, "time:approve"); + const scope = await approverScope(ctx); + const rows = await ctx.db.timeEntry.findMany({ + where: { + AND: [ + scopeWhere(scope, ctx.userId), + PENDING_WHERE, + { userId: { not: ctx.userId } }, + filter.workOrderId ? { workSession: { workOrderId: filter.workOrderId } } : {}, + filter.userId ? { userId: filter.userId } : {}, + ], + }, + orderBy: [{ startedAt: "asc" }], + take: Math.min(500, filter.limit ?? 200), + include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true } } } } }, + }); + const users = rows.length ? await ctx.db.user.findMany({ where: { id: { in: [...new Set(rows.map((r) => r.userId))] } }, select: { id: true, name: true } }) : []; + const names = new Map(users.map((u) => [u.id, u.name])); + return rows.map((r) => { + const change = parsePendingChange(r.pendingChange); + return { + id: r.id, + kind: change ? "correction" : "manual", + userId: r.userId, + userName: names.get(r.userId) ?? "—", + workOrderId: r.workSession.workOrder.id, + number: r.workSession.workOrder.number, + title: r.workSession.workOrder.title, + type: r.type, + source: r.source, + startedAt: r.startedAt, + endedAt: r.endedAt, + minutes: r.endedAt ? minutesBetween(r.startedAt, r.endedAt) : 0, + reason: change ? change.reason : r.correctionReason, + note: r.note, + proposed: change + ? { startedAt: new Date(change.startedAt), endedAt: new Date(change.endedAt), type: change.type, minutes: minutesBetween(new Date(change.startedAt), new Date(change.endedAt)) } + : null, + requestedAt: change?.requestedAt ? new Date(change.requestedAt) : r.createdAt, + }; + }); +} + +/** Number of open decisions for the badge counters (0 without `time:approve`). */ +export async function countPendingTimeEntries(ctx: ServiceCtx): Promise { + if (!can(ctx, "time:approve")) return 0; + const scope = await approverScope(ctx); + return ctx.db.timeEntry.count({ where: { AND: [scopeWhere(scope, ctx.userId), PENDING_WHERE, { userId: { not: ctx.userId } }] } }); +} + +/** Pending minutes (manual entries awaiting approval) and open proposals of one order — for totals/warnings. */ +export async function pendingTimeOfOrder(ctx: ServiceCtx, workOrderId: string): Promise<{ pendingMinutes: number; pendingEntries: number; openProposals: number }> { + const rows = await ctx.db.timeEntry.findMany({ + where: { workSession: { workOrderId }, ...PENDING_WHERE }, + select: { type: true, startedAt: true, endedAt: true, approvalStatus: true }, + }); + const pending = rows.filter((r) => r.approvalStatus === "pending"); + return { + pendingMinutes: pending.filter((r) => NON_BREAK.includes(r.type) && r.endedAt).reduce((acc, r) => acc + minutesBetween(r.startedAt, r.endedAt!), 0), + pendingEntries: pending.length, + openProposals: rows.length - pending.length, + }; +} + +export type MyTimeEntry = { + id: string; + type: TimeEntryType; + startedAt: Date; + endedAt: Date | null; + seconds: number; + source: "tracked" | "manual"; + approvalStatus: "approved" | "pending" | "rejected"; + rejectionReason: string | null; + reason: string | null; + corrected: boolean; + pendingChange: PendingChange | null; + editable: boolean; +}; + +export type MyTimeGroup = { workOrderId: string; number: string; title: string; entries: MyTimeEntry[]; approvedSeconds: number; pendingSeconds: number }; + +export type MyTimeOverview = { + date: string; + days: string[]; + groups: MyTimeGroup[]; + approvedSeconds: number; + pendingSeconds: number; +}; + +/** „Meine Zeiten": own entries of one local day across all orders, totals approved vs. pending. */ +export async function getMyTimeOverview(ctx: ServiceCtx, input: { date?: string | null } = {}, now = new Date()): Promise { + assertCan(ctx, "field:execute"); + const tz = await tenantTimezone(ctx); + const days = Array.from({ length: MAX_BACKDATE_DAYS + 1 }, (_, i) => localDateKey(new Date(now.getTime() - i * 86_400_000), tz)); + const date = input.date && days.includes(input.date) ? input.date : days[0]; + const win = dayWindow(date, tz); + const earliest = dayWindow(days[days.length - 1], tz).start; + + const rows = await ctx.db.timeEntry.findMany({ + where: { userId: ctx.userId, startedAt: { gte: win.start, lt: win.end }, workSession: { workOrder: { deletedAt: null } } }, + orderBy: { startedAt: "asc" }, + include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true, status: true } } } } }, + }); + + const groups = new Map(); + let approvedSeconds = 0; + let pendingSeconds = 0; + for (const r of rows) { + const wo = r.workSession.workOrder; + const g = groups.get(wo.id) ?? { workOrderId: wo.id, number: wo.number, title: wo.title, entries: [], approvedSeconds: 0, pendingSeconds: 0 }; + const seconds = Math.max(0, Math.round(((r.endedAt ?? now).getTime() - r.startedAt.getTime()) / 1000)); + const counts = NON_BREAK.includes(r.type); + if (counts && r.approvalStatus === "approved") { + g.approvedSeconds += seconds; + approvedSeconds += seconds; + } else if (counts && r.approvalStatus === "pending") { + g.pendingSeconds += seconds; + pendingSeconds += seconds; + } + g.entries.push({ + id: r.id, + type: r.type, + startedAt: r.startedAt, + endedAt: r.endedAt, + seconds, + source: r.source, + approvalStatus: r.approvalStatus, + rejectionReason: r.rejectionReason, + reason: r.correctionReason, + corrected: r.corrected, + pendingChange: parsePendingChange(r.pendingChange), + editable: !!r.endedAt && r.approvalStatus !== "rejected" && r.startedAt >= earliest && TIME_RECORDABLE.includes(wo.status), + }); + groups.set(wo.id, g); + } + return { date, days, groups: [...groups.values()], approvedSeconds, pendingSeconds }; +} + +/** Orders selectable in „Zeit nachtragen": visible, still recordable, touched in the last days or planned in that window. */ +export async function listRecordableOrders(ctx: ServiceCtx, now = new Date()): Promise> { + const scope = await workOrderScope(ctx); + const earliest = await earliestStart(ctx, now); + const end = new Date(now.getTime() + 86_400_000); + return ctx.db.workOrder.findMany({ + where: { + AND: [ + scope, + { status: { in: [...TIME_RECORDABLE] } }, + { + OR: [ + { workSessions: { some: { userId: ctx.userId, startedAt: { gte: earliest } } } }, + { plannedStart: { gte: earliest, lt: end } }, + { plannedStart: { lt: end }, plannedEnd: { gte: earliest } }, + { status: { in: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"] } }, + ], + }, + ], + }, + orderBy: [{ plannedStart: { sort: "desc", nulls: "last" } }], + take: 60, + select: { id: true, number: true, title: true }, + }); +} + +/** People the user may record time for directly (`field:correct_time`): team members, all field users for back office. */ +export async function listRecordableUsers(ctx: ServiceCtx): Promise> { + if (!can(ctx, "field:correct_time")) return []; + const scope = await approverScope(ctx); + const where: Prisma.UserWhereInput = scope.all + ? { status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "field:execute" } } } } } } } + : { status: "ACTIVE", id: { in: scope.memberIds } }; + const users = await ctx.db.user.findMany({ where: { AND: [where, { id: { not: ctx.userId } }] }, orderBy: { name: "asc" }, take: 200, select: { id: true, name: true } }); + return users; +} diff --git a/src/server/services/lotse/completeness.ts b/src/server/services/lotse/completeness.ts index fbe6aa4..0560fdd 100644 --- a/src/server/services/lotse/completeness.ts +++ b/src/server/services/lotse/completeness.ts @@ -21,7 +21,7 @@ export async function checkCompleteness(ctx: ServiceCtx, workOrderId: string): P ctx.db.checklistItem.findMany({ where: { workOrderId: wo.id, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { label: true } }), ctx.db.materialPlan.findMany({ where: { workOrderId: wo.id }, orderBy: { sortOrder: "asc" }, select: { id: true, name: true, plannedQuantity: true } }), ctx.db.materialUsage.findMany({ where: { workOrderId: wo.id }, select: { name: true, materialPlanId: true, usageStatus: true, actualQuantity: true, deviationReason: true } }), - ctx.db.timeEntry.findMany({ where: { type: "work", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }), + ctx.db.timeEntry.findMany({ where: { type: "work", approvalStatus: "approved", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }), ctx.db.activityNote.count({ where: { workOrderId: wo.id, deletedAt: null, kind: { in: ["work_done", "general"] } } }), ctx.db.report.findMany({ where: { workOrderId: wo.id, status: { not: "superseded" } }, diff --git a/src/server/services/notifications/handle-event.ts b/src/server/services/notifications/handle-event.ts index aa09d5e..c9d97cf 100644 --- a/src/server/services/notifications/handle-event.ts +++ b/src/server/services/notifications/handle-event.ts @@ -135,7 +135,7 @@ function textVars(locale: Locale, f: EventFacts): Record 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 }, diff --git a/src/server/services/reports/build-content.ts b/src/server/services/reports/build-content.ts index 97d45d0..70a3a20 100644 --- a/src/server/services/reports/build-content.ts +++ b/src/server/services/reports/build-content.ts @@ -56,7 +56,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp const win = dayWindow(input.reportDate, timeZone); const inDay = daily ? { gte: win.start, lt: win.end } : undefined; - const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([ + const [wo, settings, tenant, allEntries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([ ctx.db.workOrder.findFirstOrThrow({ where: { id: input.workOrderId }, include: { @@ -69,10 +69,11 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp }), ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }), + // L12: only approved entries count; pending manual entries are reported separately (pendingMinutes) ctx.db.timeEntry.findMany({ - where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) }, + where: { workSession: { workOrderId: input.workOrderId }, approvalStatus: { in: ["approved", "pending"] }, ...(inDay ? { startedAt: inDay } : {}) }, orderBy: { startedAt: "asc" }, - select: { userId: true, type: true, startedAt: true, endedAt: true }, + select: { userId: true, type: true, startedAt: true, endedAt: true, approvalStatus: true }, }), ctx.db.activityNote.findMany({ where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) }, @@ -96,6 +97,10 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp ]); // ---- people & time ---- + const pendingMinutes = allEntries + .filter((e) => e.approvalStatus === "pending" && e.type !== "break") + .reduce((acc, e) => acc + Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000)), 0); + const entries = allEntries.filter((e) => e.approvalStatus === "approved"); const userIds = new Set(entries.map((e) => e.userId)); if (signature?.capturedById) userIds.add(signature.capturedById); const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } }); @@ -252,6 +257,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp totalsByPerson: [...byPerson.values()], totalMinutes, hasRunningEntries, + pendingMinutes, }, texts, materials: { used, notUsed, additional }, diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts index de9aaf6..0f122a4 100644 --- a/src/server/services/sync/apply.ts +++ b/src/server/services/sync/apply.ts @@ -5,7 +5,8 @@ import { ServiceError, type ServiceCtx } from "@/server/services/context"; import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope"; import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops"; -import { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions"; +import { endSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "@/server/services/field/sessions"; +import { addManualTimeEntry, proposeTimeCorrection } from "@/server/services/field/time-entries"; import { createNote } from "@/server/services/field/notes"; import { toggleChecklistItem } from "@/server/services/field/checklist"; import { upsertMaterialUsage } from "@/server/services/field/materials"; @@ -58,8 +59,23 @@ const FIELD_HANDLERS: Partial> = { "material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })), "photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })), "voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })), + // L12 Zeiterfassung + "session.stop_day": h<"session.stop_day">(async (ctx, p) => ({ entityVersion: (await stopForToday(ctx, p)).workOrderVersion })), + "session.segment": h<"session.segment">(async (ctx, p) => ({ entityVersion: (await switchSegment(ctx, p)).workOrderVersion })), + "time.add_manual": h<"time.add_manual">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await addManualTimeEntry(ctx, p)).id) })), + "time.propose_correction": h<"time.propose_correction">(async (ctx, p) => { + await proposeTimeCorrection(ctx, p); + return {}; + }), }; +/** Message of a rejected op: blockers as JSON, the auto-switch conflict with the other order (L12). */ +function errorMessage(err: ServiceError): string { + if (err.code === "blocked") return JSON.stringify(err.details ?? []); + if (err.message === "other_session_running") return `other_session_running:${JSON.stringify(err.details ?? {})}`; + return err.message; +} + class NotAvailable extends Error {} /** Route a validated op to the field handler or the registered module of another lane. */ @@ -205,12 +221,12 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu // a transition conflict detected inside the service (race after the pre-check) const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected"; const details = err.code === "blocked" ? { blockers: err.details } : {}; - const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code); + const rec = await record(ctx, op, deviceId, status, { message: errorMessage(err), ...details }, err.code); if (rec === "duplicate") return { ...base, status: "duplicate" }; if (status === "conflict") { await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } }); } - return { ...base, status, errorCode: err.code, message: err.code === "blocked" ? JSON.stringify(err.details ?? []) : err.message }; + return { ...base, status, errorCode: err.code, message: errorMessage(err) }; } console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err); return { ...base, status: "rejected", errorCode: "internal", message: "internal error" }; diff --git a/src/server/services/work-orders/completion.ts b/src/server/services/work-orders/completion.ts index 717028e..39da8c5 100644 --- a/src/server/services/work-orders/completion.ts +++ b/src/server/services/work-orders/completion.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status"; import type { ServiceCtx } from "@/server/services/context"; import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared"; @@ -61,8 +62,15 @@ export async function transitionBlockers( } if (to === "released_for_billing" && wo.status === "in_review") { - const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }); - return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }]; + const [approved, pendingTime] = await Promise.all([ + ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }), + // L12: manual entries / correction proposals waiting for approval block the billing release + ctx.db.timeEntry.count({ where: { workSession: { workOrderId: wo.id }, OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] } }), + ]); + const blockers: CompletionBlocker[] = []; + if (approved === 0) blockers.push({ kind: "missing_field", field: "approved_completion_report" }); + if (pendingTime > 0) blockers.push({ kind: "missing_field", field: "pending_time_entries" }); + return blockers; } return []; diff --git a/src/server/services/work-orders/dashboard.ts b/src/server/services/work-orders/dashboard.ts index c5f3f7f..a137619 100644 --- a/src/server/services/work-orders/dashboard.ts +++ b/src/server/services/work-orders/dashboard.ts @@ -2,8 +2,9 @@ import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/fi import { can, type ServiceCtx } from "@/server/services/context"; import { tenantTimezone } from "@/server/services/work-orders/_shared"; import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; -export type DashboardTiles = Record & { reportsToReview: number; syncConflicts: number }; +export type DashboardTiles = Record & { reportsToReview: number; syncConflicts: number; timeApprovals: number }; /** * Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope. @@ -17,13 +18,14 @@ export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter const counts = await Promise.all( PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })), ); - const [reportsToReview, syncConflicts] = await Promise.all([ + const [reportsToReview, syncConflicts, timeApprovals] = await Promise.all([ ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }), can(ctx, "work_order:write") ? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } }) : Promise.resolve(0), + countPendingTimeEntries(ctx), // L12 „Zeiten zur Freigabe" (0 without time:approve) ]); const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record; - return { ...tiles, reportsToReview, syncConflicts }; + return { ...tiles, reportsToReview, syncConflicts, timeApprovals }; } diff --git a/src/server/services/work-orders/detail.ts b/src/server/services/work-orders/detail.ts index 60c026f..32cd8ec 100644 --- a/src/server/services/work-orders/detail.ts +++ b/src/server/services/work-orders/detail.ts @@ -46,12 +46,14 @@ export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) { orderBy: { startedAt: "desc" }, include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } }, }); - return sessions.map((s) => { - const minutes = s.entries - .filter((e) => e.type !== "break") - .reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0); - return { ...s, workMinutes: Math.round(minutes) }; - }); + // L12: only approved entries count; pending manual entries are shown separately + const minutesOf = (entries: typeof sessions[number]["entries"], status: "approved" | "pending") => + Math.round( + entries + .filter((e) => e.type !== "break" && e.approvalStatus === status) + .reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0), + ); + return sessions.map((s) => ({ ...s, workMinutes: minutesOf(s.entries, "approved"), pendingMinutes: minutesOf(s.entries, "pending") })); } export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {