diff --git a/frontend/package.json b/frontend/package.json index 911ee76f..5271bab0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dev": "vite", "prototype:database-management": "vite --config vite.database-management-prototype.config.ts", "prototype:administration-pages": "vite --config vite.administration-pages-prototype.config.ts", + "prototype:administration-review": "vite --config vite.administration-review.config.ts", "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/prototypes/administration-review/App.tsx b/frontend/prototypes/administration-review/App.tsx new file mode 100644 index 00000000..9aac566b --- /dev/null +++ b/frontend/prototypes/administration-review/App.tsx @@ -0,0 +1,144 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ArrowLeft, ArrowRight, Database, FileText, Layers, LayoutDashboard, Menu, Search, Settings2, Terminal, Users, X, ChevronRight } from "lucide-react"; +import { inventory, modules, readLocation, variants, workspaces, type Item, type Scenario, type Surface, type Variant } from "./model"; +import { Content, Status, type ContentProps, type ConnectionValues } from "./Content"; + +// Three structural variants of the same five Administration routes. Throwaway, +// synthetic data, in-memory actions only. No production modules or API imports. +const icons = { workspace: Layers, database: Database, evidence: FileText, memory: LayoutDashboard, pi: Terminal }; +const order: Surface[] = ["database", "memory", "evidence", "workspace", "pi"]; +const initial = readLocation(); +export function Button({ children, primary, onClick, disabled, className = "" }: { children: ReactNode; primary?: boolean; onClick?: () => void; disabled?: boolean; className?: string }) { + return ; +} + +export function App() { + const [route, setRoute] = useState(initial); + const { surface, variant, workspace } = route; + const [collapsed, setCollapsed] = useState(false); + const [hostOpen, setHostOpen] = useState(false); + const [adminOpen, setAdminOpen] = useState(false); + const [width, setWidth] = useState("full"); + const [scenarioMap, setScenarioMap] = useState>({}); + const [selection, setSelection] = useState>({}); + const [filters, setFilters] = useState>({}); + const [section, setSection] = useState(0); + const [focused, setFocused] = useState(false); + const [editing, setEditing] = useState(false); + const [dirty, setDirty] = useState(false); + const [notice, setNotice] = useState(""); + const [savedCards, setSavedCards] = useState>({}); + const [connections, setConnections] = useState>({}); + const scope = `${surface}:${surface === "pi" ? "installation" : workspace}`; + const scenario = scenarioMap[scope] ?? "ready"; + const rows = surface === "memory" ? savedCards[workspace] ?? inventory.memory : inventory[surface]; + const selected = surface === "workspace" ? workspace : selection[scope] ?? rows[0]?.id; + const item = rows.find(r => r.id === selected) ?? rows[0]; + const query = filters[scope] ?? ""; + const shownRows = rows.filter(r => `${r.title} ${r.category}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())); + const currentWorkspace = workspaces.find(w => w.id === workspace)!; + const currentUrl = useRef(location.href); + const dirtyRef = useRef(dirty); dirtyRef.current = dirty; + const notify = (text: string) => setNotice(text); + const canLeave = () => !dirtyRef.current || window.confirm("Discard unsaved changes and leave this form?"); + function writeRoute(next: typeof route, replace = false) { + const url = new URL(location.href); + url.searchParams.set("thoth_route", `administration/${next.surface}`); + url.searchParams.set("thoth_workspace", next.workspace); + url.searchParams.set("variant", next.variant); + if (url.href !== location.href) history[replace ? "replaceState" : "pushState"](history.state, "", url); + currentUrl.current = url.href; + setRoute(next); + } + function navigate(next: Partial, replace = false) { + if (!canLeave()) return; + writeRoute({ ...route, ...next }, replace); + setDirty(false); setEditing(false); setFocused(false); setSection(0); setNotice(""); setAdminOpen(false); + } + useEffect(() => { + const onPop = () => { + if (dirtyRef.current && !window.confirm("Discard unsaved changes and follow browser history?")) { + history.pushState(history.state, "", currentUrl.current); return; + } + setRoute(readLocation()); currentUrl.current = location.href; + setDirty(false); setEditing(false); setFocused(false); setSection(0); setNotice(""); + }; + const onUnload = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ""; } }; + window.addEventListener("popstate", onPop); window.addEventListener("beforeunload", onUnload); + return () => { window.removeEventListener("popstate", onPop); window.removeEventListener("beforeunload", onUnload); }; + }, []); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.target as HTMLElement).closest("input,textarea,select,button,[contenteditable],[role=tablist]")) return; + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); cycle(e.key === "ArrowRight" ? 1 : -1); + }; + window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); + }); + function cycle(delta: number) { const keys: Variant[] = ["A", "B", "C"]; navigate({ variant: keys[(keys.indexOf(variant) + delta + 3) % 3] }, true); } + function choose(id: string) { + if (!canLeave()) return; + if (surface === "workspace") writeRoute({ ...route, workspace: id }); + setSelection(s => ({ ...s, [scope]: id })); setFocused(true); setEditing(false); setDirty(false); setNotice(""); + if (surface === "pi") setSection(inventory.pi.findIndex(r => r.id === id)); + } + function changeSection(n: number) { + if (!canLeave()) return; + setEditing(false); setDirty(false); setSection(n); setFocused(true); + } + function setScenario(next: Scenario) { if (!canLeave()) return; setEditing(false); setDirty(false); setScenarioMap(s => ({ ...s, [scope]: next })); } + function beginOperation(label: string) { + setScenarioMap(s => ({ ...s, [scope]: "running" })); + notify(`${label}: simulated operation started. You can switch pages and return. Use “Complete operation” to finish the demonstration.`); + } + function beginEdit() { setEditing(true); setFocused(true); } + function primaryAction() { + if (surface === "memory") { if (!canLeave()) return; setSelection(s => ({ ...s, [scope]: "new" })); setEditing(true); setDirty(false); setFocused(true); setSection(0); } + else if (surface === "evidence") changeSection(2); + else beginOperation(modules[surface].primary); + } + const props: ContentProps = { + surface, workspace: currentWorkspace, scenario, item: selected === "new" ? undefined : item, section, editing, + connectionValues: connections[workspace], + onConnectionSave: (values) => { setConnections(s => ({ ...s, [workspace]: values })); setDirty(false); setEditing(false); notify("Connection saved in memory for this prototype. No real credentials or database were used."); }, + onEdit: beginEdit, onDirty: setDirty, onCancel: () => { if (canLeave()) { setEditing(false); setDirty(false); } }, + onSave: (updated) => { + if (surface === "memory" && updated) { + const next = selected === "new" ? [updated, ...(scenario === "empty" ? [] : rows)] : rows.map(r => r.id === updated.id ? updated : r); + setSavedCards(s => ({ ...s, [workspace]: next })); setSelection(s => ({ ...s, [scope]: updated.id })); + } + setScenarioMap(s => ({ ...s, [scope]: "ready" })); + setDirty(false); setEditing(false); notify("Saved in this prototype only. No installation data was changed."); + }, + onDelete: () => { setSavedCards(s => ({ ...s, [workspace]: rows.filter(r => r.id !== selected) })); setSelection(s => ({ ...s, [scope]: rows.find(r => r.id !== selected)?.id ?? "" })); notify("Card and its links removed from the demo. Other cards are kept."); }, + onNavigate: (next) => navigate({ surface: next }), onOperation: beginOperation, onNotice: notify, + onSection: changeSection, onScenario: setScenario, + }; + const context =
{surface === "pi" ? "Installation scope" : "Administrative workspace"}{surface === "pi" ? All workspaces : }Core workspace: PSD Clinical
; + const heading =

