feat(ai): dashboard chat widget with invoice import + budget indicator
This commit is contained in:
326
src/admin/components/dashboard/DashAssistant.tsx
Normal file
326
src/admin/components/dashboard/DashAssistant.tsx
Normal file
@@ -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<ChatTurn[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [review, setReview] = useState<ReviewInvoice[]>([]);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
AI asistent
|
||||||
|
</Typography>
|
||||||
|
{usage && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
||||||
|
{usage.budget_usd.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1,
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: "auto",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{turns.map((t, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
alignSelf: t.role === "user" ? "flex-end" : "flex-start",
|
||||||
|
maxWidth: "85%",
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: t.role === "user" ? "primary.main" : "action.hover",
|
||||||
|
color: t.role === "user" ? "common.white" : "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{t.content}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{review.map((inv, idx) => (
|
||||||
|
<Card key={inv.uid} variant="outlined" sx={{ p: 1.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{inv.file_name}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 1,
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Dodavatel"
|
||||||
|
value={inv.supplier_name}
|
||||||
|
onChange={(e) => patch(idx, "supplier_name", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Číslo faktury"
|
||||||
|
value={inv.invoice_number ?? ""}
|
||||||
|
onChange={(e) => patch(idx, "invoice_number", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Částka"
|
||||||
|
value={inv.amount}
|
||||||
|
onChange={(e) => patch(idx, "amount", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Měna"
|
||||||
|
value={inv.currency}
|
||||||
|
onChange={(e) => patch(idx, "currency", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Sazba DPH %"
|
||||||
|
value={inv.vat_rate}
|
||||||
|
onChange={(e) => patch(idx, "vat_rate", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Datum vystavení"
|
||||||
|
value={inv.issue_date ?? ""}
|
||||||
|
onChange={(e) => patch(idx, "issue_date", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
|
<Button onClick={() => saveInvoice(inv, idx)} disabled={busy}>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setReview((r) => r.filter((_, i) => i !== idx))}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Zahodit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Přiložit fakturu
|
||||||
|
</Button>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Napište zprávu…"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendChat();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={sendChat} disabled={busy || !input.trim()}>
|
||||||
|
Odeslat
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/admin/lib/queries/ai.ts
Normal file
27
src/admin/lib/queries/ai.ts
Normal file
@@ -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<AiUsage>("/api/admin/ai/usage"),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import DashSessions from "../components/dashboard/DashSessions";
|
|||||||
import DashTodayPlan, {
|
import DashTodayPlan, {
|
||||||
type TodayPlan,
|
type TodayPlan,
|
||||||
} from "../components/dashboard/DashTodayPlan";
|
} from "../components/dashboard/DashTodayPlan";
|
||||||
|
import DashAssistant from "../components/dashboard/DashAssistant";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -236,6 +237,9 @@ export default function Dashboard() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* AI Assistant widget */}
|
||||||
|
{hasPermission("ai.use") && <DashAssistant />}
|
||||||
|
|
||||||
{/* 2FA Required Banner */}
|
{/* 2FA Required Banner */}
|
||||||
{user?.require2FA && !user?.totpEnabled && (
|
{user?.require2FA && !user?.totpEnabled && (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
Reference in New Issue
Block a user