- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { ChevronRight, CircleCheck, Compass, Info, LoaderCircle, MessageSquarePlus, SendHorizontal, TriangleAlert, WifiOff } from "lucide-react";
|
|
import { LOTSE_CHAT_IDLE, type ChatMessageView, type ChatView, type LotseChatActionState, type LotseChatErrorCode } from "@/lib/lotse/chat";
|
|
import { confirmAllProposalsAction, confirmProposalAction, discardProposalAction, newConversationAction, sendChatMessageAction } from "@/server/actions/lotse/chat";
|
|
import { cn } from "@/lib/utils";
|
|
import { chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui";
|
|
import { LotseMark } from "../lotse-mark";
|
|
import { MicButton } from "./mic-button";
|
|
import { ProposalCard } from "./proposal-card";
|
|
|
|
type Action = (prev: LotseChatActionState, fd: FormData) => Promise<LotseChatActionState>;
|
|
|
|
const draftKey = (workOrderId: string | null) => `craftvia:lotse-chat-draft:${workOrderId ?? "general"}`;
|
|
|
|
/** next-intl typing does not know dynamic keys with values — loose signature for server-provided keys. */
|
|
type LooseT = (key: string, values?: Record<string, string | number>) => string;
|
|
|
|
function subscribeOnline(cb: () => void) {
|
|
window.addEventListener("online", cb);
|
|
window.addEventListener("offline", cb);
|
|
return () => {
|
|
window.removeEventListener("online", cb);
|
|
window.removeEventListener("offline", cb);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lotse chat for technicians (lane L16, `/m/lotse`): history, input with microphone key, chips, jump
|
|
* links and action cards. Needs a connection — offline the input stays (also in localStorage) and
|
|
* nothing is sent; there is no offline execution.
|
|
*/
|
|
export function LotseChat({
|
|
initial,
|
|
workOrderId,
|
|
configured,
|
|
transcription,
|
|
locale,
|
|
timeZone,
|
|
quotaExhausted = false,
|
|
}: {
|
|
initial: ChatView;
|
|
workOrderId: string | null;
|
|
configured: boolean;
|
|
transcription: boolean;
|
|
locale: string;
|
|
timeZone: string;
|
|
/** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */
|
|
quotaExhausted?: boolean;
|
|
}) {
|
|
const t = useTranslations("lotse");
|
|
const router = useRouter();
|
|
const [view, setView] = useState(initial);
|
|
const [text, setText] = useState("");
|
|
const [pending, setPending] = useState<string | null>(null);
|
|
const [error, setError] = useState<LotseChatErrorCode | null>(null);
|
|
const [micError, setMicError] = useState<string | null>(null);
|
|
const online = useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true);
|
|
const [sentText, setSentText] = useState<string | null>(null);
|
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
let saved: string | null = null;
|
|
try {
|
|
saved = window.localStorage.getItem(draftKey(workOrderId));
|
|
} catch {
|
|
// storage unavailable (private mode) — draft only in memory
|
|
}
|
|
if (!saved) return;
|
|
const restore = setTimeout(() => setText((current) => current || saved), 0);
|
|
return () => clearTimeout(restore);
|
|
}, [workOrderId]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
if (text) window.localStorage.setItem(draftKey(workOrderId), text);
|
|
else window.localStorage.removeItem(draftKey(workOrderId));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [text, workOrderId]);
|
|
|
|
useEffect(() => {
|
|
endRef.current?.scrollIntoView({ block: "end" });
|
|
}, [view.messages.length, pending]);
|
|
|
|
async function run(key: string, action: Action, values: Record<string, string | null | undefined>): Promise<boolean> {
|
|
if (!navigator.onLine) {
|
|
setError("offline");
|
|
return false;
|
|
}
|
|
const fd = new FormData();
|
|
for (const [k, v] of Object.entries(values)) if (v) fd.set(k, v);
|
|
setPending(key);
|
|
setError(null);
|
|
try {
|
|
const res = await action(LOTSE_CHAT_IDLE, fd);
|
|
if (res.status === "ok") {
|
|
setView(res.view);
|
|
return true;
|
|
}
|
|
if (res.status === "error") {
|
|
setError(res.code);
|
|
if (res.view) setView(res.view);
|
|
}
|
|
return false;
|
|
} catch {
|
|
setError(navigator.onLine ? "generic" : "offline");
|
|
return false;
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
async function send(value: string) {
|
|
const message = value.trim();
|
|
if (!message || pending) return;
|
|
setSentText(message);
|
|
const ok = await run("send", sendChatMessageAction, { text: message, conversationId: view.conversationId, workOrderId });
|
|
setSentText(null);
|
|
if (ok && message === text.trim()) setText("");
|
|
}
|
|
|
|
async function confirm(proposalId: string, edits: Record<string, unknown> | null) {
|
|
const ok = await run(`confirm:${proposalId}`, confirmProposalAction, { proposalId, conversationId: view.conversationId, edits: edits ? JSON.stringify(edits) : null });
|
|
if (ok) router.refresh(); // clock bar / order status in the shell
|
|
}
|
|
|
|
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
|
|
const sendLocked = quotaExhausted || error === "quota_exhausted";
|
|
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
|
|
|
|
function notice(m: ChatMessageView): string | null {
|
|
const key = m.content.noticeKey;
|
|
if (!key || !t.has(`chat.system.${key}`)) return null;
|
|
const values = { ...(m.content.noticeValues ?? {}) } as Record<string, string | number>;
|
|
if (key === "failed") {
|
|
const code = String(values.code ?? "generic");
|
|
values.reason = t.has(`chat.errors.${code}`) ? t(`chat.errors.${code}`) : t("chat.errors.generic");
|
|
}
|
|
return (t as unknown as LooseT)(`chat.system.${key}`, values);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="text-[14px] text-muted-foreground">{view.workOrder ? t("chat.context", { number: view.workOrder.number, title: view.workOrder.title }) : t("chat.noContext")}</p>
|
|
<button
|
|
type="button"
|
|
disabled={pending !== null}
|
|
onClick={() => run("new", newConversationAction, { workOrderId })}
|
|
className="inline-flex min-h-12 items-center gap-1.5 rounded-xl border border-border bg-card px-3.5 text-[14px] font-semibold text-primary disabled:opacity-50"
|
|
>
|
|
<MessageSquarePlus className="size-4.5" aria-hidden />
|
|
{t("chat.newChat")}
|
|
</button>
|
|
</div>
|
|
|
|
{view.messages.length === 0 && (
|
|
<p className="flex items-start gap-2 rounded-xl border bg-card p-3.5 text-[15px]">
|
|
<Compass className="mt-0.5 size-5 shrink-0 text-[var(--ui-accent)]" aria-hidden />
|
|
{t("chat.intro")}
|
|
</p>
|
|
)}
|
|
|
|
<ol className="space-y-3" aria-live="polite" aria-relevant="additions">
|
|
{view.messages.map((m) => {
|
|
const openCards = m.proposals.filter((p) => p.status === "proposed");
|
|
const noticeText = notice(m);
|
|
if (m.role === "user") {
|
|
return (
|
|
<li key={m.id} className="flex justify-end">
|
|
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground">
|
|
<span className="sr-only">{t("chat.userLabel")}: </span>
|
|
{m.text}
|
|
</p>
|
|
</li>
|
|
);
|
|
}
|
|
if (m.role === "tool") {
|
|
return (
|
|
<li key={m.id} className="space-y-2">
|
|
{noticeText && (
|
|
<p className={m.content.noticeKey === "failed" || m.content.noticeKey === "stopped" ? noticeError : noticeWarn}>
|
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
|
{noticeText}
|
|
</p>
|
|
)}
|
|
<ChatLinks links={m.content.links} />
|
|
</li>
|
|
);
|
|
}
|
|
return (
|
|
<li key={m.id} className="space-y-2">
|
|
<div className="max-w-[92%] rounded-2xl rounded-bl-md border bg-card px-3.5 py-2.5 shadow-card">
|
|
<LotseMark label={t("chat.lotseLabel")} className="text-[13px]" />
|
|
{m.text && <p className="mt-1 text-[15px] whitespace-pre-line">{m.text}</p>}
|
|
{noticeText && (
|
|
<p className="mt-1 flex items-start gap-1.5 text-[14px] text-muted-foreground">
|
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
{noticeText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<ChatLinks links={m.content.links} />
|
|
{m.proposals.length > 0 && (
|
|
<div className="space-y-2">
|
|
{m.proposals.map((p) => (
|
|
<ProposalCard
|
|
key={p.id}
|
|
proposal={p}
|
|
locale={locale}
|
|
timeZone={timeZone}
|
|
busy={pending === `confirm:${p.id}` || pending === `all:${m.id}`}
|
|
disabled={pending !== null}
|
|
onConfirm={(edits) => confirm(p.id, edits)}
|
|
onDiscard={() => run(`discard:${p.id}`, discardProposalAction, { proposalId: p.id, conversationId: view.conversationId })}
|
|
/>
|
|
))}
|
|
{openCards.length > 1 && (
|
|
<button
|
|
type="button"
|
|
disabled={pending !== null}
|
|
onClick={async () => {
|
|
if (await run(`all:${m.id}`, confirmAllProposalsAction, { messageId: m.id, conversationId: view.conversationId })) router.refresh();
|
|
}}
|
|
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border-2 border-[var(--ui-accent)] bg-card px-4 font-heading text-[15px] font-semibold text-foreground disabled:opacity-50"
|
|
>
|
|
{pending === `all:${m.id}` ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <CircleCheck className="size-5" aria-hidden />}
|
|
{t("chat.confirmAll", { count: openCards.length })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
|
|
{m.content.chips.map((c) => (
|
|
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured || sendLocked} onClick={() => send(c.value)}>
|
|
{c.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
{pending === "send" && sentText && (
|
|
<li className="space-y-2">
|
|
<div className="flex justify-end">
|
|
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5 text-[15px] whitespace-pre-line text-primary-foreground opacity-70">{sentText}</p>
|
|
</div>
|
|
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold text-muted-foreground">
|
|
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
|
{t("chat.sending")}
|
|
</p>
|
|
</li>
|
|
)}
|
|
</ol>
|
|
<div ref={endRef} />
|
|
|
|
{!configured && (
|
|
<p className={noticeWarn}>
|
|
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
|
{t("chat.notConfigured")}
|
|
</p>
|
|
)}
|
|
{!online && (
|
|
<p className={noticeWarn} role="status">
|
|
<WifiOff className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
|
{t("chat.offline")}
|
|
</p>
|
|
)}
|
|
{errorText && error !== "quota_exhausted" && (
|
|
<p className={noticeError} role="alert">
|
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
|
{errorText}
|
|
</p>
|
|
)}
|
|
|
|
{sendLocked ? (
|
|
<p className={noticeWarn} role="status">
|
|
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
|
{t("chat.quotaExhausted")}
|
|
</p>
|
|
) : (
|
|
<form
|
|
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void send(text);
|
|
}}
|
|
>
|
|
<label htmlFor="lotse-chat-input" className="sr-only">
|
|
{t("chat.input")}
|
|
</label>
|
|
<textarea
|
|
id="lotse-chat-input"
|
|
value={text}
|
|
maxLength={4000}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
void send(text);
|
|
}
|
|
}}
|
|
placeholder={t("chat.input")}
|
|
className={cn(inputClass, "min-h-24 resize-y py-2.5")}
|
|
/>
|
|
{micError && <p className="text-[13.5px] text-[var(--risk)]">{micError}</p>}
|
|
<div className="flex items-start gap-2">
|
|
<MicButton enabled={transcription} disabled={pending !== null} onError={setMicError} onText={(value) => setText((current) => (current.trim() ? `${current.trim()} ${value}` : value))} />
|
|
<button
|
|
type="submit"
|
|
disabled={!configured || pending !== null || !text.trim()}
|
|
className="inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cta px-4 font-heading text-[15px] font-semibold text-cta-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
>
|
|
{pending === "send" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <SendHorizontal className="size-5" aria-hidden />}
|
|
{t("chat.send")}
|
|
</button>
|
|
</div>
|
|
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatLinks({ links }: { links?: ChatMessageView["content"]["links"] }) {
|
|
const t = useTranslations("lotse");
|
|
if (!links?.length) return null;
|
|
return (
|
|
<nav aria-label={t("chat.linksLabel")} className="rounded-xl border bg-card">
|
|
<ul className="divide-y">
|
|
{links.map((l) => (
|
|
<li key={`${l.href}|${l.label ?? l.labelKey}`}>
|
|
<Link href={l.href} className="flex min-h-12 items-center gap-2 px-3.5 py-2 text-[15px]">
|
|
<span className="flex-1">{t.has(l.labelKey) ? (t as unknown as LooseT)(l.labelKey, { label: l.label ?? "", text: l.label ?? "" }) : (l.label ?? l.href)}</span>
|
|
<ChevronRight className="size-4.5 text-muted-foreground" aria-hidden />
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|