+
+ {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
+
+ {t("backoffice")}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(app)/m/emergency/layout.tsx b/src/app/(field)/m/emergency/layout.tsx
similarity index 100%
rename from src/app/(app)/m/emergency/layout.tsx
rename to src/app/(field)/m/emergency/layout.tsx
diff --git a/src/app/(app)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx
similarity index 100%
rename from src/app/(app)/m/emergency/page.tsx
rename to src/app/(field)/m/emergency/page.tsx
diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx
new file mode 100644
index 0000000..f42b53d
--- /dev/null
+++ b/src/app/(field)/m/layout.tsx
@@ -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 ;
+ const t = await getTranslations("field.nav");
+
+ return (
+
+
+
+
+
+
+ {/* Slot für L6: aus src/components/notifications/bell.tsx */}
+
+
+
+
{children}
+
+
+ );
+}
diff --git a/src/app/(field)/m/sync/page.tsx b/src/app/(field)/m/sync/page.tsx
new file mode 100644
index 0000000..9a81ea5
--- /dev/null
+++ b/src/app/(field)/m/sync/page.tsx
@@ -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 (
+
+
{t("title")}
+
+
{t("status")}
+
+
{t("immediate")}
+
{t("offlineHint")}
+
+
+ );
+}
diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts
new file mode 100644
index 0000000..0fe60db
--- /dev/null
+++ b/src/app/api/v1/field/bundle/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { getFieldBundle } from "@/server/services/field/queries";
+import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
+
+/** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */
+export async function GET(req: Request) {
+ return withApi(req, async () => {
+ const ctx = await requireApiContext("field", "field:execute");
+ const raw = new URL(req.url).searchParams.get("since");
+ const since = raw ? new Date(raw) : null;
+ if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since");
+ return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } });
+ });
+}
diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts
new file mode 100644
index 0000000..778985b
--- /dev/null
+++ b/src/app/api/v1/field/documents/[id]/route.ts
@@ -0,0 +1,27 @@
+import { openFieldDocument } from "@/server/services/field/documents";
+import { requireApiContext, withApi } from "@/server/services/sync/api-context";
+
+/**
+ * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile
+ * app (visibility + scope checked in the service). Only magic-byte-verified media types are served
+ * inline; everything else is a download.
+ */
+const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/;
+
+export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ return withApi(req, async () => {
+ const ctx = await requireApiContext("field");
+ const { id } = await params;
+ const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original";
+ const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant);
+ const safeName = fileName.replace(/["\\\r\n]/g, "_");
+ const headers = new Headers({
+ "Content-Type": mimeType,
+ "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`,
+ "X-Content-Type-Options": "nosniff",
+ "Cache-Control": "private, max-age=300",
+ });
+ if (content.size != null) headers.set("Content-Length", String(content.size));
+ return new Response(content.stream, { headers });
+ });
+}
diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts
new file mode 100644
index 0000000..145838a
--- /dev/null
+++ b/src/app/api/v1/sync/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { syncRequestSchema } from "@/lib/sync/envelope";
+import { applyOperations } from "@/server/services/sync/apply";
+import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
+
+/** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */
+export async function POST(req: Request) {
+ return withApi(req, async () => {
+ const ctx = await requireApiContext("field");
+ const body = syncRequestSchema.safeParse(await req.json().catch(() => null));
+ if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10));
+ return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } });
+ });
+}
diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts
new file mode 100644
index 0000000..0eac1ca
--- /dev/null
+++ b/src/app/api/v1/uploads/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads";
+import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
+
+/**
+ * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note),
+ * optional preview (thumbnail). Returns { documentId }; the same clientId returns the same document.
+ */
+const MAX_BYTES = 25 * 1024 * 1024;
+
+export async function POST(req: Request) {
+ return withApi(req, async () => {
+ const ctx = await requireApiContext("field", "field:execute");
+ const declared = Number(req.headers.get("content-length") ?? "0");
+ if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large");
+
+ const form = await req.formData().catch(() => null);
+ if (!form) return apiError("invalid", 400, "multipart body expected");
+ const file = form.get("file");
+ if (!(file instanceof File)) return apiError("invalid", 400, "file missing");
+ if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large");
+ const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") });
+ if (!meta.success) return apiError("invalid", 400, "invalid upload metadata");
+ const preview = form.get("preview");
+
+ const result = await storeFieldUpload(
+ ctx,
+ meta.data,
+ { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type },
+ preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null,
+ );
+ return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } });
+ });
+}
diff --git a/src/app/login/mfa/page.tsx b/src/app/login/mfa/page.tsx
index 98718b3..7b0a641 100644
--- a/src/app/login/mfa/page.tsx
+++ b/src/app/login/mfa/page.tsx
@@ -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
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 1b136f3..7024f41 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -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)}` : ""}`);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index a74cb27..2f55512 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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()));
}
diff --git a/src/components/account-inactive-notice.tsx b/src/components/account-inactive-notice.tsx
new file mode 100644
index 0000000..b5e005b
--- /dev/null
+++ b/src/components/account-inactive-notice.tsx
@@ -0,0 +1,19 @@
+import { signOut } from "@/server/auth";
+import { Button } from "@/components/ui/button";
+import { CraftviaLogo } from "@/components/brand/craftvia-logo";
+
+/** Hinweis für deaktivierte Konten — gemeinsam für Backoffice- und Mobile-Shell (src/server/app-access.ts). */
+export function AccountInactiveNotice() {
+ return (
+
+
+
+
Konto deaktiviert
+
Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.