diff --git a/messages/de/nav.json b/messages/de/nav.json index 12b90ae..e0a2726 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -12,5 +12,6 @@ "notifications": "Benachrichtigungen", "audit": "Audit-Protokoll", "email": "E-Mail-Versand", + "lotse": "Lotse (KI)", "admin": "Admin-Konsole" } diff --git a/messages/en/nav.json b/messages/en/nav.json index fdeec50..8ec2bd7 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -12,5 +12,6 @@ "notifications": "Notifications", "audit": "Audit log", "email": "E-mail delivery", + "lotse": "Lotse (AI)", "admin": "Admin console" } diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx index 69f65f1..53268ce 100644 --- a/src/app/(app)/reports/[id]/page.tsx +++ b/src/app/(app)/reports/[id]/page.tsx @@ -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
+
+ +
diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx new file mode 100644 index 0000000..19f5d59 --- /dev/null +++ b/src/app/(app)/settings/lotse/page.tsx @@ -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 ( + + {ok ? : } + {ok ? labels.ok : labels.off} + + ); +} + +/** + * /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 ( +
+ + {t("settings.back")} + + + + {sp.saved && ( +

+ {t("settings.saved")} +

+ )} + {sp.error && ( +

+ {t("settings.error")} +

+ )} + +
+
+

+ · {t("settings.use")} +

+ +
+ {t("settings.addressForm")} +

{t("settings.addressFormHint")}

+
+ {(["neutral", "sie", "du"] as const).map((v) => ( + + ))} +
+
+ +
+ +
+

{t("settings.dataTitle")}

+
+
+
+ {t("settings.draftProvider")} +
+
{t("settings.providerLine", { provider: s.draft.provider, model: s.draft.model })}
+
+
+
+ {t("settings.transcriptionProvider")} +
+
{t("settings.transcriptionLine", { host: s.transcription.host ?? "—", model: s.transcription.model })}
+
+
+
+

{t("settings.sentTitle")}

+
    + {(["order", "notes", "work", "audio"] as const).map((k) => ( +
  • {t(`settings.sent.${k}`)}
  • + ))} +
+
+
+

{t("settings.notSentTitle")}

+
    + {(["contact", "names", "customer"] as const).map((k) => ( +
  • {t(`settings.notSent.${k}`)}
  • + ))} +
+
+

+ + {t("settings.principle")} +

+ + + {t("settings.protocolLink")} + +
+
+
+ ); +} diff --git a/src/app/(app)/settings/lotse/protocol/page.tsx b/src/app/(app)/settings/lotse/protocol/page.tsx new file mode 100644 index 0000000..896f9fe --- /dev/null +++ b/src/app/(app)/settings/lotse/protocol/page.tsx @@ -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> | 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 ( +
+ + {t("protocol.back")} + + + {!list.canSeeContent &&

{t("protocol.contentHint")}

} + +
+ {list.items.length === 0 ? ( +

{t("protocol.empty")}

+ ) : ( + + + + + + + + + {list.canSeeContent && } + + + + {list.items.map((r) => ( + + + + + + + {list.canSeeContent && ( + + )} + + ))} + +
{t("protocol.time")}{t("protocol.kind")}{t("protocol.model")}{t("protocol.tokens")}{t("protocol.user")}{t("protocol.content")}
{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "short" })}{kind(r.kind)} + {r.model} + {r.provider} + + {r.inputTokens ?? "—"} / {r.outputTokens ?? "—"} + {r.userName ?? t("protocol.system")} + + {t("protocol.show")} + +
+ )} +
+ + {pages > 1 && ( + + )} + + {detail && ( + +
+
+

{t("protocol.input")}

+
{JSON.stringify(detail.input, null, 2)}
+
+
+

{t("protocol.output")}

+
{JSON.stringify(detail.output, null, 2)}
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx index 37f1c87..7436ff8 100644 --- a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx @@ -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 (
@@ -33,8 +35,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri {v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}