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:
@@ -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}</>;
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user