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