Administration / {surface === "pi" ? "Installation" : currentWorkspace.name}

{modules[surface].title}

{modules[surface].subtitle}

; + const sections = ; + const index =

{modules[surface].noun}

{scenario === "empty" ? 0 : rows.length}
{surface !== "pi" && }
{scenario === "empty" ?

No records in this demonstration state.

: shownRows.length ? shownRows.map(r => ) :

No matches. Try a shorter search.

}
; + const content = ; + const status =
{surface === "pi" ? "Installation catalog" : `Scope: ${currentWorkspace.id}`}Illustrative records
; + const navigation = <>

ThothII

Administration

Working context

{currentWorkspace.name}Browsing here does not change the core session.
; + + return <>
+
Omics PortalClinical data & researchAdministrator AD
+
+
Omics PortalThothIIAdministration
+
ThothII administration
+ +
+ {context}{heading}{status} + {notice &&
{notice}
} + {dirty &&
Unsaved changes. Save or cancel before leaving.
} + {variant === "A" &&
{index}
{surface !== "pi" && sections}{content}
} + {variant === "B" &&
{sections}{!focused && section === 0 && surface !== "pi" ? <>

{modules[surface].noun}

Select a record to open its full page. Search and selection are kept when you return.

{index} : <>{surface !== "pi" && }{content}}
} + {variant === "C" &&

Next action

{scenario === "running" ? "An operation is in progress" : scenario === "attention" ? "Resolve the preparation step" : surface === "evidence" ? "Maintain the local knowledge archive" : "Ready for administrative work"}

{surface === "workspace" ? "Check the catalog prerequisite, then build the reference indexes for this workspace." : surface === "database" ? "Synchronize the source schema before reviewing metadata and preparing the workspace." : surface === "memory" ? "Review the authoritative cards. Retry any pending retrieval-index updates separately." : surface === "evidence" ? "Edit the Markdown files externally, consolidate locally, then review and push your Git changes." : "Check the runtime and its configured default. Changes to models are made in the installation file."}

{section === 0 && surface !== "pi" &&
Choose {surface === "workspace" ? "workspace" : "record"}{item?.title}{index}
}{content}
} +
{surface === "pi" ? "Global installation controls" : "Workspace-scoped administration"}No active core session required
+
+
+
+ {import.meta.env.DEV && } + ; +} diff --git a/frontend/prototypes/administration-review/Content.tsx b/frontend/prototypes/administration-review/Content.tsx new file mode 100644 index 00000000..ae54eabe --- /dev/null +++ b/frontend/prototypes/administration-review/Content.tsx @@ -0,0 +1,95 @@ +import { useState, type ReactNode } from "react"; +import { ArrowUpRight, Check, CircleAlert, Clock3, Copy, Database, FileText, GitBranch, KeyRound, Play, RefreshCw, ShieldCheck } from "lucide-react"; +import { Button } from "./App"; +import { type Item, type Scenario, type Surface, type workspaces } from "./model"; + +export function Status({ scenario }: { scenario: Scenario }) { + const Icon = scenario === "ready" ? Check : scenario === "running" ? Clock3 : CircleAlert; + return {{ ready: "Ready", attention: "Needs attention", running: "In progress", empty: "Not configured", error: "Unavailable" }[scenario]}; +} +function Section({ title, children, label }: { title: string; children: ReactNode; label?: string }) { + return
{label &&

{label}

}

{title}

{children}
; +} +function Facts({ data }: { data: Array<[string, ReactNode]> }) { return
{data.map(([name,value]) =>
{name}
{value}
)}
; } +function CopyValue({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + return
{value}
; +} +export type ConnectionValues = { host: string; port: string; database: string; user: string }; +export type ContentProps = { + surface: Surface; workspace: typeof workspaces[number]; scenario: Scenario; item?: Item; section: number; editing: boolean; + onEdit: () => void; onDirty: (v: boolean) => void; onCancel: () => void; onSave: (item?: Item) => void; + onDelete: () => void; onNavigate: (s: Surface) => void; onOperation: (label: string) => void; + onNotice: (text: string) => void; onSection: (section: number) => void; onScenario: (s: Scenario) => void; + connectionValues?: ConnectionValues; onConnectionSave: (values: ConnectionValues) => void; +}; +export function Content(p: ContentProps) { + if (p.scenario === "error") return

This page could not be loaded

The service is unavailable. Your last saved data has not been changed.

; + if (p.scenario === "empty" && !p.editing) return

{p.surface === "memory" ? "No Memory cards yet" : p.surface === "evidence" ? "No Evidence files in this workspace" : p.surface === "database" ? "Database not configured" : p.surface === "workspace" ? "Workspace preparation required" : "No available session models"}

{p.surface === "memory" ? "Create a card to record a reusable clarification or an approved SQL rule." : p.surface === "evidence" ? "Create a Markdown file in the curated archive, then run consolidation to activate it." : p.surface === "database" ? "Configure one catalog binding for this workspace. The source database remains read-only." : p.surface === "workspace" ? "Check the source and configure the database before running preprocessing." : "Add eligible models to the Installation Model Catalog and reload the runtime."}

{p.surface === "database" && }{p.surface === "workspace" && }{p.surface === "evidence" && }
; + return
+ {p.scenario === "running" &&
Operation in progress

The job belongs to {p.surface === "pi" ? "this installation" : p.workspace.name}. Changing pages does not cancel it.

} + {p.surface === "workspace" ? : p.surface === "database" ? : p.surface === "evidence" ? : p.surface === "memory" ? : } +
; +} + +function Workspace(p: ContentProps) { + const [clear, setClear] = useState(false); + const [secret, setSecret] = useState(""); + return <>

Selected workspace

{p.workspace.name}

{p.workspace.id}

+ {p.section === 0 && <> +

Preprocessing builds the reference indexes for this workspace from its catalog snapshot and Evidence. Memory has its own independent index.

  1. Workspace sourceActive Git revision · a71c9f2
  2. Database catalog{p.workspace.database} · Metadata revision 12
  3. {p.scenario === "attention" ? : }
    Reference indexes{p.scenario === "attention" ? "Built from revision 11. Run preprocessing to use revision 12." : "Current for metadata revision 12"}
+
Workspace-scoped operation

Uses the catalog snapshot, prepares schema and reference indexes, and includes configured Evidence. It does not modify the source database.

+
Clear derived data

Remove schema/Evidence vectors, LSH and derived checkpoints. Keep the curated files, catalog, source database, Memory and sessions.

{clear ?
Clear derived data for {p.workspace.name}?
: }
+ } + {p.section === 1 && <>

Change identity and Evidence source declarations in the workspace repository. Fetch and validate the candidate revision before activating it.

p.onNavigate("database")}>{p.workspace.database} ]]}/>

