252 lines
13 KiB
TypeScript
252 lines
13 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { ChatPanel } from "@/components/chat"
|
|
|
|
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 [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
|
|
|
|
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">
|
|
<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">Vergunningzoeker</h1>
|
|
<div className="flex gap-1">
|
|
{(["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>
|
|
|
|
<main className="container mx-auto px-4 py-6 max-w-4xl space-y-4">
|
|
|
|
{/* ---- SEARCH TAB ---- */}
|
|
{tab === "search" && <>
|
|
<Card>
|
|
<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>
|
|
<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>
|
|
</>}
|
|
|
|
{/* ---- 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="h-[calc(100vh-8rem)]">
|
|
<ChatPanel />
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- 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>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>{detail.permit_number || detail.source_file}</CardTitle>
|
|
<div className="flex gap-2">
|
|
<Button variant="destructive" size="sm" onClick={() => del(detail.id)}>Verwijderen</Button>
|
|
<Button variant="outline" size="sm" onClick={() => setDetail(null)}>Sluiten</Button>
|
|
</div>
|
|
</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>}
|
|
|
|
{/* 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>
|
|
|
|
{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>
|
|
)
|
|
}
|
|
|
|
export default App
|