feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements
AI Assistant (HartBOT): - Chat panel with inline layout, session persistence, message history (up-arrow recall) - OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status) - RBAC-aware tool filtering, fuzzy search with word-level matching - Navigation actions (router.push) and data invalidation after mutations - Country/metro city/org unit/role filtering on resource search Demand Filling Enhancements: - Two-phase fill modal: plan multiple resources, then confirm & assign all at once - Availability preview per resource (available/partial/conflict days, existing bookings) - Coverage bar showing demand hours distribution across assigned resources - Fill demand from project detail page (new Assign button per demand) - Fixed: filled demands no longer shown on timeline, demand bars no longer overlap Budget per Role: - DemandRequirement.budgetCents field (schema + API + UI) - Project wizard step 3: budget input per role with allocation summary bar - Project detail: allocated vs booked budget per demand - Fill demand modal: role budget display with cost estimates - AllocationModal: budget field for demand editing Project Favorites: - User.favoriteProjectIds (JSONB) with toggle API - Star button on projects list and detail page (optimistic updates) - "My Projects" dashboard widget (favorites + responsible person projects) Project Management: - Edit project from detail page (ProjectModal integration) - Edit demands from detail page (AllocationModal integration) - Admin-only project deletion (cascades assignments + demands) - Create user accounts from admin panel Timeline Fixes: - Country multi-select filter with backend support - URL param sync for same-page navigation (AI assistant integration) - Demand lane stacking (no more overlapping bars) - Single-day booking resize handles (always visible, min 6px) - Single-day resize allowed (start === end) - "All Clients" toggle (select all / deselect all) Other Fixes: - crypto.randomUUID fallback for non-secure contexts - Chat message limit raised (200 max, client sends last 40) - Status dropdown portal (no longer clipped by table overflow) - Cents display restored in budget views (2 decimal places) - Allocations grouped view with project sub-groups (collapsed by default) - Server-side resource search for project wizard (no 500 limit) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePathname } 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 {
|
||||
// Try exact match first, then prefix match (longest first)
|
||||
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;
|
||||
}
|
||||
|
||||
export function ChatDrawer({ onClose }: { onClose: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const chatMutation = trpc.assistant.chat.useMutation();
|
||||
|
||||
// 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 () => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setInput("");
|
||||
setError(null);
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
messages: updated.map((m) => ({ role: m.role, content: m.content })),
|
||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||
});
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, chatMutation]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||
<svg className="h-4 w-4" 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">Planarchy Assistant</h2>
|
||||
</div>
|
||||
<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-5 w-5" 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>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
<svg className="mb-3 h-10 w-10" 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?" oder "Budget von Z033T593?"</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} role={msg.role} content={msg.content} />
|
||||
))}
|
||||
{isLoading && <TypingIndicator />}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 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-4 py-3 dark:border-slate-700">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ChatMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight inline markdown renderer — handles bold, italic, code,
|
||||
* bullet lists, and numbered lists without a full markdown library.
|
||||
*/
|
||||
function renderMarkdown(text: string) {
|
||||
const lines = text.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let listItems: React.ReactNode[] = [];
|
||||
let listType: "ul" | "ol" | null = null;
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0 && listType) {
|
||||
const Tag = listType;
|
||||
elements.push(
|
||||
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
|
||||
{listItems}
|
||||
</Tag>,
|
||||
);
|
||||
listItems = [];
|
||||
listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const [i, line] of lines.entries()) {
|
||||
// Bullet list: "- item" or "* item"
|
||||
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
|
||||
if (bulletMatch?.[1]) {
|
||||
if (listType !== "ul") flushList();
|
||||
listType = "ul";
|
||||
listItems.push(<li key={`li-${i}`}>{inlineFormat(bulletMatch[1])}</li>);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered list: "1. item"
|
||||
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
|
||||
if (numMatch?.[1]) {
|
||||
if (listType !== "ol") flushList();
|
||||
listType = "ol";
|
||||
listItems.push(<li key={`li-${i}`}>{inlineFormat(numMatch[1])}</li>);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a list item — flush any pending list
|
||||
flushList();
|
||||
|
||||
// Empty line → spacing
|
||||
if (line.trim() === "") {
|
||||
elements.push(<div key={`br-${i}`} className="h-2" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Parse inline formatting: **bold**, *italic*, `code` */
|
||||
function inlineFormat(text: string): React.ReactNode {
|
||||
// Split by inline patterns, preserving delimiters
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Regex: **bold**, *italic*, `code`
|
||||
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Text before this match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
if (match[2]) {
|
||||
// **bold**
|
||||
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
|
||||
} else if (match[3]) {
|
||||
// *italic*
|
||||
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
|
||||
} else if (match[4]) {
|
||||
// `code`
|
||||
parts.push(
|
||||
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
|
||||
{match[4]}
|
||||
</code>,
|
||||
);
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content }: ChatMessageProps) {
|
||||
const isUser = role === "user";
|
||||
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||
isUser
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<span className="whitespace-pre-wrap break-words">{content}</span>
|
||||
) : (
|
||||
<div className="space-y-0.5 break-words">{rendered}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl bg-gray-100 px-4 py-3 dark:bg-slate-800">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "0ms" }} />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "150ms" }} />
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "planarchy-chat-messages";
|
||||
|
||||
/** 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) return JSON.parse(raw) as Message[];
|
||||
} catch { /* ignore corrupt data */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */
|
||||
let cachedMessages: Message[] | null = null;
|
||||
|
||||
export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const [messages, setMessages] = useState<Message[]>(() => cachedMessages ?? loadPersistedMessages());
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const chatMutation = trpc.assistant.chat.useMutation();
|
||||
|
||||
// Sync to module-level cache + sessionStorage on every change
|
||||
useEffect(() => {
|
||||
cachedMessages = messages;
|
||||
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ }
|
||||
}, [messages]);
|
||||
|
||||
// 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 () => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setInput("");
|
||||
setError(null);
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
|
||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||
});
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
|
||||
|
||||
// Handle actions from the AI (navigation, data invalidation)
|
||||
const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, chatMutation, pathname]);
|
||||
|
||||
// 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);
|
||||
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>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
||||
{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) => (
|
||||
<ChatMessage key={i} role={msg.role} content={msg.content} />
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user