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
+78
View File
@@ -0,0 +1,78 @@
import type { SyncOpResult, SyncOpType, SyncResponse } from "@/lib/sync/envelope";
import type { OpPayload } from "@/lib/sync/ops";
/**
* Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Today every op is sent to
* POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox —
* keep the signature `submitOp(op) → Promise<SyncOpResult>` stable.
*/
export type ClientOp<T extends SyncOpType = SyncOpType> = {
opType: T;
payload: OpPayload<T>;
entityType?: string;
entityId?: string;
baseVersion?: number;
};
/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */
export function newClientId(): string {
const c = globalThis.crypto;
if (typeof c?.randomUUID === "function") return c.randomUUID();
const b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
const DEVICE_KEY = "craftvia.field.deviceId";
export function deviceId(): string {
try {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) {
id = newClientId();
localStorage.setItem(DEVICE_KEY, id);
}
return id;
} catch {
return "unknown-device";
}
}
function errorCodeForStatus(status: number): SyncOpResult["errorCode"] {
if (status === 400 || status === 413) return "invalid";
if (status === 401 || status === 403) return "forbidden";
if (status === 404) return "not_found";
if (status === 409) return "conflict";
return "internal";
}
export async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
const clientOpId = newClientId();
try {
const res = await fetch("/api/v1/sync", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: deviceId(), operations: [{ ...op, clientOpId, clientCreatedAt: new Date().toISOString() }] }),
});
if (!res.ok) return { clientOpId, status: "rejected", errorCode: errorCodeForStatus(res.status), message: `HTTP ${res.status}` };
const body = (await res.json()) as SyncResponse;
return body.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" };
} catch {
return { clientOpId, status: "rejected", errorCode: "internal", message: "network" };
}
}
/** i18n key (messages field.errors.*) for a failed op result. */
export function errorKey(result: SyncOpResult): "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal" | "network" {
if (result.message === "network") return "network";
return result.errorCode ?? "internal";
}
export function isSuccess(result: SyncOpResult): boolean {
return result.status === "applied" || (result.status === "duplicate" && !result.errorCode);
}
+42
View File
@@ -0,0 +1,42 @@
/** Date/time formatting of the mobile app with an explicit time zone (identical on server and client). */
export const FIELD_TIME_ZONE = "Europe/Berlin";
const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE");
export function fmtTime(d: Date | string, locale: string): string {
return new Intl.DateTimeFormat(tag(locale), { hour: "2-digit", minute: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
}
export function fmtDate(d: Date | string, locale: string): string {
return new Intl.DateTimeFormat(tag(locale), { weekday: "short", day: "2-digit", month: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
}
export function fmtDateTime(d: Date | string, locale: string): string {
return `${fmtDate(d, locale)} ${fmtTime(d, locale)}`;
}
function dayKey(d: Date): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: FIELD_TIME_ZONE }).format(d);
}
/** "Mo., 14.09. · 08:00–12:00" or "Mo., 14.09. 08:00 – Di., 15.09. 12:00"; null without start. */
export function fmtWindow(start: Date | string | null, end: Date | string | null, locale: string): string | null {
if (!start) return null;
const s = new Date(start);
if (!end) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}`;
const e = new Date(end);
if (dayKey(s) === dayKey(e)) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${fmtTime(e, locale)}`;
return `${fmtDateTime(s, locale)} – ${fmtDateTime(e, locale)}`;
}
export function fmtDuration(seconds: number): string {
const total = Math.max(0, Math.round(seconds / 60));
const h = Math.floor(total / 60);
const m = total % 60;
return h > 0 ? `${h} h ${String(m).padStart(2, "0")} min` : `${m} min`;
}
export function secondsBetween(start: Date | string, end: Date | string | null, now = new Date()): number {
return Math.max(0, Math.round(((end ? new Date(end) : now).getTime() - new Date(start).getTime()) / 1000));
}
+81
View File
@@ -0,0 +1,81 @@
/**
* In-browser photo compression before upload (ARCHITEKTUR §4.3, Spec §14.4):
* longest edge max 2560 px, JPEG quality 0.82, plus a 400 px thumbnail.
* EXIF orientation: decoded with `imageOrientation: "from-image"` (createImageBitmap) — modern
* browsers also apply it for <img> decoding, which is the fallback path.
*/
export const MAX_EDGE = 2560;
export const JPEG_QUALITY = 0.82;
export const THUMB_EDGE = 400;
type Drawable = { source: CanvasImageSource; width: number; height: number; close: () => void };
async function decode(file: Blob): Promise<Drawable> {
if (typeof createImageBitmap === "function") {
try {
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
return { source: bmp, width: bmp.width, height: bmp.height, close: () => bmp.close() };
} catch {
// fall through to <img> decoding (e.g. older Safari)
}
}
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("decode failed"));
el.src = url;
});
return { source: img, width: img.naturalWidth, height: img.naturalHeight, close: () => URL.revokeObjectURL(url) };
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
function render(d: Drawable, maxEdge: number, quality: number): Promise<Blob> {
const scale = Math.min(1, maxEdge / Math.max(d.width, d.height));
const w = Math.max(1, Math.round(d.width * scale));
const h = Math.max(1, Math.round(d.height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const g = canvas.getContext("2d");
if (!g) return Promise.reject(new Error("canvas unavailable"));
g.imageSmoothingQuality = "high";
g.drawImage(d.source, 0, 0, w, h);
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", quality));
}
export async function compressImage(file: Blob): Promise<{ image: Blob; thumbnail: Blob; width: number; height: number }> {
const d = await decode(file);
try {
const image = await render(d, MAX_EDGE, JPEG_QUALITY);
const thumbnail = await render(d, THUMB_EDGE, 0.75);
const scale = Math.min(1, MAX_EDGE / Math.max(d.width, d.height));
return { image, thumbnail, width: Math.round(d.width * scale), height: Math.round(d.height * scale) };
} finally {
d.close();
}
}
/** Optional GPS position (never blocks longer than `timeoutMs`, null when denied/unavailable). */
export function currentPosition(timeoutMs = 4000): Promise<{ latitude: number; longitude: number } | null> {
if (typeof navigator === "undefined" || !navigator.geolocation) return Promise.resolve(null);
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), timeoutMs + 500);
navigator.geolocation.getCurrentPosition(
(p) => {
clearTimeout(timer);
resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
},
() => {
clearTimeout(timer);
resolve(null);
},
{ enableHighAccuracy: false, timeout: timeoutMs, maximumAge: 120_000 },
);
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Binary upload to POST /api/v1/uploads with progress (XHR — fetch has no upload progress).
* Idempotent over `clientId`: retrying the same clientId returns the same documentId.
*/
export type UploadResult = { ok: true; documentId: string } | { ok: false; error: "network" | "invalid" | "forbidden" | "not_found" | "internal" };
export function uploadFieldFile(opts: {
workOrderId: string;
kind: "photo" | "voice_note";
clientId: string;
file: Blob;
fileName: string;
preview?: Blob | null;
onProgress?: (percent: number) => void;
}): Promise<UploadResult> {
return new Promise((resolve) => {
const form = new FormData();
form.append("clientId", opts.clientId);
form.append("workOrderId", opts.workOrderId);
form.append("kind", opts.kind);
form.append("file", opts.file, opts.fileName);
if (opts.preview) form.append("preview", opts.preview, `thumb-${opts.fileName}`);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/uploads");
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts.onProgress?.(Math.round((e.loaded / e.total) * 100));
};
xhr.onerror = () => resolve({ ok: false, error: "network" });
xhr.onload = () => {
if (xhr.status === 200 || xhr.status === 201) {
try {
const body = JSON.parse(xhr.responseText) as { documentId: string };
opts.onProgress?.(100);
resolve({ ok: true, documentId: body.documentId });
} catch {
resolve({ ok: false, error: "internal" });
}
return;
}
const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
resolve({ ok: false, error });
};
xhr.send(form);
});
}