b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
595 lines
24 KiB
TypeScript
595 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { ChatMessage, TypingIndicator } from "./ChatMessage.js";
|
|
|
|
/** Map route prefixes to human-readable page context for the AI */
|
|
const ROUTE_CONTEXT: Record<string, string> = {
|
|
"/dashboard": "Dashboard — Übersicht mit KPIs, aktive Projekte, Ressourcen-Auslastung",
|
|
"/timeline": "Timeline — Gantt-artige Ansicht aller Allokationen und Projekte",
|
|
"/allocations": "Allokationen — Liste aller Zuweisungen von Ressourcen zu Projekten",
|
|
"/staffing": "Staffing — Projektbesetzung und Kapazitätsplanung",
|
|
"/resources": "Ressourcen — Liste aller Mitarbeiter mit Details (FTE, LCR, Skills, Chapter)",
|
|
"/projects": "Projekte — Liste aller Projekte mit Budget, Status, Zeitraum",
|
|
"/roles": "Rollen — Verwaltung der verfügbaren Rollen",
|
|
"/estimates": "Estimating — Aufwandsschätzungen für Projekte",
|
|
"/vacations/my": "Meine Urlaube — Eigene Urlaubsanträge und Saldo",
|
|
"/vacations": "Urlaubsverwaltung — Alle Urlaubsanträge, Genehmigungen, Team-Kalender",
|
|
"/analytics/skills": "Skills Analytics — Skill-Verteilung und -Analyse über alle Ressourcen",
|
|
"/analytics/computation-graph": "Computation Graph — Berechnungsvisualisierung für Budget/Kosten",
|
|
"/reports/chargeability": "Chargeability Report — Auslastungsanalyse pro Ressource",
|
|
"/admin/settings": "Admin-Einstellungen — System-Konfiguration, AI-Credentials, SMTP",
|
|
"/admin/users": "Benutzerverwaltung — Rollen, Berechtigungen, Zugänge",
|
|
};
|
|
|
|
function resolvePageContext(pathname: string): string {
|
|
const exact = ROUTE_CONTEXT[pathname];
|
|
if (exact) return exact;
|
|
const sorted = Object.keys(ROUTE_CONTEXT).sort((a, b) => b.length - a.length);
|
|
for (const prefix of sorted) {
|
|
const ctx = ROUTE_CONTEXT[prefix];
|
|
if (pathname.startsWith(prefix) && ctx) return ctx;
|
|
}
|
|
return pathname;
|
|
}
|
|
|
|
interface Message {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
insights?: AssistantInsight[];
|
|
approval?: AssistantApproval;
|
|
}
|
|
|
|
interface AssistantApproval {
|
|
id: string;
|
|
status: "pending" | "approved" | "cancelled";
|
|
conversationId: string;
|
|
toolName: string;
|
|
summary: string;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
interface AssistantInsightMetric {
|
|
label: string;
|
|
value: string;
|
|
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
|
}
|
|
|
|
interface AssistantInsightSection {
|
|
title: string;
|
|
metrics: AssistantInsightMetric[];
|
|
}
|
|
|
|
interface AssistantInsight {
|
|
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
|
title: string;
|
|
subtitle?: string;
|
|
metrics: AssistantInsightMetric[];
|
|
sections?: AssistantInsightSection[];
|
|
}
|
|
|
|
const STORAGE_KEY = "nexus-chat-messages";
|
|
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
|
|
|
|
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
|
if (!value || typeof value !== "object") return false;
|
|
const approval = value as Partial<AssistantApproval>;
|
|
return (
|
|
typeof approval.id === "string" &&
|
|
(approval.status === "pending" ||
|
|
approval.status === "approved" ||
|
|
approval.status === "cancelled") &&
|
|
typeof approval.conversationId === "string" &&
|
|
typeof approval.toolName === "string" &&
|
|
typeof approval.summary === "string" &&
|
|
typeof approval.createdAt === "string" &&
|
|
typeof approval.expiresAt === "string"
|
|
);
|
|
}
|
|
|
|
/** Load messages from sessionStorage (survives page reloads, clears on tab close). */
|
|
function loadPersistedMessages(): Message[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
return parsed
|
|
.filter(
|
|
(item): item is Partial<Message> & { role: Message["role"]; content: string } =>
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
(item.role === "user" || item.role === "assistant") &&
|
|
typeof item.content === "string",
|
|
)
|
|
.map((item) => ({
|
|
role: item.role,
|
|
content: item.content,
|
|
...(Array.isArray(item.insights)
|
|
? { insights: item.insights as AssistantInsight[] }
|
|
: {}),
|
|
...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}),
|
|
}));
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore corrupt data */
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */
|
|
let cachedMessages: Message[] | null = null;
|
|
|
|
function generateConversationId(): string {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `conversation-${Date.now()}`;
|
|
}
|
|
|
|
function loadConversationId(): string {
|
|
if (typeof window === "undefined") return generateConversationId();
|
|
try {
|
|
const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim();
|
|
if (raw) return raw;
|
|
} catch {
|
|
// ignore storage access errors
|
|
}
|
|
return generateConversationId();
|
|
}
|
|
|
|
export function ChatPanel({ onClose }: { onClose: () => void }) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const utils = trpc.useUtils();
|
|
const [messages, setMessages] = useState<Message[]>(
|
|
() => cachedMessages ?? loadPersistedMessages(),
|
|
);
|
|
const [conversationId, setConversationId] = useState<string>(() => loadConversationId());
|
|
const [input, setInput] = useState("");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [approvalNotice, setApprovalNotice] = useState<string | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const chatMutation = trpc.assistant.chat.useMutation();
|
|
const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, {
|
|
refetchInterval: 30_000,
|
|
});
|
|
const pendingApprovals = pendingApprovalsQuery.data ?? [];
|
|
const inlineApprovalIds = new Set(
|
|
messages
|
|
.map((message) => message.approval?.id)
|
|
.filter((approvalId): approvalId is string => typeof approvalId === "string"),
|
|
);
|
|
const visiblePendingApprovals = pendingApprovals.filter(
|
|
(approval) => !inlineApprovalIds.has(approval.id),
|
|
);
|
|
|
|
// Sync to module-level cache + sessionStorage on every change
|
|
useEffect(() => {
|
|
cachedMessages = messages;
|
|
try {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
|
} catch {
|
|
/* quota exceeded */
|
|
}
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId);
|
|
} catch {
|
|
/* quota exceeded */
|
|
}
|
|
}, [conversationId]);
|
|
|
|
// Auto-scroll to bottom on new messages
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [messages, isLoading]);
|
|
|
|
// Focus input on mount
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
const sendMessage = useCallback(
|
|
async (
|
|
overrideText?: string,
|
|
targetConversationId?: string,
|
|
options?: { persistInCurrentChat?: boolean },
|
|
) => {
|
|
const text = (overrideText ?? input).trim();
|
|
if (!text || isLoading) return;
|
|
const persistInCurrentChat = options?.persistInCurrentChat ?? true;
|
|
const activeConversationId = targetConversationId ?? conversationId;
|
|
|
|
if (!overrideText) {
|
|
setInput("");
|
|
}
|
|
setError(null);
|
|
setApprovalNotice(null);
|
|
|
|
const userMsg: Message = { role: "user", content: text };
|
|
const updated = persistInCurrentChat ? [...messages, userMsg] : messages;
|
|
if (persistInCurrentChat) {
|
|
setMessages(updated);
|
|
}
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const reply = await chatMutation.mutateAsync({
|
|
messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({
|
|
role: message.role,
|
|
content: message.content,
|
|
})),
|
|
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
|
conversationId: activeConversationId,
|
|
});
|
|
const typedReply = reply as {
|
|
content: string;
|
|
role: "assistant";
|
|
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
|
|
insights?: AssistantInsight[];
|
|
approval?: AssistantApproval;
|
|
};
|
|
if (persistInCurrentChat) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: typedReply.content,
|
|
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0
|
|
? { insights: typedReply.insights }
|
|
: {}),
|
|
...(isAssistantApproval(typedReply.approval)
|
|
? { approval: typedReply.approval }
|
|
: {}),
|
|
},
|
|
]);
|
|
} else {
|
|
setApprovalNotice(typedReply.content);
|
|
}
|
|
|
|
// Handle actions from the AI (navigation, data invalidation)
|
|
const actions = typedReply.actions;
|
|
if (actions) {
|
|
for (const action of actions) {
|
|
if (action.type === "navigate" && action.url) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
router.push(action.url as any);
|
|
} else if (action.type === "invalidate" && action.scope) {
|
|
// Invalidate relevant tRPC queries so the UI refreshes
|
|
for (const scope of action.scope) {
|
|
if (scope === "allocation" || scope === "timeline") {
|
|
void utils.allocation.invalidate();
|
|
void utils.timeline.invalidate();
|
|
}
|
|
if (scope === "resource") void utils.resource.invalidate();
|
|
if (scope === "project") void utils.project.invalidate();
|
|
if (scope === "country") void utils.country.invalidate();
|
|
if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate();
|
|
if (scope === "vacation") void utils.vacation.invalidate();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await utils.assistant.listPendingApprovals.invalidate();
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Something went wrong";
|
|
setError(msg);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[conversationId, input, isLoading, messages, chatMutation, pathname, router, utils],
|
|
);
|
|
|
|
// Track user message history for up-arrow recall
|
|
const userHistory = useRef<string[]>([]);
|
|
const historyIndex = useRef(-1);
|
|
|
|
// Keep userHistory in sync with messages
|
|
useEffect(() => {
|
|
userHistory.current = messages.filter((m) => m.role === "user").map((m) => m.content);
|
|
historyIndex.current = -1;
|
|
}, [messages]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "ArrowUp" && input === "" && userHistory.current.length > 0) {
|
|
e.preventDefault();
|
|
const nextIdx =
|
|
historyIndex.current < 0
|
|
? userHistory.current.length - 1
|
|
: Math.max(0, historyIndex.current - 1);
|
|
historyIndex.current = nextIdx;
|
|
setInput(userHistory.current[nextIdx] ?? "");
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown" && historyIndex.current >= 0) {
|
|
e.preventDefault();
|
|
const nextIdx = historyIndex.current + 1;
|
|
if (nextIdx >= userHistory.current.length) {
|
|
historyIndex.current = -1;
|
|
setInput("");
|
|
} else {
|
|
historyIndex.current = nextIdx;
|
|
setInput(userHistory.current[nextIdx] ?? "");
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void sendMessage();
|
|
}
|
|
};
|
|
|
|
const clearChat = () => {
|
|
setMessages([]);
|
|
setError(null);
|
|
setApprovalNotice(null);
|
|
setConversationId(generateConversationId());
|
|
cachedMessages = null;
|
|
try {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-96 shrink-0 flex flex-col border-l border-gray-200 bg-white dark:border-slate-800 dark:bg-slate-950/75">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-slate-800">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600 text-white">
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">HartBOT</h2>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{messages.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={clearChat}
|
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
|
|
title="Chat leeren"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Disclaimer (EGAI 4.3.1.4) */}
|
|
<div className="px-3 py-2 text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
|
AI responses may be inaccurate. Always verify critical information before acting on it.
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
|
{(visiblePendingApprovals.length > 0 || approvalNotice) && (
|
|
<div
|
|
data-testid="assistant-open-approvals"
|
|
className="space-y-2 rounded-2xl border border-amber-200 bg-amber-50/80 p-3 text-xs text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-50"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-amber-800 dark:text-amber-200">
|
|
Open approvals
|
|
</div>
|
|
{visiblePendingApprovals.length > 0 && (
|
|
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[11px] font-medium dark:border-amber-800">
|
|
{visiblePendingApprovals.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{approvalNotice && (
|
|
<div className="rounded-xl border border-amber-300/80 bg-white/70 px-3 py-2 text-[12px] text-amber-900 dark:border-amber-800 dark:bg-slate-900/40 dark:text-amber-100">
|
|
{approvalNotice}
|
|
</div>
|
|
)}
|
|
{visiblePendingApprovals.map((approval) => {
|
|
const belongsToCurrentConversation = approval.conversationId === conversationId;
|
|
return (
|
|
<div
|
|
key={approval.id}
|
|
data-testid="assistant-approval-card"
|
|
data-approval-id={approval.id}
|
|
data-conversation-scope={belongsToCurrentConversation ? "current" : "other"}
|
|
className="rounded-xl border border-amber-200/90 bg-white/80 px-3 py-2.5 shadow-sm dark:border-amber-900/50 dark:bg-slate-900/40"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-amber-950 dark:text-amber-50">
|
|
{approval.summary}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
|
|
<span>{approval.toolName}</span>
|
|
<span>Expires {new Date(approval.expiresAt).toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
<span className="rounded-full border border-amber-300/80 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] dark:border-amber-800">
|
|
{belongsToCurrentConversation ? "This chat" : "Other chat"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 flex gap-2">
|
|
<button
|
|
type="button"
|
|
data-testid="assistant-approval-confirm"
|
|
onClick={() =>
|
|
void sendMessage("Ja, bitte ausführen.", approval.conversationId, {
|
|
persistInCurrentChat: belongsToCurrentConversation,
|
|
})
|
|
}
|
|
disabled={isLoading}
|
|
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="assistant-approval-cancel"
|
|
onClick={() =>
|
|
void sendMessage("Abbrechen.", approval.conversationId, {
|
|
persistInCurrentChat: belongsToCurrentConversation,
|
|
})
|
|
}
|
|
disabled={isLoading}
|
|
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{messages.length === 0 && !isLoading && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
|
<svg className="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
|
/>
|
|
</svg>
|
|
<p className="font-medium">Frag mich etwas!</p>
|
|
<p className="mt-1 text-xs">z.B. "Welche Ressourcen gibt es?"</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<ChatMessage
|
|
role={msg.role}
|
|
content={msg.content}
|
|
{...(msg.insights ? { insights: msg.insights } : {})}
|
|
/>
|
|
{msg.role === "assistant" && msg.approval && (
|
|
<div className="flex justify-start">
|
|
<div className="max-w-[85%] rounded-2xl border border-amber-200 bg-amber-50/90 px-3 py-2.5 text-xs text-amber-900 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-semibold uppercase tracking-[0.08em]">
|
|
{msg.approval.status === "pending" ? "Approval pending" : msg.approval.status}
|
|
</span>
|
|
<span className="rounded-full border border-amber-300/70 px-2 py-0.5 font-medium dark:border-amber-800">
|
|
{msg.approval.toolName}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1.5 text-sm font-medium text-amber-950 dark:text-amber-50">
|
|
{msg.approval.summary}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-amber-700 dark:text-amber-200/80">
|
|
<span>Created: {new Date(msg.approval.createdAt).toLocaleString()}</span>
|
|
{msg.approval.status === "pending" && (
|
|
<span>Expires: {new Date(msg.approval.expiresAt).toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
{msg.approval.status === "pending" && (
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void sendMessage("Ja, bitte ausführen.")}
|
|
disabled={isLoading}
|
|
className="rounded-lg bg-amber-900 px-3 py-1.5 font-medium text-amber-50 transition hover:bg-amber-950 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void sendMessage("Abbrechen.")}
|
|
disabled={isLoading}
|
|
className="rounded-lg border border-amber-300 px-3 py-1.5 font-medium text-amber-900 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-800 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{isLoading && <TypingIndicator />}
|
|
{error && (
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="border-t border-gray-200 px-3 py-2.5 dark:border-slate-800">
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Nachricht eingeben..."
|
|
rows={1}
|
|
className="flex-1 resize-none rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-brand-500"
|
|
style={{ maxHeight: "120px" }}
|
|
onInput={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
target.style.height = "auto";
|
|
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void sendMessage()}
|
|
disabled={!input.trim() || isLoading}
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white transition-colors hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 12h14M12 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|