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