Add AI review workflow for Paperless documents
This commit is contained in:
@@ -3,6 +3,8 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
|
||||
|
||||
import Layout from "./components/Layout";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import AiReviewDetail from "./routes/AiReviewDetail";
|
||||
import AiReviewList from "./routes/AiReviewList";
|
||||
import CalendarView from "./routes/CalendarView";
|
||||
import ContractDetail from "./routes/ContractDetail";
|
||||
import ContractForm from "./routes/ContractForm";
|
||||
@@ -45,6 +47,8 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="ai-reviews" element={<AiReviewList />} />
|
||||
<Route path="ai-reviews/:reviewId" element={<AiReviewDetail />} />
|
||||
<Route path="contracts" element={<ContractsList />} />
|
||||
<Route path="contracts/new" element={<ContractForm mode="create" />} />
|
||||
<Route path="contracts/:contractId" element={<ContractDetail />} />
|
||||
|
||||
33
frontend/src/api/aiReviews.ts
Normal file
33
frontend/src/api/aiReviews.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { AiReview, AiReviewStatus, Contract, ContractPayload } from "../types";
|
||||
import { request } from "./client";
|
||||
|
||||
export async function fetchAiReviews(status?: AiReviewStatus): Promise<AiReview[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (status) {
|
||||
params.set("status", status);
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<AiReview[]>(`/ai/reviews${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
}
|
||||
|
||||
export async function fetchAiReview(id: number): Promise<AiReview> {
|
||||
return request<AiReview>(`/ai/reviews/${id}`, { method: "GET" });
|
||||
}
|
||||
|
||||
export async function retryAiReview(id: number): Promise<AiReview> {
|
||||
return request<AiReview>(`/ai/reviews/${id}/retry`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function rejectAiReview(id: number): Promise<AiReview> {
|
||||
return request<AiReview>(`/ai/reviews/${id}/reject`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function approveAiReview(
|
||||
id: number,
|
||||
contract: ContractPayload
|
||||
): Promise<{ review: AiReview; contract: Contract }> {
|
||||
return request<{ review: AiReview; contract: Contract }>(`/ai/reviews/${id}/approve`, {
|
||||
method: "POST",
|
||||
body: { contract }
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export interface ServerConfig {
|
||||
ntfyConfigured: boolean;
|
||||
authEnabled: boolean;
|
||||
authTokenExpiresInHours: number;
|
||||
aiEnabled: boolean;
|
||||
aiConfigured: boolean;
|
||||
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||
paperlessWebhookConfigured: boolean;
|
||||
}
|
||||
|
||||
export async function fetchServerConfig(): Promise<ServerConfig> {
|
||||
@@ -42,12 +46,21 @@ export interface SettingsResponse {
|
||||
ntfyTopic: string | null;
|
||||
ntfyPriority: string | null;
|
||||
authUsername: string | null;
|
||||
aiEnabled: boolean;
|
||||
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||
aiBaseUrl: string | null;
|
||||
aiModel: string | null;
|
||||
aiSystemPrompt: string | null;
|
||||
aiTimeoutSeconds: number;
|
||||
aiMaxTokens: number;
|
||||
};
|
||||
secrets: {
|
||||
paperlessTokenSet: boolean;
|
||||
mailPasswordSet: boolean;
|
||||
ntfyTokenSet: boolean;
|
||||
authPasswordSet: boolean;
|
||||
aiApiKeySet: boolean;
|
||||
paperlessWebhookSecretSet: boolean;
|
||||
};
|
||||
icalSecret: string | null;
|
||||
}
|
||||
@@ -74,6 +87,15 @@ export type UpdateSettingsPayload = Partial<{
|
||||
authUsername: string | null;
|
||||
authPassword: string | null;
|
||||
icalSecret: string | null;
|
||||
aiEnabled: boolean;
|
||||
aiProvider: "openai" | "openai-compatible" | "gemini" | null;
|
||||
aiBaseUrl: string | null;
|
||||
aiModel: string | null;
|
||||
aiApiKey: string | null;
|
||||
aiSystemPrompt: string | null;
|
||||
aiTimeoutSeconds: number;
|
||||
aiMaxTokens: number;
|
||||
paperlessWebhookSecret: string | null;
|
||||
}>;
|
||||
|
||||
export async function fetchSettings(): Promise<SettingsResponse> {
|
||||
@@ -95,3 +117,7 @@ export async function triggerMailTest(): Promise<void> {
|
||||
export async function triggerNtfyTest(): Promise<void> {
|
||||
await request("/settings/test/ntfy", { method: "POST" });
|
||||
}
|
||||
|
||||
export async function triggerAiTest(): Promise<void> {
|
||||
await request("/settings/test/ai", { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import CalendarMonthIcon from "@mui/icons-material/CalendarMonth";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import {
|
||||
AppBar,
|
||||
@@ -33,6 +34,7 @@ const drawerWidth = 240;
|
||||
|
||||
const navItems = [
|
||||
{ key: "nav.dashboard", icon: <DashboardIcon />, path: "/dashboard" },
|
||||
{ key: "nav.aiReviews", icon: <AutoAwesomeIcon />, path: "/ai-reviews" },
|
||||
{ key: "nav.contracts", icon: <DescriptionIcon />, path: "/contracts" },
|
||||
{ key: "nav.calendar", icon: <CalendarMonthIcon />, path: "/calendar" },
|
||||
{ key: "nav.settings", icon: <SettingsIcon />, path: "/settings" }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"aiReviews": "AI-Prüfung",
|
||||
"contracts": "Verträge",
|
||||
"calendar": "Kalender",
|
||||
"settings": "Einstellungen",
|
||||
@@ -175,6 +176,29 @@
|
||||
"scheduler": "Fristen & Benachrichtigungen",
|
||||
"mail": "E-Mail",
|
||||
"ntfy": "ntfy Push",
|
||||
"ai": "AI-Analyse",
|
||||
"aiEnabled": "AI-Analyse aktivieren",
|
||||
"aiProvider": "AI-Anbieter",
|
||||
"aiProviderNone": "Kein Anbieter",
|
||||
"aiBaseUrl": "AI Base URL",
|
||||
"aiBaseUrlPlaceholder": "Leer lassen für Anbieter-Standard",
|
||||
"aiBaseUrlHelp": "Für OpenAI-kompatible Anbieter oder eigene Gateways.",
|
||||
"aiModel": "Modell",
|
||||
"aiApiKey": "API-Key",
|
||||
"aiApiKeyNew": "Neuen API-Key hinterlegen",
|
||||
"aiApiKeyRemove": "API-Key löschen",
|
||||
"aiApiKeyInfo": "Ein API-Key ist hinterlegt. Lasse das Feld leer, um ihn unverändert zu lassen.",
|
||||
"aiSystemPrompt": "Systemprompt",
|
||||
"aiTimeout": "Timeout (Sekunden)",
|
||||
"aiMaxTokens": "Max Tokens",
|
||||
"aiTest": "AI testen",
|
||||
"aiTestSuccess": "AI-Test erfolgreich",
|
||||
"aiTestError": "AI-Test fehlgeschlagen",
|
||||
"webhookSecret": "Paperless Webhook-Secret",
|
||||
"webhookSecretNew": "Neues Webhook-Secret hinterlegen",
|
||||
"webhookSecretRemove": "Webhook-Secret löschen",
|
||||
"webhookSecretInfo": "Ein Webhook-Secret ist hinterlegt. Lasse das Feld leer, um es unverändert zu lassen.",
|
||||
"webhookSecretHelp": "Paperless sendet diesen Wert im Header x-contract-companion-secret.",
|
||||
"ical": "iCal-Abo",
|
||||
"icalFeedUrl": "Feed-URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
@@ -261,6 +285,46 @@
|
||||
"correspondent": "Korrespondent",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"aiReviews": {
|
||||
"title": "AI-Prüfung",
|
||||
"subtitle": "Neue Paperless-Dokumente prüfen, Vertragsentwürfe freigeben und nach Paperless zurückschreiben.",
|
||||
"refresh": "Aktualisieren",
|
||||
"aiNotConfigured": "AI ist noch nicht vollständig konfiguriert.",
|
||||
"webhookNotConfigured": "Das Paperless Webhook-Secret ist noch nicht gesetzt.",
|
||||
"document": "Dokument",
|
||||
"review": "Prüfen",
|
||||
"loadError": "AI-Prüfungen konnten nicht geladen werden.",
|
||||
"empty": "Noch keine AI-Prüfungen vorhanden.",
|
||||
"detailSubtitle": "Paperless-Dokument #{{id}}",
|
||||
"summary": "AI-Zusammenfassung",
|
||||
"detectedContract": "Die AI stuft das Dokument als Vertrag ein.",
|
||||
"detectedNoContract": "Die AI stuft das Dokument nicht als Vertrag ein.",
|
||||
"noAnalysis": "Noch keine Analyse vorhanden.",
|
||||
"contractDraft": "Vertragsentwurf",
|
||||
"noContractDraft": "Die Analyse enthält keinen Vertragsentwurf. Du kannst manuell Daten ergänzen oder den Fall ablehnen.",
|
||||
"approve": "Freigeben und Vertrag anlegen",
|
||||
"approved": "Vertrag angelegt und Paperless aktualisiert",
|
||||
"reject": "Ablehnen",
|
||||
"rejected": "Prüfung abgelehnt",
|
||||
"retry": "Erneut analysieren",
|
||||
"retryStarted": "Analyse erneut gestartet",
|
||||
"actionFailed": "Aktion fehlgeschlagen",
|
||||
"columns": {
|
||||
"document": "Dokument",
|
||||
"status": "Status",
|
||||
"confidence": "Sicherheit",
|
||||
"updated": "Aktualisiert",
|
||||
"action": "Aktion"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Wartet",
|
||||
"analyzing": "Analyse läuft",
|
||||
"needs_review": "Prüfung nötig",
|
||||
"approved": "Freigegeben",
|
||||
"rejected": "Abgelehnt",
|
||||
"failed": "Fehler"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Anmeldung",
|
||||
"welcome": "Willkommen zurück! Bitte melde dich an.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"aiReviews": "AI review",
|
||||
"contracts": "Contracts",
|
||||
"calendar": "Calendar",
|
||||
"settings": "Settings",
|
||||
@@ -175,6 +176,29 @@
|
||||
"scheduler": "Deadlines & notifications",
|
||||
"mail": "E-mail",
|
||||
"ntfy": "ntfy push",
|
||||
"ai": "AI analysis",
|
||||
"aiEnabled": "Enable AI analysis",
|
||||
"aiProvider": "AI provider",
|
||||
"aiProviderNone": "No provider",
|
||||
"aiBaseUrl": "AI base URL",
|
||||
"aiBaseUrlPlaceholder": "Leave empty for provider default",
|
||||
"aiBaseUrlHelp": "For OpenAI-compatible providers or custom gateways.",
|
||||
"aiModel": "Model",
|
||||
"aiApiKey": "API key",
|
||||
"aiApiKeyNew": "Provide new API key",
|
||||
"aiApiKeyRemove": "Remove API key",
|
||||
"aiApiKeyInfo": "An API key is stored. Leave empty to keep it.",
|
||||
"aiSystemPrompt": "System prompt",
|
||||
"aiTimeout": "Timeout (seconds)",
|
||||
"aiMaxTokens": "Max tokens",
|
||||
"aiTest": "Test AI",
|
||||
"aiTestSuccess": "AI test successful",
|
||||
"aiTestError": "AI test failed",
|
||||
"webhookSecret": "Paperless webhook secret",
|
||||
"webhookSecretNew": "Provide new webhook secret",
|
||||
"webhookSecretRemove": "Remove webhook secret",
|
||||
"webhookSecretInfo": "A webhook secret is stored. Leave empty to keep it.",
|
||||
"webhookSecretHelp": "Paperless sends this value in the x-contract-companion-secret header.",
|
||||
"ical": "iCal subscription",
|
||||
"icalFeedUrl": "Feed URL",
|
||||
"paperlessApiUrl": "Paperless API URL",
|
||||
@@ -261,6 +285,46 @@
|
||||
"correspondent": "Correspondent",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"aiReviews": {
|
||||
"title": "AI review",
|
||||
"subtitle": "Review new Paperless documents, approve contract drafts, and write metadata back to Paperless.",
|
||||
"refresh": "Refresh",
|
||||
"aiNotConfigured": "AI is not fully configured yet.",
|
||||
"webhookNotConfigured": "The Paperless webhook secret is not set yet.",
|
||||
"document": "Document",
|
||||
"review": "Review",
|
||||
"loadError": "Unable to load AI reviews.",
|
||||
"empty": "No AI reviews yet.",
|
||||
"detailSubtitle": "Paperless document #{{id}}",
|
||||
"summary": "AI summary",
|
||||
"detectedContract": "AI classified this document as a contract.",
|
||||
"detectedNoContract": "AI did not classify this document as a contract.",
|
||||
"noAnalysis": "No analysis available yet.",
|
||||
"contractDraft": "Contract draft",
|
||||
"noContractDraft": "The analysis contains no contract draft. You can add data manually or reject the review.",
|
||||
"approve": "Approve and create contract",
|
||||
"approved": "Contract created and Paperless updated",
|
||||
"reject": "Reject",
|
||||
"rejected": "Review rejected",
|
||||
"retry": "Analyze again",
|
||||
"retryStarted": "Analysis restarted",
|
||||
"actionFailed": "Action failed",
|
||||
"columns": {
|
||||
"document": "Document",
|
||||
"status": "Status",
|
||||
"confidence": "Confidence",
|
||||
"updated": "Updated",
|
||||
"action": "Action"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"analyzing": "Analyzing",
|
||||
"needs_review": "Needs review",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"welcome": "Welcome back! Please sign in.",
|
||||
|
||||
328
frontend/src/routes/AiReviewDetail.tsx
Normal file
328
frontend/src/routes/AiReviewDetail.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { approveAiReview, fetchAiReview, rejectAiReview, retryAiReview } from "../api/aiReviews";
|
||||
import { fetchServerConfig } from "../api/config";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { ContractPayload } from "../types";
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
provider: string;
|
||||
category: string;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
terminationNoticeDays: string;
|
||||
renewalPeriodMonths: string;
|
||||
autoRenew: boolean;
|
||||
price: string;
|
||||
currency: string;
|
||||
notes: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
title: "",
|
||||
provider: "",
|
||||
category: "",
|
||||
contractStartDate: "",
|
||||
contractEndDate: "",
|
||||
terminationNoticeDays: "",
|
||||
renewalPeriodMonths: "",
|
||||
autoRenew: false,
|
||||
price: "",
|
||||
currency: "EUR",
|
||||
notes: "",
|
||||
tags: ""
|
||||
};
|
||||
|
||||
function toStringValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function parseNumber(value: string, integer = false): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed.replace(",", "."));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error("Invalid number");
|
||||
}
|
||||
return integer ? Math.round(parsed) : parsed;
|
||||
}
|
||||
|
||||
export default function AiReviewDetail() {
|
||||
const { reviewId } = useParams<{ reviewId: string }>();
|
||||
const id = reviewId ? Number(reviewId) : null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { showMessage } = useSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const {
|
||||
data: review,
|
||||
isLoading,
|
||||
isError
|
||||
} = useQuery({
|
||||
queryKey: ["ai-review", id],
|
||||
queryFn: () => fetchAiReview(id ?? 0),
|
||||
enabled: id !== null
|
||||
});
|
||||
const { data: serverConfig } = useQuery({
|
||||
queryKey: ["server-config"],
|
||||
queryFn: fetchServerConfig
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!review?.contractPayload) {
|
||||
return;
|
||||
}
|
||||
const payload = review.contractPayload;
|
||||
setForm({
|
||||
title: payload.title ?? "",
|
||||
provider: payload.provider ?? "",
|
||||
category: payload.category ?? "",
|
||||
contractStartDate: payload.contractStartDate ?? "",
|
||||
contractEndDate: payload.contractEndDate ?? "",
|
||||
terminationNoticeDays: toStringValue(payload.terminationNoticeDays),
|
||||
renewalPeriodMonths: toStringValue(payload.renewalPeriodMonths),
|
||||
autoRenew: payload.autoRenew ?? false,
|
||||
price: toStringValue(payload.price),
|
||||
currency: payload.currency ?? "EUR",
|
||||
notes: payload.notes ?? "",
|
||||
tags: payload.tags?.join(", ") ?? ""
|
||||
});
|
||||
}, [review]);
|
||||
|
||||
const paperlessUrl = useMemo(() => {
|
||||
if (!review || !serverConfig) {
|
||||
return null;
|
||||
}
|
||||
const base = serverConfig.paperlessExternalUrl ?? serverConfig.paperlessBaseUrl;
|
||||
return base ? `${base.replace(/\/$/, "")}/documents/${review.paperlessDocumentId}` : null;
|
||||
}, [review, serverConfig]);
|
||||
|
||||
const refreshQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-reviews"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-review", id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["contracts"] })
|
||||
]);
|
||||
};
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: () => retryAiReview(id ?? 0),
|
||||
onSuccess: async () => {
|
||||
showMessage(t("aiReviews.retryStarted"), "success");
|
||||
await refreshQueries();
|
||||
},
|
||||
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: () => rejectAiReview(id ?? 0),
|
||||
onSuccess: async () => {
|
||||
showMessage(t("aiReviews.rejected"), "success");
|
||||
await refreshQueries();
|
||||
navigate("/ai-reviews");
|
||||
},
|
||||
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const contract: ContractPayload = {
|
||||
title: form.title.trim(),
|
||||
provider: form.provider.trim() || null,
|
||||
category: form.category.trim() || null,
|
||||
paperlessDocumentId: review?.paperlessDocumentId ?? null,
|
||||
contractStartDate: form.contractStartDate || null,
|
||||
contractEndDate: form.contractEndDate || null,
|
||||
terminationNoticeDays: parseNumber(form.terminationNoticeDays, true),
|
||||
renewalPeriodMonths: parseNumber(form.renewalPeriodMonths, true),
|
||||
autoRenew: form.autoRenew,
|
||||
price: parseNumber(form.price),
|
||||
currency: form.currency.trim() || "EUR",
|
||||
notes: form.notes.trim() || null,
|
||||
tags: form.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
return approveAiReview(id ?? 0, contract);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
showMessage(t("aiReviews.approved"), "success");
|
||||
await refreshQueries();
|
||||
navigate(`/contracts/${result.contract.id}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message;
|
||||
showMessage(message ?? t("aiReviews.actionFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Typography>{t("messages.loading")}</Typography>;
|
||||
}
|
||||
|
||||
if (isError || !review) {
|
||||
return <Alert severity="error">{t("aiReviews.loadError")}</Alert>;
|
||||
}
|
||||
|
||||
const canApprove = review.status !== "approved" && review.status !== "rejected" && Boolean(form.title.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
|
||||
subtitle={t("aiReviews.detailSubtitle", { id: review.paperlessDocumentId })}
|
||||
action={
|
||||
paperlessUrl ? (
|
||||
<Button href={paperlessUrl} target="_blank" rel="noreferrer" startIcon={<LaunchIcon />} variant="outlined">
|
||||
{t("contractDetail.openInPaperless")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Chip label={t(`aiReviews.status.${review.status}`)} />
|
||||
{review.confidence !== null && (
|
||||
<Chip label={`${Math.round(review.confidence * 100)}%`} color="primary" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
{review.error && <Alert severity="error">{review.error}</Alert>}
|
||||
{review.analysis ? (
|
||||
<>
|
||||
<Typography variant="h6">{t("aiReviews.summary")}</Typography>
|
||||
<Typography whiteSpace="pre-wrap">{review.analysis.summary}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{review.analysis.isContract ? t("aiReviews.detectedContract") : t("aiReviews.detectedNoContract")}
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="info">{t("aiReviews.noAnalysis")}</Alert>
|
||||
)}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||
<Button
|
||||
startIcon={<ReplayIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => retryMutation.mutate()}
|
||||
disabled={retryMutation.isPending || review.status === "approved"}
|
||||
>
|
||||
{t("aiReviews.retry")}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<CloseIcon />}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
onClick={() => rejectMutation.mutate()}
|
||||
disabled={rejectMutation.isPending || review.status === "approved" || review.status === "rejected"}
|
||||
>
|
||||
{t("aiReviews.reject")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={7}>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6">{t("aiReviews.contractDraft")}</Typography>
|
||||
{!review.contractPayload && (
|
||||
<Alert severity="warning">{t("aiReviews.noContractDraft")}</Alert>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.title")} value={form.title} onChange={(event) => updateField("title", event.target.value)} fullWidth required />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.provider")} value={form.provider} onChange={(event) => updateField("provider", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.category")} value={form.category} onChange={(event) => updateField("category", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField label={t("contractForm.fields.currency")} value={form.currency} onChange={(event) => updateField("currency", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField type="date" label={t("contractForm.fields.contractStart")} value={form.contractStartDate} onChange={(event) => updateField("contractStartDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField type="date" label={t("contractForm.fields.contractEnd")} value={form.contractEndDate} onChange={(event) => updateField("contractEndDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField label={t("contractForm.fields.terminationNotice")} value={form.terminationNoticeDays} onChange={(event) => updateField("terminationNoticeDays", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField label={t("contractForm.fields.renewalPeriod")} value={form.renewalPeriodMonths} onChange={(event) => updateField("renewalPeriodMonths", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField label={t("contractForm.fields.price")} value={form.price} onChange={(event) => updateField("price", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.autoRenew} onChange={(event) => updateField("autoRenew", event.target.checked)} />}
|
||||
label={t("contractForm.fields.autoRenew")}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField label={t("contractForm.fields.tags")} value={form.tags} onChange={(event) => updateField("tags", event.target.value)} fullWidth />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField label={t("contractForm.fields.notes")} value={form.notes} onChange={(event) => updateField("notes", event.target.value)} fullWidth multiline minRows={4} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Button
|
||||
startIcon={<CheckIcon />}
|
||||
variant="contained"
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={!canApprove || approveMutation.isPending}
|
||||
>
|
||||
{t("aiReviews.approve")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
frontend/src/routes/AiReviewList.tsx
Normal file
154
frontend/src/routes/AiReviewList.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { fetchAiReviews } from "../api/aiReviews";
|
||||
import { fetchServerConfig } from "../api/config";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { formatDate } from "../utils/date";
|
||||
|
||||
function statusColor(status: string): "default" | "primary" | "success" | "warning" | "error" {
|
||||
if (status === "approved") return "success";
|
||||
if (status === "needs_review") return "primary";
|
||||
if (status === "failed") return "error";
|
||||
if (status === "analyzing" || status === "pending") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export default function AiReviewList() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: reviews,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching
|
||||
} = useQuery({
|
||||
queryKey: ["ai-reviews"],
|
||||
queryFn: () => fetchAiReviews()
|
||||
});
|
||||
const { data: serverConfig } = useQuery({
|
||||
queryKey: ["server-config"],
|
||||
queryFn: fetchServerConfig
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t("aiReviews.title")}
|
||||
subtitle={t("aiReviews.subtitle")}
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{t("aiReviews.refresh")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!serverConfig?.aiConfigured && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t("aiReviews.aiNotConfigured")}
|
||||
</Alert>
|
||||
)}
|
||||
{!serverConfig?.paperlessWebhookConfigured && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t("aiReviews.webhookNotConfigured")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ borderRadius: 3, p: 2.5 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("aiReviews.columns.document")}</TableCell>
|
||||
<TableCell>{t("aiReviews.columns.status")}</TableCell>
|
||||
<TableCell>{t("aiReviews.columns.confidence")}</TableCell>
|
||||
<TableCell>{t("aiReviews.columns.updated")}</TableCell>
|
||||
<TableCell align="right">{t("aiReviews.columns.action")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>{t("messages.loading")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Typography color="error">{t("aiReviews.loadError")}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && !isError && (reviews ?? []).length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AutoAwesomeIcon color="disabled" />
|
||||
<Typography color="text.secondary">{t("aiReviews.empty")}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{(reviews ?? []).map((review) => (
|
||||
<TableRow
|
||||
hover
|
||||
key={review.id}
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/ai-reviews/${review.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography fontWeight={600}>
|
||||
{review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Paperless #{review.paperlessDocumentId}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
color={statusColor(review.status)}
|
||||
label={t(`aiReviews.status.${review.status}`)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{review.confidence !== null ? `${Math.round(review.confidence * 100)}%` : "-"}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(review.updatedAt, "dd.MM.yyyy HH:mm")}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
navigate(`/ai-reviews/${review.id}`);
|
||||
}}>
|
||||
{t("aiReviews.review")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
fetchServerConfig,
|
||||
fetchSettings,
|
||||
resetIcalSecret,
|
||||
triggerAiTest,
|
||||
triggerMailTest,
|
||||
triggerNtfyTest,
|
||||
ServerConfig,
|
||||
@@ -75,6 +76,15 @@ type FormValues = {
|
||||
ntfyPriority: string;
|
||||
authUsername: string;
|
||||
authPassword: string;
|
||||
aiEnabled: boolean;
|
||||
aiProvider: "openai" | "openai-compatible" | "gemini" | "";
|
||||
aiBaseUrl: string;
|
||||
aiModel: string;
|
||||
aiApiKey: string;
|
||||
aiSystemPrompt: string;
|
||||
aiTimeoutSeconds: number;
|
||||
aiMaxTokens: number;
|
||||
paperlessWebhookSecret: string;
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
@@ -97,7 +107,16 @@ const defaultValues: FormValues = {
|
||||
ntfyToken: "",
|
||||
ntfyPriority: "default",
|
||||
authUsername: "",
|
||||
authPassword: ""
|
||||
authPassword: "",
|
||||
aiEnabled: false,
|
||||
aiProvider: "",
|
||||
aiBaseUrl: "",
|
||||
aiModel: "",
|
||||
aiApiKey: "",
|
||||
aiSystemPrompt: "",
|
||||
aiTimeoutSeconds: 60,
|
||||
aiMaxTokens: 2000,
|
||||
paperlessWebhookSecret: ""
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -146,6 +165,8 @@ export default function SettingsPage() {
|
||||
const [removeMailPassword, setRemoveMailPassword] = useState(false);
|
||||
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
|
||||
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
|
||||
const [removeAiApiKey, setRemoveAiApiKey] = useState(false);
|
||||
const [removeWebhookSecret, setRemoveWebhookSecret] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -182,7 +203,16 @@ export default function SettingsPage() {
|
||||
ntfyToken: "",
|
||||
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
|
||||
authUsername: settingsData.values.authUsername ?? "",
|
||||
authPassword: ""
|
||||
authPassword: "",
|
||||
aiEnabled: settingsData.values.aiEnabled ?? false,
|
||||
aiProvider: settingsData.values.aiProvider ?? "",
|
||||
aiBaseUrl: settingsData.values.aiBaseUrl ?? "",
|
||||
aiModel: settingsData.values.aiModel ?? "",
|
||||
aiApiKey: "",
|
||||
aiSystemPrompt: settingsData.values.aiSystemPrompt ?? "",
|
||||
aiTimeoutSeconds: settingsData.values.aiTimeoutSeconds ?? 60,
|
||||
aiMaxTokens: settingsData.values.aiMaxTokens ?? 2000,
|
||||
paperlessWebhookSecret: ""
|
||||
};
|
||||
|
||||
initialValuesRef.current = values;
|
||||
@@ -190,6 +220,8 @@ export default function SettingsPage() {
|
||||
setRemoveMailPassword(false);
|
||||
setRemoveNtfyToken(false);
|
||||
setRemoveAuthPassword(false);
|
||||
setRemoveAiApiKey(false);
|
||||
setRemoveWebhookSecret(false);
|
||||
reset(values);
|
||||
}, [settingsData, reset]);
|
||||
|
||||
@@ -225,6 +257,12 @@ export default function SettingsPage() {
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
|
||||
});
|
||||
|
||||
const aiTestMutation = useMutation({
|
||||
mutationFn: () => triggerAiTest(),
|
||||
onSuccess: () => showMessage(t("settings.aiTestSuccess"), "success"),
|
||||
onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error")
|
||||
});
|
||||
|
||||
const createCategoryMutation = useMutation({
|
||||
mutationFn: (name: string) => apiCreateCategory(name),
|
||||
onSuccess: async (category) => {
|
||||
@@ -386,6 +424,38 @@ export default function SettingsPage() {
|
||||
payload.authPassword = formValues.authPassword.trim();
|
||||
}
|
||||
|
||||
if (formValues.aiEnabled !== initial.aiEnabled) {
|
||||
payload.aiEnabled = formValues.aiEnabled;
|
||||
}
|
||||
if (formValues.aiProvider !== initial.aiProvider) {
|
||||
payload.aiProvider = formValues.aiProvider ? formValues.aiProvider : null;
|
||||
}
|
||||
if (formValues.aiBaseUrl !== initial.aiBaseUrl) {
|
||||
payload.aiBaseUrl = trimOrNull(formValues.aiBaseUrl);
|
||||
}
|
||||
if (formValues.aiModel !== initial.aiModel) {
|
||||
payload.aiModel = trimOrNull(formValues.aiModel);
|
||||
}
|
||||
if (removeAiApiKey) {
|
||||
payload.aiApiKey = null;
|
||||
} else if (formValues.aiApiKey.trim().length > 0) {
|
||||
payload.aiApiKey = formValues.aiApiKey.trim();
|
||||
}
|
||||
if (formValues.aiSystemPrompt !== initial.aiSystemPrompt) {
|
||||
payload.aiSystemPrompt = trimOrNull(formValues.aiSystemPrompt);
|
||||
}
|
||||
if (formValues.aiTimeoutSeconds !== initial.aiTimeoutSeconds) {
|
||||
payload.aiTimeoutSeconds = formValues.aiTimeoutSeconds;
|
||||
}
|
||||
if (formValues.aiMaxTokens !== initial.aiMaxTokens) {
|
||||
payload.aiMaxTokens = formValues.aiMaxTokens;
|
||||
}
|
||||
if (removeWebhookSecret) {
|
||||
payload.paperlessWebhookSecret = null;
|
||||
} else if (formValues.paperlessWebhookSecret.trim().length > 0) {
|
||||
payload.paperlessWebhookSecret = formValues.paperlessWebhookSecret.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
showMessage(t("settings.noChanges"), "info");
|
||||
return;
|
||||
@@ -425,6 +495,8 @@ export default function SettingsPage() {
|
||||
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
|
||||
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
|
||||
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
|
||||
const aiApiKeySet = settings?.secrets.aiApiKeySet ?? false;
|
||||
const paperlessWebhookSecretSet = settings?.secrets.paperlessWebhookSecretSet ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -675,6 +747,135 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("settings.ai")}
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Controller
|
||||
name="aiEnabled"
|
||||
control={control}
|
||||
render={({ field }) => <Switch {...field} checked={field.value} />}
|
||||
/>
|
||||
}
|
||||
label={t("settings.aiEnabled")}
|
||||
/>
|
||||
<TextField select label={t("settings.aiProvider")} {...register("aiProvider")} fullWidth>
|
||||
<MenuItem value="">{t("settings.aiProviderNone")}</MenuItem>
|
||||
<MenuItem value="openai">OpenAI</MenuItem>
|
||||
<MenuItem value="openai-compatible">OpenAI compatible</MenuItem>
|
||||
<MenuItem value="gemini">Gemini</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label={t("settings.aiBaseUrl")}
|
||||
{...register("aiBaseUrl")}
|
||||
placeholder={t("settings.aiBaseUrlPlaceholder")}
|
||||
helperText={t("settings.aiBaseUrlHelp")}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField label={t("settings.aiModel")} {...register("aiModel")} fullWidth />
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={aiApiKeySet && !removeAiApiKey ? t("settings.aiApiKeyNew") : t("settings.aiApiKey")}
|
||||
type="password"
|
||||
{...register("aiApiKey")}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removeAiApiKey ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removeAiApiKey) {
|
||||
setRemoveAiApiKey(false);
|
||||
} else {
|
||||
setRemoveAiApiKey(true);
|
||||
setValue("aiApiKey", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeAiApiKey ? t("settings.paperlessTokenKeep") : t("settings.aiApiKeyRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{aiApiKeySet && !removeAiApiKey && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.aiApiKeyInfo")}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
label={t("settings.aiSystemPrompt")}
|
||||
{...register("aiSystemPrompt")}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label={t("settings.aiTimeout")}
|
||||
type="number"
|
||||
inputProps={{ min: 5, max: 300 }}
|
||||
{...register("aiTimeoutSeconds", { valueAsNumber: true })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("settings.aiMaxTokens")}
|
||||
type="number"
|
||||
inputProps={{ min: 256, max: 16000 }}
|
||||
{...register("aiMaxTokens", { valueAsNumber: true })}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<TextField
|
||||
label={paperlessWebhookSecretSet && !removeWebhookSecret ? t("settings.webhookSecretNew") : t("settings.webhookSecret")}
|
||||
type="password"
|
||||
{...register("paperlessWebhookSecret")}
|
||||
fullWidth
|
||||
helperText={t("settings.webhookSecretHelp")}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={removeWebhookSecret ? "inherit" : "warning"}
|
||||
onClick={() => {
|
||||
if (removeWebhookSecret) {
|
||||
setRemoveWebhookSecret(false);
|
||||
} else {
|
||||
setRemoveWebhookSecret(true);
|
||||
setValue("paperlessWebhookSecret", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeWebhookSecret ? t("settings.paperlessTokenKeep") : t("settings.webhookSecretRemove")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{paperlessWebhookSecretSet && !removeWebhookSecret && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("settings.webhookSecretInfo")}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => aiTestMutation.mutate()}
|
||||
disabled={aiTestMutation.isPending || !serverConfig?.aiConfigured}
|
||||
>
|
||||
{t("settings.aiTest")}
|
||||
</Button>
|
||||
{aiTestMutation.isPending && <CircularProgress size={24} />}
|
||||
</Stack>
|
||||
{aiTestMutation.isError && (
|
||||
<Alert severity="error">{(aiTestMutation.error as Error).message ?? t("settings.aiTestError")}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
|
||||
@@ -54,3 +54,47 @@ export interface PaperlessSearchResponse {
|
||||
previous?: string | null;
|
||||
results: PaperlessDocument[];
|
||||
}
|
||||
|
||||
export type AiReviewStatus =
|
||||
| "pending"
|
||||
| "analyzing"
|
||||
| "needs_review"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "failed";
|
||||
|
||||
export interface ContractAnalysisResult {
|
||||
isContract: boolean;
|
||||
title: string | null;
|
||||
provider: string | null;
|
||||
category: string | null;
|
||||
contractStartDate: string | null;
|
||||
contractEndDate: string | null;
|
||||
terminationNoticeDays: number | null;
|
||||
renewalPeriodMonths: number | null;
|
||||
autoRenew: boolean;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
summary: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AiReview {
|
||||
id: number;
|
||||
paperlessDocumentId: number;
|
||||
status: AiReviewStatus;
|
||||
documentTitle: string | null;
|
||||
analysis: ContractAnalysisResult | null;
|
||||
contractPayload: ContractPayload | null;
|
||||
confidence: number | null;
|
||||
error: string | null;
|
||||
rawAiResponse: string | null;
|
||||
contractId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
analyzedAt: string | null;
|
||||
approvedAt: string | null;
|
||||
rejectedAt: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user