diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 94eb659..14810ec 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -251,7 +251,23 @@ "rejectReason": "Grund", "approved": "Freigegeben.", "rejected": "Abgelehnt.", - "openApprovals": "Alle offenen Freigaben" + "openApprovals": "Alle offenen Freigaben", + "record": { + "title": "Zeit für Mitarbeiter erfassen", + "hint": "Vom Büro oder Teamleiter erfasste Zeiten gelten sofort als freigegeben und werden protokolliert. Entweder Bis-Uhrzeit oder Dauer angeben.", + "user": "Mitarbeiter", + "userPlaceholder": "Mitarbeiter wählen", + "type": "Art", + "date": "Datum", + "from": "Von", + "to": "Bis", + "duration": "Dauer (Minuten)", + "durationHint": "Nur wenn keine Bis-Uhrzeit angegeben ist.", + "reason": "Begründung", + "note": "Notiz (optional)", + "submit": "Zeit speichern", + "saved": "Zeit gespeichert." + } }, "photos": { "empty": "Noch keine Fotos.", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 9ff39e2..b290e0e 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -251,7 +251,23 @@ "rejectReason": "Reason", "approved": "Approved.", "rejected": "Rejected.", - "openApprovals": "All open approvals" + "openApprovals": "All open approvals", + "record": { + "title": "Record time for a staff member", + "hint": "Times recorded by the office or a team lead are approved immediately and audited. Enter either an end time or a duration.", + "user": "Staff member", + "userPlaceholder": "Select staff member", + "type": "Type", + "date": "Date", + "from": "From", + "to": "To", + "duration": "Duration (minutes)", + "durationHint": "Only if no end time is given.", + "reason": "Reason", + "note": "Note (optional)", + "submit": "Save time", + "saved": "Time saved." + } }, "photos": { "empty": "No photos yet.", diff --git a/scripts/test-backoffice-time.ts b/scripts/test-backoffice-time.ts new file mode 100644 index 0000000..fd0a622 --- /dev/null +++ b/scripts/test-backoffice-time.ts @@ -0,0 +1,83 @@ +// Backoffice / team lead records time for staff (field:correct_time, User 2026-09-15): +// (1) back office records for a technician → approved directly, source manual, audited +// (2) team lead of ANOTHER team → forbidden (user_not_in_team); own team member → approved +// (3) technician cannot record for a colleague → forbidden +// (4) other tenant's back office → not_found for the order +// (5) back office role carries field:correct_time +// +// Lauf: npx tsx scripts/test-backoffice-time.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { ROLE_DEFS } from "../src/server/rbac"; +import { addManualTimeEntry } from "../src/server/services/field/time-entries"; +import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-bo-time-a"; +const SLUG_B = "zz-bo-time-b"; + +async function order(A: TenantFixture) { + return prisma.workOrder.create({ + data: { + tenantId: A.tenantId, + number: `BT-${randomUUID().slice(0, 6)}`, + customerId: A.customerId, + siteId: A.siteId, + title: "Heizungswartung", + status: "in_progress", + assignedTeamId: A.teamId, + }, + }); +} + +void runSuite("Backoffice erfasst Zeiten", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const wo = await order(A); + + section("Rolle"); + ok(ROLE_DEFS.backoffice.permissions.includes("field:correct_time"), "(5) Backoffice-Rolle hat field:correct_time"); + + section("Backoffice"); + const entry = await addManualTimeEntry(A.ctx.backoffice, { + workOrderId: wo.id, + forUserId: A.users.tech.id, + type: "work", + startedAt: berlinAt(-1, 8), + endedAt: berlinAt(-1, 10), + reason: "Stundenzettel nachgetragen", + }); + ok(entry.userId === A.users.tech.id, "(1) Eintrag gehört dem Monteur"); + ok(entry.approvalStatus === "approved" && entry.source === "manual", "(1) direkt freigegeben, Quelle manuell"); + const audited = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "time_entry", entityId: entry.id } }); + ok(audited >= 1, "(1) Audit-Eintrag vorhanden"); + + section("Teamleiter / Monteur"); + await expectCode( + () => addManualTimeEntry(A.ctx.lead2, { workOrderId: wo.id, forUserId: A.users.tech.id, type: "work", startedAt: berlinAt(-1, 11), endedAt: berlinAt(-1, 12), reason: "fremdes Team" }), + ["forbidden", "not_found"], + "(2) Teamleiter eines anderen Teams → abgewiesen", + ); + const leadEntry = await addManualTimeEntry(A.ctx.lead, { + workOrderId: wo.id, + forUserId: A.users.tech2.id, + type: "travel", + startedAt: berlinAt(-1, 7), + durationMinutes: 30, + reason: "Anfahrt nachgetragen", + }); + ok(leadEntry.approvalStatus === "approved", "(2) Teamleiter für eigenes Teammitglied → freigegeben"); + await expectCode( + () => addManualTimeEntry(A.ctx.tech, { workOrderId: wo.id, forUserId: A.users.tech2.id, type: "work", startedAt: berlinAt(-2, 8), endedAt: berlinAt(-2, 9), reason: "für Kollegen" }), + "forbidden", + "(3) Monteur für Kollegen → forbidden", + ); + + section("Mandantentrennung"); + await expectCode( + () => addManualTimeEntry(B.ctx.backoffice, { workOrderId: wo.id, forUserId: A.users.tech.id, type: "work", startedAt: berlinAt(-1, 13), endedAt: berlinAt(-1, 14), reason: "fremder Mandant" }), + ["not_found", "forbidden"], + "(4) Backoffice anderer Mandant → abgewiesen", + ); +}); diff --git a/src/components/work-orders/detail-tabs.tsx b/src/components/work-orders/detail-tabs.tsx index 51cb57a..55d7841 100644 --- a/src/components/work-orders/detail-tabs.tsx +++ b/src/components/work-orders/detail-tabs.tsx @@ -27,7 +27,7 @@ import { removePhotoRequirementAction, } from "@/server/actions/work_orders/work-orders"; import { formatDate, formatDateTime } from "@/lib/work-orders/time"; -import { approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals"; +import { approveTimeEntryAction, recordTimeForUserAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals"; import { ActionForm } from "@/components/work-orders/action-form"; import { buttonCls } from "@/components/work-orders/button-cls"; import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui"; @@ -297,10 +297,89 @@ export async function MaterialTab({ ctx, wo, locale, canEdit }: TabProps) { ); } +/** Back office / team lead: record time for a staff member (`field:correct_time`), approved directly. */ +async function RecordTimeSection({ ctx, wo }: { ctx: ServiceCtx; wo: WorkOrderDetail }) { + if (!ctx.permissions.has("field:correct_time")) return null; + const t = await getTranslations("workOrders"); + const staff = await ctx.db.user.findMany({ + where: { status: "ACTIVE", userRoles: { some: { role: { key: { in: ["technician", "team-lead"] } } } } }, + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }); + if (staff.length === 0) return null; + const types = ["work", "travel", "return_travel", "material_procurement", "break"] as const; + const label = "mb-1 block text-xs font-semibold text-muted-foreground"; + return ( +
+ {t("times.record.title")} +

{t("times.record.hint")}

+ + + + + + + + + + + +
+ ); +} + export async function TimesTab({ ctx, wo, locale, tz }: TabProps) { const t = await getTranslations("workOrders"); const sessions = await getTimesTab(ctx, wo.id); - if (sessions.length === 0) return {t("times.empty")}; + if (sessions.length === 0) + return ( +
+ + {t("times.empty")} +
+ ); // L12: approved vs. pending totals, badges and inline approval const canApprove = ctx.permissions.has("time:approve"); const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0); @@ -308,6 +387,7 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) { const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold"; return (
+
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })} diff --git a/src/server/actions/work_orders/time-approvals.ts b/src/server/actions/work_orders/time-approvals.ts index 6749978..1fa56d0 100644 --- a/src/server/actions/work_orders/time-approvals.ts +++ b/src/server/actions/work_orders/time-approvals.ts @@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache"; import type { ActionState } from "@/lib/work-orders/action-state"; import { moduleGuard } from "@/server/action-guard"; import { ctxFromGuard } from "@/server/services/context"; -import { approveTimeEntries, approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries"; +import { addManualTimeEntry, approveTimeEntries, approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries"; +import { wallTimeToUtc } from "@/lib/work-orders/time"; import { ok, str, toErrorState } from "./_form"; /** @@ -69,3 +70,47 @@ export async function approveTimeEntriesAction(_prev: ActionState, fd: FormData) return toErrorState(err, { tenantId, actorId, entity: "time_entry" }); } } + +const RECORD_TYPES = ["work", "travel", "return_travel", "material_procurement", "break"] as const; + +/** + * Back office / team lead: record a time entry for a staff member (`field:correct_time`, User 2026-09-15). + * Date + wall-clock times are interpreted in the tenant time zone. Permission, team scope, overlap, + * 7-day window, audit and "approved directly" are enforced by services/field/time-entries.ts. + */ +export async function recordTimeForUserAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("field:correct_time"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const ctx = ctxFromGuard(g); + const settings = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } }); + const tz = settings?.timezone ?? "Europe/Berlin"; + const date = str(fd, "date") ?? ""; + const from = str(fd, "from") ?? ""; + const to = str(fd, "to"); + const duration = Number(str(fd, "durationMinutes") ?? ""); + const type = str(fd, "type") ?? "work"; + const startedAt = wallTimeToUtc(`${date}T${from}`, tz); + const endedAt = to ? wallTimeToUtc(`${date}T${to}`, tz) : undefined; + if (!startedAt || (to && !endedAt) || (!to && !(duration > 0))) { + return { status: "error", code: "invalid", message: "time_invalid_range", at: Date.now() }; + } + await addManualTimeEntry(ctx, { + workOrderId, + forUserId: str(fd, "forUserId") ?? "", + type: (RECORD_TYPES as readonly string[]).includes(type) ? (type as (typeof RECORD_TYPES)[number]) : "work", + startedAt, + ...(endedAt ? { endedAt } : { durationMinutes: Math.round(duration) }), + reason: str(fd, "reason") ?? "", + note: str(fd, "note") ?? null, + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: workOrderId }); + } + revalidate(workOrderId); + return ok(); +} diff --git a/src/server/rbac.ts b/src/server/rbac.ts index f50e0f3..3cb0e1b 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -76,7 +76,8 @@ export const ROLE_DEFS: Record !ADMIN_ONLY.includes(p) && !p.startsWith("field:") && p !== "emergency:create", + // field:correct_time (User 2026-09-15): the back office may record/correct time entries for staff (approved directly). + (p) => !ADMIN_ONLY.includes(p) && (!p.startsWith("field:") || p === "field:correct_time") && p !== "emergency:create", ), }, "team-lead": { diff --git a/src/server/services/field/time-entries.ts b/src/server/services/field/time-entries.ts index 648766e..4e72158 100644 --- a/src/server/services/field/time-entries.ts +++ b/src/server/services/field/time-entries.ts @@ -158,7 +158,9 @@ export async function addManualTimeEntry(ctx: ServiceCtx, raw: ManualTimeInput) 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"); + // Own time always needs field:record_own_time (field staff). field:correct_time only covers OTHER staff — + // the back office holds it (User 2026-09-15) but records no own time. + if (!can(ctx, "field:record_own_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time"); } else { assertCan(ctx, "field:correct_time"); }