L16 Lotse-Chat für Monteure: mobile Chat-Seite, Aktionskarten, Mikrofon, Einstieg und Einstellung

/m/lotse mit Verlauf, Chips, Sprungzielen, Aktionskarten (Bestätigen/Bearbeiten/Verwerfen),
Spracheingabe mit editierbarem Transkript und Offline-Hinweis; Navigationseintrag, Button
„Lotse fragen“ im Auftragsdetail, Schalter und Datenfluss in /settings/lotse, Audit-Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:57:20 +02:00
co-authored by Claude Opus 5
parent 68f4eb32dc
commit 63baaf29df
15 changed files with 1196 additions and 15 deletions
+1
View File
@@ -88,6 +88,7 @@ const ENTITY_LABEL: Record<string, string> = {
number_sequence: "Nummernkreis",
ai_generation: "Lotse (KI)",
lotse_settings: "Lotse-Einstellungen",
lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16
};
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
+5 -4
View File
@@ -3,12 +3,13 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react";
import { ClipboardList, Compass, House, RefreshCw, Siren, User } from "lucide-react";
import { cn } from "@/lib/utils";
const ITEMS = [
{ href: "/m", key: "today", icon: House, exact: true },
{ href: "/m/orders", key: "orders", icon: ClipboardList, exact: false },
{ href: "/m/lotse", key: "lotse", icon: Compass, exact: false }, // L16 Lotse-Chat (only when usable)
{ href: "/m/emergency", key: "emergency", icon: Siren, exact: false },
{ href: "/m/sync", key: "sync", icon: RefreshCw, exact: false },
{ href: "/m/profile", key: "profile", icon: User, exact: false },
@@ -18,13 +19,13 @@ const ITEMS = [
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
*/
export function BottomNav({ approvals = 0 }: { approvals?: number }) {
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) {
const t = useTranslations("field.nav");
const pathname = usePathname();
return (
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
<ul className="mx-auto grid max-w-xl grid-cols-5">
{ITEMS.map((item) => {
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}>
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return (
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Compass } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
import { btnSecondary } from "@/components/field/ui";
/** „Lotse fragen" in the mobile order detail (lane L16) — opens the chat with this order as context. Hidden when the chat is not usable. */
export async function LotseAskButton({ ctx, workOrderId }: { ctx: ServiceCtx; workOrderId: string }) {
if (!(await canUseLotseChat(ctx))) return null;
const t = await getTranslations("lotse");
return (
<Link href={`/m/lotse?order=${encodeURIComponent(workOrderId)}`} className={btnSecondary}>
<Compass className="size-5 text-[var(--ui-accent)]" aria-hidden />
{t("chat.ask")}
</Link>
);
}
+339
View File
@@ -0,0 +1,339 @@
"use client";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { ChevronRight, CircleCheck, Compass, Info, LoaderCircle, MessageSquarePlus, SendHorizontal, TriangleAlert, WifiOff } from "lucide-react";
import { LOTSE_CHAT_IDLE, type ChatMessageView, type ChatView, type LotseChatActionState, type LotseChatErrorCode } from "@/lib/lotse/chat";
import { confirmAllProposalsAction, confirmProposalAction, discardProposalAction, newConversationAction, sendChatMessageAction } from "@/server/actions/lotse/chat";
import { cn } from "@/lib/utils";
import { chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui";
import { LotseMark } from "../lotse-mark";
import { MicButton } from "./mic-button";
import { ProposalCard } from "./proposal-card";
type Action = (prev: LotseChatActionState, fd: FormData) => Promise<LotseChatActionState>;
const draftKey = (workOrderId: string | null) => `craftvia:lotse-chat-draft:${workOrderId ?? "general"}`;
/** next-intl typing does not know dynamic keys with values — loose signature for server-provided keys. */
type LooseT = (key: string, values?: Record<string, string | number>) => string;
function subscribeOnline(cb: () => void) {
window.addEventListener("online", cb);
window.addEventListener("offline", cb);
return () => {
window.removeEventListener("online", cb);
window.removeEventListener("offline", cb);
};
}
/**
* Lotse chat for technicians (lane L16, `/m/lotse`): history, input with microphone key, chips, jump
* links and action cards. Needs a connection — offline the input stays (also in localStorage) and
* nothing is sent; there is no offline execution.
*/
export function LotseChat({
initial,
workOrderId,
configured,
transcription,
locale,
timeZone,
}: {
initial: ChatView;
workOrderId: string | null;
configured: boolean;
transcription: boolean;
locale: string;
timeZone: string;
}) {
const t = useTranslations("lotse");
const router = useRouter();
const [view, setView] = useState(initial);
const [text, setText] = useState("");
const [pending, setPending] = useState<string | null>(null);
const [error, setError] = useState<LotseChatErrorCode | null>(null);
const [micError, setMicError] = useState<string | null>(null);
const online = useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true);
const [sentText, setSentText] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let saved: string | null = null;
try {
saved = window.localStorage.getItem(draftKey(workOrderId));
} catch {
// storage unavailable (private mode) — draft only in memory
}
if (!saved) return;
const restore = setTimeout(() => setText((current) => current || saved), 0);
return () => clearTimeout(restore);
}, [workOrderId]);
useEffect(() => {
try {
if (text) window.localStorage.setItem(draftKey(workOrderId), text);
else window.localStorage.removeItem(draftKey(workOrderId));
} catch {
// ignore
}
}, [text, workOrderId]);
useEffect(() => {
endRef.current?.scrollIntoView({ block: "end" });
}, [view.messages.length, pending]);
async function run(key: string, action: Action, values: Record<string, string | null | undefined>): Promise<boolean> {
if (!navigator.onLine) {
setError("offline");
return false;
}
const fd = new FormData();
for (const [k, v] of Object.entries(values)) if (v) fd.set(k, v);
setPending(key);
setError(null);
try {
const res = await action(LOTSE_CHAT_IDLE, fd);
if (res.status === "ok") {
setView(res.view);
return true;
}
if (res.status === "error") {
setError(res.code);
if (res.view) setView(res.view);
}
return false;
} catch {
setError(navigator.onLine ? "generic" : "offline");
return false;
} finally {
setPending(null);
}
}
async function send(value: string) {
const message = value.trim();
if (!message || pending) return;
setSentText(message);
const ok = await run("send", sendChatMessageAction, { text: message, conversationId: view.conversationId, workOrderId });
setSentText(null);
if (ok && message === text.trim()) setText("");
}
async function confirm(proposalId: string, edits: Record<string, unknown> | null) {
const ok = await run(`confirm:${proposalId}`, confirmProposalAction, { proposalId, conversationId: view.conversationId, edits: edits ? JSON.stringify(edits) : null });
if (ok) router.refresh(); // clock bar / order status in the shell
}
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
function notice(m: ChatMessageView): string | null {
const key = m.content.noticeKey;
if (!key || !t.has(`chat.system.${key}`)) return null;
const values = { ...(m.content.noticeValues ?? {}) } as Record<string, string | number>;
if (key === "failed") {
const code = String(values.code ?? "generic");
values.reason = t.has(`chat.errors.${code}`) ? t(`chat.errors.${code}`) : t("chat.errors.generic");
}
return (t as unknown as LooseT)(`chat.system.${key}`, values);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[14px] text-muted-foreground">{view.workOrder ? t("chat.context", { number: view.workOrder.number, title: view.workOrder.title }) : t("chat.noContext")}</p>
<button
type="button"
disabled={pending !== null}
onClick={() => run("new", newConversationAction, { workOrderId })}
className="inline-flex min-h-12 items-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[14px] font-semibold text-primary disabled:opacity-50"
>
<MessageSquarePlus className="size-4.5" aria-hidden />
{t("chat.newChat")}
</button>
</div>
{view.messages.length === 0 && (
<p className="flex items-start gap-2 rounded-xl border bg-card p-3.5 text-[15px]">
<Compass className="mt-0.5 size-5 shrink-0 text-[var(--ui-accent)]" aria-hidden />
{t("chat.intro")}
</p>
)}
<ol className="space-y-3" aria-live="polite" aria-relevant="additions">
{view.messages.map((m) => {
const openCards = m.proposals.filter((p) => p.status === "proposed");
const noticeText = notice(m);
if (m.role === "user") {
return (
<li key={m.id} className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground">
<span className="sr-only">{t("chat.userLabel")}: </span>
{m.text}
</p>
</li>
);
}
if (m.role === "tool") {
return (
<li key={m.id} className="space-y-2">
{noticeText && (
<p className={m.content.noticeKey === "failed" || m.content.noticeKey === "stopped" ? noticeError : noticeWarn}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{noticeText}
</p>
)}
<ChatLinks links={m.content.links} />
</li>
);
}
return (
<li key={m.id} className="space-y-2">
<div className="max-w-[92%] rounded-2xl rounded-bl-md border bg-card px-3.5 py-2.5 shadow-card">
<LotseMark label={t("chat.lotseLabel")} className="text-[13px]" />
{m.text && <p className="mt-1 text-[15px] whitespace-pre-line">{m.text}</p>}
{noticeText && (
<p className="mt-1 flex items-start gap-1.5 text-[14px] text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
{noticeText}
</p>
)}
</div>
<ChatLinks links={m.content.links} />
{m.proposals.length > 0 && (
<div className="space-y-2">
{m.proposals.map((p) => (
<ProposalCard
key={p.id}
proposal={p}
locale={locale}
timeZone={timeZone}
busy={pending === `confirm:${p.id}` || pending === `all:${m.id}`}
disabled={pending !== null}
onConfirm={(edits) => confirm(p.id, edits)}
onDiscard={() => run(`discard:${p.id}`, discardProposalAction, { proposalId: p.id, conversationId: view.conversationId })}
/>
))}
{openCards.length > 1 && (
<button
type="button"
disabled={pending !== null}
onClick={async () => {
if (await run(`all:${m.id}`, confirmAllProposalsAction, { messageId: m.id, conversationId: view.conversationId })) router.refresh();
}}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border-2 border-[var(--ui-accent)] bg-card px-4 font-heading text-[15px] font-semibold text-foreground disabled:opacity-50"
>
{pending === `all:${m.id}` ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <CircleCheck className="size-5" aria-hidden />}
{t("chat.confirmAll", { count: openCards.length })}
</button>
)}
</div>
)}
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
{m.content.chips.map((c) => (
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}>
{c.label}
</button>
))}
</div>
)}
</li>
);
})}
{pending === "send" && sentText && (
<li className="space-y-2">
<div className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground opacity-70">{sentText}</p>
</div>
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold text-muted-foreground">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("chat.sending")}
</p>
</li>
)}
</ol>
<div ref={endRef} />
{!configured && (
<p className={noticeWarn}>
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("chat.notConfigured")}
</p>
)}
{!online && (
<p className={noticeWarn} role="status">
<WifiOff className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("chat.offline")}
</p>
)}
{errorText && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{errorText}
</p>
)}
<form
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
onSubmit={(e) => {
e.preventDefault();
void send(text);
}}
>
<label htmlFor="lotse-chat-input" className="sr-only">
{t("chat.input")}
</label>
<textarea
id="lotse-chat-input"
value={text}
maxLength={4000}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void send(text);
}
}}
placeholder={t("chat.input")}
className={cn(inputClass, "min-h-24 resize-y py-2.5")}
/>
{micError && <p className="text-[13.5px] text-[var(--risk)]">{micError}</p>}
<div className="flex items-start gap-2">
<MicButton enabled={transcription} disabled={pending !== null} onError={setMicError} onText={(value) => setText((current) => (current.trim() ? `${current.trim()} ${value}` : value))} />
<button
type="submit"
disabled={!configured || pending !== null || !text.trim()}
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cta px-4 font-heading text-[15px] font-semibold text-cta-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{pending === "send" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <SendHorizontal className="size-5" aria-hidden />}
{t("chat.send")}
</button>
</div>
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
</form>
</div>
);
}
function ChatLinks({ links }: { links?: ChatMessageView["content"]["links"] }) {
const t = useTranslations("lotse");
if (!links?.length) return null;
return (
<nav aria-label={t("chat.linksLabel")} className="rounded-xl border bg-card">
<ul className="divide-y">
{links.map((l) => (
<li key={`${l.href}|${l.label ?? l.labelKey}`}>
<Link href={l.href} className="flex min-h-12 items-center gap-2 px-3.5 py-2 text-[15px]">
<span className="flex-1">{t.has(l.labelKey) ? (t as unknown as LooseT)(l.labelKey, { label: l.label ?? "", text: l.label ?? "" }) : (l.label ?? l.href)}</span>
<ChevronRight className="size-4.5 text-muted-foreground" aria-hidden />
</Link>
</li>
))}
</ul>
</nav>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { LoaderCircle, Mic, Square } from "lucide-react";
import { cn } from "@/lib/utils";
const MAX_SECONDS = 120;
function pickMimeType(): string | undefined {
if (typeof MediaRecorder === "undefined") return undefined;
for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) if (MediaRecorder.isTypeSupported(type)) return type;
return undefined;
}
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
/**
* Microphone key of the Lotse chat (lane L16): record (max. 2 min) → POST /api/v1/lotse/transcribe →
* the text is handed to the input field, where it can be edited before sending. Without a
* configured transcription provider the key is disabled with a hint.
*/
export function MicButton({ enabled, disabled, onText, onError }: { enabled: boolean; disabled?: boolean; onText: (text: string) => void; onError: (message: string | null) => void }) {
const t = useTranslations("lotse.chat.mic");
const [state, setState] = useState<"idle" | "recording" | "transcribing">("idle");
const [seconds, setSeconds] = useState(0);
const recorder = useRef<MediaRecorder | null>(null);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
// same detection as the L4 voice recorder (server render: assume support)
const supported = typeof window === "undefined" || (typeof MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia);
useEffect(() => {
return () => {
if (timer.current) clearInterval(timer.current);
recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
};
}, []);
function stop() {
if (timer.current) clearInterval(timer.current);
timer.current = null;
if (recorder.current?.state === "recording") recorder.current.stop();
}
async function transcribe(blob: Blob) {
setState("transcribing");
try {
const body = new FormData();
body.append("file", blob, blob.type.includes("mp4") ? "spracheingabe.m4a" : blob.type.includes("ogg") ? "spracheingabe.ogg" : "spracheingabe.webm");
const res = await fetch("/api/v1/lotse/transcribe", { method: "POST", body });
const data = (await res.json().catch(() => null)) as { text?: string } | null;
if (!res.ok || typeof data?.text !== "string") throw new Error("failed");
onText(data.text);
onError(null);
} catch {
onError(t("failed"));
} finally {
setState("idle");
}
}
async function start() {
onError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = pickMimeType();
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());
void transcribe(new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" }));
};
recorder.current = rec;
rec.start(1000);
setSeconds(0);
setState("recording");
timer.current = setInterval(() => {
setSeconds((s) => {
if (s + 1 >= MAX_SECONDS) stop();
return Math.min(s + 1, MAX_SECONDS);
});
}, 1000);
} catch {
onError(t("denied"));
}
}
const unavailable = !enabled || !supported;
const label = state === "recording" ? t("recording", { time: clock(seconds) }) : state === "transcribing" ? t("transcribing") : t("record");
return (
<div className="flex flex-col items-stretch gap-1">
<button
type="button"
onClick={state === "recording" ? stop : start}
disabled={unavailable || disabled || state === "transcribing"}
aria-label={state === "recording" ? t("stop") : t("record")}
className={cn(
"inline-flex min-h-12 min-w-12 items-center justify-center gap-2 rounded-xl border px-3.5 font-heading text-[15px] font-semibold transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
state === "recording" ? "border-[var(--risk)] bg-[color-mix(in_oklch,var(--risk)_10%,transparent)] text-[var(--risk)]" : "border-border bg-card text-primary hover:bg-muted",
)}
>
{state === "recording" ? <Square className="size-5" aria-hidden /> : state === "transcribing" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Mic className="size-5" aria-hidden />}
<span aria-live="polite">{label}</span>
</button>
{unavailable && <p className="text-[12.5px] text-muted-foreground">{!enabled ? t("unavailable") : t("unsupported")}</p>}
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Ban, CircleCheck, CircleDashed, LoaderCircle, Pencil, TimerOff, TriangleAlert, X } from "lucide-react";
import { CHAT_TIME_TYPES, EDITABLE_FIELDS, type ChatProposalView } from "@/lib/lotse/chat";
import { NOTE_KINDS } from "@/lib/sync/ops";
import { cn } from "@/lib/utils";
import { chip, inputClass } from "@/components/field/ui";
type Values = Record<string, unknown>;
const STATUS_ICON = { proposed: CircleDashed, confirmed: CircleCheck, discarded: Ban, expired: TimerOff, failed: TriangleAlert } as const;
const STATUS_TONE = {
proposed: "text-foreground",
confirmed: "text-[var(--ok)]",
discarded: "text-muted-foreground",
expired: "text-muted-foreground",
failed: "text-[var(--risk)]",
} as const;
const s = (v: unknown) => (typeof v === "string" ? v : v === null || v === undefined ? "" : String(v));
const hm = (minutes: number) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
function fmtDate(key: string, locale: string) {
const [y, m, d] = key.split("-").map(Number);
if (!y) return key;
return new Intl.DateTimeFormat(locale, { weekday: "short", day: "2-digit", month: "2-digit", timeZone: "UTC" }).format(new Date(Date.UTC(y, m - 1, d)));
}
/**
* Action card of the Lotse chat (lane L16): clear values, Bestätigen / Bearbeiten / Verwerfen, status
* after execution as text + icon (never colour only). Touch targets ≥ 48 px.
*/
export function ProposalCard({
proposal,
busy,
disabled,
locale,
timeZone,
onConfirm,
onDiscard,
}: {
proposal: ChatProposalView;
busy: boolean;
disabled: boolean;
locale: string;
timeZone: string;
onConfirm: (edits: Values | null) => void;
onDiscard: () => void;
}) {
const t = useTranslations("lotse.chat");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<Values>(proposal.payload);
const p = proposal.payload;
const open = proposal.status === "proposed";
const editable = EDITABLE_FIELDS[proposal.kind].length > 0;
const StatusIcon = STATUS_ICON[proposal.status];
const set = (key: string, value: unknown) => setDraft((d) => ({ ...d, [key]: value }));
const errorText = proposal.errorCode ? (t.has(`errors.${proposal.errorCode}`) ? t(`errors.${proposal.errorCode}`) : t("errors.generic")) : null;
const validUntil = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone }).format(new Date(proposal.expiresAt));
function submitEdits() {
const allowed = EDITABLE_FIELDS[proposal.kind];
const edits = Object.fromEntries(Object.entries(draft).filter(([k, v]) => allowed.includes(k) && JSON.stringify(v) !== JSON.stringify(p[k])));
onConfirm(Object.keys(edits).length ? edits : null);
}
const label = (text: string, children: React.ReactNode) => (
<label className="block space-y-1">
<span className="text-[13px] font-semibold text-muted-foreground">{text}</span>
{children}
</label>
);
let body: React.ReactNode = null;
let form: React.ReactNode = null;
switch (proposal.kind) {
case "transition_work_order":
body = (
<>
<p className="text-[16px] font-semibold">{(t as unknown as (key: string, values: Record<string, string>) => string)(`card.action.${s(p.action)}`, { number: s(p.number) })}</p>
{s(p.switchFrom) && <p className="text-[14px]">{t("card.switchFrom", { number: s(p.switchFrom) })}</p>}
{p.action === "complete" && <p className="text-[13.5px] text-muted-foreground">{t("card.completeHint")}</p>}
</>
);
break;
case "book_time": {
const minutes = Number(p.durationMinutes ?? 0);
body = (
<>
<p className="text-[16px] font-semibold">
{t(`card.timeType.${s(p.type)}` as never)} · {t("card.order", { number: s(p.number) })}
</p>
<p className="text-[15px]">{t("card.timeLine", { date: fmtDate(s(p.date), locale), from: s(p.from), to: s(p.to), duration: hm(minutes) })}</p>
<p className="text-[14px]">
<span className="text-muted-foreground">{t("card.reason")}: </span>
{s(p.reason)}
</p>
{s(p.note) && (
<p className="text-[14px]">
<span className="text-muted-foreground">{t("card.note")}: </span>
{s(p.note)}
</p>
)}
<p className="text-[13.5px] text-muted-foreground">{proposal.result?.approvalStatus === "pending" ? t("card.pendingApproval") : t("card.approvalHint")}</p>
</>
);
form = (
<div className="space-y-3">
<div className="flex flex-wrap gap-2" role="group" aria-label={t("card.type")}>
{CHAT_TIME_TYPES.map((type) => (
<button key={type} type="button" className={chip(draft.type === type)} aria-pressed={draft.type === type} onClick={() => set("type", type)}>
{t(`card.timeType.${type}`)}
</button>
))}
</div>
{label(t("card.date"), <input type="date" className={inputClass} value={s(draft.date)} onChange={(e) => set("date", e.target.value)} />)}
<div className="grid grid-cols-2 gap-2">
{label(t("card.from"), <input type="time" className={inputClass} value={s(draft.from)} onChange={(e) => set("from", e.target.value)} />)}
{label(t("card.to"), <input type="time" className={inputClass} value={s(draft.to)} onChange={(e) => set("to", e.target.value)} />)}
</div>
{label(t("card.reason"), <input className={inputClass} value={s(draft.reason)} onChange={(e) => set("reason", e.target.value)} />)}
{label(t("card.note"), <input className={inputClass} value={s(draft.note)} onChange={(e) => set("note", e.target.value || null)} />)}
</div>
);
break;
}
case "record_material":
body = (
<>
<p className="text-[16px] font-semibold">
{s(p.quantity)} {s(p.unit)} · {s(p.name)}
</p>
<p className="text-[14px] text-muted-foreground">
{t("card.order", { number: s(p.number) })} · {p.materialPlanId ? t("card.planned") : t("card.additional")}
</p>
{s(p.deviationReason) && (
<p className="text-[14px]">
<span className="text-muted-foreground">{t("card.deviationReason")}: </span>
{s(p.deviationReason)}
</p>
)}
</>
);
form = (
<div className="space-y-3">
{!p.materialPlanId && label(t("card.name"), <input className={inputClass} value={s(draft.name)} onChange={(e) => set("name", e.target.value)} />)}
<div className="grid grid-cols-2 gap-2">
{label(t("card.quantity"), <input type="number" inputMode="decimal" min={0} step="any" className={inputClass} value={s(draft.quantity)} onChange={(e) => set("quantity", e.target.value === "" ? 0 : Number(e.target.value))} />)}
{label(t("card.unit"), <input className={inputClass} value={s(draft.unit)} readOnly={Boolean(p.materialPlanId)} onChange={(e) => set("unit", e.target.value)} />)}
</div>
{label(t("card.deviationReason"), <input className={inputClass} value={s(draft.deviationReason)} onChange={(e) => set("deviationReason", e.target.value || null)} />)}
</div>
);
break;
case "add_note":
body = (
<>
<p className="text-[14px] text-muted-foreground">
{t("card.order", { number: s(p.number) })} · {t(`noteKind.${s(p.kind)}` as never)}
</p>
<p className="whitespace-pre-line text-[15px]">{s(p.text)}</p>
</>
);
form = (
<div className="space-y-3">
{label(
t("card.noteKind"),
<select className={inputClass} value={s(draft.kind)} onChange={(e) => set("kind", e.target.value)}>
{NOTE_KINDS.map((k) => (
<option key={k} value={k}>
{t(`noteKind.${k}`)}
</option>
))}
</select>,
)}
{label(t("card.text"), <textarea className={cn(inputClass, "min-h-28 py-2.5")} value={s(draft.text)} onChange={(e) => set("text", e.target.value)} />)}
</div>
);
break;
case "suggest_report_fields": {
const fields = (Array.isArray(p.fields) ? p.fields : []) as Array<{ field: string; text: string }>;
const draftFields = (Array.isArray(draft.fields) ? draft.fields : []) as Array<{ field: string; text: string }>;
body = (
<>
<p className="text-[16px] font-semibold">
{t("card.reportFields", { type: t(`card.reportType.${s(p.reportType)}` as never) })} · {s(p.number)}
</p>
<dl className="space-y-2">
{fields.map((f) => (
<div key={f.field}>
<dt className="text-[13px] font-semibold text-muted-foreground">{t(`card.field.${f.field}` as never)}</dt>
<dd className="whitespace-pre-line text-[15px]">{f.text}</dd>
</div>
))}
</dl>
<p className="text-[13.5px] text-muted-foreground">{t("card.reportHint")}</p>
{proposal.status === "confirmed" && typeof proposal.result?.href === "string" && (
<Link href={proposal.result.href} className="inline-flex min-h-12 items-center font-semibold text-primary underline underline-offset-4">
{t("card.openReport")}
</Link>
)}
</>
);
form = (
<div className="space-y-3">
{draftFields.map((f, i) =>
label(
t(`card.field.${f.field}` as never),
<textarea
key={f.field}
className={cn(inputClass, "min-h-28 py-2.5")}
value={f.text}
onChange={(e) => set("fields", draftFields.map((x, j) => (j === i ? { ...x, text: e.target.value } : x)))}
/>,
),
)}
</div>
);
break;
}
}
return (
<article className={cn("rounded-xl border border-l-4 bg-card p-3.5 shadow-card", open ? "border-l-[var(--ui-accent)]" : "border-l-border")} aria-label={t(`card.kind.${proposal.kind}`)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[12.5px] font-bold uppercase tracking-wide text-muted-foreground">{t(`card.kind.${proposal.kind}`)}</span>
<span className={cn("inline-flex items-center gap-1 text-[13px] font-semibold", STATUS_TONE[proposal.status])}>
<StatusIcon className="size-4" aria-hidden />
{t(`card.status.${proposal.status}`)}
</span>
</div>
<div className="mt-1.5 space-y-1.5">{editing ? form : body}</div>
{proposal.status === "failed" && errorText && (
<p className="mt-2 flex items-start gap-1.5 text-[14px] text-[var(--risk)]" role="alert">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
{errorText}
</p>
)}
{open && (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={disabled || busy}
onClick={() => (editing ? submitEdits() : onConfirm(null))}
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cta px-4 font-heading text-[15px] font-semibold text-cta-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <CircleCheck className="size-5" aria-hidden />}
{busy ? t("card.busy") : editing ? t("card.saveConfirm") : t("card.confirm")}
</button>
{editable && (
<button
type="button"
disabled={disabled || busy}
onClick={() => {
setDraft(p);
setEditing((e) => !e);
}}
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[15px] font-semibold text-primary disabled:opacity-50"
>
<Pencil className="size-4.5" aria-hidden />
{editing ? t("card.cancel") : t("card.edit")}
</button>
)}
<button
type="button"
disabled={disabled || busy}
onClick={onDiscard}
className="inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[15px] font-semibold text-primary disabled:opacity-50"
>
<X className="size-4.5" aria-hidden />
{t("card.discard")}
</button>
</div>
<p className="text-[12.5px] text-muted-foreground">{t("card.validUntil", { time: validUntil })}</p>
</div>
)}
</article>
);
}