Automated commit
This commit is contained in:
parent
22f1cb8015
commit
f28b674080
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue