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:
2026-09-15 09:54:06 +02:00
co-authored by Claude Opus 5
parent 5ea23a179d
commit 2f312a7835
7 changed files with 250 additions and 7 deletions
+82 -2
View File
@@ -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 (
<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) {
const t = await getTranslations("workOrders");
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
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 (
<div className="space-y-3">
<RecordTimeSection ctx={ctx} wo={wo} />
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-semibold">
{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 { 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<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
View File
@@ -76,7 +76,8 @@ export const ROLE_DEFS: Record<RoleKey, { name: string; permissions: readonly Pe
backoffice: {
name: "Backoffice",
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": {
+3 -1
View File
@@ -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");
}