Automated commit

This commit is contained in:
gitea_admin 2026-06-11 11:04:39 +00:00
parent 22f1cb8015
commit f28b674080
1 changed files with 224 additions and 117 deletions

View File

@ -1,143 +1,250 @@
import { useEffect, useState } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ChatPanel } from "@/components/chat"
interface SearchResult {
title: string
url: string
snippet: string
interface PermitSummary {
id: number; permit_number: string; applicant_name: string
permit_type: string; location: string; source_file: string
source_system: string; status: string; archive_status: string
issue_date: string; upload_date: string
}
interface PermitDetail extends PermitSummary {
permit_holder_name: string; issuer_name: string
expiry_date: string; applicable_law: string
work_type: string; water_type: string; embankment_type: string
archive_nomination: string; retention_years: number | null
extracted_text: string | null; error_message: string | null
}
// Chat is handled by the template's ChatPanel component
const STATUS = {
processing: { label: "Verwerken...", cls: "text-blue-600 bg-blue-50 border-blue-200" },
processed: { label: "Verwerkt", cls: "text-green-700 bg-green-50 border-green-200" },
error: { label: "Fout", cls: "text-red-700 bg-red-50 border-red-200" },
} as Record<string, { label: string; cls: string }>
const ARCHIVE_CLS: Record<string, string> = {
"te vernietigen": "text-red-700 bg-red-50",
"te bewaren (oneindig)": "text-green-700 bg-green-50",
}
function App() {
const [appName, setAppName] = useState("My App")
const [tab, setTab] = useState<"home" | "search" | "chat">("home")
const [tab, setTab] = useState<"search" | "upload" | "chat">("search")
const [permits, setPermits] = useState<PermitSummary[]>([])
const [query, setQuery] = useState("")
const [detail, setDetail] = useState<PermitDetail | null>(null)
const [loading, setLoading] = useState(true)
// Upload
const [file, setFile] = useState<File | null>(null)
const [permitNum, setPermitNum] = useState("")
const [applicant, setApplicant] = useState("")
const [uploading, setUploading] = useState(false)
const [uploadErr, setUploadErr] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
// Chat
// Chat state managed by ChatPanel component
useEffect(() => {
fetch("/api/info")
.then((res) => res.json())
.then((data) => setAppName(data.app_name))
.catch(() => {})
const load = useCallback(async (q = "") => {
try {
const url = q ? `/api/permits/search?q=${encodeURIComponent(q)}` : "/api/permits"
const r = await fetch(url); if (r.ok) setPermits(await r.json())
} finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => {
if (!permits.some(p => p.status === "processing")) return
const id = setInterval(() => load(query), 3000); return () => clearInterval(id)
}, [permits, query, load])
// Chat scroll handled by ChatPanel
const upload = async () => {
if (!file) return; setUploading(true); setUploadErr(null)
try {
const fd = new FormData(); fd.append("file", file)
if (permitNum) fd.append("permit_number", permitNum)
if (applicant) fd.append("applicant_name", applicant)
const r = await fetch("/api/permits/upload", { method: "POST", body: fd })
const d = await r.json()
if (!r.ok) { setUploadErr(d.error); return }
setFile(null); setPermitNum(""); setApplicant("")
if (fileRef.current) fileRef.current.value = ""
load(query); setTab("search")
} catch { setUploadErr("Verbindingsfout") } finally { setUploading(false) }
}
// Chat send handled by ChatPanel → app/agent.py
const openDetail = async (id: number) => {
const r = await fetch(`/api/permits/${id}`); if (r.ok) setDetail(await r.json())
}
const del = async (id: number) => {
if (!confirm("Verwijderen?")) return
await fetch(`/api/permits/${id}`, { method: "DELETE" })
setDetail(null); load(query)
}
const fmt = (iso: string | null) => {
if (!iso) return "—"
try { return new Date(iso).toLocaleDateString("nl-NL", { dateStyle: "long" }) } catch { return iso }
}
const fmtFull = (iso: string | null) => {
if (!iso) return "—"
try { return new Date(iso).toLocaleString("nl-NL", { dateStyle: "long", timeStyle: "short" }) } catch { return iso }
}
const MetaRow = ({ label, value }: { label: string; value: string | null | undefined }) =>
value ? <div><p className="text-xs text-muted-foreground uppercase font-medium">{label}</p><p className="text-sm">{value}</p></div> : null
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card shrink-0">
<div className="min-h-screen bg-background">
<header className="border-b bg-card">
<div className="container mx-auto flex h-14 items-center justify-between px-4">
<h1 className="text-lg font-bold">{appName}</h1>
<h1 className="text-lg font-bold">Vergunningzoeker</h1>
<div className="flex gap-1">
<Button variant={tab === "home" ? "default" : "ghost"} size="sm" onClick={() => setTab("home")}>
Home
</Button>
<Button variant={tab === "search" ? "default" : "ghost"} size="sm" onClick={() => setTab("search")}>
Web Search
</Button>
<Button variant={tab === "chat" ? "default" : "ghost"} size="sm" onClick={() => setTab("chat")}>
Assistent
{(["search", "upload", "chat"] as const).map(t => (
<Button key={t} variant={tab === t ? "default" : "ghost"} size="sm" onClick={() => setTab(t)}>
{t === "search" ? `Zoeken (${permits.length})` : t === "upload" ? "Uploaden" : "Assistent"}
</Button>
))}
</div>
</div>
</header>
{tab === "home" && (
<main className="container mx-auto px-4 py-8 max-w-2xl">
<main className="container mx-auto px-4 py-6 max-w-4xl space-y-4">
{/* ---- SEARCH TAB ---- */}
{tab === "search" && <>
<Card>
<CardHeader>
<CardTitle>{appName}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Welkom! Klik op <strong>Assistent</strong> om te chatten met de AI-assistent,
of pas deze pagina aan voor je eigen applicatie.
</p>
<CardContent className="pt-5">
<div className="flex gap-2">
<Input value={query} onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && load(query)}
placeholder="Zoek op nummer, aanvrager, locatie, type, wet..." />
<Button onClick={() => load(query)}>Zoek</Button>
{query && <Button variant="outline" onClick={() => { setQuery(""); load() }}>Wis</Button>}
</div>
</CardContent>
</Card>
</main>
)}
<Card>
<CardContent className="pt-5">
{loading ? <p className="text-center text-muted-foreground py-4">Laden...</p>
: permits.length === 0 ? <p className="text-center text-muted-foreground py-4">{query ? "Geen resultaten." : "Nog geen vergunningen."}</p>
: <div className="divide-y">{permits.map(p => {
const s = STATUS[p.status] || STATUS.processed
const a = p.archive_status ? (ARCHIVE_CLS[p.archive_status] || "text-amber-700 bg-amber-50") : ""
return (
<div key={p.id} className="flex items-center gap-3 py-3 cursor-pointer hover:bg-muted/50 -mx-2 px-2 rounded" onClick={() => openDetail(p.id)}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{p.permit_number || p.source_file}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${s.cls}`}>{s.label}</span>
{p.archive_status && <span className={`text-[10px] px-1.5 py-0.5 rounded ${a}`}>{p.archive_status}</span>}
</div>
<div className="text-xs text-muted-foreground flex gap-2 mt-0.5 flex-wrap">
{p.applicant_name && <span>{p.applicant_name}</span>}
{p.location && <span>{p.location}</span>}
{p.permit_type && p.permit_type !== "onbekend" && <span className="text-primary">{p.permit_type.replace(/_/g, " ")}</span>}
</div>
</div>
<div className="text-xs text-muted-foreground shrink-0 text-right">
{p.issue_date && <div>{fmt(p.issue_date)}</div>}
<div>{p.source_system}</div>
</div>
</div>
)
})}</div>}
</CardContent>
</Card>
</>}
{tab === "search" && <SearchPage />}
{/* ---- UPLOAD TAB ---- */}
{tab === "upload" && <Card>
<CardHeader>
<CardTitle>Document uploaden</CardTitle>
<CardDescription>Upload een scan, foto of PDF. Het systeem extraheert automatisch metadata (OCR), classificeert het type en berekent de archiefstatus.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all ${dragOver ? "border-primary bg-primary/5" : file ? "border-primary/50 bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"}`}
onDragOver={e => { e.preventDefault(); setDragOver(true) }} onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); e.dataTransfer.files?.[0] && setFile(e.dataTransfer.files[0]) }}
onClick={() => !uploading && fileRef.current?.click()}>
<input ref={fileRef} type="file" accept="image/*,.pdf" className="hidden" onChange={e => e.target.files?.[0] && setFile(e.target.files[0])} />
{file ? <div><p className="font-medium">{file.name}</p><p className="text-sm text-muted-foreground">{(file.size/1024).toFixed(0)} KB</p></div>
: <div><p className="text-muted-foreground">Sleep een bestand hierheen of klik</p><p className="text-xs text-muted-foreground mt-1">JPG, PNG of PDF max 25 MB</p></div>}
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={permitNum} onChange={e => setPermitNum(e.target.value)} placeholder="Vergunningnummer (optioneel)" disabled={uploading} />
<Input value={applicant} onChange={e => setApplicant(e.target.value)} placeholder="Aanvrager (optioneel)" disabled={uploading} />
</div>
<Button onClick={upload} disabled={uploading || !file} className="w-full">
{uploading ? "Bezig met OCR, metadata-extractie en classificatie..." : "Upload & Analyseer"}
</Button>
{uploadErr && <div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md p-3">{uploadErr}</div>}
</CardContent>
</Card>}
{/* ---- CHAT TAB — uses template ChatPanel ---- */}
{tab === "chat" && (
<div className="flex-1 overflow-hidden">
<div className="h-[calc(100vh-8rem)]">
<ChatPanel />
</div>
)}
</div>
)
}
function SearchPage() {
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const search = async () => {
if (!query.trim()) return
setLoading(true)
setSearched(true)
try {
const r = await fetch("/api/ai/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.trim() }),
})
const data = await r.json()
setResults(data.results || [])
} catch {
setResults([])
} finally {
setLoading(false)
}
}
return (
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-4">
<Card>
{/* ---- DETAIL MODAL ---- */}
{detail && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setDetail(null)}>
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<CardHeader>
<CardTitle>Web Search</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<CardTitle>{detail.permit_number || detail.source_file}</CardTitle>
<div className="flex gap-2">
<Input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && search()}
placeholder="Zoek op het web..."
disabled={loading}
/>
<Button onClick={search} disabled={loading || !query.trim()}>
{loading ? "Zoeken..." : "Zoek"}
</Button>
<Button variant="destructive" size="sm" onClick={() => del(detail.id)}>Verwijderen</Button>
<Button variant="outline" size="sm" onClick={() => setDetail(null)}>Sluiten</Button>
</div>
</CardContent>
</Card>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Archive status banner */}
{detail.archive_status && <div className={`rounded-lg p-3 border text-sm ${ARCHIVE_CLS[detail.archive_status] || "bg-amber-50 text-amber-800 border-amber-200"}`}>
<span className="font-medium">Archiefstatus:</span> {detail.archive_status}
{detail.archive_nomination && <> Nominatie: {detail.archive_nomination}</>}
{detail.retention_years && <> ({detail.retention_years} jaar)</>}
</div>}
{searched && (
<Card>
<CardContent className="pt-5">
{loading ? (
<p className="text-center text-muted-foreground py-4">Zoeken...</p>
) : results.length === 0 ? (
<p className="text-center text-muted-foreground py-4">Geen resultaten gevonden.</p>
) : (
<div className="divide-y">
{results.map((r, i) => (
<div key={i} className="py-3">
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">
{r.title}
</a>
<p className="text-xs text-muted-foreground mt-0.5">{r.url}</p>
<p className="text-sm mt-1">{r.snippet}</p>
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3">
<MetaRow label="Vergunningnummer" value={detail.permit_number} />
<MetaRow label="Type (AI)" value={detail.permit_type?.replace(/_/g, " ")} />
<MetaRow label="Aanvrager" value={detail.applicant_name} />
<MetaRow label="Vergunninghouder" value={detail.permit_holder_name} />
<MetaRow label="Uitgever" value={detail.issuer_name} />
<MetaRow label="Locatie" value={detail.location} />
<MetaRow label="Uitgiftedatum" value={fmt(detail.issue_date)} />
<MetaRow label="Geldigheidsdatum" value={fmt(detail.expiry_date)} />
<MetaRow label="Toepasselijke wet" value={detail.applicable_law} />
<MetaRow label="Type werk" value={detail.work_type} />
<MetaRow label="Type oppervlaktewater" value={detail.water_type} />
<MetaRow label="Type waterkering" value={detail.embankment_type} />
<MetaRow label="Bronsysteem" value={detail.source_system} />
<MetaRow label="Bronbestand" value={detail.source_file} />
<MetaRow label="Upload datum" value={fmtFull(detail.upload_date)} />
</div>
))}
</div>
)}
{detail.error_message && <div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md p-3">{detail.error_message}</div>}
{detail.extracted_text && !detail.extracted_text.startsWith("Error:") && <div>
<p className="text-xs text-muted-foreground uppercase font-medium mb-2">Geëxtraheerde tekst</p>
<pre className="p-3 bg-muted rounded-lg text-xs whitespace-pre-wrap max-h-72 overflow-y-auto">{detail.extracted_text}</pre>
</div>}
</CardContent>
</Card>
)}
</div>}
</main>
</div>
)
}