L4 Einsatz mobil: Mobile Shell und Einsatz-Oberfläche

- /m in eigene Route-Group (field)/m verschoben (emergency-Platzhalter mit),
  Zugriffsprüfungen aus (app)/layout.tsx nach server/app-access.ts extrahiert
  und von Backoffice- und Mobile-Shell gemeinsam genutzt
- Mobile Shell mit Bottom-Nav (Heute · Aufträge · Notdienst · Sync · Profil)
  und Online/Offline-Badge; Startseite rollenabhängig (Feldrollen → /m),
  Login-Default-Redirect auf /
- Heute, Auftragsliste mit Tabs, Auftragsdetail mit einer Primäraktion je
  Zustand, Unterseiten Fotos (Kamera, Kompression, Upload-Fortschritt),
  Notizen + Sprachaufnahme, Material mit Stepper, Checkliste, Zeiten mit
  Korrektur, Profil; Sync-Platzhalter für L7
- Client-Wrapper submitOp (lib/field/client-ops.ts), Upload mit Fortschritt,
  Bildkompression, Formatierung; Texte in messages/{de,en}/field.json

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:30:03 +02:00
co-authored by Claude Opus 5
parent 952e74cddd
commit 95f41b29f5
42 changed files with 3186 additions and 66 deletions
+9 -56
View File
@@ -1,10 +1,8 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { auth, signOut } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { isTokenStillValid } from "@/server/sessions";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
@@ -12,61 +10,16 @@ import { NavLink } from "@/components/nav-link";
import { TenantBrand } from "@/components/brand/tenant-brand";
import { TenantSwitcher } from "@/components/tenant-switcher";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
export default async function AppLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const session = await auth();
if (!session?.user) redirect("/login");
// Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
// Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
if (!session.user.tenantId) redirect("/select-tenant");
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
const account = await dbForTenant(session.user.tenantId).user.findUnique({
where: { id: session.user.id },
select: { status: true },
});
const identity = session.user.identityId
? await prisma.identity.findUnique({
where: { id: session.user.identityId },
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
})
: null;
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
await signOut({ redirectTo: "/login" });
}
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
return (
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
</form>
</div>
</main>
);
}
if (identity.mustChangePassword) redirect("/change-password");
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts).
if (!identity.mfaEnrolledAt) {
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
if (resolveMfaRequired(settings?.securityPolicy)) {
const passkeys = session.user.identityId
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
: 0;
if (passkeys === 0) redirect("/enroll-mfa");
}
}
// Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell
// (src/server/app-access.ts).
const access = await requireAppAccess();
if (access.kind === "inactive") return <AccountInactiveNotice />;
const { session, identity } = access;
const t = await getTranslations("nav");
const tc = await getTranslations("common");
-5
View File
@@ -1,5 +0,0 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="field" />;
}
@@ -0,0 +1,31 @@
import { getTranslations } from "next-intl/server";
import { ChecklistItemRow } from "@/components/field/checklist-item";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { loadOrder } from "../load";
/** `/m/orders/[id]/checklist` — checklist execution (Spec §12.4). */
export default async function ChecklistPage({ params }: { params: Promise<{ id: string }> }) {
const { order } = await loadOrder(params);
const t = await getTranslations("field");
const done = order.checklistItems.filter((i) => i.checked).length;
return (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("checklist.title")} />
<div className="space-y-3 px-4">
{order.checklistItems.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("checklist.empty")}</p>
) : (
<>
<p className="text-[15px] font-semibold">{t("detail.checklistProgress", { done, total: order.checklistItems.length })}</p>
<ul className="space-y-3">
{order.checklistItems.map((item) => (
<ChecklistItemRow key={item.id} workOrderId={order.id} item={item} />
))}
</ul>
</>
)}
</div>
</main>
);
}
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { getFieldOrderDetail } from "@/server/services/field/queries";
/** Shared loader of the order detail and its sub pages: scope violations render 404 (no existence leak). */
export async function loadOrder(params: Promise<{ id: string }>) {
const { id } = await params;
const ctx = await fieldPageContext();
try {
return { ctx, order: await getFieldOrderDetail(ctx, id) };
} catch (err) {
if (err instanceof ServiceError && err.code === "not_found") notFound();
throw err;
}
}
@@ -0,0 +1,53 @@
import { getTranslations } from "next-intl/server";
import { AdditionalMaterialForm, MaterialPlanItem } from "@/components/field/material-forms";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { card } from "@/components/field/ui";
import { loadOrder } from "../load";
/** `/m/orders/[id]/materials` — confirm planned material, record additional material (Spec §13.2/§13.3). */
export default async function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
const { order } = await loadOrder(params);
const t = await getTranslations("field");
const additional = order.materialUsages.filter((u) => !u.materialPlanId);
return (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("materials.title")} />
<div className="space-y-4 px-4">
{order.materialPlans.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("materials.noPlan")}</p>
) : (
<ul className="space-y-3">
{order.materialPlans.map((p) => {
const usage = order.materialUsages.find((u) => u.materialPlanId === p.id);
return (
<MaterialPlanItem
key={p.id}
workOrderId={order.id}
plan={{ id: p.id, name: p.name, articleNumber: p.articleNumber, plannedQuantity: Number(p.plannedQuantity), unit: p.unit, notes: p.notes }}
usage={usage ? { usageStatus: usage.usageStatus, quantity: Number(usage.actualQuantity), unit: usage.unit, deviationReason: usage.deviationReason } : null}
/>
);
})}
</ul>
)}
<h2 className="pt-2 text-[20px]">{t("materials.additionalTitle")}</h2>
{additional.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("materials.additionalEmpty")}</p>
) : (
<ul className="space-y-2">
{additional.map((u) => (
<li key={u.id} className={card}>
<p className="text-[16px] font-semibold">{u.name}</p>
<p className="text-[14px]">{t("materials.recorded", { quantity: String(Number(u.actualQuantity)).replace(".", ","), unit: u.unit })}</p>
{u.deviationReason && <p className="text-[14px] text-muted-foreground">{u.deviationReason}</p>}
</li>
))}
</ul>
)}
<AdditionalMaterialForm workOrderId={order.id} />
</div>
</main>
);
}
@@ -0,0 +1,60 @@
import { getLocale, getTranslations } from "next-intl/server";
import { fmtDateTime, fmtDuration } from "@/lib/field/format";
import { NoteForm } from "@/components/field/note-form";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { VoiceRecorder } from "@/components/field/voice-recorder";
import { card } from "@/components/field/ui";
import { loadOrder } from "../load";
/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */
export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
const { order } = await loadOrder(params);
const t = await getTranslations("field");
const locale = await getLocale();
return (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("notes.title")} />
<div className="space-y-4 px-4">
<section className={card}>
<NoteForm workOrderId={order.id} />
</section>
<section className={card}>
<h2 className="mb-3 text-[17px]">{t("voice.title")}</h2>
<VoiceRecorder workOrderId={order.id} />
</section>
{order.voiceNotes.length > 0 && (
<ul className="space-y-3">
{order.voiceNotes.map((v) => (
<li key={v.id} className={card}>
<p className="text-[13px] text-muted-foreground">
{t("voice.title")} · {fmtDateTime(v.recordedAt, locale)}
{v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
</p>
<audio controls preload="none" src={`/api/v1/field/documents/${v.documentId}`} className="mt-2 w-full" />
<p className="mt-2 text-[13px] font-semibold">{t(`voice.status.${v.transcriptionStatus}`)}</p>
{v.transcript && <p className="mt-1 whitespace-pre-line text-[15px]">{v.transcript}</p>}
</li>
))}
</ul>
)}
{order.notes.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("notes.empty")}</p>
) : (
<ul className="space-y-3">
{order.notes.map((n) => (
<li key={n.id} className={card}>
<p className="text-[13px] text-muted-foreground">
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
</p>
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
</li>
))}
</ul>
)}
</div>
</main>
);
}
@@ -0,0 +1,312 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import {
Building2,
ChevronLeft,
ChevronRight,
Clock,
FileText,
History,
Info,
KeyRound,
ListChecks,
Mail,
Navigation,
Package,
Phone,
ShieldAlert,
SquareParking,
StickyNote,
User,
Wrench,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { can } from "@/server/services/context";
import { customerDisplayName, formatAddress, mapsUrl } from "@/server/services/field/queries";
import { fmtDate, fmtDuration, fmtWindow, secondsBetween } from "@/lib/field/format";
import { STATUS_GROUP } from "@/lib/work-orders/status";
import { PrimaryAction } from "@/components/field/primary-action";
import { QuickPhotoButton } from "@/components/field/photo-capture";
import { StatusBadge } from "@/components/field/status-badge";
import { card, toneClasses } from "@/components/field/ui";
import { loadOrder } from "./load";
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
const head = (
<div className="flex min-h-12 items-center gap-2.5">
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="flex-1">
<h2 className="text-[17px]">{title}</h2>
{summary && <p className="text-[14px] text-muted-foreground">{summary}</p>}
</div>
{href && <ChevronRight className="size-5 text-muted-foreground" aria-hidden />}
</div>
);
return (
<section className={card}>
{href ? (
<Link href={href} className="block">
{head}
</Link>
) : (
head
)}
{children && <div className="mt-2 space-y-2 text-[15px]">{children}</div>}
</section>
);
}
function ContactButtons({ phone, email, labels }: { phone?: string | null; email?: string | null; labels: { call: string; mail: string } }) {
if (!phone && !email) return null;
const cls = "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary";
return (
<div className="flex gap-2">
{phone && (
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={cls}>
<Phone className="size-4.5" aria-hidden />
{labels.call}
</a>
)}
{email && (
<a href={`mailto:${email}`} className={cls}>
<Mail className="size-4.5" aria-hidden />
{labels.mail}
</a>
)}
</div>
);
}
function Notice({ icon: Icon, label, text, tone }: { icon: LucideIcon; label: string; text: string; tone: "warn" | "risk" | "info" }) {
const toneCls = {
warn: "bg-[color-mix(in_oklch,var(--warn)_12%,transparent)]",
risk: "bg-[color-mix(in_oklch,var(--risk)_10%,transparent)]",
info: "bg-[color-mix(in_oklch,var(--info)_10%,transparent)]",
}[tone];
return (
<div className={cn("flex items-start gap-2.5 rounded-xl px-3.5 py-3", toneCls)}>
<Icon className="mt-0.5 size-5 shrink-0" aria-hidden />
<p>
<span className="block text-[13px] font-bold uppercase tracking-wide">{label}</span>
<span className="whitespace-pre-line">{text}</span>
</p>
</div>
);
}
/** `/m/orders/[id]` — order detail with status, one primary action and all field sections (Spec §22, US-005/006). */
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { ctx, order } = await loadOrder(params);
const t = await getTranslations("field");
const locale = await getLocale();
const base = `/m/orders/${order.id}`;
const address = formatAddress(order.site) ?? formatAddress(order.customer);
const route = mapsUrl(address);
const contact = order.contact ?? order.site?.contact ?? null;
const blockers = order.blockers
.filter((b) => !(b.kind === "running_session" && b.userId === ctx.userId))
.map((b) => t(`blocker.${b.kind}`, { label: "label" in b ? b.label : "field" in b ? b.field : "" }));
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;
const myWorkSeconds = order.workSessions
.filter((s) => s.userId === ctx.userId)
.flatMap((s) => s.entries)
.filter((e) => e.type === "work")
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
const editable = can(ctx, "field:execute");
const quick = [
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
{ href: `${base}/materials`, label: t("detail.quick.material"), icon: Package },
{ href: `${base}/checklist`, label: t("detail.quick.checklist"), icon: ListChecks },
{ href: `${base}/time`, label: t("detail.quick.time"), icon: Clock },
];
return (
<main className="space-y-3 p-4">
<Link href="/m/orders" 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("detail.back")}
</Link>
<section className={cn(card, "border-l-4", toneClasses(STATUS_GROUP[order.status]).edge)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[14px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status} large />
</div>
<h1 className="mt-2 text-[22px] leading-snug">{order.title}</h1>
<p className="text-[16px] font-semibold">{customerDisplayName(order.customer)}</p>
<p className="mt-2 flex items-center gap-2 text-[15px]">
<Clock className="size-4.5 text-muted-foreground" aria-hidden />
{fmtWindow(order.plannedStart, order.plannedEnd, locale) ?? t("card.noDate")}
</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} />
</div>
)}
</section>
{editable && (
<nav aria-label={t("detail.order")} className="grid grid-cols-5 gap-2">
<QuickPhotoButton workOrderId={order.id} label={t("detail.quick.photo")} />
{quick.map((q) => (
<Link key={q.href} href={q.href} className="flex min-h-18 flex-col items-center justify-center gap-1 rounded-xl border bg-card px-1 text-[13px] font-semibold text-primary">
<q.icon className="size-6" aria-hidden />
{q.label}
</Link>
))}
</nav>
)}
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
{order.site && (
<Section title={t("detail.site")} icon={Building2}>
<p className="font-semibold">{order.site.name}</p>
{address && <p>{address}</p>}
{route && (
<a href={route} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary">
<Navigation className="size-4.5" aria-hidden />
{t("detail.route")}
</a>
)}
{order.site.accessNotes && <Notice icon={KeyRound} label={t("detail.access")} text={order.site.accessNotes} tone="warn" />}
{order.site.parkingNotes && <Notice icon={SquareParking} label={t("detail.parking")} text={order.site.parkingNotes} tone="info" />}
{order.site.safetyNotes && <Notice icon={ShieldAlert} label={t("detail.safety")} text={order.site.safetyNotes} tone="risk" />}
{order.site.technicalNotes && <Notice icon={Wrench} label={t("detail.technical")} text={order.site.technicalNotes} tone="info" />}
{order.site.onSiteContact && (
<p>
<span className="text-muted-foreground">{t("detail.onSiteContact")}: </span>
{order.site.onSiteContact}
</p>
)}
<ContactButtons phone={order.site.phone} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
</Section>
)}
<Section title={t("detail.customer")} icon={User}>
<p className="font-semibold">{customerDisplayName(order.customer)}</p>
{!order.site && address && <p>{address}</p>}
<ContactButtons phone={order.customer.mobile ?? order.customer.phone} email={order.customer.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
{contact && (
<div className="mt-2 border-t pt-2">
<p className="text-[13px] text-muted-foreground">{t("detail.contact")}</p>
<p className="font-semibold">
{contact.name}
{contact.role && <span className="font-normal text-muted-foreground"> · {contact.role}</span>}
</p>
<ContactButtons phone={contact.mobile ?? contact.phone} email={contact.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
</div>
)}
</Section>
<Section title={t("detail.order")} icon={FileText}>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{order.orderType && (
<>
<dt className="text-muted-foreground">{t("detail.orderType")}</dt>
<dd>{order.orderType.name}</dd>
</>
)}
{order.externalOrderNumber && (
<>
<dt className="text-muted-foreground">{t("detail.externalNumber")}</dt>
<dd>{order.externalOrderNumber}</dd>
</>
)}
{order.team && (
<>
<dt className="text-muted-foreground">{t("detail.team")}</dt>
<dd>{order.team.name}</dd>
</>
)}
</dl>
{order.description && <p className="whitespace-pre-line">{order.description}</p>}
{order.scope && (
<div>
<p className="text-[13px] font-semibold text-muted-foreground">{t("detail.scope")}</p>
<p className="whitespace-pre-line">{order.scope}</p>
</div>
)}
</Section>
<Section title={t("detail.documents")} icon={FileText}>
{order.documents.length === 0 ? (
<p className="text-muted-foreground">{t("detail.noDocuments")}</p>
) : (
<ul className="divide-y">
{order.documents.map((d) => (
<li key={d.id}>
<a href={`/api/v1/field/documents/${d.id}`} target="_blank" rel="noopener" className="flex min-h-12 items-center gap-2 py-1.5">
<FileText className="size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">
{d.title ?? d.fileName}
{d.source === "site" && <span className="ml-1.5 text-[12.5px] text-muted-foreground">({t("detail.siteDocument")})</span>}
</span>
<span className="text-[13px] font-semibold text-primary">{t("detail.openDocument")}</span>
</a>
</li>
))}
</ul>
)}
</Section>
{order.site && (
<Section title={t("detail.history")} icon={History}>
{order.siteHistory.length === 0 ? (
<p className="text-muted-foreground">{t("detail.noHistory")}</p>
) : (
<ul className="divide-y">
{order.siteHistory.map((h) => (
<li key={h.reportId} className="flex min-h-12 items-center gap-2 py-1.5">
<span className="flex-1">
<span className="block font-semibold">
{t(`detail.reportType.${h.reportType}`)} · {fmtDate(h.reportDate, locale)}
</span>
<span className="text-[13px] text-muted-foreground">
{h.workOrderNumber} · {h.workOrderTitle}
</span>
</span>
{h.pdfDocumentId && (
<a href={`/api/v1/field/documents/${h.pdfDocumentId}`} target="_blank" rel="noopener" className="inline-flex min-h-12 items-center px-2 text-[13px] font-semibold text-primary">
{t("detail.openDocument")}
</a>
)}
</li>
))}
</ul>
)}
</Section>
)}
<Section
title={t("detail.checklist")}
icon={ListChecks}
href={`${base}/checklist`}
summary={order.checklistItems.length ? t("detail.checklistProgress", { done: checklistDone, total: order.checklistItems.length }) : t("checklist.empty")}
/>
<Section
title={t("detail.materials")}
icon={Package}
href={`${base}/materials`}
summary={order.materialPlans.length ? t("detail.materialsProgress", { done: plannedDone, total: order.materialPlans.length }) : t("detail.materialsNone")}
/>
<Section
title={t("detail.photos")}
icon={FileText}
href={`${base}/photos`}
summary={
order.photoRequirements.length
? t("detail.photosProgress", { count: order.photos.length, done: requirementsDone, total: order.photoRequirements.length })
: t("detail.photosCount", { count: order.photos.length })
}
/>
<Section title={t("detail.notes")} icon={StickyNote} href={`${base}/notes`} summary={t("detail.notesCount", { count: order.notes.length + order.voiceNotes.length })} />
<Section title={t("detail.time")} icon={Clock} href={`${base}/time`} summary={t("detail.myTime", { duration: fmtDuration(myWorkSeconds) })} />
</main>
);
}
@@ -0,0 +1,71 @@
import { getLocale, getTranslations } from "next-intl/server";
import { CircleCheck, CircleDot } from "lucide-react";
import { fmtDateTime } from "@/lib/field/format";
import { PhotoCapture } from "@/components/field/photo-capture";
import { SubPageHeader } from "@/components/field/sub-page-header";
import { card } from "@/components/field/ui";
import { loadOrder } from "../load";
/** `/m/orders/[id]/photos` — capture (camera/gallery) + gallery + required photos (Spec §14). */
export default async function PhotosPage({ params }: { params: Promise<{ id: string }> }) {
const { order } = await loadOrder(params);
const t = await getTranslations("field");
const locale = await getLocale();
const requirementLabel = new Map(order.photoRequirements.map((r) => [r.id, r.label]));
return (
<main className="space-y-4 pb-4">
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("photos.title")} />
<div className="space-y-4 px-4">
<section className={card}>
<PhotoCapture
workOrderId={order.id}
requirements={order.photoRequirements.map((r) => ({ id: r.id, label: r.label }))}
checklistItems={order.checklistItems.map((c) => ({ id: c.id, label: c.label }))}
/>
</section>
{order.photoRequirements.length > 0 && (
<section className={card}>
<h2 className="mb-2 text-[17px]">{t("photos.requirements")}</h2>
<ul className="space-y-1.5">
{order.photoRequirements.map((r) => {
const done = r._count.photos > 0;
return (
<li key={r.id} className="flex min-h-10 items-center gap-2 text-[15px]">
{done ? <CircleCheck className="size-5 text-[var(--ok)]" aria-hidden /> : <CircleDot className="size-5 text-[var(--warn)]" aria-hidden />}
<span className="flex-1">{r.label}</span>
<span className="text-[13px] font-semibold">{done ? t("photos.done") : t("photos.missing")}</span>
</li>
);
})}
</ul>
</section>
)}
{order.photos.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("photos.empty")}</p>
) : (
<ul className="grid grid-cols-2 gap-3">
{order.photos.map((p) => (
<li key={p.id} className="overflow-hidden rounded-xl border bg-card">
<a href={`/api/v1/field/documents/${p.documentId}`} target="_blank" rel="noopener">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/api/v1/field/documents/${p.documentId}?variant=preview`} alt={p.comment ?? t("photos.preview")} loading="lazy" className="aspect-square w-full bg-muted object-cover" />
</a>
<div className="space-y-0.5 p-2 text-[12.5px]">
<p className="font-semibold">
{p.phase ? t(`photos.phase.${p.phase}`) : "—"}
{p.photoRequirementId && requirementLabel.get(p.photoRequirementId) ? ` · ${requirementLabel.get(p.photoRequirementId)}` : ""}
</p>
{p.comment && <p className="line-clamp-2">{p.comment}</p>}
<p className="text-muted-foreground">{fmtDateTime(p.takenAt, locale)}</p>
</div>
</li>
))}
</ul>
)}
</div>
</main>
);
}
@@ -0,0 +1,55 @@
import { getLocale, getTranslations } from "next-intl/server";
import { can } from "@/server/services/context";
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 { loadOrder } from "../load";
/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */
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);
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>
{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.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>
))}
</ul>
</section>
))}
</div>
</main>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { cn } from "@/lib/utils";
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";
import { chip } from "@/components/field/ui";
/** `/m/orders` — tabs upcoming · running · to complete · past (Spec §11.2). */
export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
const ctx = await fieldPageContext();
const t = await getTranslations("field");
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
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);
return (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("orders.title")}</h1>
<nav aria-label={t("orders.title")} className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2">
{ORDER_TABS.map((key) => (
<li key={key} className="shrink-0">
<Link href={`/m/orders?tab=${key}`} aria-current={tab === key ? "page" : undefined} className={cn(chip(tab === key), "px-4")}>
{t(`orders.tabs.${key}`)}
</Link>
</li>
))}
</ul>
</nav>
{orders.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("orders.empty")}</p>
) : (
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
</li>
))}
</ul>
)}
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ChevronRight } from "lucide-react";
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
import { listTodayOrders } from "@/server/services/field/queries";
import { OrderCard } from "@/components/field/order-card";
import { btnSecondary } from "@/components/field/ui";
/** `/m` — Heute: today's, running and paused orders (Spec §11.2). */
export default async function TodayPage() {
const ctx = await fieldPageContext();
const t = await getTranslations("field");
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
return (
<main className="space-y-4 p-4">
<div>
<p className="text-[14px] text-muted-foreground">{t("today.greeting", { name: me?.name ?? "" })}</p>
<h1 className="text-[26px]">{t("today.title")}</h1>
<p className="text-[15px] font-semibold">{t("today.count", { count: orders.length })}</p>
</div>
{orders.length === 0 ? (
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("today.empty")}</p>
) : (
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id}>
<OrderCard order={o} />
</li>
))}
</ul>
)}
<Link href="/m/orders" className={btnSecondary}>
{t("today.allOrders")}
<ChevronRight className="size-5" aria-hidden />
</Link>
</main>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { LogOut } from "lucide-react";
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 { fieldPageContext } from "@/server/services/field/page-context";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { btnSecondary, card } from "@/components/field/ui";
/** `/m/profile` — name, team, company, language, sign out. */
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 [me, teams, identity] = await Promise.all([
ctx.db.user.findFirst({
where: { id: ctx.userId },
select: { name: true, email: true, tenant: { select: { name: true } }, userRoles: { select: { role: { select: { name: true } } } } },
}),
teamIds.length ? ctx.db.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true }, orderBy: { name: "asc" } }) : Promise.resolve([]),
session?.user?.identityId ? prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { uiLocale: true } }) : Promise.resolve(null),
]);
async function logout() {
"use server";
await signOut({ redirectTo: "/login" });
}
const row = "flex flex-col gap-0.5 py-2.5";
return (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<section className={card}>
<p className="text-[20px] font-semibold">{me?.name}</p>
<p className="text-[14px] text-muted-foreground">{me?.email}</p>
<dl className="mt-2 divide-y text-[15px]">
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("tenant")}</dt>
<dd>{me?.tenant.name}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("teams")}</dt>
<dd>{teams.length ? teams.map((tm) => tm.name).join(", ") : t("noTeam")}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("roles")}</dt>
<dd>{me?.userRoles.map((r) => r.role.name).join(", ")}</dd>
</div>
<div className={row}>
<dt className="text-[13px] text-muted-foreground">{t("language")}</dt>
<dd className="pt-1">
<UiLocaleSwitcher current={identity?.uiLocale ?? "de"} />
</dd>
</div>
</dl>
</section>
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
<Link href="/dashboard" className={btnSecondary}>
{t("backoffice")}
</Link>
)}
<form action={logout}>
<button type="submit" className={btnSecondary}>
<LogOut className="size-5" aria-hidden />
{t("logout")}
</button>
</form>
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requireAppAccess } from "@/server/app-access";
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";
/**
* 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".
*/
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");
return (
<div className="flex min-h-screen flex-1 flex-col bg-background">
<header className="sticky top-0 z-20 flex h-14 items-center justify-between gap-3 border-b bg-card px-4 pt-[env(safe-area-inset-top)]">
<Link href="/m" aria-label={t("today")} className="flex min-h-12 items-center">
<CraftviaLogo variant="horizontal" height={26} />
</Link>
<div className="flex items-center gap-2">
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
<OnlineBadge />
</div>
</header>
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div>
<BottomNav />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { getTranslations } from "next-intl/server";
import { OnlineBadge } from "@/components/field/online-badge";
import { card } from "@/components/field/ui";
/**
* PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page
* with the outbox status, errors and conflicts. Until then it shows the connection state and that
* ops are sent immediately (src/lib/field/client-ops.ts).
*/
export default async function SyncPlaceholderPage() {
const t = await getTranslations("field.sync");
return (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<section className={`${card} space-y-3`}>
<p className="text-[13px] font-semibold text-muted-foreground">{t("status")}</p>
<OnlineBadge large />
<p className="text-[15px]">{t("immediate")}</p>
<p className="text-[14px] text-muted-foreground">{t("offlineHint")}</p>
</section>
</main>
);
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default async function LoginMfaPage({
// MFA erfüllt → pending entwerten und aus dem Ticket die Session prägen.
jar.delete(MFA_PENDING_COOKIE);
try {
await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target });
await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
} catch (err) {
if (err instanceof AuthError) redirect("/login?error=1");
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchpropagieren
+2 -2
View File
@@ -30,7 +30,7 @@ export default async function LoginPage({
// Already signed in → straight to the app
const session = await auth();
if (session?.user) redirect("/dashboard");
if (session?.user) redirect("/"); // rollenabhängige Startseite (src/app/page.tsx)
// WS5: Two-Step-Login, Schritt 1 (E-Mail + Passwort). Bei aktiver MFA wird ein
// kurzlebiger, einzweckiger `mfa_pending`-Cookie gesetzt und auf /login/mfa geleitet —
@@ -65,7 +65,7 @@ export default async function LoginPage({
}
try {
await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target });
await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
} catch (err) {
if (err instanceof AuthError) {
redirect(`/login?error=1${target !== "/dashboard" ? `&callbackUrl=${encodeURIComponent(target)}` : ""}`);
+5 -2
View File
@@ -1,5 +1,8 @@
import { redirect } from "next/navigation";
import { auth } from "@/server/auth";
import { landingPath } from "@/server/app-access";
export default function Home() {
redirect("/dashboard");
export default async function Home() {
// Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5)
redirect(landingPath(await auth()));
}