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.hint")}{t("times.record.title")}
+