From ef61754a0a53ed9893e2950d4bd5a0908a60b435 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 13:12:33 +0200 Subject: [PATCH] L8 Notdienst: mobile Erfassung /m/emergency in drei Schritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kunde suchen oder vorläufig anlegen, Einsatzort mit Ansprechpartner, Grund mit optionaler Sprachnotiz, Beginn und Team; Start über die Sync-Op. Co-Authored-By: Claude Opus 5 --- messages/de/emergency.json | 236 +++++++ messages/en/emergency.json | 236 +++++++ src/app/(field)/m/emergency/page.tsx | 26 +- src/components/emergency/emergency-wizard.tsx | 618 ++++++++++++++++++ src/server/actions/emergency/capture.ts | 38 ++ src/server/actions/emergency/review.ts | 125 ++++ 6 files changed, 1276 insertions(+), 3 deletions(-) create mode 100644 messages/de/emergency.json create mode 100644 messages/en/emergency.json create mode 100644 src/components/emergency/emergency-wizard.tsx create mode 100644 src/server/actions/emergency/capture.ts create mode 100644 src/server/actions/emergency/review.ts diff --git a/messages/de/emergency.json b/messages/de/emergency.json new file mode 100644 index 0000000..189ad4e --- /dev/null +++ b/messages/de/emergency.json @@ -0,0 +1,236 @@ +{ + "capture": { + "title": "Notdienst", + "sub": "Einsatz in 3 Schritten anlegen und sofort starten.", + "noAccess": "Die Notdienst-Erfassung ist für Ihre Rolle nicht freigeschaltet.", + "stepOf": "Schritt {step} von 3", + "steps": { + "customer": "Kunde", + "site": "Einsatzort", + "reason": "Grund & Start" + }, + "next": "Weiter", + "back": "Zurück", + "required": "Bitte die markierten Pflichtfelder ausfüllen.", + "start": "Einsatz starten", + "starting": "Einsatz wird angelegt …", + "queued": "Einsatz ist gespeichert und wird übertragen, sobald wieder Verbindung besteht.", + "customer": { + "existing": "Bestehender Kunde", + "new": "Neuer Kunde", + "search": "Kunde suchen", + "searchPlaceholder": "Name, Ort oder Kundennummer", + "searchHint": "Mindestens 2 Zeichen eingeben.", + "searching": "Suche läuft …", + "offline": "Suche gerade nicht möglich. Ohne Verbindung bitte einen neuen Kunden anlegen.", + "noHits": "Kein Kunde gefunden. Legen Sie einen neuen Kunden an.", + "provisional": "Vorläufig", + "change": "Anderen Kunden wählen", + "selectRequired": "Bitte einen Kunden auswählen oder neu anlegen.", + "newHint": "Wird als vorläufiger Kunde angelegt. Das Büro prüft ihn am nächsten Arbeitstag.", + "companyName": "Firma", + "firstName": "Vorname", + "lastName": "Nachname", + "nameHint": "Firma oder Nachname angeben.", + "phone": "Telefon", + "email": "E-Mail (optional)", + "street": "Straße", + "houseNumber": "Nr.", + "postalCode": "PLZ", + "city": "Ort" + }, + "site": { + "loading": "Objekte werden geladen …", + "existing": "Objekt des Kunden", + "newAddress": "Andere Einsatzadresse", + "none": "Für diesen Kunden ist kein Objekt hinterlegt. Bitte die Einsatzadresse erfassen.", + "newHint": "Die Einsatzadresse wird als vorläufiges Objekt angelegt.", + "useCustomerAddress": "Kundenadresse übernehmen", + "name": "Bezeichnung (optional)", + "contactTitle": "Ansprechpartner vor Ort", + "contactName": "Name", + "contactPhone": "Telefon" + }, + "reason": { + "label": "Grund des Notdiensteinsatzes", + "placeholder": "z. B. Wasserrohrbruch im Keller, Heizung ausgefallen", + "voice": "Sprachnotiz (optional)", + "voiceRecord": "Sprachnotiz aufnehmen", + "voiceRecording": "Aufnahme läuft · {seconds} s", + "voiceStop": "Aufnahme beenden", + "voiceDiscard": "Aufnahme verwerfen", + "voiceHint": "Wird nach dem Start am Einsatz gespeichert.", + "voiceDenied": "Kein Zugriff auf das Mikrofon. Bitte in den Geräteeinstellungen erlauben.", + "startedAt": "Beginn", + "team": "Team", + "noTeam": "Ohne Team", + "technicians": "Monteure", + "me": "Ich (immer dabei)" + }, + "errors": { + "not_found": "Kunde oder Objekt nicht gefunden. Bitte Auswahl prüfen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "invalid": "Bitte die Eingaben prüfen.", + "conflict": "Der Einsatz wurde bereits angelegt. Bitte die Auftragsliste prüfen.", + "blocked": "Der Einsatz kann gerade nicht angelegt werden.", + "internal": "Einsatz konnte nicht angelegt werden. Bitte erneut versuchen.", + "network": "Keine Verbindung. Bitte erneut auf „Einsatz starten“ tippen." + } + }, + "review": { + "crumb": "Aufträge · Notdienst", + "title": "Notdienst-Prüfung", + "sub": "Notdiensteinsätze mit vorläufigen Stammdaten oder zur Prüfung.", + "filterLabel": "Filter Notdiensteinsätze", + "filter": { + "open": "Zu prüfen", + "all": "Alle Notdienste" + }, + "emptyOpen": "Keine Notdiensteinsätze zu prüfen.", + "emptyAll": "Noch keine Notdiensteinsätze.", + "customer": "Kunde", + "site": "Objekt", + "technician": "Monteur", + "startedAt": "Beginn", + "provisional": "Vorläufig", + "progress": "{count} von {total} Prüfschritten erledigt", + "back": "Zur Notdienst-Prüfung", + "detailSub": "Monteur: {technician} · Einsatzbeginn: {start} · Einsatzende: {end}", + "running": "läuft noch", + "openOrder": "Auftrag öffnen", + "steps": { + "customer": "Kunde klären", + "site": "Objekt klären", + "order": "Auftrag ergänzen", + "report": "Bericht freigeben", + "billing": "Abrechnung freigeben" + }, + "stepDone": "Erledigt", + "stepOpen": "Offen", + "reason": "Grund des Notdiensteinsatzes", + "customerName": "Name", + "customerNumber": "Kundennummer", + "numberOnConfirm": "wird bei Bestätigung vergeben", + "status": "Status", + "customerStatus": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig – Prüfung durch Backoffice erforderlich", + "merged": "Zusammengeführt" + }, + "phone": "Telefon", + "email": "E-Mail", + "address": "Adresse", + "contact": "Ansprechpartner", + "provisionalHint": "Vorläufiger Kunde: bestätigen, einem bestehenden Kunden zuordnen oder mit einer Dublette zusammenführen.", + "saved": "Gespeichert.", + "confirmCustomer": { + "title": "Kunden bestätigen", + "hint": "Der Kunde wird aktiv und erhält eine Kundennummer.", + "submit": "Kunden bestätigen" + }, + "duplicates": { + "title": "Mögliche Dubletten", + "none": "Keine ähnlichen Kunden gefunden.", + "score": "Übereinstimmung {score} %" + }, + "target": { + "select": "Bestehender Kunde", + "none": "Bitte wählen", + "number": "oder Kundennummer" + }, + "assignCustomer": { + "title": "Bestehendem Kunden zuordnen", + "hint": "Auftrag, Objekt und Ansprechpartner werden zum gewählten Kunden umgehängt. Der vorläufige Datensatz wird als zusammengeführt markiert.", + "submit": "Zuordnen" + }, + "mergeCustomer": { + "title": "Dublette zusammenführen", + "hint": "Alle Ansprechpartner, Objekte, Aufträge und Dokumente dieses Kunden gehen auf den gewählten Kunden über. Nicht umkehrbar.", + "confirm": "Ich habe geprüft, dass es derselbe Kunde ist.", + "submit": "Zusammenführen" + }, + "siteName": "Bezeichnung", + "siteStatus": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig – Prüfung durch Backoffice erforderlich" + }, + "onSiteContact": "Ansprechpartner vor Ort", + "noSite": "Kein Objekt am Auftrag.", + "street": "Straße", + "houseNumber": "Nr.", + "postalCode": "PLZ", + "city": "Ort", + "accessNotes": "Zugangshinweise", + "correctSite": { + "title": "Objekt korrigieren", + "confirm": "Objekt bestätigen (vorläufig → aktiv)", + "submit": "Objekt speichern" + }, + "assignSite": { + "title": "Bestehendes Objekt zuordnen", + "hint": "Der Auftrag wird dem gewählten Objekt zugeordnet. Ein nicht mehr genutztes vorläufiges Objekt wird entfernt.", + "select": "Objekt des Kunden", + "none": "Der Kunde hat keine weiteren Objekte.", + "submit": "Objekt zuordnen" + }, + "completeOrder": { + "title": "Titel", + "description": "Beschreibung", + "orderType": "Auftragsart", + "billingType": "Abrechnungsart", + "choose": "Bitte wählen", + "hint": "Erledigt, sobald Auftragsart und Abrechnungsart gesetzt sind.", + "submit": "Auftrag speichern" + }, + "billingTypes": { + "fixed": "Pauschal", + "time_material": "Nach Aufwand", + "maintenance_contract": "Wartungsvertrag", + "warranty": "Gewährleistung" + }, + "report": { + "none": "Noch kein Bericht eingereicht.", + "open": "Bericht prüfen", + "hint": "Freigabe und Zurückweisung erfolgen auf der Berichtsseite.", + "version": "Version {version}", + "type": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "status": { + "draft": "Entwurf", + "submitted": "Eingereicht", + "team_approved": "Vom Teamleiter geprüft", + "approved": "Freigegeben", + "rejected": "Zurückgewiesen", + "superseded": "Ersetzt" + } + }, + "billing": { + "hint": "Möglich, wenn Kunde und Objekt geklärt sind und der Abschlussbericht freigegeben ist.", + "submit": "Zur Abrechnung freigeben", + "current": "Aktueller Status" + }, + "errors": { + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "invalid": "Bitte die Eingaben prüfen.", + "not_found": "Nicht gefunden.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "conflict": "Der Datensatz wurde inzwischen geändert. Bitte Seite neu laden.", + "blocked": "Noch nicht möglich: Voraussetzungen fehlen.", + "not_provisional": "Der Kunde ist nicht mehr vorläufig.", + "same_customer": "Bitte einen anderen Kunden wählen.", + "same_site": "Der Auftrag ist diesem Objekt bereits zugeordnet.", + "target_not_found": "Kunde nicht gefunden. Bitte Auswahl oder Kundennummer prüfen.", + "not_found_target": "Kunde nicht gefunden.", + "confirm_required": "Bitte die Prüfung bestätigen.", + "already_merged": "Einer der Kunden wurde bereits zusammengeführt.", + "no_site": "Der Auftrag hat kein Objekt.", + "master_data_open": "Erst Kunde und Objekt klären, dann zur Abrechnung freigeben.", + "number_allocation": "Kundennummer konnte nicht vergeben werden. Bitte erneut versuchen.", + "number_taken": "Diese Kundennummer ist bereits vergeben." + } + } +} diff --git a/messages/en/emergency.json b/messages/en/emergency.json new file mode 100644 index 0000000..a8f8460 --- /dev/null +++ b/messages/en/emergency.json @@ -0,0 +1,236 @@ +{ + "capture": { + "title": "Emergency call-out", + "sub": "Create the call-out in 3 steps and start right away.", + "noAccess": "Emergency capture is not enabled for your role.", + "stepOf": "Step {step} of 3", + "steps": { + "customer": "Customer", + "site": "Location", + "reason": "Reason & start" + }, + "next": "Next", + "back": "Back", + "required": "Please fill in the marked required fields.", + "start": "Start call-out", + "starting": "Creating call-out …", + "queued": "Call-out saved. It will be transferred once you are back online.", + "customer": { + "existing": "Existing customer", + "new": "New customer", + "search": "Search customer", + "searchPlaceholder": "Name, city or customer number", + "searchHint": "Enter at least 2 characters.", + "searching": "Searching …", + "offline": "Search is not available right now. Without a connection, please create a new customer.", + "noHits": "No customer found. Create a new customer.", + "provisional": "Provisional", + "change": "Choose another customer", + "selectRequired": "Please select a customer or create a new one.", + "newHint": "Will be created as a provisional customer. The office reviews it on the next working day.", + "companyName": "Company", + "firstName": "First name", + "lastName": "Last name", + "nameHint": "Enter company or last name.", + "phone": "Phone", + "email": "E-mail (optional)", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postcode", + "city": "City" + }, + "site": { + "loading": "Loading sites …", + "existing": "Customer site", + "newAddress": "Other call-out address", + "none": "No site on file for this customer. Please enter the call-out address.", + "newHint": "The call-out address is created as a provisional site.", + "useCustomerAddress": "Use customer address", + "name": "Label (optional)", + "contactTitle": "Contact on site", + "contactName": "Name", + "contactPhone": "Phone" + }, + "reason": { + "label": "Reason for the emergency call-out", + "placeholder": "e.g. burst water pipe in the basement, heating failure", + "voice": "Voice note (optional)", + "voiceRecord": "Record voice note", + "voiceRecording": "Recording · {seconds} s", + "voiceStop": "Stop recording", + "voiceDiscard": "Discard recording", + "voiceHint": "Saved to the call-out after it has started.", + "voiceDenied": "No microphone access. Please allow it in the device settings.", + "startedAt": "Start", + "team": "Team", + "noTeam": "No team", + "technicians": "Technicians", + "me": "Me (always included)" + }, + "errors": { + "not_found": "Customer or site not found. Please check your selection.", + "forbidden": "You do not have permission for this.", + "invalid": "Please check your entries.", + "conflict": "The call-out has already been created. Please check the order list.", + "blocked": "The call-out cannot be created right now.", + "internal": "The call-out could not be created. Please try again.", + "network": "No connection. Please tap “Start call-out” again." + } + }, + "review": { + "crumb": "Work orders · Emergency", + "title": "Emergency review", + "sub": "Emergency call-outs with provisional master data or awaiting review.", + "filterLabel": "Filter emergency call-outs", + "filter": { + "open": "To review", + "all": "All call-outs" + }, + "emptyOpen": "No emergency call-outs to review.", + "emptyAll": "No emergency call-outs yet.", + "customer": "Customer", + "site": "Site", + "technician": "Technician", + "startedAt": "Start", + "provisional": "Provisional", + "progress": "{count} of {total} review steps done", + "back": "Back to emergency review", + "detailSub": "Technician: {technician} · Start: {start} · End: {end}", + "running": "still running", + "openOrder": "Open work order", + "steps": { + "customer": "Clarify customer", + "site": "Clarify site", + "order": "Complete order", + "report": "Approve report", + "billing": "Release for billing" + }, + "stepDone": "Done", + "stepOpen": "Open", + "reason": "Reason for the emergency call-out", + "customerName": "Name", + "customerNumber": "Customer number", + "numberOnConfirm": "assigned on confirmation", + "status": "Status", + "customerStatus": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional – review by back office required", + "merged": "Merged" + }, + "phone": "Phone", + "email": "E-mail", + "address": "Address", + "contact": "Contact", + "provisionalHint": "Provisional customer: confirm, assign to an existing customer or merge with a duplicate.", + "saved": "Saved.", + "confirmCustomer": { + "title": "Confirm customer", + "hint": "The customer becomes active and receives a customer number.", + "submit": "Confirm customer" + }, + "duplicates": { + "title": "Possible duplicates", + "none": "No similar customers found.", + "score": "Match {score} %" + }, + "target": { + "select": "Existing customer", + "none": "Please choose", + "number": "or customer number" + }, + "assignCustomer": { + "title": "Assign to existing customer", + "hint": "Order, site and contacts move to the chosen customer. The provisional record is marked as merged.", + "submit": "Assign" + }, + "mergeCustomer": { + "title": "Merge duplicate", + "hint": "All contacts, sites, orders and documents of this customer move to the chosen customer. Cannot be undone.", + "confirm": "I have checked that this is the same customer.", + "submit": "Merge" + }, + "siteName": "Label", + "siteStatus": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional – review by back office required" + }, + "onSiteContact": "Contact on site", + "noSite": "No site on this order.", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postcode", + "city": "City", + "accessNotes": "Access notes", + "correctSite": { + "title": "Correct site", + "confirm": "Confirm site (provisional → active)", + "submit": "Save site" + }, + "assignSite": { + "title": "Assign existing site", + "hint": "The order is assigned to the chosen site. A provisional site that is no longer used is removed.", + "select": "Customer site", + "none": "The customer has no other sites.", + "submit": "Assign site" + }, + "completeOrder": { + "title": "Title", + "description": "Description", + "orderType": "Order type", + "billingType": "Billing type", + "choose": "Please choose", + "hint": "Done once order type and billing type are set.", + "submit": "Save order" + }, + "billingTypes": { + "fixed": "Fixed price", + "time_material": "Time and material", + "maintenance_contract": "Maintenance contract", + "warranty": "Warranty" + }, + "report": { + "none": "No report submitted yet.", + "open": "Review report", + "hint": "Approval and rejection happen on the report page.", + "version": "Version {version}", + "type": { + "daily": "Daily report", + "completion": "Completion report" + }, + "status": { + "draft": "Draft", + "submitted": "Submitted", + "team_approved": "Checked by team lead", + "approved": "Approved", + "rejected": "Rejected", + "superseded": "Superseded" + } + }, + "billing": { + "hint": "Possible once customer and site are clarified and the completion report is approved.", + "submit": "Release for billing", + "current": "Current status" + }, + "errors": { + "generic": "That did not work. Please try again.", + "invalid": "Please check your entries.", + "not_found": "Not found.", + "forbidden": "You do not have permission for this.", + "conflict": "The record has changed in the meantime. Please reload the page.", + "blocked": "Not possible yet: prerequisites are missing.", + "not_provisional": "The customer is no longer provisional.", + "same_customer": "Please choose a different customer.", + "same_site": "The order is already assigned to this site.", + "target_not_found": "Customer not found. Please check the selection or customer number.", + "not_found_target": "Customer not found.", + "confirm_required": "Please confirm the check.", + "already_merged": "One of the customers has already been merged.", + "no_site": "The order has no site.", + "master_data_open": "Clarify customer and site first, then release for billing.", + "number_allocation": "The customer number could not be assigned. Please try again.", + "number_taken": "This customer number is already taken." + } + } +} diff --git a/src/app/(field)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx index 5064860..f96b880 100644 --- a/src/app/(field)/m/emergency/page.tsx +++ b/src/app/(field)/m/emergency/page.tsx @@ -1,5 +1,25 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import { getTranslations } from "next-intl/server"; +import { moduleGuard } from "@/server/action-guard"; +import { can, ctxFromGuard } from "@/server/services/context"; +import { emergencyTeamOptions } from "@/server/services/emergency/lookup"; +import { EmergencyWizard } from "@/components/emergency/emergency-wizard"; -export default function Page() { - return ; +/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */ +export default async function EmergencyPage() { + const ctx = ctxFromGuard(await moduleGuard("emergency")()); + const t = await getTranslations("emergency.capture"); + if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) { + return

{t("noAccess")}

; + } + const teams = await emergencyTeamOptions(ctx); + + return ( +
+
+

{t("title")}

+

{t("sub")}

+
+ +
+ ); } diff --git a/src/components/emergency/emergency-wizard.tsx b/src/components/emergency/emergency-wizard.tsx new file mode 100644 index 0000000..9e89f1b --- /dev/null +++ b/src/components/emergency/emergency-wizard.tsx @@ -0,0 +1,618 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Building2, Check, ChevronLeft, ChevronRight, LoaderCircle, MapPin, Mic, Search, Siren, Square, TriangleAlert, UserPlus, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { uploadFieldFile } from "@/lib/field/upload"; +import type { EmergencyCreateInput } from "@/lib/emergency/schemas"; +import { btnPrimary, btnSecondary, card, chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui"; +import { listEmergencySitesAction, searchEmergencyCustomersAction } from "@/server/actions/emergency/capture"; +import type { EmergencyCustomerHit, EmergencySiteHit, EmergencyTeamOption } from "@/server/services/emergency/lookup"; + +type Step = 1 | 2 | 3; +type NewCustomer = { companyName: string; firstName: string; lastName: string; phone: string; email: string; street: string; houseNumber: string; postalCode: string; city: string }; +type Address = { name: string; street: string; houseNumber: string; postalCode: string; city: string }; + +const EMPTY_CUSTOMER: NewCustomer = { companyName: "", firstName: "", lastName: "", phone: "", email: "", street: "", houseNumber: "", postalCode: "", city: "" }; +const EMPTY_ADDRESS: Address = { name: "", street: "", houseNumber: "", postalCode: "", city: "" }; +const MAX_VOICE_SECONDS = 300; + +function localNow(): string { + const d = new Date(); + d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); + return d.toISOString().slice(0, 16); +} + +function TextField({ + id, + label, + value, + onChange, + type = "text", + required, + autoComplete, + inputMode, + className, + invalid, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + type?: string; + required?: boolean; + autoComplete?: string; + inputMode?: React.HTMLAttributes["inputMode"]; + className?: string; + invalid?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + required={required} + autoComplete={autoComplete} + inputMode={inputMode} + aria-invalid={invalid || undefined} + className={cn(inputClass, invalid && "border-[var(--risk)]")} + /> +
+ ); +} + +export function EmergencyWizard({ teams, userId }: { teams: EmergencyTeamOption[]; userId: string }) { + const t = useTranslations("emergency.capture"); + const router = useRouter(); + const [step, setStep] = useState(1); + const [showErrors, setShowErrors] = useState(false); + + // step 1 — customer + const [customerMode, setCustomerMode] = useState<"existing" | "new">("existing"); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [searching, startSearch] = useTransition(); + const [searchFailed, setSearchFailed] = useState(false); + const [selected, setSelected] = useState(null); + const [newCustomer, setNewCustomer] = useState(EMPTY_CUSTOMER); + + // step 2 — site + const [sites, setSites] = useState(null); + const [siteMode, setSiteMode] = useState<"existing" | "new">("new"); + const [siteId, setSiteId] = useState(""); + const [address, setAddress] = useState
(EMPTY_ADDRESS); + const [contact, setContact] = useState({ name: "", phone: "" }); + + // step 3 — reason / start / team + const [reason, setReason] = useState(""); + const [startedAt, setStartedAt] = useState(""); + const [teamId, setTeamId] = useState(teams[0]?.id ?? ""); + const [colleagues, setColleagues] = useState([]); + + // voice note (kept locally, uploaded after the call-out exists) + const [recState, setRecState] = useState<"idle" | "recording" | "recorded">("idle"); + const [recSeconds, setRecSeconds] = useState(0); + const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null); + const recorder = useRef(null); + const timer = useRef | null>(null); + + // submit + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // stable client ids → a retry after a network error is idempotent (same session client id) + const clientIds = useRef({ workOrder: newClientId(), session: newClientId(), customer: newClientId(), site: newClientId() }); + + const searchTimer = useRef | null>(null); + const selectionSeq = useRef(0); + + useEffect( + () => () => { + if (timer.current) clearInterval(timer.current); + if (searchTimer.current) clearTimeout(searchTimer.current); + recorder.current?.stream.getTracks().forEach((tr) => tr.stop()); + }, + [], + ); + useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]); + + /** Debounced customer search (event-driven, no state updates inside effects). */ + function onQueryChange(value: string) { + setQuery(value); + if (searchTimer.current) clearTimeout(searchTimer.current); + const q = value.trim(); + if (q.length < 2) { + setHits([]); + setSearchFailed(false); + return; + } + searchTimer.current = setTimeout(() => { + startSearch(async () => { + const res = await searchEmergencyCustomersAction(q); + setSearchFailed(!res.ok); + setHits(res.ok ? res.items : []); + }); + }, 300); + } + + function resetSites() { + selectionSeq.current++; + setSites(null); + setSiteMode("new"); + setSiteId(""); + } + + /** Select a customer and load its sites for step 2. */ + function selectCustomer(hit: EmergencyCustomerHit | null) { + setSelected(hit); + resetSites(); + if (!hit) return; + const seq = selectionSeq.current; + listEmergencySitesAction(hit.id).then((res) => { + if (seq !== selectionSeq.current) return; + const items = res.ok ? res.items : []; + setSites(items); + if (items.length) { + setSiteMode("existing"); + setSiteId(items[0].id); + } + }); + } + + function switchCustomerMode(mode: "existing" | "new") { + setCustomerMode(mode); + selectCustomer(null); + } + + const team = useMemo(() => teams.find((x) => x.id === teamId) ?? null, [teams, teamId]); + + const step1Valid = + customerMode === "existing" ? !!selected : !!(newCustomer.companyName.trim() || newCustomer.lastName.trim()) && !!newCustomer.phone.trim(); + const step2Valid = + (siteMode === "existing" ? !!siteId : !!address.street.trim() && !!address.city.trim()) && !!contact.name.trim() && !!contact.phone.trim(); + const step3Valid = !!reason.trim() && !!startedAt; + + function go(next: Step) { + const valid = step === 1 ? step1Valid : step === 2 ? step2Valid : step3Valid; + if (next > step && !valid) { + setShowErrors(true); + return; + } + setShowErrors(false); + setError(null); + if (next === 2 && customerMode === "new" && !contact.name && !contact.phone) { + const name = [newCustomer.firstName, newCustomer.lastName].filter((s) => s.trim()).join(" ") || newCustomer.companyName; + setContact({ name, phone: newCustomer.phone }); + } + if (next === 3 && !startedAt) setStartedAt(localNow()); // default "now" when the step opens + setStep(next); + window.scrollTo({ top: 0 }); + } + + function useCustomerAddress() { + setAddress((a) => ({ ...a, street: newCustomer.street, houseNumber: newCustomer.houseNumber, postalCode: newCustomer.postalCode, city: newCustomer.city })); + } + + async function startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]; + const mimeType = typeof MediaRecorder !== "undefined" ? types.find((x) => MediaRecorder.isTypeSupported(x)) : undefined; + const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + const chunks: BlobPart[] = []; + rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data); + rec.onstop = () => { + stream.getTracks().forEach((tr) => tr.stop()); + const blob = new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" }); + setClip({ blob, url: URL.createObjectURL(blob) }); + setRecState("recorded"); + }; + recorder.current = rec; + rec.start(1000); + setRecSeconds(0); + setRecState("recording"); + timer.current = setInterval(() => { + setRecSeconds((s) => { + if (s + 1 >= MAX_VOICE_SECONDS) stopRecording(); + return Math.min(s + 1, MAX_VOICE_SECONDS); + }); + }, 1000); + } catch { + setError(t("reason.voiceDenied")); + } + } + + function stopRecording() { + if (timer.current) clearInterval(timer.current); + timer.current = null; + if (recorder.current?.state === "recording") recorder.current.stop(); + } + + async function submit() { + if (!step3Valid) { + setShowErrors(true); + return; + } + setSubmitting(true); + setError(null); + const payload: EmergencyCreateInput = { + clientIds: clientIds.current, + customer: + customerMode === "existing" && selected + ? { mode: "existing", customerId: selected.id } + : { mode: "new", ...newCustomer }, + site: siteMode === "existing" ? { mode: "existing", siteId } : { mode: "new", ...address }, + onSiteContact: contact, + reason, + startedAt: new Date(startedAt).toISOString(), + teamId: teamId || null, + assigneeIds: colleagues.filter((c) => c !== userId), + offline: typeof navigator !== "undefined" && !navigator.onLine, + deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined, + }; + const result = await submitOp({ opType: "emergency.create", payload }); + const workOrderId = result.idMap?.[clientIds.current.workOrder]; + if (!isSuccess(result) || !workOrderId) { + setSubmitting(false); + const key = isSuccess(result) ? "queued" : errorKey(result); + setError(key === "queued" ? t("queued") : t(`errors.${key}`)); + return; + } + if (clip) { + const type = clip.blob.type || "audio/webm"; + const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm"; + const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `notdienst.${ext}` }); + if (up.ok) { + await submitOp({ + opType: "voice.attach", + payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: recSeconds, recordedAt: new Date().toISOString(), kind: "problem" }, + }); + } + } + router.push(`/m/orders/${workOrderId}`); + } + + const stepLabels = [t("steps.customer"), t("steps.site"), t("steps.reason")]; + const err = (cond: boolean) => showErrors && cond; + + return ( +
+
    + {stepLabels.map((label, i) => { + const n = (i + 1) as Step; + const state = n < step ? "done" : n === step ? "current" : "todo"; + return ( +
  1. + + {state === "done" ? : n} + + {label} +
  2. + ); + })} +
+

+ {t("stepOf", { step })}: {stepLabels[step - 1]} +

+ + {step === 1 && ( +
+

{t("steps.customer")}

+
+ + +
+ + {customerMode === "existing" && + (selected ? ( +
+
+ +
+

{selected.name}

+

+ {[selected.customerNumber, selected.city].filter(Boolean).join(" · ")} + {selected.provisional && ` · ${t("customer.provisional")}`} +

+
+
+ +
+ ) : ( +
+
+ + onQueryChange(e.target.value)} + placeholder={t("customer.searchPlaceholder")} + autoComplete="off" + className={inputClass} + /> +

{t("customer.searchHint")}

+
+ {searching && ( +

+ + {t("customer.searching")} +

+ )} + {searchFailed &&

{t("customer.offline")}

} + {!searching && query.trim().length >= 2 && hits.length === 0 && !searchFailed && ( +

{t("customer.noHits")}

+ )} +
    + {hits.map((h) => ( +
  • + +
  • + ))} +
+ {err(!selected) &&

{t("customer.selectRequired")}

} +
+ ))} + + {customerMode === "new" && ( +
+

{t("customer.newHint")}

+ setNewCustomer({ ...newCustomer, companyName: v })} autoComplete="organization" /> +
+ setNewCustomer({ ...newCustomer, firstName: v })} autoComplete="given-name" /> + setNewCustomer({ ...newCustomer, lastName: v })} + autoComplete="family-name" + invalid={err(!newCustomer.companyName.trim() && !newCustomer.lastName.trim())} + /> +
+

{t("customer.nameHint")}

+ setNewCustomer({ ...newCustomer, phone: v })} invalid={err(!newCustomer.phone.trim())} /> + setNewCustomer({ ...newCustomer, email: v })} /> +
+ setNewCustomer({ ...newCustomer, street: v })} autoComplete="address-line1" /> + setNewCustomer({ ...newCustomer, houseNumber: v })} /> +
+
+ setNewCustomer({ ...newCustomer, postalCode: v })} /> + setNewCustomer({ ...newCustomer, city: v })} autoComplete="address-level2" /> +
+ {err(!step1Valid) &&

{t("required")}

} +
+ )} + + +
+ )} + + {step === 2 && ( +
+

{t("steps.site")}

+ {customerMode === "existing" && sites === null && ( +

+ + {t("site.loading")} +

+ )} + {customerMode === "existing" && sites !== null && sites.length > 0 && ( +
+ {t("site.existing")} + {sites.map((s) => ( + + ))} + +
+ )} + {customerMode === "existing" && sites !== null && sites.length === 0 &&

{t("site.none")}

} + + {siteMode === "new" && (sites !== null || customerMode === "new") && ( +
+

{t("site.newHint")}

+ {customerMode === "new" && (newCustomer.street || newCustomer.city) && ( + + )} +
+ setAddress({ ...address, street: v })} autoComplete="address-line1" invalid={err(!address.street.trim())} /> + setAddress({ ...address, houseNumber: v })} /> +
+
+ setAddress({ ...address, postalCode: v })} /> + setAddress({ ...address, city: v })} autoComplete="address-level2" invalid={err(!address.city.trim())} /> +
+ setAddress({ ...address, name: v })} /> +
+ )} + +
+ {t("site.contactTitle")} + setContact({ ...contact, name: v })} autoComplete="name" invalid={err(!contact.name.trim())} /> + setContact({ ...contact, phone: v })} invalid={err(!contact.phone.trim())} /> +
+ {err(!step2Valid) &&

{t("required")}

} + +
+ + +
+
+ )} + + {step === 3 && ( +
+

{t("steps.reason")}

+
+ +