Repository updates apply to all workspaces listed in the registry. An invalid candidate leaves the previous revision active.

} + {p.section === 2 &&

Only non-database runtime requirements belong here. Database credentials are maintained in Database management. Stored values are never revealed.

} + ; +} +function LayersMark() { return ; } + +function Catalog(p: ContentProps) { + const [tab, setTab] = useState("Columns"); + const [connection, setConnection] = useState(p.connectionValues ?? { host: "warehouse.internal", port: "5432", database: p.workspace.database, user: "readonly_analyst" }); + const [password, setPassword] = useState(""); + return <>

{p.workspace.name} / Catalog

{p.section === 0 ? `${p.workspace.schema}.${p.item?.title ?? "patients"}` : p.workspace.database}

{p.section === 0 ? p.item?.detail : "One workspace binding · PostgreSQL · Read-only source access"}

+
Workspace preparation {p.scenario === "attention" ? "needs a refresh" : "uses metadata revision 12"}.
+ {p.section === 0 && <>{tab === "Columns" ? <>
{[["patient_id","uuid","Identificativo del paziente","Identifier"],["recorded_at","timestamp","Data di registrazione","Not marked"],["status","varchar","Stato del record","Not marked"],["source_code","varchar","Codice del sistema origine","Needs review"]].map(([name,type,description,sensitivity]) => )}
Catalog columns · illustrative excerpt
ColumnTypeDescriptionSensitivity
{name}{type}{description}{sensitivity}

Column descriptions and sensitivity are catalog metadata. Source values are not displayed in this prototype.

:
Catalog relationships
FromToKind
visits.patient_idpatients.patient_idForeign key
samples.patient_idpatients.patient_idForeign key
}
} + {p.section === 1 &&

Connection and credentials are scoped to this workspace binding. Saving a connection does not synchronize the schema.

{ e.preventDefault(); p.onConnectionSave(connection); }}>
{Object.entries(connection).map(([key,value]) => )}
{p.editing ? <> : <>}
} + {p.section === 2 && <>

Generate draft descriptions from the catalog and permitted source samples. Review and consolidate them using the existing catalog workflow.

Review column sensitivity before sampling and analytical use. Source synchronization, description generation and sensitivity analysis remain distinct operations.

} + ; +} + +function Evidence(p: ContentProps) { + const [decision, setDecision] = useState(""); + return <> + {p.section === 0 && <>

{p.item?.category ?? "Domain knowledge"}

{p.item?.title}

Active locally Canonical Markdown archive

{p.item?.detail}

Edit this document with your preferred external editor. Consolidation activates valid local changes; Git review and push are manual.

} + {p.section === 1 && <>

Source maintenance

Refresh deliberately, review changes

External sources are reacquired only when requested. A manual correction remains in force until you resolve the comparison.

Current manual correction

Contare i pazienti distinti con almeno una visita valida.

Refreshed source proposal

Contare i record delle visite nel periodo selezionato.

Replacing the content requires an explicit decision. Refresh alone does not overwrite it.

{decision ?

{decision} (prototype only)

:
}
} + {p.section === 2 && <>

External editing

Maintain the Evidence archive

The browser helps you locate and verify knowledge. Your editor maintains the Markdown files.

  1. Edit the canonical files

    Use the installation host path shown by the real application. This demonstration shows relative paths only.

  2. Validate and consolidate

    Correct validation errors, then activate the local index. Source refresh is a separate operation.

  3. Review and publish with Git

    From the Evidence working tree, inspect the status and diff, then commit and push manually.

} + ; +} + +function Memory(p: ContentProps) { + const [title,setTitle] = useState(p.item?.title ?? ""); + const [detail,setDetail] = useState(p.item?.detail ?? ""); + const [category,setCategory] = useState(p.item?.category ?? "Domain clarification"); + const [confirmDelete,setConfirmDelete] = useState(false); + if (p.editing) return
{ e.preventDefault(); p.onSave({ id: p.item?.id ?? `demo-${Date.now()}`, title, detail, category, meta: "Manual correction · Indexed (demo)" }); }}>

{p.workspace.name}

{p.item ? "Edit Memory card" : "New Memory card"}

Save an explicit, reusable rule. Changes stay in this prototype.