L9 Lotse – KI-Assistent: UI im Berichtseditor, Auftragsdetail, Sprachnotizen und Einstellungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,5 +12,6 @@
|
|||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"audit": "Audit-Protokoll",
|
"audit": "Audit-Protokoll",
|
||||||
"email": "E-Mail-Versand",
|
"email": "E-Mail-Versand",
|
||||||
|
"lotse": "Lotse (KI)",
|
||||||
"admin": "Admin-Konsole"
|
"admin": "Admin-Konsole"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"audit": "Audit log",
|
"audit": "Audit log",
|
||||||
"email": "E-mail delivery",
|
"email": "E-mail delivery",
|
||||||
|
"lotse": "Lotse (AI)",
|
||||||
"admin": "Admin console"
|
"admin": "Admin console"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RejectForm } from "@/components/reports/reject-form";
|
|||||||
import { ReportView } from "@/components/reports/report-view";
|
import { ReportView } from "@/components/reports/report-view";
|
||||||
import { ReviewActions } from "@/components/reports/review-actions";
|
import { ReviewActions } from "@/components/reports/review-actions";
|
||||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||||
|
import { LotseReportPanel } from "@/components/lotse/report-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ServiceError } from "@/server/services/context";
|
import { ServiceError } from "@/server/services/context";
|
||||||
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
||||||
@@ -83,6 +84,9 @@ export default async function ReportDetailPage({ params, searchParams }: { param
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<ReviewActions reportId={id} can={permissions} rejectHref={`${base}?reject=1`} />
|
<ReviewActions reportId={id} can={permissions} rejectHref={`${base}?reject=1`} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-4 max-w-3xl">
|
||||||
|
<LotseReportPanel reportId={id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
||||||
|
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { saveLotseSettings } from "@/server/actions/lotse-settings";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
|
import { getLotseSettings } from "@/server/services/lotse/settings";
|
||||||
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
|
||||||
|
/** Configured / not configured — text + icon, never colour alone. */
|
||||||
|
function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off: string } }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[12.5px] font-semibold ${ok ? "text-[var(--ok)]" : "text-muted-foreground"}`}>
|
||||||
|
{ok ? <CheckCircle2 className="size-4" aria-hidden /> : <MinusCircle className="size-4" aria-hidden />}
|
||||||
|
{ok ? labels.ok : labels.off}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
|
||||||
|
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
|
||||||
|
*/
|
||||||
|
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
|
||||||
|
const ctx = await readCtx();
|
||||||
|
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
||||||
|
const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]);
|
||||||
|
const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("settings.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead crumb={t("settings.crumb")} title={t("settings.title")} sub={t("settings.sub")} />
|
||||||
|
|
||||||
|
{sp.saved && (
|
||||||
|
<p role="status" className="mb-4 flex items-center gap-2 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden /> {t("settings.saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sp.error && (
|
||||||
|
<p role="alert" className="mb-4 flex items-center gap-2 rounded-lg border border-[var(--risk)] bg-card px-4 py-3 text-sm text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4" aria-hidden /> {t("settings.error")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid max-w-5xl gap-5 lg:grid-cols-2">
|
||||||
|
<form action={saveLotseSettings} className="shadow-card space-y-5 rounded-xl border bg-card p-5">
|
||||||
|
<h2 className="font-heading text-sm font-semibold">
|
||||||
|
<LotseMark label={t("name")} /> · {t("settings.use")}
|
||||||
|
</h2>
|
||||||
|
<label className="flex min-h-11 cursor-pointer items-start gap-3">
|
||||||
|
<input type="checkbox" name="enabled" defaultChecked={s.enabled} className="mt-0.5 size-5 accent-[var(--ui-accent)]" />
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold">{t("settings.enabled")}</span>
|
||||||
|
<span className="block text-[12px] text-muted-foreground">{t("settings.enabledHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="text-sm font-semibold">{t("settings.addressForm")}</legend>
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("settings.addressFormHint")}</p>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
{(["neutral", "sie", "du"] as const).map((v) => (
|
||||||
|
<label key={v} className="flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 text-sm has-[:checked]:border-[var(--ui-accent)] has-[:checked]:font-semibold">
|
||||||
|
<input type="radio" name="addressForm" value={v} defaultChecked={(s.addressForm ?? "neutral") === v} className="size-4 accent-[var(--ui-accent)]" />
|
||||||
|
{t(`settings.address.${v}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<Button type="submit" className="min-h-11">
|
||||||
|
{t("settings.save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className="shadow-card space-y-4 rounded-xl border bg-card p-5 text-sm">
|
||||||
|
<h2 className="font-heading text-sm font-semibold">{t("settings.dataTitle")}</h2>
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt className="flex flex-wrap items-center justify-between gap-2 font-semibold">
|
||||||
|
{t("settings.draftProvider")} <ProviderStatus ok={s.draft.configured} labels={statusLabels} />
|
||||||
|
</dt>
|
||||||
|
<dd className="text-muted-foreground">{t("settings.providerLine", { provider: s.draft.provider, model: s.draft.model })}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="flex flex-wrap items-center justify-between gap-2 font-semibold">
|
||||||
|
{t("settings.transcriptionProvider")} <ProviderStatus ok={s.transcription.configured} labels={statusLabels} />
|
||||||
|
</dt>
|
||||||
|
<dd className="text-muted-foreground">{t("settings.transcriptionLine", { host: s.transcription.host ?? "—", model: s.transcription.model })}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{t("settings.sentTitle")}</h3>
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||||
|
{(["order", "notes", "work", "audio"] as const).map((k) => (
|
||||||
|
<li key={k}>{t(`settings.sent.${k}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{t("settings.notSentTitle")}</h3>
|
||||||
|
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||||
|
{(["contact", "names", "customer"] as const).map((k) => (
|
||||||
|
<li key={k}>{t(`settings.notSent.${k}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="flex gap-2 rounded-lg bg-muted p-3 text-[12.5px]">
|
||||||
|
<ShieldCheck className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t("settings.principle")}
|
||||||
|
</p>
|
||||||
|
<Link href="/settings/lotse/protocol" className="inline-flex min-h-11 items-center gap-2 font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
<ListChecks className="size-4" aria-hidden />
|
||||||
|
{t("settings.protocolLink")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { canReadProtocol, getAiGenerationContent, listAiGenerations } from "@/server/services/lotse/protocol";
|
||||||
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
|
||||||
|
/** /settings/lotse/protocol — AiGeneration log (tenant:manage or audit:read; contents only tenant:manage). */
|
||||||
|
export default async function LotseProtocolPage({ searchParams }: { searchParams: Promise<{ page?: string; detail?: string }> }) {
|
||||||
|
const ctx = await readCtx();
|
||||||
|
if (!canReadProtocol(ctx)) redirect("/dashboard");
|
||||||
|
const sp = await searchParams;
|
||||||
|
const page = Math.max(1, Number(sp.page) || 1);
|
||||||
|
const [list, t, format] = await Promise.all([listAiGenerations(ctx, { page }), getTranslations("lotse"), getFormatter()]);
|
||||||
|
let detail: Awaited<ReturnType<typeof getAiGenerationContent>> | null = null;
|
||||||
|
if (sp.detail && list.canSeeContent) {
|
||||||
|
try {
|
||||||
|
detail = await getAiGenerationContent(ctx, sp.detail);
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ServiceError)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
|
||||||
|
const base = `/settings/lotse/protocol?page=${page}`;
|
||||||
|
const kind = (k: string) => (t.has(`protocol.kinds.${k}`) ? t(`protocol.kinds.${k}`) : k);
|
||||||
|
const th = "px-3 py-2 text-left text-[11.5px] font-semibold text-muted-foreground";
|
||||||
|
const td = "px-3 py-2 align-top";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/settings/lotse" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("protocol.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead crumb={t("protocol.crumb")} title={t("protocol.title")} sub={t("protocol.sub")} />
|
||||||
|
{!list.canSeeContent && <p className="mb-3 text-[12.5px] text-muted-foreground">{t("protocol.contentHint")}</p>}
|
||||||
|
|
||||||
|
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||||
|
{list.items.length === 0 ? (
|
||||||
|
<p className="p-5 text-sm text-muted-foreground">{t("protocol.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[640px] text-[13px]">
|
||||||
|
<thead className="border-b">
|
||||||
|
<tr>
|
||||||
|
<th className={th}>{t("protocol.time")}</th>
|
||||||
|
<th className={th}>{t("protocol.kind")}</th>
|
||||||
|
<th className={th}>{t("protocol.model")}</th>
|
||||||
|
<th className={th}>{t("protocol.tokens")}</th>
|
||||||
|
<th className={th}>{t("protocol.user")}</th>
|
||||||
|
{list.canSeeContent && <th className={th}>{t("protocol.content")}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{list.items.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className={td}>{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "short" })}</td>
|
||||||
|
<td className={td}>{kind(r.kind)}</td>
|
||||||
|
<td className={`${td} font-mono text-[12px]`}>
|
||||||
|
{r.model}
|
||||||
|
<span className="block text-muted-foreground">{r.provider}</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${td} tabular-nums`}>
|
||||||
|
{r.inputTokens ?? "—"} / {r.outputTokens ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className={td}>{r.userName ?? t("protocol.system")}</td>
|
||||||
|
{list.canSeeContent && (
|
||||||
|
<td className={td}>
|
||||||
|
<Link href={`${base}&detail=${r.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("protocol.show")}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages > 1 && (
|
||||||
|
<nav className="mt-3 flex items-center gap-3 text-sm" aria-label={t("protocol.pageOf", { page, pages })}>
|
||||||
|
{page > 1 && (
|
||||||
|
<Link href={`/settings/lotse/protocol?page=${page - 1}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)]">
|
||||||
|
{t("protocol.prev")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">{t("protocol.pageOf", { page, pages })}</span>
|
||||||
|
{page < pages && (
|
||||||
|
<Link href={`/settings/lotse/protocol?page=${page + 1}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)]">
|
||||||
|
{t("protocol.next")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<Modal title={t("protocol.contentTitle")} sub={`${kind(detail.kind)} · ${detail.model}`} closeHref={base} closeLabel={t("protocol.close")}>
|
||||||
|
<div className="grid gap-4 p-5 lg:grid-cols-2">
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("protocol.input")}</h3>
|
||||||
|
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-[12px]">{JSON.stringify(detail.input, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("protocol.output")}</h3>
|
||||||
|
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-[12px]">{JSON.stringify(detail.output, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import { SubPageHeader } from "@/components/field/sub-page-header";
|
|||||||
import { VoiceRecorder } from "@/components/field/voice-recorder";
|
import { VoiceRecorder } from "@/components/field/voice-recorder";
|
||||||
import { card } from "@/components/field/ui";
|
import { card } from "@/components/field/ui";
|
||||||
import { loadOrder } from "../load";
|
import { loadOrder } from "../load";
|
||||||
|
import { LotseVoiceNote } from "@/components/lotse/voice-note-panel";
|
||||||
|
|
||||||
/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */
|
/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */
|
||||||
export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { order } = await loadOrder(params);
|
const { order } = await loadOrder(params);
|
||||||
const t = await getTranslations("field");
|
const t = await getTranslations("field");
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
|
const tl = await getTranslations("lotse");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="space-y-4 pb-4">
|
<main className="space-y-4 pb-4">
|
||||||
@@ -33,8 +35,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri
|
|||||||
{v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
|
{v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<audio controls preload="none" src={`/api/v1/field/documents/${v.documentId}`} className="mt-2 w-full" />
|
<audio controls preload="none" src={`/api/v1/field/documents/${v.documentId}`} className="mt-2 w-full" />
|
||||||
<p className="mt-2 text-[13px] font-semibold">{t(`voice.status.${v.transcriptionStatus}`)}</p>
|
<LotseVoiceNote voiceNote={v} />
|
||||||
{v.transcript && <p className="mt-1 whitespace-pre-line text-[15px]">{v.transcript}</p>}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -48,6 +49,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri
|
|||||||
<li key={n.id} className={card}>
|
<li key={n.id} className={card}>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
|
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
|
||||||
|
{n.voiceNoteId && ` · ${tl("voice.fromVoice")}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
|
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { QuickPhotoButton } from "@/components/field/photo-capture";
|
|||||||
import { StatusBadge } from "@/components/field/status-badge";
|
import { StatusBadge } from "@/components/field/status-badge";
|
||||||
import { card, toneClasses } from "@/components/field/ui";
|
import { card, toneClasses } from "@/components/field/ui";
|
||||||
import { loadOrder } from "./load";
|
import { loadOrder } from "./load";
|
||||||
|
import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
|
||||||
|
|
||||||
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
|
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
|
||||||
const head = (
|
const head = (
|
||||||
@@ -162,6 +163,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editable && <LotseCompletenessCard workOrderId={order.id} />}
|
||||||
|
|
||||||
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
|
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
|
||||||
|
|
||||||
{order.site && (
|
{order.site && (
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AlertTriangle, ChevronRight, Compass } from "lucide-react";
|
||||||
|
import type { CompletenessItem } from "@/lib/lotse/completeness";
|
||||||
|
import { can, ServiceError } from "@/server/services/context";
|
||||||
|
import { checkCompleteness } from "@/server/services/lotse/completeness";
|
||||||
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
import { LotseMark } from "./lotse-mark";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hint card „3 Angaben fehlen – Lotse prüfen lassen“ for the mobile order detail (Brandbook §12.4).
|
||||||
|
* Deterministic rules + Lotse hints, each with a deep link. Hidden when nothing is missing, the Lotse
|
||||||
|
* is off or the user may not use it.
|
||||||
|
*/
|
||||||
|
export async function LotseCompletenessCard({ workOrderId }: { workOrderId: string }) {
|
||||||
|
const ctx = await readCtx();
|
||||||
|
if (!can(ctx, "lotse:use")) return null;
|
||||||
|
let items: CompletenessItem[];
|
||||||
|
try {
|
||||||
|
items = await checkCompleteness(ctx, workOrderId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!items.length) return null;
|
||||||
|
const t = await getTranslations("lotse");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`lotse-complete-${workOrderId}`} className="rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4 shadow-card">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id={`lotse-complete-${workOrderId}`} className="flex items-center gap-2 text-[17px]">
|
||||||
|
<AlertTriangle className="size-5 text-[var(--warn)]" aria-hidden />
|
||||||
|
{t("completeness.title", { count: items.length })}
|
||||||
|
</h2>
|
||||||
|
<LotseMark label={t("name")} className="text-[13px]" />
|
||||||
|
</div>
|
||||||
|
<details className="group mt-2">
|
||||||
|
<summary className="inline-flex min-h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-border px-4 font-heading text-[15px] font-semibold text-primary hover:bg-muted">
|
||||||
|
<Compass className="size-4.5" aria-hidden />
|
||||||
|
{t("completeness.check")}
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 divide-y">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Link href={item.href} className="flex min-h-12 items-center gap-2 py-2 text-[15px]">
|
||||||
|
<span className="flex-1">
|
||||||
|
{item.source === "lotse" && <span className="block text-[12px] font-semibold text-[var(--ui-accent)]">{t("completeness.fromLotse")}</span>}
|
||||||
|
{t(`completeness.item.${item.code}`, { label: item.label ?? "", text: item.text ?? "" })}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center text-[13px] font-semibold text-primary">
|
||||||
|
{t("completeness.fix")}
|
||||||
|
<ChevronRight className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CheckCircle2, Compass, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { LOTSE_IDLE } from "@/lib/lotse/action-state";
|
||||||
|
import { draftReportAction } from "@/server/actions/lotse/assist";
|
||||||
|
|
||||||
|
/** „Bericht mit Lotse vorbereiten“ with loading state, plain-language errors and not-configured hint. */
|
||||||
|
export function LotseDraftButton({ reportId, configured, hasDraft }: { reportId: string; configured: boolean; hasDraft: boolean }) {
|
||||||
|
const t = useTranslations("lotse");
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, action, pending] = useActionState(draftReportAction, LOTSE_IDLE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "ok") router.refresh();
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-2">
|
||||||
|
<input type="hidden" name="reportId" value={reportId} />
|
||||||
|
{!hasDraft && <p className="text-[13.5px] text-muted-foreground">{t("draft.hint")}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!configured || pending}
|
||||||
|
className={
|
||||||
|
hasDraft
|
||||||
|
? "inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
: "inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl bg-cta px-5 font-heading text-[15px] font-semibold text-cta-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pending ? <Loader2 className="size-5 animate-spin" aria-hidden /> : <Compass className="size-5" aria-hidden />}
|
||||||
|
{pending ? t("draft.running") : hasDraft ? t("draft.again") : t("draft.button")}
|
||||||
|
</button>
|
||||||
|
<div aria-live="polite">
|
||||||
|
{!configured && <p className="text-[13px] text-muted-foreground">{t("draft.notConfigured")}</p>}
|
||||||
|
{configured && !pending && <p className="text-[12.5px] text-muted-foreground">{t("draft.unsavedHint")}</p>}
|
||||||
|
{state.status === "ok" && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-[13.5px] font-semibold text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{t("draft.done", { count: state.count ?? 0 })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[13.5px] font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t(`errors.${state.code}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Compass } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lotse identity in the interface (Brandbook §4.3/§12.4): simple icon + word mark in Signalorange.
|
||||||
|
* No bitmap from the brand PNG. `label` comes from messages (`lotse.name`).
|
||||||
|
*/
|
||||||
|
export function LotseMark({ label, className }: { label: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-1.5 font-heading font-bold text-[var(--ui-accent)]", className)}>
|
||||||
|
<Compass className="size-[1.15em]" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import { CheckCircle2, Info, ShieldCheck } from "lucide-react";
|
||||||
|
import { pendingSuggestions } from "@/lib/lotse/content";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { getLotseReportState } from "@/server/services/lotse/state";
|
||||||
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
import { LotseDraftButton } from "./draft-button";
|
||||||
|
import { LotseMark } from "./lotse-mark";
|
||||||
|
import { LotseSuggestionCard } from "./suggestion-card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lotse block of the report editors (mobile /m/orders/[id]/report, backoffice /reports/[id]):
|
||||||
|
* prepare button, marked suggestions (accept/discard/edit), missing information, review proof.
|
||||||
|
* Renders nothing when the Lotse is off (and never drafted) or the user may not use it.
|
||||||
|
*/
|
||||||
|
export async function LotseReportPanel({ reportId }: { reportId: string }) {
|
||||||
|
const ctx = await readCtx();
|
||||||
|
let state: Awaited<ReturnType<typeof getLotseReportState>>;
|
||||||
|
try {
|
||||||
|
state = await getLotseReportState(ctx, reportId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!state || (!state.canDraft && !state.lotse)) return null;
|
||||||
|
const [t, format] = await Promise.all([getTranslations("lotse"), getFormatter()]);
|
||||||
|
const pending = pendingSuggestions(state.lotse);
|
||||||
|
const lotse = state.lotse;
|
||||||
|
const date = (iso: string) => format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={`lotse-panel-${reportId}`} className="shadow-card space-y-3 rounded-xl border border-l-4 border-l-[var(--ui-accent)] bg-card p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id={`lotse-panel-${reportId}`} className="text-[16px]">
|
||||||
|
<LotseMark label={t("name")} />
|
||||||
|
</h2>
|
||||||
|
{lotse && <span className="text-[12.5px] text-muted-foreground">{t("draft.draftedAt", { date: date(lotse.draftedAt) })}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.editable && state.canDraft && <LotseDraftButton reportId={state.reportId} configured={state.configured} hasDraft={Boolean(lotse)} />}
|
||||||
|
|
||||||
|
{state.editable &&
|
||||||
|
pending.map((s) => <LotseSuggestionCard key={`${lotse?.generationId}-${s.field}`} reportId={state.reportId} field={s.field} suggestion={s.text} current={state.texts[s.field] ?? ""} />)}
|
||||||
|
|
||||||
|
{state.editable && lotse && lotse.suggestions.length > 0 && pending.length === 0 && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-[13.5px] font-semibold text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{t("draft.allDecided")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.editable && lotse && lotse.missingInformation.length > 0 && (
|
||||||
|
<div className="rounded-xl bg-[color-mix(in_oklch,var(--info)_10%,transparent)] p-3">
|
||||||
|
<p className="flex items-center gap-2 text-[13.5px] font-semibold">
|
||||||
|
<Info className="size-4.5 text-[var(--info)]" aria-hidden />
|
||||||
|
{t("draft.missingTitle")}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1.5 list-disc space-y-1 pl-6 text-[13.5px]">
|
||||||
|
{lotse.missingInformation.map((m, i) => (
|
||||||
|
<li key={i}>{m}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lotse?.reviewedAt && (
|
||||||
|
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||||
|
<ShieldCheck className="size-4" aria-hidden />
|
||||||
|
{t("draft.reviewedAt", { date: date(lotse.reviewedAt) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandatory confirmation before submitting a Lotse-drafted report (Spec §15.3). The checkbox only sends
|
||||||
|
* `aiReviewed=on`; the server (submitReport → applyLotseReview) enforces it.
|
||||||
|
*/
|
||||||
|
export function LotseReviewConfirm({ invalid = false }: { invalid?: boolean }) {
|
||||||
|
const t = useTranslations("lotse");
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border p-3", invalid ? "border-[var(--risk)]" : "border-[var(--ui-accent)]")}>
|
||||||
|
<label className="flex min-h-12 cursor-pointer items-center gap-3 text-[14.5px] font-semibold">
|
||||||
|
<input type="checkbox" name="aiReviewed" className="size-6 shrink-0 accent-[var(--ui-accent)]" aria-invalid={invalid} aria-describedby="lotse-review-hint" />
|
||||||
|
{t("review.label")}
|
||||||
|
</label>
|
||||||
|
<p id="lotse-review-hint" className="mt-1 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
|
||||||
|
<ShieldCheck className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t("review.hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Check, Sparkles, X, XCircle } from "lucide-react";
|
||||||
|
import { LOTSE_IDLE, type LotseActionState } from "@/lib/lotse/action-state";
|
||||||
|
import type { LotseTextField } from "@/lib/lotse/content";
|
||||||
|
import { decideSuggestionAction } from "@/server/actions/lotse/assist";
|
||||||
|
|
||||||
|
async function accept(prev: LotseActionState, fd: FormData) {
|
||||||
|
fd.set("decision", "accept");
|
||||||
|
return decideSuggestionAction(prev, fd);
|
||||||
|
}
|
||||||
|
async function discard(prev: LotseActionState, fd: FormData) {
|
||||||
|
fd.set("decision", "discard");
|
||||||
|
fd.delete("text");
|
||||||
|
return decideSuggestionAction(prev, fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One Lotse suggestion: clearly marked, editable, accept or discard. */
|
||||||
|
export function LotseSuggestionCard({ reportId, field, suggestion, current }: { reportId: string; field: LotseTextField; suggestion: string; current: string }) {
|
||||||
|
const t = useTranslations("lotse");
|
||||||
|
const tr = useTranslations("reports");
|
||||||
|
const router = useRouter();
|
||||||
|
const [text, setText] = useState(suggestion);
|
||||||
|
const [acceptState, acceptAction, accepting] = useActionState(accept, LOTSE_IDLE);
|
||||||
|
const [discardState, discardAction, discarding] = useActionState(discard, LOTSE_IDLE);
|
||||||
|
const busy = accepting || discarding;
|
||||||
|
const error = acceptState.status === "error" ? acceptState : discardState.status === "error" ? discardState : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (acceptState.status === "ok" || discardState.status === "ok") router.refresh();
|
||||||
|
}, [acceptState, discardState, router]);
|
||||||
|
|
||||||
|
const id = `lotse-${reportId}-${field}`;
|
||||||
|
return (
|
||||||
|
<form action={acceptAction} className="space-y-2.5 rounded-xl border border-dashed border-[var(--ui-accent)] bg-[color-mix(in_oklch,var(--ui-accent)_6%,transparent)] p-3.5">
|
||||||
|
<input type="hidden" name="reportId" value={reportId} />
|
||||||
|
<input type="hidden" name="field" value={field} />
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="font-heading text-[15px] font-semibold">{tr(`texts.${field}`)}</h3>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklch,var(--ui-accent)_16%,transparent)] px-2 py-1 text-[12px] font-semibold text-foreground">
|
||||||
|
<Sparkles className="size-3.5 text-[var(--ui-accent)]" aria-hidden />
|
||||||
|
{t("suggestionBadge")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<details className="text-[13px]">
|
||||||
|
<summary className="min-h-11 cursor-pointer content-center font-semibold text-muted-foreground">{t("suggestion.current")}</summary>
|
||||||
|
<p className="whitespace-pre-line rounded-lg bg-muted p-2.5">{current.trim() ? current : t("suggestion.empty")}</p>
|
||||||
|
</details>
|
||||||
|
<label htmlFor={id} className="block text-[13px] font-semibold">
|
||||||
|
{t("suggestion.text")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
name="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={Math.min(10, Math.max(3, text.split("\n").length + 1))}
|
||||||
|
maxLength={10_000}
|
||||||
|
className="min-h-24 w-full rounded-xl border border-input bg-card px-3 py-2 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="flex items-center gap-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t(`errors.${error.code}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !text.trim()}
|
||||||
|
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-heading text-[15px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Check className="size-4.5" aria-hidden />
|
||||||
|
{t("suggestion.accept")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formAction={discardAction}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-foreground hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<X className="size-4.5" aria-hidden />
|
||||||
|
{t("suggestion.discard")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CheckCircle2, Compass, Loader2, MinusCircle, Pencil, Sparkles, XCircle, type LucideIcon } from "lucide-react";
|
||||||
|
import { LOTSE_IDLE, type LotseActionState } from "@/lib/lotse/action-state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { adoptSummaryAction, saveTranscriptAction, summarizeVoiceNoteAction } from "@/server/actions/lotse/assist";
|
||||||
|
|
||||||
|
type Status = "pending" | "done" | "failed" | "disabled";
|
||||||
|
|
||||||
|
const STATUS: Record<Status, { icon: LucideIcon; cls: string }> = {
|
||||||
|
pending: { icon: Loader2, cls: "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]" },
|
||||||
|
done: { icon: CheckCircle2, cls: "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]" },
|
||||||
|
failed: { icon: XCircle, cls: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]" },
|
||||||
|
disabled: { icon: MinusCircle, cls: "bg-muted text-muted-foreground" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnOutline =
|
||||||
|
"inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
function ErrorLine({ state }: { state: LotseActionState }) {
|
||||||
|
const t = useTranslations("lotse");
|
||||||
|
if (state.status !== "error") return null;
|
||||||
|
return (
|
||||||
|
<p role="alert" className="flex items-center gap-2 text-[13.5px] font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{t(`errors.${state.code}`)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceNoteLotse({
|
||||||
|
voiceNoteId,
|
||||||
|
status,
|
||||||
|
transcript,
|
||||||
|
canEdit,
|
||||||
|
canSummarize,
|
||||||
|
latestSummary,
|
||||||
|
}: {
|
||||||
|
voiceNoteId: string;
|
||||||
|
status: Status;
|
||||||
|
transcript: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
canSummarize: boolean;
|
||||||
|
latestSummary: { generationId: string; summary: string } | null;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("lotse");
|
||||||
|
const router = useRouter();
|
||||||
|
// timestamps instead of effects: a newer successful action result closes the editor / re-shows the summary
|
||||||
|
const [editingAt, setEditingAt] = useState<number | null>(null);
|
||||||
|
const [hiddenAt, setHiddenAt] = useState<number | null>(null);
|
||||||
|
const [saveState, save, saving] = useActionState(saveTranscriptAction, LOTSE_IDLE);
|
||||||
|
const [sumState, summarize, summarizing] = useActionState(summarizeVoiceNoteAction, LOTSE_IDLE);
|
||||||
|
const [adoptState, adopt, adopting] = useActionState(adoptSummaryAction, LOTSE_IDLE);
|
||||||
|
const S = STATUS[status];
|
||||||
|
|
||||||
|
const editing = editingAt !== null && !(saveState.status === "ok" && saveState.at > editingAt);
|
||||||
|
const hidden = hiddenAt !== null && !(sumState.status === "ok" && sumState.at > hiddenAt);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.status === "ok") router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (adoptState.status === "ok") router.refresh();
|
||||||
|
}, [adoptState, router]);
|
||||||
|
|
||||||
|
const summary = sumState.status === "ok" && sumState.summary ? { generationId: sumState.generationId ?? "", summary: sumState.summary } : latestSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<span className={cn("inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[13px] font-semibold", S.cls)}>
|
||||||
|
<S.icon className={cn("size-4", status === "pending" && "animate-spin")} aria-hidden />
|
||||||
|
{t(`voice.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form action={save} className="space-y-2">
|
||||||
|
<input type="hidden" name="voiceNoteId" value={voiceNoteId} />
|
||||||
|
<label htmlFor={`tr-${voiceNoteId}`} className="block text-[13px] font-semibold">
|
||||||
|
{t("voice.transcript")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`tr-${voiceNoteId}`}
|
||||||
|
name="transcript"
|
||||||
|
defaultValue={transcript ?? ""}
|
||||||
|
rows={5}
|
||||||
|
maxLength={10_000}
|
||||||
|
className="min-h-28 w-full rounded-xl border border-input bg-card px-3 py-2 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
<ErrorLine state={saveState} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={saving} className={cn(btnOutline, "flex-1 border-primary bg-primary text-primary-foreground hover:bg-primary/90")}>
|
||||||
|
{t("voice.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setEditingAt(null)} className={cn(btnOutline, "flex-1")}>
|
||||||
|
{t("voice.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{transcript ? (
|
||||||
|
<p className="whitespace-pre-line text-[15px]">{transcript}</p>
|
||||||
|
) : (
|
||||||
|
status !== "pending" && <p className="text-[14px] text-muted-foreground">{t("voice.noTranscript")}</p>
|
||||||
|
)}
|
||||||
|
{saveState.status === "ok" && <p role="status" className="text-[13px] font-semibold text-[var(--ok)]">{t("voice.saved")}</p>}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{canEdit && status !== "pending" && (
|
||||||
|
<button type="button" onClick={() => setEditingAt(Date.now())} className={cn(btnOutline, "flex-1")}>
|
||||||
|
<Pencil className="size-4" aria-hidden />
|
||||||
|
{t("voice.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canSummarize && transcript && (
|
||||||
|
<form action={summarize} className="flex flex-1">
|
||||||
|
<input type="hidden" name="voiceNoteId" value={voiceNoteId} />
|
||||||
|
<button type="submit" disabled={summarizing} className={cn(btnOutline, "flex-1")}>
|
||||||
|
{summarizing ? <Loader2 className="size-4 animate-spin" aria-hidden /> : <Compass className="size-4 text-[var(--ui-accent)]" aria-hidden />}
|
||||||
|
{summarizing ? t("voice.summarizing") : t("voice.summarize")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ErrorLine state={sumState} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summary && !hidden && (
|
||||||
|
<div className="space-y-2 rounded-xl border border-dashed border-[var(--ui-accent)] bg-[color-mix(in_oklch,var(--ui-accent)_6%,transparent)] p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-[13.5px] font-semibold">{t("voice.summaryTitle")}</p>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[12px] font-semibold">
|
||||||
|
<Sparkles className="size-3.5 text-[var(--ui-accent)]" aria-hidden />
|
||||||
|
{t("suggestionBadge")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-line text-[15px]">{summary.summary}</p>
|
||||||
|
{adoptState.status === "ok" ? (
|
||||||
|
<p role="status" className="text-[13px] font-semibold text-[var(--ok)]">{t("voice.adopted")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{canEdit && summary.generationId && (
|
||||||
|
<form action={adopt} className="flex flex-1">
|
||||||
|
<input type="hidden" name="generationId" value={summary.generationId} />
|
||||||
|
<button type="submit" disabled={adopting} className={cn(btnOutline, "flex-1")}>
|
||||||
|
{t("voice.adopt")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => setHiddenAt(Date.now())} className={cn(btnOutline, "flex-1 text-foreground")}>
|
||||||
|
{t("voice.dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ErrorLine state={adoptState} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { TranscriptionStatus } from "@prisma/client";
|
||||||
|
import { isAiConfigured } from "@/server/ai/client";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
|
import { isLotseEnabled } from "@/server/services/lotse/settings";
|
||||||
|
import { latestVoiceSummaries } from "@/server/services/lotse/voice";
|
||||||
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
import { VoiceNoteLotse } from "./voice-note-client";
|
||||||
|
|
||||||
|
/** Transcript + status + „Sprachnotiz zusammenfassen“ below a voice note (mobile notes page). */
|
||||||
|
export async function LotseVoiceNote({ voiceNote }: { voiceNote: { id: string; transcript: string | null; transcriptionStatus: TranscriptionStatus } }) {
|
||||||
|
const ctx = await readCtx();
|
||||||
|
const [enabled, summaries] = await Promise.all([isLotseEnabled(ctx), latestVoiceSummaries(ctx, [voiceNote.id])]);
|
||||||
|
return (
|
||||||
|
<VoiceNoteLotse
|
||||||
|
voiceNoteId={voiceNote.id}
|
||||||
|
status={voiceNote.transcriptionStatus}
|
||||||
|
transcript={voiceNote.transcript}
|
||||||
|
canEdit={enabled && can(ctx, "field:execute")}
|
||||||
|
canSummarize={enabled && can(ctx, "lotse:use") && isAiConfigured()}
|
||||||
|
latestSummary={enabled ? (summaries.get(voiceNote.id) ?? null) : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,13 @@ import { IDLE } from "@/lib/reports/action-state";
|
|||||||
import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, type ReportType } from "@/lib/reports/content";
|
import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, type ReportType } from "@/lib/reports/content";
|
||||||
import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
|
import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
|
||||||
import { ActionMessage } from "../action-message";
|
import { ActionMessage } from "../action-message";
|
||||||
|
import { LotseReviewConfirm } from "@/components/lotse/review-confirm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile report editor: technician checks/extends the prefilled texts.
|
* Mobile report editor: technician checks/extends the prefilled texts.
|
||||||
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
|
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
|
||||||
*/
|
*/
|
||||||
export function ReportEditor({ reportId, type, texts, signHref }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string }) {
|
export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = false }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string; aiDrafted?: boolean }) {
|
||||||
const t = useTranslations("reports");
|
const t = useTranslations("reports");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [intent, setIntent] = useState<"save" | "sign">("save");
|
const [intent, setIntent] = useState<"save" | "sign">("save");
|
||||||
@@ -52,6 +53,7 @@ export function ReportEditor({ reportId, type, texts, signHref }: { reportId: st
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{aiDrafted && !(type === "completion" && signHref) && <LotseReviewConfirm invalid={submitState.status === "error" && submitState.field === "aiReviewed"} />}
|
||||||
<ActionMessage state={saveState} okText={intent === "save" ? t("mobile.saved") : undefined} />
|
<ActionMessage state={saveState} okText={intent === "save" ? t("mobile.saved") : undefined} />
|
||||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { StepIndicator } from "../step-indicator";
|
|||||||
import { CreateReportForm } from "./create-report-form";
|
import { CreateReportForm } from "./create-report-form";
|
||||||
import { ReportEditor } from "./report-editor";
|
import { ReportEditor } from "./report-editor";
|
||||||
import { ReportReview } from "./report-review";
|
import { ReportReview } from "./report-review";
|
||||||
|
import { LotseReportPanel } from "@/components/lotse/report-panel";
|
||||||
|
|
||||||
/** /m/orders/[id]/report — blockers → create → check/extend → (completion) continue to signature. */
|
/** /m/orders/[id]/report — blockers → create → check/extend → (completion) continue to signature. */
|
||||||
export async function ReportScreen({ workOrderId, type }: { workOrderId: string; type: "daily" | "completion" }) {
|
export async function ReportScreen({ workOrderId, type }: { workOrderId: string; type: "daily" | "completion" }) {
|
||||||
@@ -89,8 +90,9 @@ export async function ReportScreen({ workOrderId, type }: { workOrderId: string;
|
|||||||
<StepIndicator steps={steps} current={2} label={t("mobile.stepOf", { current: 2, total: steps.length })} />
|
<StepIndicator steps={steps} current={2} label={t("mobile.stepOf", { current: 2, total: steps.length })} />
|
||||||
)}
|
)}
|
||||||
{editable && blockers.length > 0 && <BlockerList blockers={blockers} />}
|
{editable && blockers.length > 0 && <BlockerList blockers={blockers} />}
|
||||||
|
{editable && <LotseReportPanel reportId={report.id} />}
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<ReportEditor reportId={report.id} type={report.type} texts={content.texts} signHref={type === "completion" ? `${base}/sign` : undefined} />
|
<ReportEditor key={report.updatedAt.getTime()} aiDrafted={report.aiDrafted} reportId={report.id} type={report.type} texts={content.texts} signHref={type === "completion" ? `${base}/sign` : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<p role="status" className="shadow-card rounded-xl border bg-card p-3 text-[14px]">
|
<p role="status" className="shadow-card rounded-xl border bg-card p-3 text-[14px]">
|
||||||
{report.status === "approved" || report.status === "submitted" || report.status === "team_approved" ? t("mobile.submitted") : t("mobile.readOnly")}
|
{report.status === "approved" || report.status === "submitted" || report.status === "team_approved" ? t("mobile.submitted") : t("mobile.readOnly")}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { captureSignatureAction } from "@/server/actions/reports/signature";
|
|||||||
import { submitReportAction } from "@/server/actions/reports/workflow";
|
import { submitReportAction } from "@/server/actions/reports/workflow";
|
||||||
import { ActionMessage } from "../action-message";
|
import { ActionMessage } from "../action-message";
|
||||||
import { SignaturePad } from "../signature-pad";
|
import { SignaturePad } from "../signature-pad";
|
||||||
|
import { LotseReviewConfirm } from "@/components/lotse/review-confirm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile completion step 3+4: signature or documented reason (Spec §18.2), then submit.
|
* Mobile completion step 3+4: signature or documented reason (Spec §18.2), then submit.
|
||||||
@@ -28,6 +29,7 @@ export function SignFlow({
|
|||||||
existing,
|
existing,
|
||||||
editable,
|
editable,
|
||||||
doneHref,
|
doneHref,
|
||||||
|
aiDrafted = false,
|
||||||
}: {
|
}: {
|
||||||
reportId: string;
|
reportId: string;
|
||||||
reportNumber: string;
|
reportNumber: string;
|
||||||
@@ -37,6 +39,7 @@ export function SignFlow({
|
|||||||
existing: { outcome: SignatureOutcome; signerName: string | null; reason: string | null } | null;
|
existing: { outcome: SignatureOutcome; signerName: string | null; reason: string | null } | null;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
doneHref: string;
|
doneHref: string;
|
||||||
|
aiDrafted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("reports");
|
const t = useTranslations("reports");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -124,6 +127,7 @@ export function SignFlow({
|
|||||||
<form action={submit} className="space-y-2">
|
<form action={submit} className="space-y-2">
|
||||||
<input type="hidden" name="reportId" value={reportId} />
|
<input type="hidden" name="reportId" value={reportId} />
|
||||||
{!existing && <p className="text-[13px] text-[var(--warn)]">{t("mobile.signatureMissing")}</p>}
|
{!existing && <p className="text-[13px] text-[var(--warn)]">{t("mobile.signatureMissing")}</p>}
|
||||||
|
{aiDrafted && <LotseReviewConfirm invalid={submitState.status === "error" && submitState.field === "aiReviewed"} />}
|
||||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||||
<Button type="submit" disabled={submitting || !existing} className="h-12 w-full text-[15px]">
|
<Button type="submit" disabled={submitting || !existing} className="h-12 w-full text-[15px]">
|
||||||
<Send aria-hidden />
|
<Send aria-hidden />
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export async function SignScreen({ workOrderId }: { workOrderId: string }) {
|
|||||||
existing={content.signature ? { outcome: content.signature.outcome, signerName: content.signature.signerName, reason: content.signature.reason } : null}
|
existing={content.signature ? { outcome: content.signature.outcome, signerName: content.signature.signerName, reason: content.signature.reason } : null}
|
||||||
editable={REPORT_EDITABLE.includes(report.status as ReportStatus)}
|
editable={REPORT_EDITABLE.includes(report.status as ReportStatus)}
|
||||||
doneHref={`${base}/report?type=completion`}
|
doneHref={`${base}/report?type=completion`}
|
||||||
|
aiDrafted={report.aiDrafted}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
History,
|
History,
|
||||||
Mail,
|
Mail,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
Compass,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
@@ -58,6 +59,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
||||||
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||||
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||||
|
{ href: "/settings/lotse", label: "lotse", icon: Compass, permissions: ["tenant:manage"], section: "admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const DETAIL_SELECT = {
|
|||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, materialPlanId: true, name: true, articleNumber: true, actualQuantity: true, unit: true, usageStatus: true, deviationReason: true, notes: true, clientId: true },
|
select: { id: true, materialPlanId: true, name: true, articleNumber: true, actualQuantity: true, unit: true, usageStatus: true, deviationReason: true, notes: true, clientId: true },
|
||||||
},
|
},
|
||||||
notes: { where: { deletedAt: null }, orderBy: { createdAt: "desc" }, take: 100, select: { id: true, kind: true, text: true, createdAt: true, authorId: true } },
|
notes: { where: { deletedAt: null }, orderBy: { createdAt: "desc" }, take: 100, select: { id: true, kind: true, text: true, createdAt: true, authorId: true, voiceNoteId: true } },
|
||||||
photos: {
|
photos: {
|
||||||
orderBy: { takenAt: "desc" },
|
orderBy: { takenAt: "desc" },
|
||||||
select: { id: true, documentId: true, phase: true, comment: true, takenAt: true, photoRequirementId: true, checklistItemId: true, takenById: true },
|
select: { id: true, documentId: true, phase: true, comment: true, takenAt: true, photoRequirementId: true, checklistItemId: true, takenById: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user