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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user