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.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")}
+ ) : (
+
+
+
+ | {t("protocol.time")} |
+ {t("protocol.kind")} |
+ {t("protocol.model")} |
+ {t("protocol.tokens")} |
+ {t("protocol.user")} |
+ {list.canSeeContent && {t("protocol.content")} | }
+
+
+
+ {list.items.map((r) => (
+
+ | {format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "short" })} |
+ {kind(r.kind)} |
+
+ {r.model}
+ {r.provider}
+ |
+
+ {r.inputTokens ?? "—"} / {r.outputTokens ?? "—"}
+ |
+ {r.userName ?? t("protocol.system")} |
+ {list.canSeeContent && (
+
+
+ {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)}` : ""}
- {t(`voice.status.${v.transcriptionStatus}`)}
- {v.transcript && {v.transcript}
}
+
))}
@@ -48,6 +49,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri
{t(`notes.kind.${n.kind}`)} · {fmtDateTime(n.createdAt, locale)}
+ {n.voiceNoteId && ` · ${tl("voice.fromVoice")}`}
{n.text}
diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx
index 2e54d39..565a21f 100644
--- a/src/app/(field)/m/(core)/orders/[id]/page.tsx
+++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx
@@ -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
)}
+ {editable && }
+
{order.technicianNotes && }
{order.site && (
diff --git a/src/components/lotse/completeness-card.tsx b/src/components/lotse/completeness-card.tsx
new file mode 100644
index 0000000..bbed7d8
--- /dev/null
+++ b/src/components/lotse/completeness-card.tsx
@@ -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 (
+
+
+
+
+ {t("completeness.title", { count: items.length })}
+
+
+
+
+
+
+ {t("completeness.check")}
+
+
+ {items.map((item, i) => (
+ -
+
+
+ {item.source === "lotse" && {t("completeness.fromLotse")}}
+ {t(`completeness.item.${item.code}`, { label: item.label ?? "", text: item.text ?? "" })}
+
+
+ {t("completeness.fix")}
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/lotse/draft-button.tsx b/src/components/lotse/draft-button.tsx
new file mode 100644
index 0000000..66a04f2
--- /dev/null
+++ b/src/components/lotse/draft-button.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/lotse/lotse-mark.tsx b/src/components/lotse/lotse-mark.tsx
new file mode 100644
index 0000000..7140769
--- /dev/null
+++ b/src/components/lotse/lotse-mark.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/lotse/report-panel.tsx b/src/components/lotse/report-panel.tsx
new file mode 100644
index 0000000..4263bfc
--- /dev/null
+++ b/src/components/lotse/report-panel.tsx
@@ -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>;
+ 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 (
+
+
+
+
+
+ {lotse && {t("draft.draftedAt", { date: date(lotse.draftedAt) })}}
+
+
+ {state.editable && state.canDraft && }
+
+ {state.editable &&
+ pending.map((s) => )}
+
+ {state.editable && lotse && lotse.suggestions.length > 0 && pending.length === 0 && (
+
+
+ {t("draft.allDecided")}
+
+ )}
+
+ {state.editable && lotse && lotse.missingInformation.length > 0 && (
+
+
+
+ {t("draft.missingTitle")}
+
+
+ {lotse.missingInformation.map((m, i) => (
+ - {m}
+ ))}
+
+
+ )}
+
+ {lotse?.reviewedAt && (
+
+
+ {t("draft.reviewedAt", { date: date(lotse.reviewedAt) })}
+
+ )}
+
+ );
+}
diff --git a/src/components/lotse/review-confirm.tsx b/src/components/lotse/review-confirm.tsx
new file mode 100644
index 0000000..29050e5
--- /dev/null
+++ b/src/components/lotse/review-confirm.tsx
@@ -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 (
+
+
+
+
+ {t("review.hint")}
+
+
+ );
+}
diff --git a/src/components/lotse/suggestion-card.tsx b/src/components/lotse/suggestion-card.tsx
new file mode 100644
index 0000000..331a1c6
--- /dev/null
+++ b/src/components/lotse/suggestion-card.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/lotse/voice-note-client.tsx b/src/components/lotse/voice-note-client.tsx
new file mode 100644
index 0000000..c1ac9c0
--- /dev/null
+++ b/src/components/lotse/voice-note-client.tsx
@@ -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 = {
+ 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 (
+
+
+ {t(`errors.${state.code}`)}
+
+ );
+}
+
+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(null);
+ const [hiddenAt, setHiddenAt] = useState(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 (
+
+
+
+ {t(`voice.status.${status}`)}
+
+
+ {editing ? (
+
+ ) : (
+ <>
+ {transcript ? (
+
{transcript}
+ ) : (
+ status !== "pending" &&
{t("voice.noTranscript")}
+ )}
+ {saveState.status === "ok" &&
{t("voice.saved")}
}
+
+ {canEdit && status !== "pending" && (
+
+ )}
+ {canSummarize && transcript && (
+
+ )}
+
+
+ >
+ )}
+
+ {summary && !hidden && (
+
+
+
{t("voice.summaryTitle")}
+
+
+ {t("suggestionBadge")}
+
+
+
{summary.summary}
+ {adoptState.status === "ok" ? (
+
{t("voice.adopted")}
+ ) : (
+
+ {canEdit && summary.generationId && (
+
+ )}
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/lotse/voice-note-panel.tsx b/src/components/lotse/voice-note-panel.tsx
new file mode 100644
index 0000000..b27f590
--- /dev/null
+++ b/src/components/lotse/voice-note-panel.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/reports/mobile/report-editor.tsx b/src/components/reports/mobile/report-editor.tsx
index 727ad9b..9457e9c 100644
--- a/src/components/reports/mobile/report-editor.tsx
+++ b/src/components/reports/mobile/report-editor.tsx
@@ -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 { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
import { ActionMessage } from "../action-message";
+import { LotseReviewConfirm } from "@/components/lotse/review-confirm";
/**
* Mobile report editor: technician checks/extends the prefilled texts.
* 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 router = useRouter();
const [intent, setIntent] = useState<"save" | "sign">("save");
@@ -52,6 +53,7 @@ export function ReportEditor({ reportId, type, texts, signHref }: { reportId: st
/>
))}
+ {aiDrafted && !(type === "completion" && signHref) && }
diff --git a/src/components/reports/mobile/report-screen.tsx b/src/components/reports/mobile/report-screen.tsx
index 836b8b5..9812389 100644
--- a/src/components/reports/mobile/report-screen.tsx
+++ b/src/components/reports/mobile/report-screen.tsx
@@ -13,6 +13,7 @@ import { StepIndicator } from "../step-indicator";
import { CreateReportForm } from "./create-report-form";
import { ReportEditor } from "./report-editor";
import { ReportReview } from "./report-review";
+import { LotseReportPanel } from "@/components/lotse/report-panel";
/** /m/orders/[id]/report — blockers → create → check/extend → (completion) continue to signature. */
export async function ReportScreen({ workOrderId, type }: { workOrderId: string; type: "daily" | "completion" }) {
@@ -89,8 +90,9 @@ export async function ReportScreen({ workOrderId, type }: { workOrderId: string;
)}
{editable && blockers.length > 0 &&
}
+ {editable &&
}
{editable ? (
-
+
) : (
{report.status === "approved" || report.status === "submitted" || report.status === "team_approved" ? t("mobile.submitted") : t("mobile.readOnly")}
diff --git a/src/components/reports/mobile/sign-flow.tsx b/src/components/reports/mobile/sign-flow.tsx
index 658e2f2..e592299 100644
--- a/src/components/reports/mobile/sign-flow.tsx
+++ b/src/components/reports/mobile/sign-flow.tsx
@@ -15,6 +15,7 @@ import { captureSignatureAction } from "@/server/actions/reports/signature";
import { submitReportAction } from "@/server/actions/reports/workflow";
import { ActionMessage } from "../action-message";
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.
@@ -28,6 +29,7 @@ export function SignFlow({
existing,
editable,
doneHref,
+ aiDrafted = false,
}: {
reportId: string;
reportNumber: string;
@@ -37,6 +39,7 @@ export function SignFlow({
existing: { outcome: SignatureOutcome; signerName: string | null; reason: string | null } | null;
editable: boolean;
doneHref: string;
+ aiDrafted?: boolean;
}) {
const t = useTranslations("reports");
const router = useRouter();
@@ -124,6 +127,7 @@ export function SignFlow({