diff --git a/src/admin/components/dashboard/DashAssistant.tsx b/src/admin/components/dashboard/DashAssistant.tsx new file mode 100644 index 0000000..db6d6ee --- /dev/null +++ b/src/admin/components/dashboard/DashAssistant.tsx @@ -0,0 +1,326 @@ +import { useState, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Card, Button, TextField } from "../../ui"; +import apiFetch from "../../utils/api"; +import { useAlert } from "../../context/AlertContext"; +import { aiUsageOptions, type ExtractedInvoice } from "../../lib/queries/ai"; + +interface ChatTurn { + role: "user" | "assistant"; + content: string; +} +// The review card is a *form* model: every editable field is a string (what the +// inputs hold), distinct from the API's ExtractedInvoice. We convert numbers at +// save time. This keeps the type honest — no `number` field holding a string. +interface ReviewInvoice { + uid: string; + supplier_name: string; + invoice_number: string; + amount: string; + currency: string; + vat_rate: string; + issue_date: string; + due_date: string; + description: string; + file_name: string; + file: File; +} +type EditableField = + | "supplier_name" + | "invoice_number" + | "amount" + | "currency" + | "vat_rate" + | "issue_date" + | "due_date" + | "description"; + +export default function DashAssistant() { + const alert = useAlert(); + const qc = useQueryClient(); + const { data: usage } = useQuery(aiUsageOptions()); + const [turns, setTurns] = useState([]); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [review, setReview] = useState([]); + const fileRef = useRef(null); + + // AI is hidden entirely when the backend isn't configured. + if (usage && usage.configured === false) return null; + + const sendChat = async () => { + const text = input.trim(); + if (!text || busy) return; + const next = [...turns, { role: "user" as const, content: text }]; + setTurns(next); + setInput(""); + setBusy(true); + try { + const res = await apiFetch("/api/admin/ai/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: next }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba AI"); + setTurns((t) => [...t, { role: "assistant", content: body.data.reply }]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba AI"); + } finally { + setBusy(false); + } + }; + + const onFiles = async (files: FileList | null) => { + if (!files || files.length === 0 || busy) return; + setBusy(true); + try { + const fd = new FormData(); + Array.from(files).forEach((f) => fd.append("files", f)); + const res = await apiFetch("/api/admin/ai/extract-invoices", { + method: "POST", + body: fd, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur"); + // The server iterates the multipart parts serially and pushes one result + // per file in submission order (truncating only if the budget runs out + // mid-batch), so arr[i] is reliably this result's original File — more + // robust than name-matching, which would collide on duplicate filenames. + const arr = Array.from(files); + const reviews: ReviewInvoice[] = ( + body.data.invoices as Array<{ + file_name: string; + fields?: ExtractedInvoice; + error?: string; + }> + ) + .map((r, i): ReviewInvoice | null => { + const f = r.fields; + return f + ? { + uid: crypto.randomUUID(), + supplier_name: f.supplier_name ?? "", + invoice_number: f.invoice_number ?? "", + amount: f.amount != null ? String(f.amount) : "", + currency: f.currency ?? "", + vat_rate: f.vat_rate != null ? String(f.vat_rate) : "", + issue_date: f.issue_date ?? "", + due_date: f.due_date ?? "", + description: f.description ?? "", + file_name: r.file_name, + file: arr[i], + } + : null; + }) + .filter((x): x is ReviewInvoice => x !== null); + setReview((prev) => [...prev, ...reviews]); + setTurns((t) => [ + ...t, + { + role: "assistant", + content: `Načetl jsem ${reviews.length} faktur k potvrzení.`, + }, + ]); + qc.invalidateQueries({ queryKey: ["ai", "usage"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Chyba čtení faktur"); + } finally { + setBusy(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const saveInvoice = async (inv: ReviewInvoice, idx: number) => { + setBusy(true); + try { + const fd = new FormData(); + fd.append("files", inv.file, inv.file_name); + fd.append( + "invoices", + JSON.stringify([ + { + supplier_name: inv.supplier_name, + invoice_number: inv.invoice_number || undefined, + description: inv.description || undefined, + amount: inv.amount === "" ? undefined : Number(inv.amount), + currency: inv.currency, + vat_rate: inv.vat_rate === "" ? undefined : Number(inv.vat_rate), + issue_date: inv.issue_date || undefined, + due_date: inv.due_date || undefined, + }, + ]), + ); + const res = await apiFetch("/api/admin/received-invoices", { + method: "POST", + body: fd, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error || "Uložení selhalo"); + alert.success("Faktura uložena"); + setReview((r) => r.filter((_, i) => i !== idx)); + qc.invalidateQueries({ queryKey: ["received-invoices"] }); + } catch (e) { + alert.error(e instanceof Error ? e.message : "Uložení selhalo"); + } finally { + setBusy(false); + } + }; + + const patch = (idx: number, field: EditableField, value: string) => + setReview((r) => + r.map((inv, i) => (i === idx ? { ...inv, [field]: value } : inv)), + ); + + return ( + + + + AI asistent + + {usage && ( + + Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ + {usage.budget_usd.toFixed(2)} + + )} + + + + {turns.map((t, i) => ( + + + {t.content} + + + ))} + {review.map((inv, idx) => ( + + + {inv.file_name} + + + patch(idx, "supplier_name", e.target.value)} + /> + patch(idx, "invoice_number", e.target.value)} + /> + patch(idx, "amount", e.target.value)} + /> + patch(idx, "currency", e.target.value)} + /> + patch(idx, "vat_rate", e.target.value)} + /> + patch(idx, "issue_date", e.target.value)} + /> + + + + + + + ))} + + + + onFiles(e.target.files)} + /> + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendChat(); + } + }} + /> + + + + ); +} diff --git a/src/admin/lib/queries/ai.ts b/src/admin/lib/queries/ai.ts new file mode 100644 index 0000000..6a7a2a8 --- /dev/null +++ b/src/admin/lib/queries/ai.ts @@ -0,0 +1,27 @@ +import { queryOptions } from "@tanstack/react-query"; +import { jsonQuery } from "../apiAdapter"; + +export interface AiUsage { + configured: boolean; + month_spend_usd: number; + budget_usd: number; + remaining_usd: number; +} + +export interface ExtractedInvoice { + supplier_name: string; + invoice_number: string | null; + amount: number; + currency: string; + vat_rate: number; + issue_date: string | null; + due_date: string | null; + description: string | null; +} + +export const aiUsageOptions = () => + queryOptions({ + queryKey: ["ai", "usage"], + queryFn: () => jsonQuery("/api/admin/ai/usage"), + staleTime: 30_000, + }); diff --git a/src/admin/pages/Dashboard.tsx b/src/admin/pages/Dashboard.tsx index 2f95e8a..d4fcbd1 100644 --- a/src/admin/pages/Dashboard.tsx +++ b/src/admin/pages/Dashboard.tsx @@ -22,6 +22,7 @@ import DashSessions from "../components/dashboard/DashSessions"; import DashTodayPlan, { type TodayPlan, } from "../components/dashboard/DashTodayPlan"; +import DashAssistant from "../components/dashboard/DashAssistant"; const API_BASE = "/api/admin"; @@ -236,6 +237,9 @@ export default function Dashboard() { + {/* AI Assistant widget */} + {hasPermission("ai.use") && } + {/* 2FA Required Banner */} {user?.require2FA && !user?.totpEnabled && (