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
+6 -4
View File
@@ -12,6 +12,7 @@ import {
Receipt,
RefreshCw,
Siren,
Timer,
Wrench,
type LucideIcon,
} from "lucide-react";
@@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [
const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
@@ -39,6 +40,7 @@ const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[
{ key: "emergency_new", icon: Siren, tone: "var(--risk)" },
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" },
{ key: "time_approvals", icon: Timer, tone: "var(--warn)" },
];
/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */
@@ -142,9 +144,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
<li key={key}>
<Link
@@ -0,0 +1,116 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { ActionForm } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { BulkApproveForm, SelectAllBox } from "@/components/work-orders/time-approval-forms";
import { Empty, inputCls } from "@/components/work-orders/ui";
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
import { approveTimeEntriesAction, approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
import { listPendingTimeEntries } from "@/server/services/field/time-entries";
const BULK_FORM = "time-approvals-bulk";
/** L12 `/work-orders/time-approvals` — open manual entries and correction proposals (`time:approve`), single and bulk approval. */
export default async function TimeApprovalsPage() {
const { ctx, locale, tz, can } = await pageContext();
if (!can("time:approve")) redirect("/work-orders");
const t = await getTranslations("workOrders");
const tf = await getTranslations("field");
const items = await listPendingTimeEntries(ctx);
const time = (d: Date) => new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { hour: "2-digit", minute: "2-digit", timeZone: tz }).format(d);
const span = (s: Date, e: Date | null) => `${time(s)}–${e ? time(e) : "…"}`;
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("detail.back")}
</Link>
<PageHead crumb={t("timeApprovals.crumb")} title={t("timeApprovals.title")} sub={t("timeApprovals.sub")} />
{items.length === 0 ? (
<Empty>{t("timeApprovals.empty")}</Empty>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold">{t("timeApprovals.count", { count: items.length })}</p>
<BulkApproveForm id={BULK_FORM} action={approveTimeEntriesAction} label={t("timeApprovals.approveSelected")} successText={t("timeApprovals.approved")} />
</div>
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[980px] text-sm">
<thead className="border-b text-left text-xs text-muted-foreground">
<tr>
<th className="p-3">
<SelectAllBox formId={BULK_FORM} label={t("timeApprovals.selectAll")} />
</th>
<th className="p-3">{t("timeApprovals.employee")}</th>
<th className="p-3">{t("timeApprovals.order")}</th>
<th className="p-3">{t("timeApprovals.date")}</th>
<th className="p-3">{t("timeApprovals.range")}</th>
<th className="p-3">{t("timeApprovals.duration")}</th>
<th className="p-3">{t("timeApprovals.type")}</th>
<th className="p-3">{t("timeApprovals.reason")}</th>
<th className="p-3" />
</tr>
</thead>
<tbody>
{items.map((i) => (
<tr key={i.id} className="border-b align-top last:border-0">
<td className="p-3">
<input type="checkbox" name="ids" value={i.id} form={BULK_FORM} aria-label={`${t("timeApprovals.select")}: ${i.userName} ${i.number}`} className="size-5" />
</td>
<td className="p-3">
<p className="font-semibold">{i.userName}</p>
<p className="text-xs text-muted-foreground">{i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}</p>
</td>
<td className="p-3">
<Link href={`/work-orders/${i.workOrderId}?tab=times`} className="font-semibold text-[var(--primary)] hover:underline">
{i.number}
</Link>
<p className="text-xs text-muted-foreground">{i.title}</p>
</td>
<td className="p-3 whitespace-nowrap">{formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)}</td>
<td className="p-3 whitespace-nowrap">
{i.proposed ? (
t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) })
) : (
span(i.startedAt, i.endedAt)
)}
</td>
<td className="p-3 whitespace-nowrap">
{i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`}
</td>
<td className="p-3">
{i.proposed && i.proposed.type !== i.type
? t("timeApprovals.oldNew", { old: tf(`time.type.${i.type}`), new: tf(`time.type.${i.proposed.type}`) })
: tf(`time.type.${i.proposed?.type ?? i.type}`)}
</td>
<td className="p-3">
<p>{i.reason}</p>
{i.note && <p className="text-xs text-muted-foreground">{i.note}</p>}
<p className="text-xs text-muted-foreground">{formatDateTime(i.requestedAt, locale, tz)}</p>
</td>
<td className="space-y-2 p-3">
<ActionForm action={approveTimeEntryAction} submitLabel={t("timeApprovals.approve")} variant="primary" successText={t("timeApprovals.approved")} footerClassName="mt-0">
<input type="hidden" name="timeEntryId" value={i.id} />
<input type="hidden" name="workOrderId" value={i.workOrderId} />
</ActionForm>
<ActionForm action={rejectTimeEntryAction} submitLabel={t("timeApprovals.reject")} variant="outline" successText={t("timeApprovals.rejected")} footerClassName="mt-2">
<input type="hidden" name="timeEntryId" value={i.id} />
<input type="hidden" name="workOrderId" value={i.workOrderId} />
<input name="reason" required minLength={3} maxLength={500} placeholder={t("timeApprovals.rejectReason")} aria-label={t("timeApprovals.rejectReason")} className={inputCls} />
</ActionForm>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
);
}
@@ -0,0 +1,75 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { can } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { listPendingTimeEntries } from "@/server/services/field/time-entries";
import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format";
import { card } from "@/components/field/ui";
import { ApprovalActions } from "@/components/field/approval-actions";
/** `/m/approvals` — team lead mobile (L12, `time:approve`): open manual entries and correction proposals of the team. */
export default async function ApprovalsPage() {
const ctx = await fieldPageContext();
const t = await getTranslations("field");
const locale = await getLocale();
if (!can(ctx, "time:approve")) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const items = await listPendingTimeEntries(ctx);
const range = (s: Date, e: Date | null) => `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${e ? fmtTime(e, locale) : t("myTime.running")}`;
return (
<main className="space-y-4 p-4">
<div>
<h1 className="text-[26px]">{t("approvals.title")}</h1>
<p className="text-[14px] text-muted-foreground">{t("approvals.sub")}</p>
<p className="text-[15px] font-semibold">{t("approvals.count", { count: items.length })}</p>
</div>
{items.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("approvals.empty")}</p>
) : (
<ul className="space-y-3">
{items.map((i) => (
<li key={i.id} className={`${card} border-l-4 border-l-[var(--warn)]`}>
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">{i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}</p>
<p className="text-[18px] font-semibold">{i.userName}</p>
<Link href={`/m/orders/${i.workOrderId}`} className="block text-[15px] text-primary underline-offset-4 hover:underline">
{i.number} · {i.title}
</Link>
<dl className="mt-2 space-y-1 text-[15px]">
<div>
<dt className="sr-only">{i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}</dt>
<dd className={i.proposed ? "text-muted-foreground line-through" : "font-semibold"}>
{i.proposed && <span className="no-underline">{t("approvals.old")}: </span>}
{t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)}
</dd>
</div>
{i.proposed && (
<div>
<dt className="sr-only">{t("approvals.new")}</dt>
<dd className="font-semibold">
{t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)}
</dd>
</div>
)}
{i.reason && (
<div>
<dt className="inline text-muted-foreground">{t("approvals.reason")}: </dt>
<dd className="inline">{i.reason}</dd>
</div>
)}
{i.note && (
<div>
<dt className="inline text-muted-foreground">{t("approvals.note")}: </dt>
<dd className="inline">{i.note}</dd>
</div>
)}
</dl>
<div className="mt-3">
<ApprovalActions timeEntryId={i.id} />
</div>
</li>
))}
</ul>
)}
</main>
);
}
@@ -112,12 +112,15 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length;
const checklistDone = order.checklistItems.filter((i) => i.checked).length;
const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
// L12: only approved entries count
const myWorkSeconds = order.workSessions
.filter((s) => s.userId === ctx.userId)
.flatMap((s) => s.entries)
.filter((e) => e.type === "work")
.filter((e) => e.type === "work" && e.approvalStatus === "approved")
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const editable = can(ctx, "field:execute");
const ownActive = order.workSessions.find((s) => s.userId === ctx.userId && !s.manual && s.status !== "ended");
const segmentType = ownActive?.entries.find((e) => !e.endedAt)?.type ?? null;
const quick = [
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
@@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
</p>
{editable && (
<div className="mt-4">
<PrimaryAction workOrderId={order.id} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
<PrimaryAction workOrderId={order.id} number={order.number} segmentType={segmentType} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
</div>
)}
</section>
@@ -1,7 +1,13 @@
import { ReportScreen } from "@/components/reports/mobile/report-screen";
import { PendingTimeNotice } from "@/components/field/pending-time-notice";
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. L12: warning about unapproved time (not blocking). */
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
const [{ id }, sp] = await Promise.all([params, searchParams]);
return <ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />;
return (
<>
<PendingTimeNotice workOrderId={id} />
<ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />
</>
);
}
@@ -1,51 +1,75 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { can } from "@/server/services/context";
import { parsePendingChange } from "@/server/services/field/time-entries";
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { TimeCorrectionForm } from "@/components/field/time-correction-form";
import { card } from "@/components/field/ui";
import { CorrectionProposalForm } from "@/components/field/correction-proposal-form";
import { TimeEntryBadges } from "@/components/field/time-entry-badges";
import { btnSecondary, card } from "@/components/field/ui";
import { loadOrder } from "../load";
/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */
/**
* `/m/orders/[id]/time` — sessions and time segments (Spec §12.2).
* L12: only approved entries count; own entries → „Korrektur vorschlagen" (approval), entries of
* team members → direct correction with `field:correct_time`; badges manual/pending/rejected.
*/
export default async function TimePage({ params }: { params: Promise<{ id: string }> }) {
const { ctx, order } = await loadOrder(params);
const t = await getTranslations("field");
const locale = await getLocale();
const canCorrect = can(ctx, "field:correct_time");
const totalWork = order.workSessions
.flatMap((s) => s.entries)
.filter((e) => e.type === "work")
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const canPropose = can(ctx, "field:record_own_time") || canCorrect;
const entries = order.workSessions.flatMap((s) => s.entries);
const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const pendingWork = entries.filter((e) => e.type !== "break" && e.approvalStatus === "pending").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
return (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
<div className="space-y-3 px-4">
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
{pendingWork > 0 && <p className="text-[15px] font-semibold text-[var(--warn)]">{t("time.totalPending", { duration: fmtDuration(pendingWork) })}</p>}
{canPropose && (
<Link href={`/m/time/new?order=${order.id}`} className={btnSecondary}>
<Plus className="size-5" aria-hidden />
{t("myTime.add")}
</Link>
)}
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
{order.workSessions.map((s) => (
<section key={s.id} className={card}>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-[17px]">{s.user.name}</h2>
<span className="text-[13px] font-semibold">
{t(`time.session.${s.status}`)}
{s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)}
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
</span>
</div>
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
<ul className="mt-2 divide-y">
{s.entries.map((e) => (
<li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
<span>
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
</span>
</div>
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
{canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
</li>
))}
{s.entries.map((e) => {
const own = e.userId === ctx.userId;
const pendingChange = parsePendingChange(e.pendingChange);
return (
<li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
<span>
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
</span>
</div>
<TimeEntryBadges entry={{ source: e.source, approvalStatus: e.approvalStatus, rejectionReason: e.rejectionReason, corrected: e.corrected, pendingChange }} />
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
{own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && (
<CorrectionProposalForm workOrderId={order.id} entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt.toISOString() }} />
)}
{!own && canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
</li>
);
})}
</ul>
</section>
))}
+3 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { cn } from "@/lib/utils";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card";
@@ -15,6 +16,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
const { tab: rawTab } = await searchParams;
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
const orders = await listFieldOrders(ctx, tab);
const canExecute = can(ctx, "field:execute");
return (
<main className="space-y-4 p-4">
@@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
<OrderCard order={o} canExecute={canExecute} />
</li>
))}
</ul>
+3 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight } from "lucide-react";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listTodayOrders } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card";
@@ -12,6 +13,7 @@ export default async function TodayPage() {
const t = await getTranslations("field");
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const canExecute = can(ctx, "field:execute");
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
return (
@@ -27,7 +29,7 @@ export default async function TodayPage() {
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
<OrderCard order={o} canExecute={canExecute} />
</li>
))}
</ul>
+13 -1
View File
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth";
import { prisma } from "@/server/db";
import { can } from "@/server/services/context";
import { activeTeamIds } from "@/server/services/work-orders/visibility";
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { fieldPageContext } from "@/server/services/field/page-context";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { btnSecondary, card } from "@/components/field/ui";
@@ -15,7 +16,7 @@ export default async function ProfilePage() {
const ctx = await fieldPageContext();
const t = await getTranslations("field.profile");
const session = await auth();
const teamIds = await activeTeamIds(ctx);
const [teamIds, approvals] = await Promise.all([activeTeamIds(ctx), countPendingTimeEntries(ctx)]);
const [me, teams, identity] = await Promise.all([
ctx.db.user.findFirst({
where: { id: ctx.userId },
@@ -58,6 +59,17 @@ export default async function ProfilePage() {
</div>
</dl>
</section>
{can(ctx, "field:execute") && (
<Link href="/m/time" className={btnSecondary}>
{t("myTime")}
</Link>
)}
{can(ctx, "time:approve") && (
<Link href="/m/approvals" className={btnSecondary}>
{t("approvals")}
{approvals > 0 && <span className="rounded-full bg-cta px-2 text-[13px] font-bold text-cta-foreground">{approvals}</span>}
</Link>
)}
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
<Link href="/dashboard" className={btnSecondary}>
{t("backoffice")}
@@ -0,0 +1,35 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronLeft } from "lucide-react";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { getMyTimeOverview, listRecordableOrders, listRecordableUsers } from "@/server/services/field/time-entries";
import { ManualTimeForm } from "@/components/field/manual-time-form";
/** `/m/time/new` — „Zeit nachtragen" (L12). */
export default async function NewTimePage({ searchParams }: { searchParams: Promise<{ date?: string; order?: string }> }) {
const ctx = await fieldPageContext();
const t = await getTranslations("field");
const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time");
if (!canUseFieldApp(ctx) || !canRecord) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const sp = await searchParams;
const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]);
return (
<main className="space-y-4 p-4">
<Link href={`/m/time?date=${overview.date}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("myTime.title")}
</Link>
<h1 className="text-[26px]">{t("myTime.form.title")}</h1>
<ManualTimeForm
orders={orders}
users={users}
days={overview.days}
defaultDate={overview.date}
defaultWorkOrderId={orders.some((o) => o.id === sp.order) ? sp.order! : null}
/>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { ChevronRight, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { can } from "@/server/services/context";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { getMyTimeOverview } from "@/server/services/field/time-entries";
import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format";
import { btnPrimary, card, chip } from "@/components/field/ui";
import { CorrectionProposalForm } from "@/components/field/correction-proposal-form";
import { LocalPendingTimes } from "@/components/field/local-pending-times";
import { TimeEntryBadges } from "@/components/field/time-entry-badges";
/** `/m/time` — „Meine Zeiten" (L12): own entries of a day across all orders, approved vs. pending totals. */
export default async function MyTimePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) {
const ctx = await fieldPageContext();
const t = await getTranslations("field");
const locale = await getLocale();
if (!canUseFieldApp(ctx) || !can(ctx, "field:execute")) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const { date } = await searchParams;
const overview = await getMyTimeOverview(ctx, { date });
const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time");
const dayLabel = (key: string, i: number) => (i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${key}T12:00:00Z`, locale));
return (
<main className="space-y-4 p-4">
<div>
<h1 className="text-[26px]">{t("myTime.title")}</h1>
<p className="text-[14px] text-muted-foreground">{t("myTime.sub")}</p>
</div>
<nav aria-label={t("myTime.days")} className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2">
{overview.days.map((key, i) => (
<li key={key} className="shrink-0">
<Link href={`/m/time?date=${key}`} aria-current={overview.date === key ? "page" : undefined} className={cn(chip(overview.date === key), "px-4")}>
{dayLabel(key, i)}
</Link>
</li>
))}
</ul>
</nav>
<section className={cn(card, "grid grid-cols-2 gap-3")} aria-label={t("myTime.dayTotal")}>
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.approved")}</p>
<p className="font-heading text-[22px] font-bold tabular-nums">{fmtDuration(overview.approvedSeconds)}</p>
</div>
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.pending")}</p>
<p className={cn("font-heading text-[22px] font-bold tabular-nums", overview.pendingSeconds > 0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}</p>
</div>
</section>
{canRecord && (
<Link href={`/m/time/new?date=${overview.date}`} className={btnPrimary}>
<Plus className="size-5" aria-hidden />
{t("myTime.add")}
</Link>
)}
<LocalPendingTimes />
{overview.groups.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.empty")}</p>
) : (
overview.groups.map((g) => (
<section key={g.workOrderId} className={card}>
<Link href={`/m/orders/${g.workOrderId}`} className="flex min-h-12 items-center gap-2">
<span className="flex-1">
<span className="block font-mono text-[13px] font-semibold text-muted-foreground">{g.number}</span>
<span className="block text-[17px] font-semibold leading-snug">{g.title}</span>
</span>
<ChevronRight className="size-5 text-muted-foreground" aria-hidden />
</Link>
<p className="text-[14px]">
{t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })}
{g.pendingSeconds > 0 && <span className="font-semibold text-[var(--warn)]"> · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}</span>}
</p>
<ul className="mt-2 divide-y">
{g.entries.map((e) => (
<li key={e.id} className="space-y-2 py-2.5">
<div className="flex items-baseline justify-between gap-2 text-[15px]">
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
<span className="tabular-nums">
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)}
</span>
</div>
<TimeEntryBadges entry={e} />
{canRecord && e.editable && (
<CorrectionProposalForm
workOrderId={g.workOrderId}
entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }}
/>
)}
</li>
))}
</ul>
</section>
))
)}
</main>
);
}
+28 -3
View File
@@ -1,21 +1,45 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requireAppAccess } from "@/server/app-access";
import { can } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
try {
const ctx = await fieldPageContext();
const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]);
return {
clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null,
approvals,
};
} catch {
// module "field" disabled or no field permissions: shell without clock/badge
return { clock: null, approvals: 0 };
}
}
/**
* Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice
* (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge.
* Module gates live one level below: (core) → "field", emergency → "emergency".
* L12: running clock bar above the bottom navigation on every /m page.
*/
export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) {
const access = await requireAppAccess();
if (access.kind === "inactive") return <AccountInactiveNotice />;
const t = await getTranslations("field.nav");
const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]);
return (
<div className="flex min-h-screen flex-1 flex-col bg-background">
@@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge />
</div>
</header>
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div>
<BottomNav />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} />
</div>
);
}