L12 Zeiterfassung: Mobile Uhr-Leiste, Meine Zeiten, Freigaben und Backoffice-Liste

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 09:29:38 +02:00
co-authored by Claude Opus 5
parent 0e94eb0d9d
commit fe3eba6e44
42 changed files with 2319 additions and 166 deletions
+34 -4
View File
@@ -4,10 +4,18 @@ import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { correctTimeEntry } from "@/server/services/field/time-correction";
import { approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
const guard = moduleGuard("field");
export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed" };
export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed"; message?: string };
function toResult(err: unknown, label: string): TimeCorrectionResult {
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code, message: err.message };
if (err instanceof ServiceError && err.code === "conflict") return { ok: false, error: "invalid", message: err.message };
console.error(`[field] ${label} failed:`, err);
return { ok: false, error: "failed" };
}
/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
export async function correctTime(input: {
@@ -26,10 +34,32 @@ export async function correctTime(input: {
reason: input.reason,
});
} catch (err) {
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code };
console.error("[field] time correction failed:", err);
return { ok: false, error: "failed" };
return toResult(err, "time correction");
}
revalidatePath(`/m/orders/${input.workOrderId}/time`);
return { ok: true };
}
/** L12: approve a manual entry / correction proposal (team lead mobile) — services/field/time-entries. */
export async function approveTime(input: { timeEntryId: string }): Promise<TimeCorrectionResult> {
const g = await guard("time:approve");
try {
await approveTimeEntry(ctxFromGuard(g), input.timeEntryId);
} catch (err) {
return toResult(err, "time approval");
}
revalidatePath("/m/approvals");
return { ok: true };
}
/** L12: reject with mandatory reason. */
export async function rejectTime(input: { timeEntryId: string; reason: string }): Promise<TimeCorrectionResult> {
const g = await guard("time:approve");
try {
await rejectTimeEntry(ctxFromGuard(g), input.timeEntryId, input.reason);
} catch (err) {
return toResult(err, "time rejection");
}
revalidatePath("/m/approvals");
return { ok: true };
}
@@ -0,0 +1,71 @@
"use server";
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 { ok, str, toErrorState } from "./_form";
/**
* L12 back office: approve / reject manual time entries and correction proposals (`time:approve`).
* Thin adapters — permission, team scope, "never own entries", audit and events live in
* services/field/time-entries.ts.
*/
const guard = moduleGuard("work_orders");
function revalidate(workOrderId?: string) {
revalidatePath("/work-orders/time-approvals");
if (workOrderId) revalidatePath(`/work-orders/${workOrderId}`);
}
export async function approveTimeEntryAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const id = str(fd, "timeEntryId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("time:approve");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await approveTimeEntry(ctxFromGuard(g), id);
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
}
revalidate(str(fd, "workOrderId"));
return ok();
}
export async function rejectTimeEntryAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const id = str(fd, "timeEntryId") ?? "";
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("time:approve");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
await rejectTimeEntry(ctxFromGuard(g), id, str(fd, "reason") ?? "");
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
}
revalidate(str(fd, "workOrderId"));
return ok();
}
/** Bulk approval of the selected entries (checkboxes `ids`); entries that cannot be approved stay open. */
export async function approveTimeEntriesAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
const ids = fd.getAll("ids").filter((v): v is string => typeof v === "string" && v.length > 0);
let tenantId: string | undefined;
let actorId: string | undefined;
try {
const g = await guard("time:approve");
tenantId = g.session.user.tenantId;
actorId = g.session.user.id;
const results = await approveTimeEntries(ctxFromGuard(g), ids);
const failed = results.filter((r) => !r.ok).length;
revalidate();
if (ids.length === 0 || failed === results.length) return { status: "error", code: "invalid", message: "time_bulk_none", at: Date.now() };
return ok();
} catch (err) {
return toErrorState(err, { tenantId, actorId, entity: "time_entry" });
}
}