L12 Zeiterfassung: Services für Nachtrag, Freigabe, Auto-Wechsel und Für heute beenden
time-entries.ts (manuelle Einträge, Korrekturvorschläge, Freigabe/Ablehnung mit Team-Scope), Sessions in inTransaction mit switchFromOther, stopForToday, Segmentwechsel und getMyActiveSession. Sync-Ops time.add_manual, time.propose_correction, session.stop_day, session.segment inkl. optimistischer Offline-Ansicht. Bericht, Zeiten-Tab, Dashboard und Lotse zählen nur freigegebene Zeiten; Abrechnungsfreigabe blockiert bei offenen Zeiten. Empfänger und Links der Zeit-Events. Notdienst-Start pausiert eine laufende Uhr automatisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import type { SyncOpResult } from "@/lib/sync/envelope";
|
||||
|
||||
/**
|
||||
* Client-safe rules of the L12 time recording (shared by the mobile forms and the services).
|
||||
*/
|
||||
|
||||
/** Own time may be recorded/corrected for today and this many previous local days. */
|
||||
export const MAX_BACKDATE_DAYS = 7;
|
||||
/** Maximum length of a single manual entry. */
|
||||
export const MAX_ENTRY_MINUTES = 16 * 60;
|
||||
/** Quick picks of the duration mode. */
|
||||
export const DURATION_QUICK_PICKS = [15, 30, 60] as const;
|
||||
/** Entry types offered in „Zeit nachtragen" (chips). */
|
||||
export const MANUAL_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement", "break"] as const;
|
||||
/** Reason suggestions (i18n keys field.myTime.reasons.*). */
|
||||
export const REASON_SUGGESTIONS = ["forgotStart", "noNetwork", "recordedLater"] as const;
|
||||
|
||||
/** Error messages of the time services that have a plain-language text (field.myTime.errors.*). */
|
||||
export const TIME_ERROR_KEYS = ["overlap", "too_old", "in_future", "too_long", "end_before_start", "work_order_status", "not_own_entry", "own_entry", "entry_running"] as const;
|
||||
export type TimeErrorKey = (typeof TIME_ERROR_KEYS)[number];
|
||||
|
||||
export function timeErrorKey(result: Pick<SyncOpResult, "message"> | { message?: string | null }): TimeErrorKey | null {
|
||||
const m = result.message ?? "";
|
||||
return (TIME_ERROR_KEYS as readonly string[]).find((k) => m === k || m.startsWith(`${k}:`)) as TimeErrorKey | undefined ?? null;
|
||||
}
|
||||
|
||||
export type OtherSession = { workOrderId: string; number: string; title?: string };
|
||||
|
||||
/** `session.start`/`session.resume` rejected because another order is on the clock (auto switch dialog). */
|
||||
export function otherSessionOf(result: Pick<SyncOpResult, "status" | "errorCode" | "message">): OtherSession | null {
|
||||
if (result.errorCode !== "conflict" || !result.message?.startsWith("other_session_running")) return null;
|
||||
try {
|
||||
const details = JSON.parse(result.message.slice("other_session_running:".length)) as OtherSession;
|
||||
return typeof details?.number === "string" ? details : null;
|
||||
} catch {
|
||||
return { workOrderId: "", number: "" };
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export function toBundleRecords(ctxKey: string, orders: BundleOrderData[], synce
|
||||
export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean };
|
||||
export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null };
|
||||
export type SessionState = "en_route" | "running" | "paused" | null;
|
||||
/** L12: manual time entry recorded on this device, not yet confirmed by the server snapshot. */
|
||||
export type LocalManualTime = { id: string; type: string; startedAt: string; endedAt: string | null; durationMinutes: number | null; reason: string; pending: boolean };
|
||||
|
||||
export type OrderView = BundleOrderData & {
|
||||
local: {
|
||||
@@ -53,6 +55,7 @@ export type OrderView = BundleOrderData & {
|
||||
photos: LocalPhoto[];
|
||||
voiceNotes: number;
|
||||
session: SessionState;
|
||||
manualTimes: LocalManualTime[];
|
||||
pendingOps: number;
|
||||
conflict: boolean;
|
||||
rejected: boolean;
|
||||
@@ -64,7 +67,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
||||
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
|
||||
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
|
||||
const data: BundleOrderData = structuredCloneSafe(record.data);
|
||||
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } };
|
||||
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), manualTimes: [], pendingOps: 0, conflict: false, rejected: false } };
|
||||
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
|
||||
|
||||
for (const op of mine) {
|
||||
@@ -116,6 +119,26 @@ function applyOp(view: OrderView, op: OutboxEntry) {
|
||||
case "session.end":
|
||||
view.local.session = null;
|
||||
break;
|
||||
case "session.stop_day":
|
||||
// L12 „Für heute beenden": session ends, the order stays open (paused)
|
||||
view.local.session = null;
|
||||
if (view.status === "in_progress") view.status = "paused";
|
||||
break;
|
||||
case "session.segment":
|
||||
if (view.status === "paused") view.status = "in_progress";
|
||||
view.local.session = "running";
|
||||
break;
|
||||
case "time.add_manual":
|
||||
view.local.manualTimes.push({
|
||||
id: str(p.clientId) ?? op.clientOpId,
|
||||
type: str(p.type) ?? "work",
|
||||
startedAt: str(p.startedAt) ?? op.clientCreatedAt,
|
||||
endedAt: str(p.endedAt),
|
||||
durationMinutes: typeof p.durationMinutes === "number" ? p.durationMinutes : null,
|
||||
reason: str(p.reason) ?? "",
|
||||
pending: isPending(op),
|
||||
});
|
||||
break;
|
||||
case "work_order.transition":
|
||||
if (typeof p.to === "string") view.status = p.to;
|
||||
break;
|
||||
|
||||
@@ -21,7 +21,7 @@ export const BACKOFF_BASE_MS = 2_000;
|
||||
export const BACKOFF_MAX_MS = 5 * 60_000;
|
||||
|
||||
/** Ops that change WorkOrder.version on the server (status changes). */
|
||||
export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "report.submit"];
|
||||
export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "session.stop_day", "session.segment", "work_order.transition", "report.submit"];
|
||||
|
||||
const TRANSIENT_CODES = new Set<OutboxError["code"]>(["internal", "network"]);
|
||||
|
||||
@@ -337,7 +337,13 @@ export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError
|
||||
case "blocked":
|
||||
return "blocked";
|
||||
case "invalid":
|
||||
// L12: plain-language texts for rejected time entries
|
||||
if (e.lastError?.message === "overlap") return "timeOverlap";
|
||||
if (["too_old", "in_future", "too_long", "end_before_start"].includes(e.lastError?.message ?? "")) return "timeWindow";
|
||||
return "invalid";
|
||||
case "conflict":
|
||||
if (e.lastError?.message?.startsWith("other_session_running")) return "otherSession";
|
||||
return "internal";
|
||||
case "upload":
|
||||
return "upload";
|
||||
case "unauthorized":
|
||||
|
||||
@@ -160,6 +160,8 @@ export const reportContentSchema = z.object({
|
||||
totalMinutes: z.number().int().nonnegative(),
|
||||
/** true if an entry was still running while the snapshot was built */
|
||||
hasRunningEntries: z.boolean(),
|
||||
/** L12: minutes of manual entries still waiting for approval (not part of any total) */
|
||||
pendingMinutes: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
texts: reportTextsSchema,
|
||||
materials: z.object({
|
||||
|
||||
@@ -20,6 +20,11 @@ export const SYNC_OP_TYPES = [
|
||||
"report.submit",
|
||||
"signature.capture",
|
||||
"emergency.create",
|
||||
// L12 Zeiterfassung (additive, conflict-free)
|
||||
"session.stop_day",
|
||||
"session.segment",
|
||||
"time.add_manual",
|
||||
"time.propose_correction",
|
||||
] as const;
|
||||
|
||||
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
||||
|
||||
+51
-1
@@ -50,6 +50,8 @@ export const sessionStartPayload = z.object({
|
||||
longitude: lng.optional(),
|
||||
offline: z.boolean().default(false),
|
||||
deviceInfo: z.string().max(200).optional(),
|
||||
/** L12 auto switch: pause the user's running session on another order in the same transaction */
|
||||
switchFromOther: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const sessionControlPayload = z.object({
|
||||
@@ -57,6 +59,50 @@ export const sessionControlPayload = z.object({
|
||||
at: isoDate.optional(),
|
||||
});
|
||||
|
||||
/** session.resume (L12): like session.start, may pause a running session on another order. */
|
||||
export const sessionResumePayload = sessionControlPayload.extend({ switchFromOther: z.boolean().optional() });
|
||||
|
||||
/** Segment types a technician can switch to inside a running session (L12). */
|
||||
export const SESSION_SEGMENT_TYPES = ["work", "return_travel", "material_procurement"] as const;
|
||||
export type SessionSegmentType = (typeof SESSION_SEGMENT_TYPES)[number];
|
||||
|
||||
export const sessionSegmentPayload = z.object({
|
||||
workOrderId: id,
|
||||
type: z.enum(SESSION_SEGMENT_TYPES),
|
||||
at: isoDate.optional(),
|
||||
});
|
||||
|
||||
/** Time entry types (Prisma enum TimeEntryType). */
|
||||
export const TIME_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||
export type TimeType = (typeof TIME_TYPES)[number];
|
||||
|
||||
/**
|
||||
* time.add_manual (L12): manual time entry. Own entries become `pending` (approval by team lead /
|
||||
* back office); with `field:correct_time` for a team member (`forUserId`) they are approved directly.
|
||||
* Either `endedAt` or `durationMinutes` is required.
|
||||
*/
|
||||
export const timeAddManualPayload = z.object({
|
||||
workOrderId: id,
|
||||
clientId: clientId.optional(),
|
||||
type: z.enum(TIME_TYPES).default("work"),
|
||||
startedAt: isoDate,
|
||||
endedAt: isoDate.optional(),
|
||||
durationMinutes: z.number().int().min(1).max(16 * 60).optional(),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
note: z.string().trim().max(2000).optional(),
|
||||
forUserId: id.optional(),
|
||||
});
|
||||
|
||||
/** time.propose_correction (L12): correction proposal for an own entry; old values count until approval. */
|
||||
export const timeProposeCorrectionPayload = z.object({
|
||||
workOrderId: id,
|
||||
timeEntryId: id,
|
||||
type: z.enum(TIME_TYPES).optional(),
|
||||
startedAt: isoDate,
|
||||
endedAt: isoDate,
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
});
|
||||
|
||||
export const workOrderTransitionPayload = z.object({
|
||||
workOrderId: id,
|
||||
to: z.enum(WORK_ORDER_STATUSES),
|
||||
@@ -138,7 +184,7 @@ const passthrough = z.record(z.string(), z.unknown());
|
||||
export const OP_PAYLOAD_SCHEMAS = {
|
||||
"session.start": sessionStartPayload,
|
||||
"session.pause": sessionControlPayload,
|
||||
"session.resume": sessionControlPayload,
|
||||
"session.resume": sessionResumePayload,
|
||||
"session.end": sessionControlPayload,
|
||||
"work_order.transition": workOrderTransitionPayload,
|
||||
"note.create": noteCreatePayload,
|
||||
@@ -150,6 +196,10 @@ export const OP_PAYLOAD_SCHEMAS = {
|
||||
"report.submit": reportSubmitPayload,
|
||||
"signature.capture": passthrough,
|
||||
"emergency.create": emergencyCreatePayload,
|
||||
"session.stop_day": sessionControlPayload,
|
||||
"session.segment": sessionSegmentPayload,
|
||||
"time.add_manual": timeAddManualPayload,
|
||||
"time.propose_correction": timeProposeCorrectionPayload,
|
||||
} satisfies Record<SyncOpType, z.ZodType>;
|
||||
|
||||
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||
|
||||
@@ -246,6 +246,8 @@ export async function createEmergencyOrder(
|
||||
at: startedAt.toISOString(),
|
||||
offline: input.offline,
|
||||
deviceInfo: input.deviceInfo,
|
||||
// L12: an emergency call-out pauses a running session on another order (one clock per user)
|
||||
switchFromOther: true,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -68,6 +68,8 @@ export type OrderCard = {
|
||||
plannedStart: Date | null;
|
||||
plannedEnd: Date | null;
|
||||
version: number;
|
||||
/** L12: own active session on this order (card start/stop button) */
|
||||
mySession: "en_route" | "running" | "paused" | null;
|
||||
};
|
||||
|
||||
const CARD_SELECT = {
|
||||
@@ -119,9 +121,23 @@ function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>):
|
||||
plannedStart: wo.plannedStart,
|
||||
plannedEnd: wo.plannedEnd,
|
||||
version: wo.version,
|
||||
mySession: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** L12: attach the caller's own active session per card (one query). */
|
||||
async function withMySessions(ctx: ServiceCtx, cards: OrderCard[]): Promise<OrderCard[]> {
|
||||
if (!cards.length) return cards;
|
||||
const sessions = await ctx.db.workSession.findMany({
|
||||
where: { workOrderId: { in: cards.map((c) => c.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { workOrderId: true, status: true },
|
||||
});
|
||||
const byOrder = new Map<string, OrderCard["mySession"]>();
|
||||
for (const s of sessions) if (!byOrder.has(s.workOrderId)) byOrder.set(s.workOrderId, s.status as OrderCard["mySession"]);
|
||||
return cards.map((c) => ({ ...c, mySession: byOrder.get(c.id) ?? null }));
|
||||
}
|
||||
|
||||
export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<OrderCard[]> {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const rows = await ctx.db.workOrder.findMany({
|
||||
@@ -130,7 +146,7 @@ export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<O
|
||||
take: tab === "past" ? 50 : 200,
|
||||
select: CARD_SELECT,
|
||||
});
|
||||
return rows.map(toCard);
|
||||
return withMySessions(ctx, rows.map(toCard));
|
||||
}
|
||||
|
||||
/** "Heute": orders planned for today (not yet done) plus all running and paused ones. */
|
||||
@@ -160,7 +176,7 @@ export async function listTodayOrders(ctx: ServiceCtx, now = new Date()): Promis
|
||||
take: 100,
|
||||
select: CARD_SELECT,
|
||||
});
|
||||
return rows.map(toCard);
|
||||
return withMySessions(ctx, rows.map(toCard));
|
||||
}
|
||||
|
||||
const DETAIL_SELECT = {
|
||||
@@ -217,7 +233,11 @@ const DETAIL_SELECT = {
|
||||
endedAt: true,
|
||||
startedOffline: true,
|
||||
user: { select: { name: true } },
|
||||
entries: { orderBy: { startedAt: "asc" }, select: { id: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true } },
|
||||
manual: true,
|
||||
entries: {
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { id: true, userId: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true, source: true, approvalStatus: true, rejectionReason: true, pendingChange: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.WorkOrderSelect;
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { TimeEntryType, WorkSessionStatus } from "@prisma/client";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import type { TimeEntryType, WorkSession, WorkSessionStatus } from "@prisma/client";
|
||||
import { inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
|
||||
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||
|
||||
/**
|
||||
* Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists
|
||||
* of TimeEntry segments (travel → work ⇄ break). Status changes of the work order are delegated to
|
||||
* transitionWorkOrder (never a direct status update).
|
||||
* of TimeEntry segments (travel → work ⇄ break, return travel, material procurement). Status
|
||||
* changes of the work order are delegated to transitionWorkOrder (never a direct status update).
|
||||
*
|
||||
* L12 Zeiterfassung: only ONE running/en-route session per user across all orders (auto switch
|
||||
* with `switchFromOther`), "Für heute beenden" (`stopForToday`), segment switches and the
|
||||
* current session for the clock bar (`getMyActiveSession`). Multi-step writes run in inTransaction.
|
||||
*/
|
||||
|
||||
export const ACTIVE_SESSION_STATUSES: WorkSessionStatus[] = ["en_route", "running", "paused"];
|
||||
/** Sessions that are "on the clock" — at most one per user across all orders. */
|
||||
const CLOCK_STATUSES: WorkSessionStatus[] = ["en_route", "running"];
|
||||
/** Entry types that count as working time (everything except break). */
|
||||
const NON_BREAK: TimeEntryType[] = ["travel", "work", "material_procurement", "return_travel", "interruption"];
|
||||
|
||||
export type SessionResult = {
|
||||
sessionId: string;
|
||||
@@ -30,14 +37,14 @@ export type SessionEndResult = SessionResult & {
|
||||
|
||||
function activeSession(ctx: ServiceCtx, workOrderId: string) {
|
||||
return ctx.db.workSession.findFirst({
|
||||
where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } },
|
||||
where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false },
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Other users' sessions that are still working on the order. */
|
||||
function othersRunning(ctx: ServiceCtx, workOrderId: string) {
|
||||
return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: ["running", "en_route"] } } });
|
||||
return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: CLOCK_STATUSES } } });
|
||||
}
|
||||
|
||||
async function closeOpenEntries(ctx: ServiceCtx, sessionId: string, at: Date) {
|
||||
@@ -58,9 +65,38 @@ async function moveOrder(ctx: ServiceCtx, wo: FieldOrder, to: WorkOrderStatus):
|
||||
return { status: r.status, version: r.version };
|
||||
}
|
||||
|
||||
/**
|
||||
* L12 auto switch: a running/en-route session of the user on ANOTHER order. Without
|
||||
* `switchFromOther` → conflict `other_session_running` (details: workOrderId, number); with it the
|
||||
* other session is paused inside the caller's transaction.
|
||||
*/
|
||||
async function handleOtherRunning(ctx: ServiceCtx, workOrderId: string, switchFromOther: boolean | undefined, at: Date) {
|
||||
const other = await ctx.db.workSession.findFirst({
|
||||
where: { userId: ctx.userId, workOrderId: { not: workOrderId }, status: { in: CLOCK_STATUSES }, workOrder: { deletedAt: null } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { workOrder: { select: { id: true, number: true, title: true, status: true, version: true, siteId: true, customerId: true, assignedTeamId: true } } },
|
||||
});
|
||||
if (!other) return;
|
||||
if (!switchFromOther) {
|
||||
throw new ServiceError("conflict", "other_session_running", { workOrderId: other.workOrder.id, number: other.workOrder.number, title: other.workOrder.title });
|
||||
}
|
||||
await pauseLoaded(ctx, other, other.workOrder as FieldOrder, at);
|
||||
}
|
||||
|
||||
/** Pause a loaded running/en-route session (break segment) and move its order to `paused` when nobody else works on it. */
|
||||
async function pauseLoaded(ctx: ServiceCtx, session: WorkSession, wo: FieldOrder, at: Date) {
|
||||
await closeOpenEntries(ctx, session.id, at);
|
||||
await openEntry(ctx, session.id, "break", at);
|
||||
const updated = await ctx.db.workSession.update({ where: { id: session.id }, data: { status: "paused" } });
|
||||
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
||||
await audit(ctx, "update", "work_session", session.id, { status: session.status }, { status: "paused", at });
|
||||
return { session: updated, moved };
|
||||
}
|
||||
|
||||
export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.start">): Promise<SessionResult> {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
|
||||
if (input.clientId) {
|
||||
const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } });
|
||||
@@ -71,9 +107,12 @@ export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"sess
|
||||
}
|
||||
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (input.mode === "travel" && existing) throw new ServiceError("conflict", "a session is already active for this work order");
|
||||
if (input.mode === "work" && existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order");
|
||||
|
||||
await handleOtherRunning(ctx, wo.id, input.switchFromOther, at);
|
||||
|
||||
if (input.mode === "travel") {
|
||||
if (existing) throw new ServiceError("conflict", "a session is already active for this work order");
|
||||
const moved = await moveOrder(ctx, wo, "en_route");
|
||||
const session = await ctx.db.workSession.create({
|
||||
data: {
|
||||
@@ -96,7 +135,6 @@ export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"sess
|
||||
}
|
||||
|
||||
// mode "work"
|
||||
if (existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order");
|
||||
const moved = await moveOrder(ctx, wo, "in_progress");
|
||||
if (existing) {
|
||||
await closeOpenEntries(ctx, existing.id, at);
|
||||
@@ -123,39 +161,37 @@ export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"sess
|
||||
await openEntry(ctx, session.id, "work", at);
|
||||
await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at });
|
||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||
});
|
||||
}
|
||||
|
||||
export async function pauseSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.pause">): Promise<SessionResult> {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause");
|
||||
await closeOpenEntries(ctx, existing.id, at);
|
||||
await openEntry(ctx, existing.id, "break", at);
|
||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "paused" } });
|
||||
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
||||
await audit(ctx, "update", "work_session", session.id, { status: "running" }, { status: "paused", at });
|
||||
const { session, moved } = await pauseLoaded(ctx, existing, wo, at);
|
||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||
});
|
||||
}
|
||||
|
||||
export async function resumeSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.resume">): Promise<SessionResult> {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume");
|
||||
await handleOtherRunning(ctx, wo.id, input.switchFromOther, at);
|
||||
await closeOpenEntries(ctx, existing.id, at);
|
||||
await openEntry(ctx, existing.id, "work", at);
|
||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
||||
const moved = await moveOrder(ctx, wo, "in_progress");
|
||||
await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at });
|
||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||
});
|
||||
}
|
||||
|
||||
export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise<SessionEndResult> {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const at = opTime(input.at);
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing) throw new ServiceError("invalid", "no active session to end");
|
||||
async function finishSession(ctx: ServiceCtx, wo: FieldOrder, existing: WorkSession, at: Date, extra: Record<string, unknown> = {}) {
|
||||
await closeOpenEntries(ctx, existing.id, at);
|
||||
const endedAt = at < existing.startedAt ? existing.startedAt : at;
|
||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } });
|
||||
@@ -165,15 +201,107 @@ export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"sessio
|
||||
const workSeconds = sum(["work", "material_procurement", "interruption"]);
|
||||
const breakSeconds = sum(["break"]);
|
||||
const travelSeconds = sum(["travel", "return_travel"]);
|
||||
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds });
|
||||
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds, ...extra });
|
||||
return {
|
||||
sessionId: session.id,
|
||||
status: session.status,
|
||||
workOrderStatus: wo.status,
|
||||
workOrderVersion: wo.version,
|
||||
session,
|
||||
workSeconds,
|
||||
breakSeconds,
|
||||
travelSeconds,
|
||||
totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise<SessionEndResult> {
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing) throw new ServiceError("invalid", "no active session to end");
|
||||
const r = await finishSession(ctx, wo, existing, at);
|
||||
return { sessionId: r.session.id, status: r.session.status, workOrderStatus: wo.status, workOrderVersion: wo.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* „Für heute beenden" (L12): ends the own session, the order stays open. When nobody else is on
|
||||
* the clock for the order it is moved to `paused` (only where the status machine allows it).
|
||||
* No completion, no completion blockers.
|
||||
*/
|
||||
export async function stopForToday(ctx: ServiceCtx, input: ParsedOpPayload<"session.stop_day">): Promise<SessionEndResult> {
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing) throw new ServiceError("invalid", "no active session to stop");
|
||||
const r = await finishSession(ctx, wo, existing, at, { stoppedForToday: true });
|
||||
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
||||
return { sessionId: r.session.id, status: r.session.status, workOrderStatus: moved.status, workOrderVersion: moved.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds };
|
||||
});
|
||||
}
|
||||
|
||||
/** Segment switch inside the own active session (L12): „Rückfahrt starten", „Material holen", back to work. */
|
||||
export async function switchSegment(ctx: ServiceCtx, input: ParsedOpPayload<"session.segment">): Promise<SessionResult> {
|
||||
const at = opTime(input.at);
|
||||
return inTransaction(ctx, async (ctx) => {
|
||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||
const existing = await activeSession(ctx, wo.id);
|
||||
if (!existing || existing.status === "en_route") throw new ServiceError("invalid", "no running or paused session");
|
||||
const open = await ctx.db.timeEntry.findFirst({ where: { workSessionId: existing.id, endedAt: null }, orderBy: { startedAt: "desc" } });
|
||||
if (open?.type === input.type) throw new ServiceError("invalid", "segment already active");
|
||||
if (existing.status === "paused") await handleOtherRunning(ctx, wo.id, true, at);
|
||||
await closeOpenEntries(ctx, existing.id, at);
|
||||
await openEntry(ctx, existing.id, input.type, at);
|
||||
const session = existing.status === "running" ? existing : await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
||||
const moved = existing.status === "paused" ? await moveOrder(ctx, wo, "in_progress") : { status: wo.status, version: wo.version };
|
||||
await audit(ctx, "update", "work_session", session.id, { status: existing.status, segment: open?.type ?? null }, { status: "running", segment: input.type, at });
|
||||
return { sessionId: session.id, status: "running", workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||
});
|
||||
}
|
||||
|
||||
export type MyActiveSession = {
|
||||
sessionId: string;
|
||||
status: "en_route" | "running" | "paused";
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
title: string;
|
||||
workOrderStatus: WorkOrderStatus;
|
||||
workOrderVersion: number;
|
||||
startedAt: string;
|
||||
/** type + start of the open segment (null when none is open) */
|
||||
segmentType: TimeEntryType | null;
|
||||
segmentStartedAt: string | null;
|
||||
/** sum of closed non-break segments of the session (seconds); the client adds the running segment */
|
||||
closedSeconds: number;
|
||||
};
|
||||
|
||||
/** The user's current session for the clock bar: running/en-route first, otherwise the newest paused one. */
|
||||
export async function getMyActiveSession(ctx: ServiceCtx): Promise<MyActiveSession | null> {
|
||||
const sessions = await ctx.db.workSession.findMany({
|
||||
where: { userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false, workOrder: { deletedAt: null } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 10,
|
||||
include: {
|
||||
workOrder: { select: { id: true, number: true, title: true, status: true, version: true } },
|
||||
entries: { orderBy: { startedAt: "asc" }, select: { type: true, startedAt: true, endedAt: true } },
|
||||
},
|
||||
});
|
||||
const s = sessions.find((x) => CLOCK_STATUSES.includes(x.status)) ?? sessions[0];
|
||||
if (!s) return null;
|
||||
const open = [...s.entries].reverse().find((e) => !e.endedAt) ?? null;
|
||||
const closedSeconds = Math.round(
|
||||
s.entries.filter((e) => e.endedAt && NON_BREAK.includes(e.type)).reduce((acc, e) => acc + (e.endedAt!.getTime() - e.startedAt.getTime()), 0) / 1000,
|
||||
);
|
||||
return {
|
||||
sessionId: s.id,
|
||||
status: s.status as MyActiveSession["status"],
|
||||
workOrderId: s.workOrder.id,
|
||||
number: s.workOrder.number,
|
||||
title: s.workOrder.title,
|
||||
workOrderStatus: s.workOrder.status,
|
||||
workOrderVersion: s.workOrder.version,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
segmentType: open?.type ?? null,
|
||||
segmentStartedAt: open?.startedAt.toISOString() ?? null,
|
||||
closedSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
import { audit } from "./common";
|
||||
|
||||
/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */
|
||||
/**
|
||||
* Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited.
|
||||
* L12: own entries cannot be corrected directly (nobody approves their own time) → proposeTimeCorrection.
|
||||
* A direct correction supersedes an open correction proposal of the entry.
|
||||
*/
|
||||
|
||||
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||
|
||||
@@ -31,6 +36,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput
|
||||
});
|
||||
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||
await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true });
|
||||
if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry");
|
||||
if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future");
|
||||
|
||||
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason };
|
||||
@@ -43,6 +49,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput
|
||||
corrected: true,
|
||||
correctionReason: input.reason,
|
||||
correctedById: ctx.userId,
|
||||
pendingChange: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
await audit(ctx, "update", "time_entry", entry.id, before, {
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
import { Prisma, type TimeEntryType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { dayWindow, localDateKey } from "@/lib/reports/dates";
|
||||
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { TIME_TYPES } from "@/lib/sync/ops";
|
||||
import { MAX_BACKDATE_DAYS, MAX_ENTRY_MINUTES } from "@/lib/field/time-rules";
|
||||
import { audit } from "./common";
|
||||
|
||||
/**
|
||||
* L12 Zeiterfassung — manual time entries, correction proposals and their approval.
|
||||
*
|
||||
* Rules (decision of the product owner):
|
||||
* - Technicians record/correct only their OWN time of the last 7 days, reason mandatory, marked
|
||||
* `source = manual`. Own entries are `pending`; proposals for existing entries are stored in
|
||||
* `pendingChange` while the entry keeps its old (counted) values.
|
||||
* - Team leads (entries of their teams / orders of their teams) and back office (all) approve with
|
||||
* `time:approve`. Nobody approves their own entries.
|
||||
* - `field:correct_time` records entries for team members directly (approved, source manual).
|
||||
* - Only `approvalStatus = approved` counts for reports, totals and billing; billing release is
|
||||
* blocked while pending entries or proposals exist on the order.
|
||||
*
|
||||
* Manual entries live in a "manual" WorkSession (flag `manual`, status `ended`) per user + order +
|
||||
* local day, so every existing read path (sessions → entries → order) keeps working unchanged.
|
||||
*/
|
||||
|
||||
/** Order statuses in which time may still be recorded afterwards (field statuses + in review). */
|
||||
export const TIME_RECORDABLE: readonly WorkOrderStatus[] = [...FIELD_EDITABLE, "in_review"];
|
||||
|
||||
const NON_BREAK: TimeEntryType[] = ["travel", "work", "material_procurement", "return_travel", "interruption"];
|
||||
const idSchema = z.string().min(1).max(64);
|
||||
|
||||
export type PendingChange = { startedAt: string; endedAt: string; type: TimeEntryType; reason: string; requestedAt: string; requestedById: string };
|
||||
|
||||
export function parsePendingChange(v: unknown): PendingChange | null {
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.startedAt !== "string" || typeof o.endedAt !== "string" || typeof o.reason !== "string") return null;
|
||||
return {
|
||||
startedAt: o.startedAt,
|
||||
endedAt: o.endedAt,
|
||||
type: (typeof o.type === "string" && (TIME_TYPES as readonly string[]).includes(o.type) ? o.type : "work") as TimeEntryType,
|
||||
reason: o.reason,
|
||||
requestedAt: typeof o.requestedAt === "string" ? o.requestedAt : "",
|
||||
requestedById: typeof o.requestedById === "string" ? o.requestedById : "",
|
||||
};
|
||||
}
|
||||
|
||||
const minutesBetween = (a: Date, b: Date) => Math.max(0, Math.round((b.getTime() - a.getTime()) / 60_000));
|
||||
|
||||
// ---------------------------------------------------------------- validation helpers
|
||||
|
||||
async function validateWindow(ctx: ServiceCtx, start: Date, end: Date, now = new Date()) {
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new ServiceError("invalid", "invalid_timestamp");
|
||||
if (end.getTime() <= start.getTime()) throw new ServiceError("invalid", "end_before_start");
|
||||
if (end.getTime() - start.getTime() > MAX_ENTRY_MINUTES * 60_000) throw new ServiceError("invalid", "too_long");
|
||||
if (end.getTime() > now.getTime() + 60_000) throw new ServiceError("invalid", "in_future");
|
||||
if (start.getTime() < (await earliestStart(ctx, now)).getTime()) throw new ServiceError("invalid", "too_old");
|
||||
}
|
||||
|
||||
/** Start of the local day MAX_BACKDATE_DAYS ago (tenant time zone). */
|
||||
export async function earliestStart(ctx: ServiceCtx, now = new Date()): Promise<Date> {
|
||||
const tz = await tenantTimezone(ctx);
|
||||
return dayWindow(localDateKey(new Date(now.getTime() - MAX_BACKDATE_DAYS * 86_400_000), tz), tz).start;
|
||||
}
|
||||
|
||||
/** Own entries (tracked or manual, not rejected) overlapping [start, end). */
|
||||
async function assertNoOverlap(ctx: ServiceCtx, userId: string, start: Date, end: Date, excludeId?: string) {
|
||||
const hit = await ctx.db.timeEntry.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
approvalStatus: { not: "rejected" },
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
startedAt: { lt: end },
|
||||
OR: [{ endedAt: null }, { endedAt: { gt: start } }],
|
||||
},
|
||||
select: { id: true, startedAt: true, endedAt: true, type: true, workSession: { select: { workOrder: { select: { number: true } } } } },
|
||||
});
|
||||
if (hit) {
|
||||
throw new ServiceError("invalid", "overlap", {
|
||||
reason: "overlap",
|
||||
timeEntryId: hit.id,
|
||||
number: hit.workSession.workOrder.number,
|
||||
startedAt: hit.startedAt.toISOString(),
|
||||
endedAt: hit.endedAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- approver scope
|
||||
|
||||
type ApproverScope = { all: true } | { all: false; teamIds: string[]; memberIds: string[] };
|
||||
|
||||
/** Back office (work_order:read_all) → all; team lead → teams led by the user and their active members. */
|
||||
async function approverScope(ctx: ServiceCtx): Promise<ApproverScope> {
|
||||
if (can(ctx, "work_order:read_all")) return { all: true };
|
||||
const now = new Date();
|
||||
const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } });
|
||||
const teamIds = led.map((t) => t.id);
|
||||
const members = teamIds.length
|
||||
? await ctx.db.teamMember.findMany({
|
||||
where: { teamId: { in: teamIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||
select: { userId: true },
|
||||
})
|
||||
: [];
|
||||
return { all: false, teamIds, memberIds: [...new Set(members.map((m) => m.userId))] };
|
||||
}
|
||||
|
||||
function scopeWhere(scope: ApproverScope, userId: string): Prisma.TimeEntryWhereInput {
|
||||
const base: Prisma.TimeEntryWhereInput = { workSession: { workOrder: { deletedAt: null } } };
|
||||
if (scope.all) return base;
|
||||
return {
|
||||
AND: [
|
||||
base,
|
||||
{
|
||||
OR: [
|
||||
...(scope.teamIds.length ? [{ workSession: { workOrder: { assignedTeamId: { in: scope.teamIds } } } }] : []),
|
||||
{ workSession: { workOrder: { teamLeadUserId: userId } } },
|
||||
...(scope.memberIds.length ? [{ userId: { in: scope.memberIds } }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Direct recording for another person: back office → anyone, team lead → active members of the teams they lead. */
|
||||
function mayRecordFor(scope: ApproverScope, targetUserId: string): boolean {
|
||||
return scope.all || scope.memberIds.includes(targetUserId);
|
||||
}
|
||||
|
||||
const PENDING_WHERE: Prisma.TimeEntryWhereInput = { OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] };
|
||||
|
||||
// ---------------------------------------------------------------- add manual entry
|
||||
|
||||
export const manualTimeSchema = z
|
||||
.object({
|
||||
workOrderId: idSchema,
|
||||
type: z.enum(TIME_TYPES).default("work"),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().nullish(),
|
||||
durationMinutes: z.number().int().min(1).max(MAX_ENTRY_MINUTES).nullish(),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
note: z.string().trim().max(2000).nullish(),
|
||||
forUserId: idSchema.nullish(),
|
||||
clientId: z.string().uuid().nullish(),
|
||||
})
|
||||
.refine((v) => !!v.endedAt || !!v.durationMinutes, { message: "endedAt or durationMinutes required", path: ["endedAt"] });
|
||||
|
||||
export type ManualTimeInput = z.input<typeof manualTimeSchema>;
|
||||
|
||||
export async function addManualTimeEntry(ctx: ServiceCtx, raw: ManualTimeInput) {
|
||||
const parsed = manualTimeSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new ServiceError("invalid", "invalid_manual_time", parsed.error.issues);
|
||||
const input = parsed.data;
|
||||
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");
|
||||
} else {
|
||||
assertCan(ctx, "field:correct_time");
|
||||
}
|
||||
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, assignedTeamId: true, teamLeadUserId: true });
|
||||
if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status });
|
||||
|
||||
if (!own) {
|
||||
const target = await ctx.db.user.findFirst({ where: { id: targetUserId, status: "ACTIVE" }, select: { id: true } });
|
||||
if (!target) throw new ServiceError("not_found", "user not found");
|
||||
if (!mayRecordFor(await approverScope(ctx), targetUserId)) throw new ServiceError("forbidden", "user_not_in_team");
|
||||
}
|
||||
|
||||
if (input.clientId) {
|
||||
const replay = await ctx.db.timeEntry.findFirst({ where: { clientId: input.clientId } });
|
||||
if (replay) {
|
||||
if (replay.userId !== targetUserId) throw new ServiceError("invalid", "clientId already used");
|
||||
return replay;
|
||||
}
|
||||
}
|
||||
|
||||
const start = input.startedAt;
|
||||
const end = input.endedAt ?? new Date(start.getTime() + (input.durationMinutes ?? 0) * 60_000);
|
||||
await validateWindow(ctx, start, end);
|
||||
const tz = await tenantTimezone(ctx);
|
||||
|
||||
const entry = await inTransaction(ctx, async (tx) => {
|
||||
await assertNoOverlap(tx, targetUserId, start, end);
|
||||
const day = dayWindow(localDateKey(start, tz), tz);
|
||||
const existing = await tx.db.workSession.findFirst({
|
||||
where: { workOrderId: wo.id, userId: targetUserId, manual: true, startedAt: { gte: day.start, lt: day.end } },
|
||||
});
|
||||
const session = existing
|
||||
? await tx.db.workSession.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
startedAt: start < existing.startedAt ? start : existing.startedAt,
|
||||
endedAt: !existing.endedAt || end > existing.endedAt ? end : existing.endedAt,
|
||||
},
|
||||
})
|
||||
: await tx.db.workSession.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, userId: targetUserId, teamId: wo.assignedTeamId, status: "ended", manual: true, startedAt: start, endedAt: end },
|
||||
});
|
||||
const now = new Date();
|
||||
const created = await tx.db.timeEntry.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workSessionId: session.id,
|
||||
userId: targetUserId,
|
||||
type: input.type,
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
source: "manual",
|
||||
approvalStatus: own ? "pending" : "approved",
|
||||
approvedById: own ? null : ctx.userId,
|
||||
approvedAt: own ? null : now,
|
||||
// the mandatory reason of a manual entry is stored like a correction reason
|
||||
correctionReason: input.reason,
|
||||
note: input.note ?? null,
|
||||
clientId: input.clientId ?? null,
|
||||
},
|
||||
});
|
||||
await audit(tx, "create", "time_entry", created.id, null, {
|
||||
workOrderId: wo.id,
|
||||
userId: targetUserId,
|
||||
type: created.type,
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
source: "manual",
|
||||
approvalStatus: created.approvalStatus,
|
||||
reason: input.reason,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
if (entry.approvalStatus === "pending") {
|
||||
await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: "manual" } });
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- correction proposal
|
||||
|
||||
export const proposeCorrectionSchema = z.object({
|
||||
timeEntryId: idSchema,
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date(),
|
||||
type: z.enum(TIME_TYPES).nullish(),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
});
|
||||
|
||||
export async function proposeTimeCorrection(ctx: ServiceCtx, raw: z.input<typeof proposeCorrectionSchema>) {
|
||||
if (!can(ctx, "field:record_own_time") && !can(ctx, "field:correct_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time");
|
||||
const parsed = proposeCorrectionSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new ServiceError("invalid", "invalid_correction", parsed.error.issues);
|
||||
const input = parsed.data;
|
||||
|
||||
const entry = await ctx.db.timeEntry.findFirst({ where: { id: input.timeEntryId }, include: { workSession: { select: { workOrderId: true } } } });
|
||||
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||
const wo = await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true, number: true, status: true });
|
||||
if (entry.userId !== ctx.userId) throw new ServiceError("forbidden", "not_own_entry");
|
||||
if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status });
|
||||
if (entry.approvalStatus === "rejected") throw new ServiceError("invalid", "entry_rejected");
|
||||
if (!entry.endedAt) throw new ServiceError("invalid", "entry_running");
|
||||
if (entry.startedAt.getTime() < (await earliestStart(ctx)).getTime()) throw new ServiceError("invalid", "too_old");
|
||||
await validateWindow(ctx, input.startedAt, input.endedAt);
|
||||
const type = input.type ?? entry.type;
|
||||
|
||||
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||
const updated = await inTransaction(ctx, async (tx) => {
|
||||
await assertNoOverlap(tx, ctx.userId, input.startedAt, input.endedAt, entry.id);
|
||||
if (entry.approvalStatus === "pending") {
|
||||
// not yet approved: the pending values themselves are changed (nothing counts yet)
|
||||
const u = await tx.db.timeEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { startedAt: input.startedAt, endedAt: input.endedAt, type, correctionReason: input.reason, rejectionReason: null },
|
||||
});
|
||||
await audit(tx, "update", "time_entry", entry.id, before, { type, startedAt: input.startedAt, endedAt: input.endedAt, approvalStatus: "pending", reason: input.reason });
|
||||
return u;
|
||||
}
|
||||
const change: PendingChange = {
|
||||
startedAt: input.startedAt.toISOString(),
|
||||
endedAt: input.endedAt.toISOString(),
|
||||
type,
|
||||
reason: input.reason,
|
||||
requestedAt: new Date().toISOString(),
|
||||
requestedById: ctx.userId,
|
||||
};
|
||||
const u = await tx.db.timeEntry.update({ where: { id: entry.id }, data: { pendingChange: change, rejectionReason: null } });
|
||||
await audit(tx, "update", "time_entry", entry.id, before, { pendingChange: change });
|
||||
return u;
|
||||
});
|
||||
await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: entry.approvalStatus === "pending" ? "manual" : "correction" } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- approval
|
||||
|
||||
async function loadForDecision(ctx: ServiceCtx, timeEntryId: string) {
|
||||
assertCan(ctx, "time:approve");
|
||||
const id = idSchema.safeParse(timeEntryId);
|
||||
if (!id.success) throw new ServiceError("not_found", "time entry not found");
|
||||
const scope = await approverScope(ctx);
|
||||
const entry = await ctx.db.timeEntry.findFirst({
|
||||
where: { AND: [{ id: id.data }, scopeWhere(scope, ctx.userId)] },
|
||||
include: { workSession: { select: { workOrder: { select: { id: true, number: true } } } } },
|
||||
});
|
||||
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||
if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry");
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function approveTimeEntry(ctx: ServiceCtx, timeEntryId: string) {
|
||||
const entry = await loadForDecision(ctx, timeEntryId);
|
||||
const change = parsePendingChange(entry.pendingChange);
|
||||
if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending");
|
||||
const now = new Date();
|
||||
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||
|
||||
const updated = await inTransaction(ctx, async (tx) => {
|
||||
if (change) {
|
||||
const start = new Date(change.startedAt);
|
||||
const end = new Date(change.endedAt);
|
||||
await assertNoOverlap(tx, entry.userId, start, end, entry.id);
|
||||
const u = await tx.db.timeEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
type: change.type,
|
||||
corrected: true,
|
||||
correctionReason: change.reason,
|
||||
correctedById: ctx.userId,
|
||||
pendingChange: Prisma.DbNull,
|
||||
rejectionReason: null,
|
||||
approvedById: ctx.userId,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
await audit(tx, "update", "time_entry", entry.id, before, { type: u.type, startedAt: start, endedAt: end, corrected: true, correctionReason: change.reason, approvalStatus: u.approvalStatus, decision: "approved" });
|
||||
return u;
|
||||
}
|
||||
if (entry.endedAt) await assertNoOverlap(tx, entry.userId, entry.startedAt, entry.endedAt, entry.id);
|
||||
const res = await tx.db.timeEntry.updateMany({ where: { id: entry.id, approvalStatus: "pending" }, data: { approvalStatus: "approved", approvedById: ctx.userId, approvedAt: now, rejectionReason: null } });
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "entry changed concurrently");
|
||||
await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: "approved", approvedById: ctx.userId, decision: "approved" });
|
||||
return tx.db.timeEntry.findFirstOrThrow({ where: { id: entry.id } });
|
||||
});
|
||||
await emitEvent(ctx, { type: "time.approved", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, kind: change ? "correction" : "manual" } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function rejectTimeEntry(ctx: ServiceCtx, timeEntryId: string, rawReason: string) {
|
||||
const entry = await loadForDecision(ctx, timeEntryId);
|
||||
const reason = z.string().trim().min(3).max(500).safeParse(rawReason);
|
||||
if (!reason.success) throw new ServiceError("invalid", "reason_required");
|
||||
const change = parsePendingChange(entry.pendingChange);
|
||||
if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending");
|
||||
const now = new Date();
|
||||
const before = { approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||
|
||||
const updated = await inTransaction(ctx, async (tx) => {
|
||||
// proposal rejected → entry keeps its approved values; new manual entry rejected → never counts
|
||||
const u = await tx.db.timeEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: change
|
||||
? { pendingChange: Prisma.DbNull, rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now }
|
||||
: { approvalStatus: "rejected", rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now },
|
||||
});
|
||||
await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: u.approvalStatus, pendingChange: null, rejectionReason: reason.data, decision: "rejected" });
|
||||
return u;
|
||||
});
|
||||
await emitEvent(ctx, { type: "time.rejected", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, reason: reason.data, kind: change ? "correction" : "manual" } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Bulk approval (back office list): every id is decided independently. */
|
||||
export async function approveTimeEntries(ctx: ServiceCtx, ids: string[]): Promise<Array<{ id: string; ok: boolean; code?: string }>> {
|
||||
assertCan(ctx, "time:approve");
|
||||
const results: Array<{ id: string; ok: boolean; code?: string }> = [];
|
||||
for (const id of [...new Set(ids)].slice(0, 200)) {
|
||||
try {
|
||||
await approveTimeEntry(ctx, id);
|
||||
results.push({ id, ok: true });
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
results.push({ id, ok: false, code: err.code });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- read models
|
||||
|
||||
export type PendingTimeEntry = {
|
||||
id: string;
|
||||
kind: "manual" | "correction";
|
||||
userId: string;
|
||||
userName: string;
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
title: string;
|
||||
type: TimeEntryType;
|
||||
source: "tracked" | "manual";
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
minutes: number;
|
||||
reason: string | null;
|
||||
note: string | null;
|
||||
proposed: { startedAt: Date; endedAt: Date; type: TimeEntryType; minutes: number } | null;
|
||||
requestedAt: Date;
|
||||
};
|
||||
|
||||
export async function listPendingTimeEntries(ctx: ServiceCtx, filter: { workOrderId?: string; userId?: string; limit?: number } = {}): Promise<PendingTimeEntry[]> {
|
||||
assertCan(ctx, "time:approve");
|
||||
const scope = await approverScope(ctx);
|
||||
const rows = await ctx.db.timeEntry.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
scopeWhere(scope, ctx.userId),
|
||||
PENDING_WHERE,
|
||||
{ userId: { not: ctx.userId } },
|
||||
filter.workOrderId ? { workSession: { workOrderId: filter.workOrderId } } : {},
|
||||
filter.userId ? { userId: filter.userId } : {},
|
||||
],
|
||||
},
|
||||
orderBy: [{ startedAt: "asc" }],
|
||||
take: Math.min(500, filter.limit ?? 200),
|
||||
include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true } } } } },
|
||||
});
|
||||
const users = rows.length ? await ctx.db.user.findMany({ where: { id: { in: [...new Set(rows.map((r) => r.userId))] } }, select: { id: true, name: true } }) : [];
|
||||
const names = new Map(users.map((u) => [u.id, u.name]));
|
||||
return rows.map((r) => {
|
||||
const change = parsePendingChange(r.pendingChange);
|
||||
return {
|
||||
id: r.id,
|
||||
kind: change ? "correction" : "manual",
|
||||
userId: r.userId,
|
||||
userName: names.get(r.userId) ?? "—",
|
||||
workOrderId: r.workSession.workOrder.id,
|
||||
number: r.workSession.workOrder.number,
|
||||
title: r.workSession.workOrder.title,
|
||||
type: r.type,
|
||||
source: r.source,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
minutes: r.endedAt ? minutesBetween(r.startedAt, r.endedAt) : 0,
|
||||
reason: change ? change.reason : r.correctionReason,
|
||||
note: r.note,
|
||||
proposed: change
|
||||
? { startedAt: new Date(change.startedAt), endedAt: new Date(change.endedAt), type: change.type, minutes: minutesBetween(new Date(change.startedAt), new Date(change.endedAt)) }
|
||||
: null,
|
||||
requestedAt: change?.requestedAt ? new Date(change.requestedAt) : r.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Number of open decisions for the badge counters (0 without `time:approve`). */
|
||||
export async function countPendingTimeEntries(ctx: ServiceCtx): Promise<number> {
|
||||
if (!can(ctx, "time:approve")) return 0;
|
||||
const scope = await approverScope(ctx);
|
||||
return ctx.db.timeEntry.count({ where: { AND: [scopeWhere(scope, ctx.userId), PENDING_WHERE, { userId: { not: ctx.userId } }] } });
|
||||
}
|
||||
|
||||
/** Pending minutes (manual entries awaiting approval) and open proposals of one order — for totals/warnings. */
|
||||
export async function pendingTimeOfOrder(ctx: ServiceCtx, workOrderId: string): Promise<{ pendingMinutes: number; pendingEntries: number; openProposals: number }> {
|
||||
const rows = await ctx.db.timeEntry.findMany({
|
||||
where: { workSession: { workOrderId }, ...PENDING_WHERE },
|
||||
select: { type: true, startedAt: true, endedAt: true, approvalStatus: true },
|
||||
});
|
||||
const pending = rows.filter((r) => r.approvalStatus === "pending");
|
||||
return {
|
||||
pendingMinutes: pending.filter((r) => NON_BREAK.includes(r.type) && r.endedAt).reduce((acc, r) => acc + minutesBetween(r.startedAt, r.endedAt!), 0),
|
||||
pendingEntries: pending.length,
|
||||
openProposals: rows.length - pending.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type MyTimeEntry = {
|
||||
id: string;
|
||||
type: TimeEntryType;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
seconds: number;
|
||||
source: "tracked" | "manual";
|
||||
approvalStatus: "approved" | "pending" | "rejected";
|
||||
rejectionReason: string | null;
|
||||
reason: string | null;
|
||||
corrected: boolean;
|
||||
pendingChange: PendingChange | null;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export type MyTimeGroup = { workOrderId: string; number: string; title: string; entries: MyTimeEntry[]; approvedSeconds: number; pendingSeconds: number };
|
||||
|
||||
export type MyTimeOverview = {
|
||||
date: string;
|
||||
days: string[];
|
||||
groups: MyTimeGroup[];
|
||||
approvedSeconds: number;
|
||||
pendingSeconds: number;
|
||||
};
|
||||
|
||||
/** „Meine Zeiten": own entries of one local day across all orders, totals approved vs. pending. */
|
||||
export async function getMyTimeOverview(ctx: ServiceCtx, input: { date?: string | null } = {}, now = new Date()): Promise<MyTimeOverview> {
|
||||
assertCan(ctx, "field:execute");
|
||||
const tz = await tenantTimezone(ctx);
|
||||
const days = Array.from({ length: MAX_BACKDATE_DAYS + 1 }, (_, i) => localDateKey(new Date(now.getTime() - i * 86_400_000), tz));
|
||||
const date = input.date && days.includes(input.date) ? input.date : days[0];
|
||||
const win = dayWindow(date, tz);
|
||||
const earliest = dayWindow(days[days.length - 1], tz).start;
|
||||
|
||||
const rows = await ctx.db.timeEntry.findMany({
|
||||
where: { userId: ctx.userId, startedAt: { gte: win.start, lt: win.end }, workSession: { workOrder: { deletedAt: null } } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true, status: true } } } } },
|
||||
});
|
||||
|
||||
const groups = new Map<string, MyTimeGroup>();
|
||||
let approvedSeconds = 0;
|
||||
let pendingSeconds = 0;
|
||||
for (const r of rows) {
|
||||
const wo = r.workSession.workOrder;
|
||||
const g = groups.get(wo.id) ?? { workOrderId: wo.id, number: wo.number, title: wo.title, entries: [], approvedSeconds: 0, pendingSeconds: 0 };
|
||||
const seconds = Math.max(0, Math.round(((r.endedAt ?? now).getTime() - r.startedAt.getTime()) / 1000));
|
||||
const counts = NON_BREAK.includes(r.type);
|
||||
if (counts && r.approvalStatus === "approved") {
|
||||
g.approvedSeconds += seconds;
|
||||
approvedSeconds += seconds;
|
||||
} else if (counts && r.approvalStatus === "pending") {
|
||||
g.pendingSeconds += seconds;
|
||||
pendingSeconds += seconds;
|
||||
}
|
||||
g.entries.push({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
seconds,
|
||||
source: r.source,
|
||||
approvalStatus: r.approvalStatus,
|
||||
rejectionReason: r.rejectionReason,
|
||||
reason: r.correctionReason,
|
||||
corrected: r.corrected,
|
||||
pendingChange: parsePendingChange(r.pendingChange),
|
||||
editable: !!r.endedAt && r.approvalStatus !== "rejected" && r.startedAt >= earliest && TIME_RECORDABLE.includes(wo.status),
|
||||
});
|
||||
groups.set(wo.id, g);
|
||||
}
|
||||
return { date, days, groups: [...groups.values()], approvedSeconds, pendingSeconds };
|
||||
}
|
||||
|
||||
/** Orders selectable in „Zeit nachtragen": visible, still recordable, touched in the last days or planned in that window. */
|
||||
export async function listRecordableOrders(ctx: ServiceCtx, now = new Date()): Promise<Array<{ id: string; number: string; title: string }>> {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const earliest = await earliestStart(ctx, now);
|
||||
const end = new Date(now.getTime() + 86_400_000);
|
||||
return ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
scope,
|
||||
{ status: { in: [...TIME_RECORDABLE] } },
|
||||
{
|
||||
OR: [
|
||||
{ workSessions: { some: { userId: ctx.userId, startedAt: { gte: earliest } } } },
|
||||
{ plannedStart: { gte: earliest, lt: end } },
|
||||
{ plannedStart: { lt: end }, plannedEnd: { gte: earliest } },
|
||||
{ status: { in: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"] } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ plannedStart: { sort: "desc", nulls: "last" } }],
|
||||
take: 60,
|
||||
select: { id: true, number: true, title: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** People the user may record time for directly (`field:correct_time`): team members, all field users for back office. */
|
||||
export async function listRecordableUsers(ctx: ServiceCtx): Promise<Array<{ id: string; name: string }>> {
|
||||
if (!can(ctx, "field:correct_time")) return [];
|
||||
const scope = await approverScope(ctx);
|
||||
const where: Prisma.UserWhereInput = scope.all
|
||||
? { status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "field:execute" } } } } } } }
|
||||
: { status: "ACTIVE", id: { in: scope.memberIds } };
|
||||
const users = await ctx.db.user.findMany({ where: { AND: [where, { id: { not: ctx.userId } }] }, orderBy: { name: "asc" }, take: 200, select: { id: true, name: true } });
|
||||
return users;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function checkCompleteness(ctx: ServiceCtx, workOrderId: string): P
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId: wo.id, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { label: true } }),
|
||||
ctx.db.materialPlan.findMany({ where: { workOrderId: wo.id }, orderBy: { sortOrder: "asc" }, select: { id: true, name: true, plannedQuantity: true } }),
|
||||
ctx.db.materialUsage.findMany({ where: { workOrderId: wo.id }, select: { name: true, materialPlanId: true, usageStatus: true, actualQuantity: true, deviationReason: true } }),
|
||||
ctx.db.timeEntry.findMany({ where: { type: "work", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }),
|
||||
ctx.db.timeEntry.findMany({ where: { type: "work", approvalStatus: "approved", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }),
|
||||
ctx.db.activityNote.count({ where: { workOrderId: wo.id, deletedAt: null, kind: { in: ["work_done", "general"] } } }),
|
||||
ctx.db.report.findMany({
|
||||
where: { workOrderId: wo.id, status: { not: "superseded" } },
|
||||
|
||||
@@ -135,7 +135,7 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
|
||||
customer: f.customer,
|
||||
actor: f.actorName ?? fallbackText(locale, "system"),
|
||||
fileName: f.fileName ?? fallbackText(locale, "document"),
|
||||
reason: f.syncErrorCode,
|
||||
reason: f.syncErrorCode ?? f.rejectionReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +152,10 @@ export function linkFor(event: DomainEvent, f: EventFacts, permissions: Readonly
|
||||
return `/imports/${event.entityId}`;
|
||||
case "sync_operation":
|
||||
return backoffice ? "/work-orders/conflicts" : "/m/sync";
|
||||
case "time_entry":
|
||||
// L12: approvers open their approval list, the technician „Meine Zeiten"
|
||||
if (event.type === "time.approval_requested") return backoffice && permissions.has("time:approve") ? "/work-orders/time-approvals" : "/m/approvals";
|
||||
return "/m/time";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export type EventFacts = {
|
||||
actorName?: string;
|
||||
emergencyStart?: Date | null;
|
||||
emergencyEnd?: Date | null;
|
||||
/** L12: reason of a rejected time entry */
|
||||
rejectionReason?: string;
|
||||
};
|
||||
|
||||
export type RecipientPlan = {
|
||||
@@ -89,6 +91,11 @@ export function backofficeWhere(): Prisma.UserWhereInput {
|
||||
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
|
||||
}
|
||||
|
||||
/** L12: back office approving time entries (all entries): work_order:read_all AND time:approve. */
|
||||
export function timeApproverBackofficeWhere(): Prisma.UserWhereInput {
|
||||
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("time:approve")] };
|
||||
}
|
||||
|
||||
export function billingWhere(): Prisma.UserWhereInput {
|
||||
return hasPermissionWhere("work_order:release_billing");
|
||||
}
|
||||
@@ -278,6 +285,38 @@ export async function resolveRecipients(
|
||||
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
|
||||
break;
|
||||
}
|
||||
case "time.approval_requested":
|
||||
case "time.approved":
|
||||
case "time.rejected": {
|
||||
const entry = await ctx.db.timeEntry.findFirst({
|
||||
where: { id: event.entityId },
|
||||
select: { id: true, userId: true, workSession: { select: { workOrderId: true } } },
|
||||
});
|
||||
if (!entry) return null;
|
||||
const wo = await loadWorkOrder(ctx, entry.workSession.workOrderId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
if (typeof event.data?.reason === "string") facts.rejectionReason = event.data.reason;
|
||||
if (event.type === "time.approval_requested") {
|
||||
// team leads of the order's team / the technician's teams (with time:approve), otherwise back office
|
||||
const now = new Date();
|
||||
const memberships = await ctx.db.teamMember.findMany({
|
||||
where: { userId: entry.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } },
|
||||
select: { team: { select: { leaderUserId: true } } },
|
||||
});
|
||||
const candidateIds = [...teamLeadIds(wo), ...memberships.map((m) => m.team.leaderUserId)].filter(
|
||||
(id): id is string => !!id && id !== entry.userId && id !== ctx.userId,
|
||||
);
|
||||
const leads = candidateIds.length
|
||||
? await ctx.db.user.findMany({ where: { AND: [{ id: { in: [...new Set(candidateIds)] } }, { status: "ACTIVE" }, hasPermissionWhere("time:approve")] }, select: { id: true } })
|
||||
: [];
|
||||
rule = leads.length ? { users: [], userIds: leads.map((l) => l.id) } : { users: [timeApproverBackofficeWhere()], userIds: [] };
|
||||
} else {
|
||||
// decision → the technician, in-app only
|
||||
rule = { users: [], userIds: [entry.userId], mailToUsers: false };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sync.failed": {
|
||||
const op = await ctx.db.syncOperation.findFirst({
|
||||
where: { id: event.entityId },
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
||||
const win = dayWindow(input.reportDate, timeZone);
|
||||
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
|
||||
|
||||
const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
||||
const [wo, settings, tenant, allEntries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
||||
ctx.db.workOrder.findFirstOrThrow({
|
||||
where: { id: input.workOrderId },
|
||||
include: {
|
||||
@@ -69,10 +69,11 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
||||
}),
|
||||
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
||||
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
||||
// L12: only approved entries count; pending manual entries are reported separately (pendingMinutes)
|
||||
ctx.db.timeEntry.findMany({
|
||||
where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) },
|
||||
where: { workSession: { workOrderId: input.workOrderId }, approvalStatus: { in: ["approved", "pending"] }, ...(inDay ? { startedAt: inDay } : {}) },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { userId: true, type: true, startedAt: true, endedAt: true },
|
||||
select: { userId: true, type: true, startedAt: true, endedAt: true, approvalStatus: true },
|
||||
}),
|
||||
ctx.db.activityNote.findMany({
|
||||
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
|
||||
@@ -96,6 +97,10 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
||||
]);
|
||||
|
||||
// ---- people & time ----
|
||||
const pendingMinutes = allEntries
|
||||
.filter((e) => e.approvalStatus === "pending" && e.type !== "break")
|
||||
.reduce((acc, e) => acc + Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000)), 0);
|
||||
const entries = allEntries.filter((e) => e.approvalStatus === "approved");
|
||||
const userIds = new Set<string>(entries.map((e) => e.userId));
|
||||
if (signature?.capturedById) userIds.add(signature.capturedById);
|
||||
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
|
||||
@@ -252,6 +257,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
||||
totalsByPerson: [...byPerson.values()],
|
||||
totalMinutes,
|
||||
hasRunningEntries,
|
||||
pendingMinutes,
|
||||
},
|
||||
texts,
|
||||
materials: { used, notUsed, additional },
|
||||
|
||||
@@ -5,7 +5,8 @@ import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope";
|
||||
import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops";
|
||||
import { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions";
|
||||
import { endSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "@/server/services/field/sessions";
|
||||
import { addManualTimeEntry, proposeTimeCorrection } from "@/server/services/field/time-entries";
|
||||
import { createNote } from "@/server/services/field/notes";
|
||||
import { toggleChecklistItem } from "@/server/services/field/checklist";
|
||||
import { upsertMaterialUsage } from "@/server/services/field/materials";
|
||||
@@ -58,8 +59,23 @@ const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
|
||||
"material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })),
|
||||
"photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })),
|
||||
"voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })),
|
||||
// L12 Zeiterfassung
|
||||
"session.stop_day": h<"session.stop_day">(async (ctx, p) => ({ entityVersion: (await stopForToday(ctx, p)).workOrderVersion })),
|
||||
"session.segment": h<"session.segment">(async (ctx, p) => ({ entityVersion: (await switchSegment(ctx, p)).workOrderVersion })),
|
||||
"time.add_manual": h<"time.add_manual">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await addManualTimeEntry(ctx, p)).id) })),
|
||||
"time.propose_correction": h<"time.propose_correction">(async (ctx, p) => {
|
||||
await proposeTimeCorrection(ctx, p);
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
|
||||
/** Message of a rejected op: blockers as JSON, the auto-switch conflict with the other order (L12). */
|
||||
function errorMessage(err: ServiceError): string {
|
||||
if (err.code === "blocked") return JSON.stringify(err.details ?? []);
|
||||
if (err.message === "other_session_running") return `other_session_running:${JSON.stringify(err.details ?? {})}`;
|
||||
return err.message;
|
||||
}
|
||||
|
||||
class NotAvailable extends Error {}
|
||||
|
||||
/** Route a validated op to the field handler or the registered module of another lane. */
|
||||
@@ -205,12 +221,12 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
|
||||
// a transition conflict detected inside the service (race after the pre-check)
|
||||
const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected";
|
||||
const details = err.code === "blocked" ? { blockers: err.details } : {};
|
||||
const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code);
|
||||
const rec = await record(ctx, op, deviceId, status, { message: errorMessage(err), ...details }, err.code);
|
||||
if (rec === "duplicate") return { ...base, status: "duplicate" };
|
||||
if (status === "conflict") {
|
||||
await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } });
|
||||
}
|
||||
return { ...base, status, errorCode: err.code, message: err.code === "blocked" ? JSON.stringify(err.details ?? []) : err.message };
|
||||
return { ...base, status, errorCode: err.code, message: errorMessage(err) };
|
||||
}
|
||||
console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err);
|
||||
return { ...base, status: "rejected", errorCode: "internal", message: "internal error" };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared";
|
||||
@@ -61,8 +62,15 @@ export async function transitionBlockers(
|
||||
}
|
||||
|
||||
if (to === "released_for_billing" && wo.status === "in_review") {
|
||||
const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } });
|
||||
return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }];
|
||||
const [approved, pendingTime] = await Promise.all([
|
||||
ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }),
|
||||
// L12: manual entries / correction proposals waiting for approval block the billing release
|
||||
ctx.db.timeEntry.count({ where: { workSession: { workOrderId: wo.id }, OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] } }),
|
||||
]);
|
||||
const blockers: CompletionBlocker[] = [];
|
||||
if (approved === 0) blockers.push({ kind: "missing_field", field: "approved_completion_report" });
|
||||
if (pendingTime > 0) blockers.push({ kind: "missing_field", field: "pending_time_entries" });
|
||||
return blockers;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -2,8 +2,9 @@ import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/fi
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number };
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number };
|
||||
|
||||
/**
|
||||
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||
@@ -17,13 +18,14 @@ export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter
|
||||
const counts = await Promise.all(
|
||||
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||
);
|
||||
const [reportsToReview, syncConflicts] = await Promise.all([
|
||||
const [reportsToReview, syncConflicts, timeApprovals] = await Promise.all([
|
||||
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||
can(ctx, "work_order:write")
|
||||
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||
: Promise.resolve(0),
|
||||
countPendingTimeEntries(ctx), // L12 „Zeiten zur Freigabe" (0 without time:approve)
|
||||
]);
|
||||
|
||||
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||
return { ...tiles, reportsToReview, syncConflicts };
|
||||
return { ...tiles, reportsToReview, syncConflicts, timeApprovals };
|
||||
}
|
||||
|
||||
@@ -46,12 +46,14 @@ export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } },
|
||||
});
|
||||
return sessions.map((s) => {
|
||||
const minutes = s.entries
|
||||
.filter((e) => e.type !== "break")
|
||||
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0);
|
||||
return { ...s, workMinutes: Math.round(minutes) };
|
||||
});
|
||||
// L12: only approved entries count; pending manual entries are shown separately
|
||||
const minutesOf = (entries: typeof sessions[number]["entries"], status: "approved" | "pending") =>
|
||||
Math.round(
|
||||
entries
|
||||
.filter((e) => e.type !== "break" && e.approvalStatus === status)
|
||||
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0),
|
||||
);
|
||||
return sessions.map((s) => ({ ...s, workMinutes: minutesOf(s.entries, "approved"), pendingMinutes: minutesOf(s.entries, "pending") }));
|
||||
}
|
||||
|
||||
export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {
|
||||
|
||||
Reference in New Issue
Block a user