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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:57:20 +02:00
co-authored by Claude Opus 5
parent 68f4eb32dc
commit 63baaf29df
15 changed files with 1196 additions and 15 deletions
+10 -1
View File
@@ -61,6 +61,15 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
<span className="block text-[12px] text-muted-foreground">{t("settings.enabledHint")}</span>
</span>
</label>
{/* L16 Lotse-Chat für Monteure */}
<input type="hidden" name="chatEnabledPresent" value="1" />
<label className="flex min-h-11 cursor-pointer items-start gap-3">
<input type="checkbox" name="chatEnabled" defaultChecked={s.chatEnabled} className="mt-0.5 size-5 accent-[var(--ui-accent)]" />
<span>
<span className="block text-sm font-semibold">{t("settings.chatEnabled")}</span>
<span className="block text-[12px] text-muted-foreground">{t("settings.chatEnabledHint")}</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>
@@ -127,7 +136,7 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
<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) => (
{(["order", "notes", "work", "audio", "chat", "chatAudio"] as const).map((k) => (
<li key={k}>{t(`settings.sent.${k}`)}</li>
))}
</ul>
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("lotse");
return <>{children}</>;
}
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ChevronLeft, CircleSlash } from "lucide-react";
import type { ChatView } from "@/lib/lotse/chat";
import { isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { getChatView } from "@/server/services/lotse/chat/conversations";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
import { card } from "@/components/field/ui";
/**
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
* (button „Lotse fragen" in the order detail). Nothing is executed without confirmation.
*/
export default async function LotseChatPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
const [ctx, sp, t, locale] = await Promise.all([fieldPageContext(), searchParams, getTranslations("lotse"), getLocale()]);
let view: ChatView;
try {
view = await getChatView(ctx, { workOrderId: sp.order?.trim() || null });
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
if (err.code === "not_found") notFound();
const reason = (err.details as { reason?: string } | undefined)?.reason;
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
return (
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
}
const timeZone = await tenantTimezone(ctx);
return (
<main className="space-y-3 p-4">
{view.workOrder && (
<Link href={`/m/orders/${view.workOrder.id}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("chat.context", { number: view.workOrder.number, title: view.workOrder.title })}
</Link>
)}
<h1 className="flex items-center gap-2 text-[22px]">
<LotseMark label={t("chat.title")} />
</h1>
<LotseChat
key={view.conversationId ?? `new-${view.workOrder?.id ?? "general"}`}
initial={view}
workOrderId={view.workOrder?.id ?? null}
configured={isAiConfigured()}
transcription={transcriptionConfig().configured}
locale={locale}
timeZone={timeZone}
/>
</main>
);
}
@@ -33,6 +33,7 @@ import { card, toneClasses } from "@/components/field/ui";
import { loadOrder } from "./load";
import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
import { MilestonesSection } from "@/components/field/milestones";
import { LotseAskButton } from "@/components/lotse/chat/ask-button";
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
const head = (
@@ -168,6 +169,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
)}
{editable && <LotseCompletenessCard workOrderId={order.id} />}
{editable && <LotseAskButton ctx={ctx} workOrderId={order.id} />}{/* L16 Lotse-Chat */}
<MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */}
+6 -4
View File
@@ -5,6 +5,7 @@ import { can } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
@@ -14,19 +15,20 @@ import { RunningClockBar, type ClockSession } from "@/components/field/running-c
import { OfflineRuntime } from "@/components/offline/offline-runtime";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
try {
const ctx = await fieldPageContext();
const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]);
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
return {
clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null,
approvals,
lotseChat, // L16
};
} catch {
// module "field" disabled or no field permissions: shell without clock/badge
return { clock: null, approvals: 0 };
return { clock: null, approvals: 0, lotseChat: false };
}
}
@@ -55,7 +57,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
</header>
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} />
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
</div>
);
}