From 03a2ccab4f8b3b8f6bf953d61786c323d6356ee5 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:20 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L12=20Zeiterfassung:=20Datenmodell,=20Recht?= =?UTF-8?q?e=20und=20Events=20f=C3=BCr=20die=20Zeitfreigabe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration zeiterfassung_freigabe (TimeEntry source/approvalStatus/pendingChange, WorkSession.manual), Rechte field:record_own_time und time:approve, PII approvedById, Events time.approval_requested/approved/rejected. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 24 +++++++++++++++++++ prisma/schema.prisma | 24 +++++++++++++++++++ src/lib/events.ts | 6 ++++- src/server/dsgvo/pii-fields.ts | 1 + src/server/rbac.ts | 6 +++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql diff --git a/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql new file mode 100644 index 0000000..992aac3 --- /dev/null +++ b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql @@ -0,0 +1,24 @@ +-- L12 Zeiterfassung: approval workflow for manual time entries and correction proposals. +-- Additive columns only (existing rows: tracked + approved = unchanged behaviour). No new table -> no RLS/TENANT_MODELS change. + +-- CreateEnum +CREATE TYPE "TimeEntrySource" AS ENUM ('tracked', 'manual'); + +-- CreateEnum +CREATE TYPE "TimeApprovalStatus" AS ENUM ('approved', 'pending', 'rejected'); + +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "approval_status" "TimeApprovalStatus" NOT NULL DEFAULT 'approved', +ADD COLUMN "approved_at" TIMESTAMP(3), +ADD COLUMN "approved_by_id" TEXT, +ADD COLUMN "note" TEXT, +ADD COLUMN "pending_change" JSONB, +ADD COLUMN "rejection_reason" TEXT, +ADD COLUMN "source" "TimeEntrySource" NOT NULL DEFAULT 'tracked'; + +-- AlterTable +ALTER TABLE "work_sessions" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_approval_status_idx" ON "time_entries"("tenant_id", "approval_status"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc29a6c..b0f5a6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -894,6 +894,8 @@ model WorkSession { startedOffline Boolean @default(false) @map("started_offline") deviceInfo String? @map("device_info") clientId String? @map("client_id") + /// L12: container session for manually recorded entries (one per user + order + day, always `ended`) + manual Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -917,6 +919,17 @@ enum TimeEntryType { interruption } +enum TimeEntrySource { + tracked + manual +} + +enum TimeApprovalStatus { + approved + pending + rejected +} + model TimeEntry { id String @id @default(cuid()) tenantId String @map("tenant_id") @@ -930,6 +943,16 @@ model TimeEntry { correctionReason String? @map("correction_reason") correctedById String? @map("corrected_by_id") clientId String? @map("client_id") + // L12 Zeiterfassung: manual entries and correction proposals need approval; only + // `approvalStatus = approved` counts for reports, totals and billing. + source TimeEntrySource @default(tracked) + approvalStatus TimeApprovalStatus @default(approved) @map("approval_status") + approvedById String? @map("approved_by_id") + approvedAt DateTime? @map("approved_at") + rejectionReason String? @map("rejection_reason") + /// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval + pendingChange Json? @map("pending_change") + note String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -937,6 +960,7 @@ model TimeEntry { @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) + @@index([tenantId, approvalStatus]) @@unique([tenantId, clientId]) @@map("time_entries") } diff --git a/src/lib/events.ts b/src/lib/events.ts index 0a05092..16c2c17 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -19,13 +19,17 @@ export const EVENT_TYPES = [ "import.ready_for_review", "import.failed", "sync.failed", + // L12 Zeiterfassung + "time.approval_requested", + "time.approved", + "time.rejected", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; export type DomainEvent = { type: EventType; - entityType: "work_order" | "report" | "import_job" | "sync_operation"; + entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry"; entityId: string; /** Short, human-readable facts for templates (no PII beyond what the recipient may see). */ data?: Record; diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index d7a1ffe..ed081e2 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -33,6 +33,7 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [ { model: "WorkSession", field: "userId" }, { model: "TimeEntry", field: "userId" }, { model: "TimeEntry", field: "correctedById" }, + { model: "TimeEntry", field: "approvedById" }, // L12: approving/rejecting person { model: "ActivityNote", field: "authorId" }, { model: "Document", field: "uploadedById" }, { model: "Photo", field: "takenById" }, diff --git a/src/server/rbac.ts b/src/server/rbac.ts index 1a26d38..f50e0f3 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -36,6 +36,9 @@ export const PERMISSIONS = [ // Einsatz (Mobile): Einsatz starten, Zeiten, Material, Fotos, Notizen "field:execute", "field:correct_time", + "field:record_own_time", // L12: eigene Zeiten nachtragen / Korrektur vorschlagen (mit Freigabe) + // Zeiterfassung: Nachträge/Korrekturen freigeben (Teamleiter: eigenes Team, Backoffice: alle) + "time:approve", // Berichte "report:read", "report:write", @@ -85,6 +88,8 @@ export const ROLE_DEFS: Record Date: Tue, 15 Sep 2026 09:29:30 +0200 Subject: [PATCH 2/5] =?UTF-8?q?L12=20Zeiterfassung:=20Services=20f=C3=BCr?= =?UTF-8?q?=20Nachtrag,=20Freigabe,=20Auto-Wechsel=20und=20F=C3=BCr=20heut?= =?UTF-8?q?e=20beenden?= 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) { From fe3eba6e44a531c2f3bb4dcd9192ada8be35aa67 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 09:29:38 +0200 Subject: [PATCH 3/5] L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben und Backoffice-Liste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laufende-Uhr-Leiste auf allen /m-Seiten, Start/Pause direkt auf den Auftragskarten, Auto-Wechsel-Dialog, Auftragsdetail mit Pause / Für heute beenden / Abschließen-Link und Segmentwechseln, /m/time (Tagesübersicht, Nachtragen, Korrektur vorschlagen), /m/approvals für Teamleiter, /work-orders/time-approvals mit Sammelfreigabe, Zeiten-Tab mit Badges und Inline-Freigabe, Dashboard-Kachel, Texte de/en. Co-Authored-By: Claude Opus 5 --- messages/de/dashboard.json | 6 +- messages/de/field.json | 154 ++++++++++++- messages/de/nav.json | 3 +- messages/de/notifications.json | 102 +++++++-- messages/de/offline.json | 5 +- messages/de/workOrders.json | 47 +++- messages/en/dashboard.json | 6 +- messages/en/field.json | 154 ++++++++++++- messages/en/nav.json | 3 +- messages/en/notifications.json | 102 +++++++-- messages/en/offline.json | 5 +- messages/en/workOrders.json | 47 +++- src/app/(app)/dashboard/page.tsx | 10 +- .../(app)/work-orders/time-approvals/page.tsx | 116 ++++++++++ src/app/(field)/m/(core)/approvals/page.tsx | 75 +++++++ src/app/(field)/m/(core)/orders/[id]/page.tsx | 7 +- .../m/(core)/orders/[id]/report/page.tsx | 10 +- .../m/(core)/orders/[id]/time/page.tsx | 62 ++++-- src/app/(field)/m/(core)/orders/page.tsx | 4 +- src/app/(field)/m/(core)/page.tsx | 4 +- src/app/(field)/m/(core)/profile/page.tsx | 14 +- src/app/(field)/m/(core)/time/new/page.tsx | 35 +++ src/app/(field)/m/(core)/time/page.tsx | 105 +++++++++ src/app/(field)/m/layout.tsx | 31 ++- src/components/field/approval-actions.tsx | 73 ++++++ src/components/field/bottom-nav.tsx | 16 +- src/components/field/card-time-button.tsx | 56 +++++ .../field/correction-proposal-form.tsx | 150 +++++++++++++ src/components/field/local-pending-times.tsx | 64 ++++++ src/components/field/manual-time-form.tsx | 207 ++++++++++++++++++ src/components/field/order-card.tsx | 33 ++- src/components/field/pending-time-notice.tsx | 40 ++++ src/components/field/primary-action.tsx | 163 +++++++++----- src/components/field/running-clock-bar.tsx | 165 ++++++++++++++ src/components/field/switch-session-sheet.tsx | 106 +++++++++ src/components/field/time-entry-badges.tsx | 72 ++++++ src/components/offline/offline-view.tsx | 10 +- src/components/work-orders/detail-tabs.tsx | 59 ++++- .../work-orders/time-approval-forms.tsx | 53 +++++ src/lib/nav.ts | 2 + src/server/actions/field/time.ts | 38 +++- .../actions/work_orders/time-approvals.ts | 71 ++++++ 42 files changed, 2319 insertions(+), 166 deletions(-) create mode 100644 src/app/(app)/work-orders/time-approvals/page.tsx create mode 100644 src/app/(field)/m/(core)/approvals/page.tsx create mode 100644 src/app/(field)/m/(core)/time/new/page.tsx create mode 100644 src/app/(field)/m/(core)/time/page.tsx create mode 100644 src/components/field/approval-actions.tsx create mode 100644 src/components/field/card-time-button.tsx create mode 100644 src/components/field/correction-proposal-form.tsx create mode 100644 src/components/field/local-pending-times.tsx create mode 100644 src/components/field/manual-time-form.tsx create mode 100644 src/components/field/pending-time-notice.tsx create mode 100644 src/components/field/running-clock-bar.tsx create mode 100644 src/components/field/switch-session-sheet.tsx create mode 100644 src/components/field/time-entry-badges.tsx create mode 100644 src/components/work-orders/time-approval-forms.tsx create mode 100644 src/server/actions/work_orders/time-approvals.ts diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 33e7ba1..7388acd 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -16,7 +16,8 @@ "billing": "Zur Abrechnung", "emergency_new": "Neue Notdienste", "missing_signatures": "Fehlende Unterschriften", - "sync_conflicts": "Sync-Konflikte" + "sync_conflicts": "Sync-Konflikte", + "time_approvals": "Zeiten zur Freigabe" }, "hints": { "open": "Noch nicht abgerechnet oder storniert", @@ -29,7 +30,8 @@ "billing": "Bereit zur Abrechnung", "emergency_new": "Neu oder zur Prüfung", "missing_signatures": "Unterschrift ausstehend", - "sync_conflicts": "Offline-Änderungen prüfen" + "sync_conflicts": "Offline-Änderungen prüfen", + "time_approvals": "Nachträge und Korrekturen prüfen" }, "open": "Anzeigen" } diff --git a/messages/de/field.json b/messages/de/field.json index ba53ccf..c1b3f21 100644 --- a/messages/de/field.json +++ b/messages/de/field.json @@ -5,7 +5,8 @@ "orders": "Aufträge", "emergency": "Notdienst", "sync": "Sync", - "profile": "Profil" + "profile": "Profil", + "approvalsBadge": "{count} Zeiten zur Freigabe" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "Ohne Termin", "emergency": "Notdienst", "urgent": "Dringend", - "high": "Hoch" + "high": "Hoch", + "running": "Zeit läuft", + "paused": "Pausiert", + "enRoute": "In Anfahrt", + "startWork": "Arbeit starten", + "pause": "Pause", + "resume": "Weiter" }, "statusGroup": { "new": "Neu", @@ -78,7 +85,17 @@ "saving": "Wird gespeichert …", "blocked": "Vor dem Abschluss noch offen:", "reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.", - "nothing": "Keine Aktion erforderlich." + "nothing": "Keine Aktion erforderlich.", + "stop_day": "Für heute beenden", + "confirmStopDay": "Jetzt für heute beenden", + "stopDayHint": "Die Zeiterfassung endet, der Auftrag bleibt offen und kann morgen fortgesetzt werden.", + "completeLink": "Auftrag abschließen …", + "segment": { + "return_travel": "Rückfahrt starten", + "material_procurement": "Material holen", + "work": "Zurück zur Arbeit" + }, + "currentSegment": "Aktuell: {type}" }, "blocker": { "checklist_item": "Checkliste: {label}", @@ -264,7 +281,8 @@ "en_route": "In Anfahrt", "running": "Läuft", "paused": "Pausiert", - "ended": "Beendet" + "ended": "Beendet", + "manual": "Nachgetragen" }, "running": "läuft", "offlineStarted": "offline gestartet", @@ -277,7 +295,10 @@ "save": "Korrektur speichern", "saved": "Korrektur gespeichert.", "empty": "Noch keine Zeiten erfasst.", - "totalWork": "Arbeitszeit gesamt: {duration}" + "totalWork": "Arbeitszeit gesamt: {duration}", + "propose": "Korrektur vorschlagen", + "totalPending": "Zur Freigabe offen: {duration}", + "ownEntryHint": "Eigene Zeiten korrigieren Sie über „Korrektur vorschlagen“." }, "profile": { "title": "Profil", @@ -287,12 +308,133 @@ "roles": "Rolle", "language": "Sprache", "logout": "Abmelden", - "backoffice": "Zum Büro-Bereich" + "backoffice": "Zum Büro-Bereich", + "myTime": "Meine Zeiten", + "approvals": "Zeiten freigeben" }, "sync": { "title": "Synchronisation", "immediate": "Änderungen werden sofort an das Büro übertragen.", "offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.", "status": "Verbindung" + }, + "clock": { + "label": "Laufende Zeiterfassung", + "running": "Läuft", + "paused": "Pausiert", + "enRoute": "Anfahrt", + "openOrder": "Auftrag {number} öffnen", + "pause": "Pause", + "resume": "Weiter", + "stopDay": "Für heute beenden", + "stopDayConfirm": "Beenden?", + "myTime": "Meine Zeiten", + "offline": "offline" + }, + "switch": { + "title": "Andere Zeiterfassung läuft", + "text": "{from} läuft noch. Pausieren und {to} starten?", + "textUnknown": "Auf einem anderen Auftrag läuft noch eine Zeiterfassung. Pausieren und {to} starten?", + "confirm": "Wechseln", + "cancel": "Abbrechen" + }, + "myTime": { + "title": "Meine Zeiten", + "sub": "Ihre Zeiten je Tag über alle Aufträge.", + "today": "Heute", + "yesterday": "Gestern", + "days": "Tag wählen", + "empty": "An diesem Tag sind keine Zeiten erfasst.", + "approved": "Freigegeben", + "pending": "Zur Freigabe", + "dayTotal": "Tagessumme", + "totalApproved": "Freigegeben: {duration}", + "totalPending": "Offen: {duration}", + "add": "Zeit nachtragen", + "running": "läuft", + "badge": { + "manual": "Manuell", + "pending": "Zur Freigabe", + "rejected": "Abgelehnt: {reason}", + "correctionRequested": "Korrektur beantragt", + "correctionRejected": "Korrektur abgelehnt: {reason}", + "corrected": "Korrigiert" + }, + "proposed": "Vorschlag: {from}–{to} · {type}", + "localPending": "Wartet auf Übertragung", + "localTitle": "Noch nicht übertragen", + "form": { + "title": "Zeit nachtragen", + "order": "Auftrag", + "orderPlaceholder": "Auftrag wählen", + "noOrders": "Keine Aufträge der letzten 7 Tage verfügbar.", + "forUser": "Für", + "me": "Mich selbst", + "type": "Art", + "date": "Datum", + "mode": "Erfassung", + "modeRange": "Von – Bis", + "modeDuration": "Dauer", + "from": "Von", + "to": "Bis", + "duration": "Dauer in Minuten", + "quick": "{minutes} min", + "reason": "Begründung", + "reasonHint": "Pflichtangabe, mindestens 3 Zeichen.", + "note": "Notiz (optional)", + "approvalHint": "Wird nach Freigabe durch Teamleiter oder Büro gezählt.", + "directHint": "Für Teammitglieder erfasste Zeiten gelten sofort als freigegeben.", + "save": "Nachtrag speichern", + "saved": "Nachtrag gespeichert – wartet auf Freigabe.", + "savedDirect": "Zeit gespeichert.", + "queued": "Offline gespeichert – wird übertragen, sobald Verbindung besteht.", + "cancel": "Abbrechen" + }, + "correction": { + "title": "Korrektur vorschlagen", + "hint": "Bis zur Freigabe gilt die bisherige Zeit.", + "save": "Vorschlag senden", + "saved": "Korrektur beantragt." + }, + "reasons": { + "forgotStart": "Start vergessen", + "noNetwork": "Kein Netz", + "recordedLater": "Nachträglich erfasst" + }, + "errors": { + "overlap": "Der Zeitraum überschneidet sich mit einer bereits erfassten Zeit.", + "too_old": "Nachtragen ist nur für die letzten 7 Tage möglich.", + "in_future": "Die Zeit darf nicht in der Zukunft liegen.", + "too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.", + "end_before_start": "Das Ende muss nach dem Beginn liegen.", + "work_order_status": "Für diesen Auftrag können keine Zeiten mehr erfasst werden.", + "not_own_entry": "Sie können nur eigene Zeiten korrigieren.", + "own_entry": "Eigene Zeiten können Sie nicht selbst freigeben.", + "entry_running": "Die Zeit läuft noch." + } + }, + "approvals": { + "title": "Zeiten freigeben", + "sub": "Nachträge und Korrekturen Ihres Teams.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "link": "Zeiten freigeben", + "count": "{count, plural, =0 {Nichts offen} one {# offen} other {# offen}}", + "manual": "Nachtrag", + "correction": "Korrektur", + "reason": "Begründung", + "note": "Notiz", + "old": "Bisher", + "new": "Neu", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "rejectConfirm": "Ablehnung senden", + "cancel": "Abbrechen", + "approved": "Freigegeben.", + "rejected": "Abgelehnt." + }, + "pendingTime": { + "report": "Hinweis: {duration} nachgetragene Zeit ist noch nicht freigegeben und fehlt im Bericht.", + "proposals": "Hinweis: {count, plural, one {# Zeitkorrektur wartet} other {# Zeitkorrekturen warten}} auf Freigabe." } } diff --git a/messages/de/nav.json b/messages/de/nav.json index efed3cc..f49d785 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (KI)", "admin": "Admin-Konsole", "openMenu": "Menü öffnen", - "closeMenu": "Menü schließen" + "closeMenu": "Menü schließen", + "timeApprovals": "Zeiten zur Freigabe" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 3689c31..5685b8c 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Notdiensteinsatz abgeschlossen", "import_ready_for_review": "Import zur Prüfung", "import_failed": "Fehler bei Dokumentverarbeitung", - "sync_failed": "Synchronisationsfehler" + "sync_failed": "Synchronisationsfehler", + "time_approval_requested": "Zeit zur Freigabe", + "time_approved": "Zeit freigegeben", + "time_rejected": "Zeit abgelehnt" }, "events": { - "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, - "work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." }, - "work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." }, - "work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." }, - "work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." }, - "work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." }, - "work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." }, - "work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." }, - "work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." }, - "report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." }, - "report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." }, - "report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." }, - "emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." }, - "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, - "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, - "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, - "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + "work_order_assigned": { + "title": "Neuer Auftrag {number}", + "message": "{title} – {customer} wurde Ihrem Team zugewiesen." + }, + "work_order_changed": { + "title": "Auftrag {number} geändert", + "message": "{title} – {customer}: Angaben wurden aktualisiert." + }, + "work_order_cancelled": { + "title": "Auftrag {number} storniert", + "message": "{title} – {customer} wurde storniert." + }, + "work_order_started": { + "title": "Auftrag {number} gestartet", + "message": "{actor} hat den Einsatz bei {customer} begonnen." + }, + "work_order_daily_report_created": { + "title": "Tagesbericht zu {number}", + "message": "{actor} hat einen Tagesbericht für {customer} erstellt." + }, + "work_order_technically_completed": { + "title": "Auftrag {number} technisch abgeschlossen", + "message": "{title} – {customer}: Einsatz vor Ort beendet." + }, + "work_order_signature_missing": { + "title": "Unterschrift fehlt: {number}", + "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." + }, + "work_order_missing_required": { + "title": "Pflichtangaben fehlen: {number}", + "message": "{title} – {customer}: Dokumentation unvollständig." + }, + "work_order_released_for_billing": { + "title": "Auftrag {number} zur Abrechnung", + "message": "{title} – {customer} ist bereit zur Abrechnung." + }, + "report_submitted": { + "title": "Bericht zur Prüfung: {number}", + "message": "{actor} hat einen Bericht zu {title} eingereicht." + }, + "report_approved": { + "title": "Bericht freigegeben: {number}", + "message": "Der Bericht zu {title} wurde freigegeben." + }, + "report_rejected": { + "title": "Bericht zurückgewiesen: {number}", + "message": "Der Bericht zu {title} braucht eine Korrektur." + }, + "emergency_created": { + "title": "Notdiensteinsatz {number} erstellt", + "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." + }, + "emergency_completed": { + "title": "Notdiensteinsatz {number} abgeschlossen", + "message": "{customer}: zur Prüfung und Abrechnung." + }, + "import_ready_for_review": { + "title": "Import zur Prüfung bereit", + "message": "Die Daten aus {fileName} liegen zur Prüfung vor." + }, + "import_failed": { + "title": "Fehler bei Dokumentverarbeitung", + "message": "{fileName} konnte nicht verarbeitet werden." + }, + "sync_failed": { + "title": "Synchronisation fehlgeschlagen", + "message": "Eine Änderung konnte nicht übernommen werden ({reason})." + }, + "time_approval_requested": { + "title": "Zeit zur Freigabe: {number}", + "message": "{actor} hat eine Zeit zu {number} nachgetragen oder eine Korrektur beantragt." + }, + "time_approved": { + "title": "Zeit freigegeben: {number}", + "message": "{actor} hat Ihre Zeit zu {number} freigegeben." + }, + "time_rejected": { + "title": "Zeit abgelehnt: {number}", + "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" + } }, "fallback": { "unknown": "unbekannt", diff --git a/messages/de/offline.json b/messages/de/offline.json index 07a4bbb..89336f8 100644 --- a/messages/de/offline.json +++ b/messages/de/offline.json @@ -89,7 +89,10 @@ "upload": "Die Datei wurde nicht angenommen (Format oder Größe).", "unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.", "network": "Keine Verbindung – wird automatisch erneut versucht.", - "internal": "Technischer Fehler – wird automatisch erneut versucht." + "internal": "Technischer Fehler – wird automatisch erneut versucht.", + "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", + "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", + "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." }, "view": { "title": "Offline-Ansicht", diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 0cb6433..94eb659 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Materialbeschaffung", "return_travel": "Rückfahrt", "interruption": "Unterbrechung" - } + }, + "pendingMinutes": "Zur Freigabe offen: {minutes} min", + "approvedMinutes": "Freigegeben", + "badgeManual": "Manuell", + "badgePending": "Zur Freigabe", + "badgeRejected": "Abgelehnt: {reason}", + "badgeCorrection": "Korrektur beantragt: {from}–{to}", + "reason": "Begründung: {reason}", + "manualSession": "Nachgetragen", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund", + "approved": "Freigegeben.", + "rejected": "Abgelehnt.", + "openApprovals": "Alle offenen Freigaben" }, "photos": { "empty": "Noch keine Fotos.", @@ -460,8 +474,35 @@ "running_session": "Ein Einsatz läuft noch", "field": { "signature": "Unterschrift fehlt", - "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben" + "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben", + "pending_time_entries": "Zeiten warten noch auf Freigabe" } }, - "saved": "Gespeichert." + "saved": "Gespeichert.", + "timeApprovals": { + "crumb": "Aufträge", + "title": "Zeiten zur Freigabe", + "sub": "Nachträge und Korrekturen der Monteure. Erst nach Freigabe zählen sie für Bericht und Abrechnung.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "employee": "Mitarbeiter", + "order": "Auftrag", + "date": "Datum", + "range": "Von–Bis", + "duration": "Dauer", + "type": "Art", + "reason": "Begründung", + "kindManual": "Nachtrag", + "kindCorrection": "Korrektur", + "oldNew": "{old} → {new}", + "select": "Auswählen", + "selectAll": "Alle auswählen", + "approveSelected": "Ausgewählte freigeben", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "approved": "Freigegeben.", + "bulkApproved": "{ok} freigegeben, {failed} nicht möglich.", + "rejected": "Abgelehnt.", + "count": "{count, plural, one {# offener Eintrag} other {# offene Einträge}}" + } } diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index eee1199..3f36189 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -16,7 +16,8 @@ "billing": "Ready for billing", "emergency_new": "New emergencies", "missing_signatures": "Missing signatures", - "sync_conflicts": "Sync conflicts" + "sync_conflicts": "Sync conflicts", + "time_approvals": "Time approvals" }, "hints": { "open": "Not yet billed or cancelled", @@ -29,7 +30,8 @@ "billing": "Ready to invoice", "emergency_new": "New or in review", "missing_signatures": "Signature pending", - "sync_conflicts": "Review offline changes" + "sync_conflicts": "Review offline changes", + "time_approvals": "Review added time and corrections" }, "open": "Show" } diff --git a/messages/en/field.json b/messages/en/field.json index d8a5456..57a6825 100644 --- a/messages/en/field.json +++ b/messages/en/field.json @@ -5,7 +5,8 @@ "orders": "Orders", "emergency": "Emergency", "sync": "Sync", - "profile": "Profile" + "profile": "Profile", + "approvalsBadge": "{count} time entries to approve" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "No date", "emergency": "Emergency", "urgent": "Urgent", - "high": "High" + "high": "High", + "running": "Time running", + "paused": "Paused", + "enRoute": "On the way", + "startWork": "Start work", + "pause": "Pause", + "resume": "Resume" }, "statusGroup": { "new": "New", @@ -78,7 +85,17 @@ "saving": "Saving …", "blocked": "Still missing before completion:", "reportNext": "Report and signature follow in the next step.", - "nothing": "No action required." + "nothing": "No action required.", + "stop_day": "Stop for today", + "confirmStopDay": "Stop for today now", + "stopDayHint": "Time recording ends, the order stays open and can be continued tomorrow.", + "completeLink": "Complete order …", + "segment": { + "return_travel": "Start return trip", + "material_procurement": "Fetch material", + "work": "Back to work" + }, + "currentSegment": "Current: {type}" }, "blocker": { "checklist_item": "Checklist: {label}", @@ -264,7 +281,8 @@ "en_route": "On the way", "running": "Running", "paused": "Paused", - "ended": "Ended" + "ended": "Ended", + "manual": "Recorded later" }, "running": "running", "offlineStarted": "started offline", @@ -277,7 +295,10 @@ "save": "Save correction", "saved": "Correction saved.", "empty": "No time recorded yet.", - "totalWork": "Total working time: {duration}" + "totalWork": "Total working time: {duration}", + "propose": "Propose correction", + "totalPending": "Awaiting approval: {duration}", + "ownEntryHint": "Correct your own time via “Propose correction”." }, "profile": { "title": "Profile", @@ -287,12 +308,133 @@ "roles": "Role", "language": "Language", "logout": "Sign out", - "backoffice": "Go to office area" + "backoffice": "Go to office area", + "myTime": "My time", + "approvals": "Approve time" }, "sync": { "title": "Synchronisation", "immediate": "Changes are sent to the office immediately.", "offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.", "status": "Connection" + }, + "clock": { + "label": "Running time recording", + "running": "Running", + "paused": "Paused", + "enRoute": "Travel", + "openOrder": "Open order {number}", + "pause": "Pause", + "resume": "Resume", + "stopDay": "Stop for today", + "stopDayConfirm": "Stop?", + "myTime": "My time", + "offline": "offline" + }, + "switch": { + "title": "Another time recording is running", + "text": "{from} is still running. Pause it and start {to}?", + "textUnknown": "Time is still running on another order. Pause it and start {to}?", + "confirm": "Switch", + "cancel": "Cancel" + }, + "myTime": { + "title": "My time", + "sub": "Your time per day across all orders.", + "today": "Today", + "yesterday": "Yesterday", + "days": "Choose day", + "empty": "No time recorded on this day.", + "approved": "Approved", + "pending": "Awaiting approval", + "dayTotal": "Day total", + "totalApproved": "Approved: {duration}", + "totalPending": "Open: {duration}", + "add": "Add time", + "running": "running", + "badge": { + "manual": "Manual", + "pending": "Awaiting approval", + "rejected": "Rejected: {reason}", + "correctionRequested": "Correction requested", + "correctionRejected": "Correction rejected: {reason}", + "corrected": "Corrected" + }, + "proposed": "Proposal: {from}–{to} · {type}", + "localPending": "Waiting to be sent", + "localTitle": "Not sent yet", + "form": { + "title": "Add time", + "order": "Order", + "orderPlaceholder": "Choose order", + "noOrders": "No orders from the last 7 days available.", + "forUser": "For", + "me": "Myself", + "type": "Type", + "date": "Date", + "mode": "Entry", + "modeRange": "From – to", + "modeDuration": "Duration", + "from": "From", + "to": "To", + "duration": "Duration in minutes", + "quick": "{minutes} min", + "reason": "Reason", + "reasonHint": "Required, at least 3 characters.", + "note": "Note (optional)", + "approvalHint": "Counts once approved by the team lead or office.", + "directHint": "Time recorded for team members is approved immediately.", + "save": "Save entry", + "saved": "Entry saved – awaiting approval.", + "savedDirect": "Time saved.", + "queued": "Saved offline – will be sent once connected.", + "cancel": "Cancel" + }, + "correction": { + "title": "Propose correction", + "hint": "The current time stays valid until approved.", + "save": "Send proposal", + "saved": "Correction requested." + }, + "reasons": { + "forgotStart": "Forgot to start", + "noNetwork": "No network", + "recordedLater": "Recorded afterwards" + }, + "errors": { + "overlap": "The period overlaps with time already recorded.", + "too_old": "Time can only be added for the last 7 days.", + "in_future": "The time must not be in the future.", + "too_long": "An entry may be at most 16 hours long.", + "end_before_start": "The end must be after the start.", + "work_order_status": "Time can no longer be recorded for this order.", + "not_own_entry": "You can only correct your own time.", + "own_entry": "You cannot approve your own time.", + "entry_running": "The time is still running." + } + }, + "approvals": { + "title": "Approve time", + "sub": "Added entries and corrections of your team.", + "empty": "No open entries or corrections.", + "link": "Approve time", + "count": "{count, plural, =0 {Nothing open} one {# open} other {# open}}", + "manual": "Added entry", + "correction": "Correction", + "reason": "Reason", + "note": "Note", + "old": "Before", + "new": "New", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "rejectConfirm": "Send rejection", + "cancel": "Cancel", + "approved": "Approved.", + "rejected": "Rejected." + }, + "pendingTime": { + "report": "Note: {duration} of added time is not approved yet and missing from the report.", + "proposals": "Note: {count, plural, one {# time correction is} other {# time corrections are}} awaiting approval." } } diff --git a/messages/en/nav.json b/messages/en/nav.json index 91f4ba6..5e97423 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (AI)", "admin": "Admin console", "openMenu": "Open menu", - "closeMenu": "Close menu" + "closeMenu": "Close menu", + "timeApprovals": "Time approvals" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 3206fdf..13ca202 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Emergency call-out completed", "import_ready_for_review": "Import ready for review", "import_failed": "Document processing failed", - "sync_failed": "Sync failed" + "sync_failed": "Sync failed", + "time_approval_requested": "Time to approve", + "time_approved": "Time approved", + "time_rejected": "Time rejected" }, "events": { - "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, - "work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." }, - "work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." }, - "work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." }, - "work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." }, - "work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." }, - "work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." }, - "work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." }, - "work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." }, - "report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." }, - "report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." }, - "report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." }, - "emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." }, - "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, - "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, - "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, - "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + "work_order_assigned": { + "title": "New work order {number}", + "message": "{title} – {customer} was assigned to your team." + }, + "work_order_changed": { + "title": "Work order {number} changed", + "message": "{title} – {customer}: details were updated." + }, + "work_order_cancelled": { + "title": "Work order {number} cancelled", + "message": "{title} – {customer} was cancelled." + }, + "work_order_started": { + "title": "Work order {number} started", + "message": "{actor} started work at {customer}." + }, + "work_order_daily_report_created": { + "title": "Daily report for {number}", + "message": "{actor} created a daily report for {customer}." + }, + "work_order_technically_completed": { + "title": "Work order {number} technically completed", + "message": "{title} – {customer}: on-site work finished." + }, + "work_order_signature_missing": { + "title": "Signature missing: {number}", + "message": "{title} – {customer}: no customer signature." + }, + "work_order_missing_required": { + "title": "Required information missing: {number}", + "message": "{title} – {customer}: documentation incomplete." + }, + "work_order_released_for_billing": { + "title": "Work order {number} ready for billing", + "message": "{title} – {customer} is ready for billing." + }, + "report_submitted": { + "title": "Report for review: {number}", + "message": "{actor} submitted a report for {title}." + }, + "report_approved": { + "title": "Report approved: {number}", + "message": "The report for {title} was approved." + }, + "report_rejected": { + "title": "Report rejected: {number}", + "message": "The report for {title} needs a correction." + }, + "emergency_created": { + "title": "Emergency call-out {number} created", + "message": "{actor} created an emergency call-out at {customer}." + }, + "emergency_completed": { + "title": "Emergency call-out {number} completed", + "message": "{customer}: ready for review and billing." + }, + "import_ready_for_review": { + "title": "Import ready for review", + "message": "The data from {fileName} is ready for review." + }, + "import_failed": { + "title": "Document processing failed", + "message": "{fileName} could not be processed." + }, + "sync_failed": { + "title": "Sync failed", + "message": "A change could not be applied ({reason})." + }, + "time_approval_requested": { + "title": "Time to approve: {number}", + "message": "{actor} added time or requested a correction for {number}." + }, + "time_approved": { + "title": "Time approved: {number}", + "message": "{actor} approved your time for {number}." + }, + "time_rejected": { + "title": "Time rejected: {number}", + "message": "{actor} rejected your time for {number}: {reason}" + } }, "fallback": { "unknown": "unknown", diff --git a/messages/en/offline.json b/messages/en/offline.json index 2a982be..b2ec4a9 100644 --- a/messages/en/offline.json +++ b/messages/en/offline.json @@ -89,7 +89,10 @@ "upload": "The file was not accepted (format or size).", "unauthorized": "Your session expired – please sign in again.", "network": "No connection – will retry automatically.", - "internal": "Technical error – will retry automatically." + "internal": "Technical error – will retry automatically.", + "timeOverlap": "The added time overlaps with time already recorded and was not accepted.", + "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", + "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." }, "view": { "title": "Offline view", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 166e853..9ff39e2 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Material procurement", "return_travel": "Return travel", "interruption": "Interruption" - } + }, + "pendingMinutes": "Awaiting approval: {minutes} min", + "approvedMinutes": "Approved", + "badgeManual": "Manual", + "badgePending": "Awaiting approval", + "badgeRejected": "Rejected: {reason}", + "badgeCorrection": "Correction requested: {from}–{to}", + "reason": "Reason: {reason}", + "manualSession": "Recorded later", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason", + "approved": "Approved.", + "rejected": "Rejected.", + "openApprovals": "All open approvals" }, "photos": { "empty": "No photos yet.", @@ -460,8 +474,35 @@ "running_session": "A work session is still running", "field": { "signature": "Signature missing", - "approved_completion_report": "Completion report not approved yet" + "approved_completion_report": "Completion report not approved yet", + "pending_time_entries": "Time entries are still awaiting approval" } }, - "saved": "Saved." + "saved": "Saved.", + "timeApprovals": { + "crumb": "Work orders", + "title": "Time approvals", + "sub": "Added time and corrections by technicians. They count for reports and billing only after approval.", + "empty": "No open entries or corrections.", + "employee": "Employee", + "order": "Order", + "date": "Date", + "range": "From–to", + "duration": "Duration", + "type": "Type", + "reason": "Reason", + "kindManual": "Added entry", + "kindCorrection": "Correction", + "oldNew": "{old} → {new}", + "select": "Select", + "selectAll": "Select all", + "approveSelected": "Approve selected", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "approved": "Approved.", + "bulkApproved": "{ok} approved, {failed} not possible.", + "rejected": "Rejected.", + "count": "{count, plural, one {# open entry} other {# open entries}}" + } } diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0261dee..d828066 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -12,6 +12,7 @@ import { Receipt, RefreshCw, Siren, + Timer, Wrench, type LucideIcon, } from "lucide-react"; @@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings"; type SP = Record; -const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [ +const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [ { key: "open", icon: ClipboardList, tone: "var(--ui-primary)" }, { key: "today", icon: CalendarDays, tone: "var(--info)" }, { key: "running", icon: Wrench, tone: "var(--ui-accent)" }, @@ -39,6 +40,7 @@ const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[ { key: "emergency_new", icon: Siren, tone: "var(--risk)" }, { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, { key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, + { key: "time_approvals", icon: Timer, tone: "var(--warn)" }, ]; /** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */ @@ -142,9 +144,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
    - {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { - const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; - const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; + {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => { + const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; + const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; return (
  • new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { hour: "2-digit", minute: "2-digit", timeZone: tz }).format(d); + const span = (s: Date, e: Date | null) => `${time(s)}–${e ? time(e) : "…"}`; + + return ( +
    + + + {t("detail.back")} + + + + {items.length === 0 ? ( + {t("timeApprovals.empty")} + ) : ( +
    +
    +

    {t("timeApprovals.count", { count: items.length })}

    + +
    +
    + + + + + + + + + + + + + + + {items.map((i) => ( + + + + + + + + + + + + ))} + +
    + + {t("timeApprovals.employee")}{t("timeApprovals.order")}{t("timeApprovals.date")}{t("timeApprovals.range")}{t("timeApprovals.duration")}{t("timeApprovals.type")}{t("timeApprovals.reason")} +
    + + +

    {i.userName}

    +

    {i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}

    +
    + + {i.number} + +

    {i.title}

    +
    {formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)} + {i.proposed ? ( + t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) }) + ) : ( + span(i.startedAt, i.endedAt) + )} + + {i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`} + + {i.proposed && i.proposed.type !== i.type + ? t("timeApprovals.oldNew", { old: tf(`time.type.${i.type}`), new: tf(`time.type.${i.proposed.type}`) }) + : tf(`time.type.${i.proposed?.type ?? i.type}`)} + +

    {i.reason}

    + {i.note &&

    {i.note}

    } +

    {formatDateTime(i.requestedAt, locale, tz)}

    +
    + + + + + + + + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/approvals/page.tsx b/src/app/(field)/m/(core)/approvals/page.tsx new file mode 100644 index 0000000..2be1ca6 --- /dev/null +++ b/src/app/(field)/m/(core)/approvals/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { listPendingTimeEntries } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { card } from "@/components/field/ui"; +import { ApprovalActions } from "@/components/field/approval-actions"; + +/** `/m/approvals` — team lead mobile (L12, `time:approve`): open manual entries and correction proposals of the team. */ +export default async function ApprovalsPage() { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!can(ctx, "time:approve")) return

    {t("noAccess")}

    ; + const items = await listPendingTimeEntries(ctx); + const range = (s: Date, e: Date | null) => `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${e ? fmtTime(e, locale) : t("myTime.running")}`; + + return ( +
    +
    +

    {t("approvals.title")}

    +

    {t("approvals.sub")}

    +

    {t("approvals.count", { count: items.length })}

    +
    + {items.length === 0 ? ( +

    {t("approvals.empty")}

    + ) : ( +
      + {items.map((i) => ( +
    • +

      {i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}

      +

      {i.userName}

      + + {i.number} · {i.title} + +
      +
      +
      {i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}
      +
      + {i.proposed && {t("approvals.old")}: } + {t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)} +
      +
      + {i.proposed && ( +
      +
      {t("approvals.new")}
      +
      + {t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)} +
      +
      + )} + {i.reason && ( +
      +
      {t("approvals.reason")}:
      +
      {i.reason}
      +
      + )} + {i.note && ( +
      +
      {t("approvals.note")}:
      +
      {i.note}
      +
      + )} +
      +
      + +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx index 565a21f..988534f 100644 --- a/src/app/(field)/m/(core)/orders/[id]/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -112,12 +112,15 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length; const checklistDone = order.checklistItems.filter((i) => i.checked).length; const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length; + // L12: only approved entries count const myWorkSeconds = order.workSessions .filter((s) => s.userId === ctx.userId) .flatMap((s) => s.entries) - .filter((e) => e.type === "work") + .filter((e) => e.type === "work" && e.approvalStatus === "approved") .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); const editable = can(ctx, "field:execute"); + const ownActive = order.workSessions.find((s) => s.userId === ctx.userId && !s.manual && s.status !== "ended"); + const segmentType = ownActive?.entries.find((e) => !e.endedAt)?.type ?? null; const quick = [ { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote }, @@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id

    {editable && (
    - +
    )} diff --git a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx index 9211595..2cd98a7 100644 --- a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx @@ -1,7 +1,13 @@ import { ReportScreen } from "@/components/reports/mobile/report-screen"; +import { PendingTimeNotice } from "@/components/field/pending-time-notice"; -/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */ +/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. L12: warning about unapproved time (not blocking). */ export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) { const [{ id }, sp] = await Promise.all([params, searchParams]); - return ; + return ( + <> + + + + ); } diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx index e5191b1..2ab6a9b 100644 --- a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx @@ -1,51 +1,75 @@ +import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; +import { Plus } from "lucide-react"; import { can } from "@/server/services/context"; +import { parsePendingChange } from "@/server/services/field/time-entries"; import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format"; import { SubPageHeader } from "@/components/field/sub-page-header"; import { TimeCorrectionForm } from "@/components/field/time-correction-form"; -import { card } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; +import { btnSecondary, card } from "@/components/field/ui"; import { loadOrder } from "../load"; -/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */ +/** + * `/m/orders/[id]/time` — sessions and time segments (Spec §12.2). + * L12: only approved entries count; own entries → „Korrektur vorschlagen" (approval), entries of + * team members → direct correction with `field:correct_time`; badges manual/pending/rejected. + */ export default async function TimePage({ params }: { params: Promise<{ id: string }> }) { const { ctx, order } = await loadOrder(params); const t = await getTranslations("field"); const locale = await getLocale(); const canCorrect = can(ctx, "field:correct_time"); - const totalWork = order.workSessions - .flatMap((s) => s.entries) - .filter((e) => e.type === "work") - .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const canPropose = can(ctx, "field:record_own_time") || canCorrect; + const entries = order.workSessions.flatMap((s) => s.entries); + const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const pendingWork = entries.filter((e) => e.type !== "break" && e.approvalStatus === "pending").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); return (

    {t("time.totalWork", { duration: fmtDuration(totalWork) })}

    + {pendingWork > 0 &&

    {t("time.totalPending", { duration: fmtDuration(pendingWork) })}

    } + {canPropose && ( + + + {t("myTime.add")} + + )} {order.workSessions.length === 0 &&

    {t("time.empty")}

    } {order.workSessions.map((s) => (

    {s.user.name}

    - {t(`time.session.${s.status}`)} + {s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)} {s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}

    {fmtDateTime(s.startedAt, locale)}

      - {s.entries.map((e) => ( -
    • -
      - {t(`time.type.${e.type}`)} - - {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} - -
      - {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } - {canCorrect && } -
    • - ))} + {s.entries.map((e) => { + const own = e.userId === ctx.userId; + const pendingChange = parsePendingChange(e.pendingChange); + return ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} + +
      + + {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } + {own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && ( + + )} + {!own && canCorrect && } +
    • + ); + })}
    ))} diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx index d20248e..f8e6348 100644 --- a/src/app/(field)/m/(core)/orders/page.tsx +++ b/src/app/(field)/m/(core)/orders/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -15,6 +16,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi const { tab: rawTab } = await searchParams; const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running"; const orders = await listFieldOrders(ctx, tab); + const canExecute = can(ctx, "field:execute"); return (
    @@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx index 0802068..698ca65 100644 --- a/src/app/(field)/m/(core)/page.tsx +++ b/src/app/(field)/m/(core)/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ChevronRight } from "lucide-react"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listTodayOrders } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -12,6 +13,7 @@ export default async function TodayPage() { const t = await getTranslations("field"); if (!canUseFieldApp(ctx)) return

    {t("noAccess")}

    ; + const canExecute = can(ctx, "field:execute"); const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]); return ( @@ -27,7 +29,7 @@ export default async function TodayPage() {
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx index 90ad296..32ad5a1 100644 --- a/src/app/(field)/m/(core)/profile/page.tsx +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth"; import { prisma } from "@/server/db"; import { can } from "@/server/services/context"; import { activeTeamIds } from "@/server/services/work-orders/visibility"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { fieldPageContext } from "@/server/services/field/page-context"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { btnSecondary, card } from "@/components/field/ui"; @@ -15,7 +16,7 @@ export default async function ProfilePage() { const ctx = await fieldPageContext(); const t = await getTranslations("field.profile"); const session = await auth(); - const teamIds = await activeTeamIds(ctx); + const [teamIds, approvals] = await Promise.all([activeTeamIds(ctx), countPendingTimeEntries(ctx)]); const [me, teams, identity] = await Promise.all([ ctx.db.user.findFirst({ where: { id: ctx.userId }, @@ -58,6 +59,17 @@ export default async function ProfilePage() {
    + {can(ctx, "field:execute") && ( + + {t("myTime")} + + )} + {can(ctx, "time:approve") && ( + + {t("approvals")} + {approvals > 0 && {approvals}} + + )} {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && ( {t("backoffice")} diff --git a/src/app/(field)/m/(core)/time/new/page.tsx b/src/app/(field)/m/(core)/time/new/page.tsx new file mode 100644 index 0000000..ac20763 --- /dev/null +++ b/src/app/(field)/m/(core)/time/new/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ChevronLeft } from "lucide-react"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview, listRecordableOrders, listRecordableUsers } from "@/server/services/field/time-entries"; +import { ManualTimeForm } from "@/components/field/manual-time-form"; + +/** `/m/time/new` — „Zeit nachtragen" (L12). */ +export default async function NewTimePage({ searchParams }: { searchParams: Promise<{ date?: string; order?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + if (!canUseFieldApp(ctx) || !canRecord) return

    {t("noAccess")}

    ; + + const sp = await searchParams; + const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]); + + return ( +
    + + + {t("myTime.title")} + +

    {t("myTime.form.title")}

    + o.id === sp.order) ? sp.order! : null} + /> +
    + ); +} diff --git a/src/app/(field)/m/(core)/time/page.tsx b/src/app/(field)/m/(core)/time/page.tsx new file mode 100644 index 0000000..804c85d --- /dev/null +++ b/src/app/(field)/m/(core)/time/page.tsx @@ -0,0 +1,105 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ChevronRight, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { btnPrimary, card, chip } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { LocalPendingTimes } from "@/components/field/local-pending-times"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; + +/** `/m/time` — „Meine Zeiten" (L12): own entries of a day across all orders, approved vs. pending totals. */ +export default async function MyTimePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!canUseFieldApp(ctx) || !can(ctx, "field:execute")) return

    {t("noAccess")}

    ; + + const { date } = await searchParams; + const overview = await getMyTimeOverview(ctx, { date }); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + const dayLabel = (key: string, i: number) => (i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${key}T12:00:00Z`, locale)); + + return ( +
    +
    +

    {t("myTime.title")}

    +

    {t("myTime.sub")}

    +
    + + + +
    +
    +

    {t("myTime.approved")}

    +

    {fmtDuration(overview.approvedSeconds)}

    +
    +
    +

    {t("myTime.pending")}

    +

    0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}

    +
    +
    + + {canRecord && ( + + + {t("myTime.add")} + + )} + + + + {overview.groups.length === 0 ? ( +

    {t("myTime.empty")}

    + ) : ( + overview.groups.map((g) => ( +
    + + + {g.number} + {g.title} + + + +

    + {t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })} + {g.pendingSeconds > 0 && · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}} +

    +
      + {g.entries.map((e) => ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)} + +
      + + {canRecord && e.editable && ( + + )} +
    • + ))} +
    +
    + )) + )} +
    + ); +} diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index 5c7619b..ca6f601 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -1,21 +1,45 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { requireAppAccess } from "@/server/app-access"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { getMyActiveSession } from "@/server/services/field/sessions"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; +import { cn } from "@/lib/utils"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { BottomNav } from "@/components/field/bottom-nav"; import { OnlineBadge } from "@/components/field/online-badge"; +import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar"; import { OfflineRuntime } from "@/components/offline/offline-runtime"; +/** L12: own running/paused session + open approvals for the shell (never blocks the page). */ +async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> { + try { + const ctx = await fieldPageContext(); + const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]); + return { + clock: session + ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } + : null, + approvals, + }; + } catch { + // module "field" disabled or no field permissions: shell without clock/badge + return { clock: null, approvals: 0 }; + } +} + /** * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice * (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge. * Module gates live one level below: (core) → "field", emergency → "emergency". + * L12: running clock bar above the bottom navigation on every /m page. */ export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) { const access = await requireAppAccess(); if (access.kind === "inactive") return ; - const t = await getTranslations("field.nav"); + const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]); return (
    @@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
    -
    {children}
    - +
    {children}
    + + ); } diff --git a/src/components/field/approval-actions.tsx b/src/components/field/approval-actions.tsx new file mode 100644 index 0000000..63029bb --- /dev/null +++ b/src/components/field/approval-actions.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Check, TriangleAlert, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { approveTime, rejectTime } from "@/server/actions/field/time"; +import { timeErrorKey } from "@/lib/field/time-rules"; +import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui"; + +/** L12 team lead mobile: approve / reject (reason mandatory) with large buttons. */ +export function ApprovalActions({ timeEntryId }: { timeEntryId: string }) { + const t = useTranslations("field"); + const router = useRouter(); + const [rejecting, setRejecting] = useState(false); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(kind: "approve" | "reject") { + setError(null); + if (kind === "reject" && reason.trim().length < 3) { + setError(t("myTime.form.reasonHint")); + return; + } + setBusy(true); + const result = await (kind === "approve" ? approveTime({ timeEntryId }) : rejectTime({ timeEntryId, reason: reason.trim() })).catch(() => ({ ok: false as const, error: "failed" as const, message: undefined })); + setBusy(false); + if (!result.ok) { + const key = timeErrorKey({ message: result.message }); + setError(key ? t(`myTime.errors.${key}`) : t(`errors.${result.error === "failed" ? "internal" : result.error}`)); + return; + } + router.refresh(); + } + + return ( +
    + {!rejecting ? ( +
    + + +
    + ) : ( +
    +