Zeiterfassung: Backoffice erfasst Zeiten für Mitarbeiter
- Backoffice-Rolle erhält field:correct_time (User-Entscheidung): Zeiten für Monteure/Teamleiter direkt freigegeben anlegen und korrigieren - Eigene Zeiten erfordern weiterhin field:record_own_time (Backoffice erfasst keine eigenen Zeiten) - Auftragsdetail › Zeiten: Formular „Zeit für Mitarbeiter erfassen“ (Mitarbeiter, Art, Datum, von–bis oder Dauer, Begründung; Mandanten-Zeitzone) - Server Action recordTimeForUserAction (moduleGuard work_orders) - Test test-backoffice-time (Backoffice, fremdes Team, Monteur, Mandantentrennung) Demo-Daten lokal bereinigt: je Monteur nur noch eine aktive Uhr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -251,7 +251,23 @@
|
|||||||
"rejectReason": "Grund",
|
"rejectReason": "Grund",
|
||||||
"approved": "Freigegeben.",
|
"approved": "Freigegeben.",
|
||||||
"rejected": "Abgelehnt.",
|
"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": {
|
"photos": {
|
||||||
"empty": "Noch keine Fotos.",
|
"empty": "Noch keine Fotos.",
|
||||||
|
|||||||
@@ -251,7 +251,23 @@
|
|||||||
"rejectReason": "Reason",
|
"rejectReason": "Reason",
|
||||||
"approved": "Approved.",
|
"approved": "Approved.",
|
||||||
"rejected": "Rejected.",
|
"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": {
|
"photos": {
|
||||||
"empty": "No photos yet.",
|
"empty": "No photos yet.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
removePhotoRequirementAction,
|
removePhotoRequirementAction,
|
||||||
} from "@/server/actions/work_orders/work-orders";
|
} from "@/server/actions/work_orders/work-orders";
|
||||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
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 { ActionForm } from "@/components/work-orders/action-form";
|
||||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||||
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
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 (
|
||||||
|
<details className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<summary className="cursor-pointer text-sm font-semibold">{t("times.record.title")}</summary>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{t("times.record.hint")}</p>
|
||||||
|
<ActionForm action={recordTimeForUserAction} submitLabel={t("times.record.submit")} variant="primary" successText={t("times.record.saved")} className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.user")}</span>
|
||||||
|
<select name="forUserId" required className={inputCls} defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("times.record.userPlaceholder")}
|
||||||
|
</option>
|
||||||
|
{staff.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.type")}</span>
|
||||||
|
<select name="type" className={inputCls} defaultValue="work">
|
||||||
|
{types.map((ty) => (
|
||||||
|
<option key={ty} value={ty}>
|
||||||
|
{t(`times.entryType.${ty}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.date")}</span>
|
||||||
|
<input type="date" name="date" required className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.from")}</span>
|
||||||
|
<input type="time" name="from" required className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.to")}</span>
|
||||||
|
<input type="time" name="to" className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("times.record.duration")}</span>
|
||||||
|
<input type="number" name="durationMinutes" min={1} max={960} step={5} className={inputCls} aria-describedby="record-duration-hint" />
|
||||||
|
<span id="record-duration-hint" className="mt-1 block text-xs text-muted-foreground">
|
||||||
|
{t("times.record.durationHint")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2 lg:col-span-3">
|
||||||
|
<span className={label}>{t("times.record.reason")}</span>
|
||||||
|
<input name="reason" required minLength={3} maxLength={500} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block sm:col-span-2 lg:col-span-3">
|
||||||
|
<span className={label}>{t("times.record.note")}</span>
|
||||||
|
<input name="note" maxLength={2000} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
</ActionForm>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||||
const t = await getTranslations("workOrders");
|
const t = await getTranslations("workOrders");
|
||||||
const sessions = await getTimesTab(ctx, wo.id);
|
const sessions = await getTimesTab(ctx, wo.id);
|
||||||
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
if (sessions.length === 0)
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<RecordTimeSection ctx={ctx} wo={wo} />
|
||||||
|
<Empty>{t("times.empty")}</Empty>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
// L12: approved vs. pending totals, badges and inline approval
|
// L12: approved vs. pending totals, badges and inline approval
|
||||||
const canApprove = ctx.permissions.has("time:approve");
|
const canApprove = ctx.permissions.has("time:approve");
|
||||||
const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0);
|
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";
|
const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<RecordTimeSection ctx={ctx} wo={wo} />
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
|
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
|||||||
import type { ActionState } from "@/lib/work-orders/action-state";
|
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||||
import { moduleGuard } from "@/server/action-guard";
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
import { ctxFromGuard } from "@/server/services/context";
|
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";
|
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" });
|
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<ActionState> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -76,7 +76,8 @@ export const ROLE_DEFS: Record<RoleKey, { name: string; permissions: readonly Pe
|
|||||||
backoffice: {
|
backoffice: {
|
||||||
name: "Backoffice",
|
name: "Backoffice",
|
||||||
permissions: PERMISSIONS.filter(
|
permissions: PERMISSIONS.filter(
|
||||||
(p) => !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": {
|
"team-lead": {
|
||||||
|
|||||||
@@ -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 targetUserId = input.forUserId && input.forUserId !== ctx.userId ? input.forUserId : ctx.userId;
|
||||||
const own = targetUserId === ctx.userId;
|
const own = targetUserId === ctx.userId;
|
||||||
if (own) {
|
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 {
|
} else {
|
||||||
assertCan(ctx, "field:correct_time");
|
assertCan(ctx, "field:correct_time");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user