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:
@@ -8,6 +8,7 @@ import { RejectForm } from "@/components/reports/reject-form";
|
||||
import { ReportView } from "@/components/reports/report-view";
|
||||
import { ReviewActions } from "@/components/reports/review-actions";
|
||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||
import { LotseReportPanel } from "@/components/lotse/report-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
||||
@@ -83,6 +84,9 @@ export default async function ReportDetailPage({ params, searchParams }: { param
|
||||
<div className="mb-4">
|
||||
<ReviewActions reportId={id} can={permissions} rejectHref={`${base}?reject=1`} />
|
||||
</div>
|
||||
<div className="mb-4 max-w-3xl">
|
||||
<LotseReportPanel reportId={id} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<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 { card } from "@/components/field/ui";
|
||||
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). */
|
||||
export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { order } = await loadOrder(params);
|
||||
const t = await getTranslations("field");
|
||||
const locale = await getLocale();
|
||||
const tl = await getTranslations("lotse");
|
||||
|
||||
return (
|
||||
<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)}` : ""}
|
||||
</p>
|
||||
<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>
|
||||
{v.transcript && <p className="mt-1 whitespace-pre-line text-[15px]">{v.transcript}</p>}
|
||||
<LotseVoiceNote voiceNote={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -48,6 +49,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri
|
||||
<li key={n.id} className={card}>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
|
||||
{n.voiceNoteId && ` · ${tl("voice.fromVoice")}`}
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
|
||||
</li>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { QuickPhotoButton } from "@/components/field/photo-capture";
|
||||
import { StatusBadge } from "@/components/field/status-badge";
|
||||
import { card, toneClasses } from "@/components/field/ui";
|
||||
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 }) {
|
||||
const head = (
|
||||
@@ -162,6 +163,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{editable && <LotseCompletenessCard workOrderId={order.id} />}
|
||||
|
||||
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
|
||||
|
||||
{order.site && (
|
||||
|
||||
Reference in New Issue
Block a user