// ─── Discovery wizard (4a–c) ────────────────────────────────────────────────── // The first page after Clerk login. Walks the YAML-driven question sections, then // shows the fork screen (Email vs Manual). Pure presentational component: App // passes the questionnaire + saved answers in, and the submit/navigation handlers. // // Props: // data { sections, profile, contacts, completed } (from GET /wizard) // onSubmit async (answers) => void (POST /wizard/answers) // onPickPath (path: "email" | "manual") => void (first-run only) // editMode bool — Wizard tab: pre-filled, ends on Save (no fork) function DiscoveryWizard({ data, onSubmit, onPickPath, editMode = false }) { const { useState } = React; // Personal details (name/DOB/address) are edited only on the "Edit personal // details" card, never in this linear flow — drop that section from the steps. const sections = (data.sections || []).filter(s => s.id !== "personal_details"); // Repeatable people: children, parents and siblings are fixed slots in the YAML // (child_1.., parent_1.., sibling_1..) but we reveal them one at a time via an // "Add a X / Add another" button instead of showing every empty slot at once. // Defined before `answers` because the seed below needs it to know which // synthesized slot ids (executor_2, executor_3, ...) to look for. const REPEAT_GROUPS = [ { prefix: "child_", addFirst: "+ Add a child" }, { prefix: "parent_", addFirst: "+ Add a parent" }, { prefix: "sibling_", addFirst: "+ Add a sibling" }, ...sections .flatMap(s => s.questions) .filter(q => q.target.repeatable) .map(q => ({ prefix: q.target.role.replace(/_1$/, "_"), max: q.target.max_repeat, addFirst: `+ Add ${q.prompt.toLowerCase()}`, base: q, })), ]; // Seed answers: scalar profile facts + any saved contact matched to its question // by (contact_type, role) so the Wizard tab pre-fills people too. const [answers, setAnswers] = useState(() => { const seed = { ...(data.profile || {}) }; const contacts = data.contacts || []; for (const s of sections) { for (const q of s.questions) { // bool answers come back from the DB as strings/numbers — coerce to a real // boolean so the Yes/No buttons (compared with ===) preselect correctly. if (q.type === "bool" && q.id in seed) { const v = seed[q.id]; seed[q.id] = v === true || v === "true" || v === "True" || v === 1 || v === "1"; continue; } if (q.target.sink !== "contact") continue; const match = contacts.find(c => c.contact_type === q.target.contact_type && c.role === q.target.role); if (match) seed[q.id] = { name: match.name, email: match.email || "" }; } } // The loop above only matches literal YAML questions (e.g. executor_1). Slots // beyond n=1 (executor_2..8) are synthesized client-side and never appear in // s.questions, so they'd otherwise be left out of the seed even when a saved // contact exists for that role — pre-fill those here from REPEAT_GROUPS. for (const g of REPEAT_GROUPS) { if (!g.max) continue; // legacy fixed-slot groups (child_/parent_/sibling_) are all literal YAML questions, already seeded above for (let n = 2; n <= g.max; n++) { const role = `${g.prefix}${n}`; const match = contacts.find(c => c.contact_type === g.base.target.contact_type && c.role === role); if (match) seed[role] = { name: match.name, email: match.email || "" }; } } return seed; }); const [sectionIdx, setSectionIdx] = useState(0); const [phase, setPhase] = useState("questions"); // "questions" | "fork" const [busy, setBusy] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); const [repeatCounts, setRepeatCounts] = useState(() => { const contacts = data.contacts || []; const counts = {}; for (const g of REPEAT_GROUPS) { counts[g.prefix] = contacts.filter(c => c.role && c.role.startsWith(g.prefix) && c.name).length; } return counts; }); const setAnswer = (qid, value) => { setSaved(false); setAnswers(a => ({ ...a, [qid]: value })); }; // A question is shown only if every show_if entry matches the answers so far. const isVisible = (q) => Object.entries(q.show_if || {}).every(([k, v]) => String(answers[k]) === String(v)); const section = sections[sectionIdx]; const visible = section ? section.questions.filter(isVisible) : []; const isLast = sectionIdx >= sections.length - 1; // Split the repeatable people (child/parent/sibling) out of the visible list so // we can reveal each group progressively; everything else renders normally. const groupOf = (q) => REPEAT_GROUPS.find(g => q.target.role && q.target.role.startsWith(g.prefix)); const otherQuestions = visible.filter(q => !groupOf(q)); // First-run: every visible question must be answered before advancing. People // (contact questions) stay optional — you may genuinely have no spouse/solicitor. const isAnswered = (q) => { const v = answers[q.id]; if (q.type === "contact") { // Next of kin is mandatory; other contacts (spouse, advisors) stay optional. if (q.target.role === "next_of_kin") return !!(v && v.name); return true; } return v !== undefined && v !== null && v !== ""; }; const unanswered = visible.filter(q => !isAnswered(q)); const canAdvance = editMode || unanswered.length === 0; async function save() { setBusy(true); setError(null); try { await onSubmit(answers); if (editMode) setSaved(true); else setPhase("fork"); } catch (e) { setError(e.message || "Could not save your answers"); } finally { setBusy(false); } } function primaryAction() { if (editMode || isLast) return save(); setSectionIdx(i => i + 1); } if (phase === "fork") { return (

How do you want to add your accounts?

Pick one — you can do the other later.

); } return (
Step {sectionIdx + 1} of {sections.length}

{section ? section.title : "About you"}

{otherQuestions.map(q => ( setAnswer(q.id, v)} onRemove={q.type === "contact" ? () => setAnswer(q.id, { name: "", email: "" }) : null} /> ))} {REPEAT_GROUPS.map(g => { const groupQuestions = visible.filter(q => groupOf(q) === g); if (groupQuestions.length === 0) return null; const count = repeatCounts[g.prefix]; // Legacy groups (child_/parent_/sibling_) cap at however many literal // YAML slots exist. New repeatable groups (executor_ etc.) cap at // target.max_repeat and synthesize slots beyond the single declared // YAML question — same id/role naming convention (`${prefix}${n}`). const max = g.max || groupQuestions.length; const slots = []; for (let n = 1; n <= Math.min(count, max); n++) { const slotId = `${g.prefix}${n}`; const literal = groupQuestions.find(q => q.target.role === slotId); slots.push(literal || { ...g.base, id: slotId, target: { ...g.base.target, role: slotId } }); } return ( {slots.map(q => ( setAnswer(q.id, v)} onRemove={() => setAnswer(q.id, { name: "", email: "" })} /> ))} {count < max && ( )} ); })}
{error &&

{error}

} {saved &&

Saved ✓

} {!canAdvance &&

Please answer every question to continue.

}
{sectionIdx > 0 && ( )} {editMode && !isLast && ( )}
); } // One question, rendered by type. function QuestionField({ q, value, onChange, onRemove }) { const hasContact = q.type === "contact" && value && value.name; return (
{q.type === "select" && (
{(q.options || []).map(opt => ( ))}
)} {q.type === "bool" && (
{[["Yes", true], ["No", false]].map(([label, val]) => ( ))}
)} {(q.type === "text" || q.type === "number") && ( onChange(e.target.value)} /> )} {q.type === "contact" && (
onChange({ ...(value || {}), name: e.target.value })} /> onChange({ ...(value || {}), email: e.target.value })} /> {onRemove && hasContact && ( )}
)}
); } if (typeof window !== "undefined") window.DiscoveryWizard = DiscoveryWizard; // ─── Wizard tab hub ─────────────────────────────────────────────────────────── // The Wizard tab landing: section cards. Click one to open its form. // About you → the profile/people wizard (edit mode, pre-filled) // Your estate → manual preset-category builder (resource sink) [next build] // Fill AI gaps → complete what email ingestion missed [next build] // ─── Life graph ─────────────────────────────────────────────────────────────── // A radial relationship map: "You" in the centre, spokes out to the family and // advisers the user added in "About you". Pure SVG (no chart lib — the frontend // has no build step). Family vs adviser are coloured differently. const _ROLE_LABEL = { spouse: "Spouse", next_of_kin: "Next of kin", child_1: "Child", child_2: "Child", child_3: "Child", child_4: "Child", parent_1: "Parent", parent_2: "Parent", sibling_1: "Sibling", sibling_2: "Sibling", sibling_3: "Sibling", sibling_4: "Sibling", accountant: "Accountant", solicitor: "Solicitor", ifa: "Financial adviser", trustee: "Trustee", guardian: "Guardian", }; // Repeatable estate roles (executor_2, poa_holder_3, ...) aren't individually // listed above — strip the trailing "_N" and label by the base role instead. const _REPEATABLE_ROLE_LABEL = { executor: "Executor", beneficiary: "Beneficiary", poa_holder: "POA holder", business_partner: "Business partner", }; function _roleLabel(role) { if (_ROLE_LABEL[role]) return _ROLE_LABEL[role]; const base = String(role || "").replace(/_\d+$/, ""); return _REPEATABLE_ROLE_LABEL[base] || role; } // Estimate the half-width (px) a single text line needs, so a circle can grow to // fit its label instead of clipping it. ~0.6 of font size per glyph is a safe avg. const _halfTextW = (s, fontPx) => (String(s || "").length * fontPx * 0.6) / 2; // Three relationship arcs, each a clustered third of the circle instead of one // flat ring — family, estate roles (new), and professional advisors read as // visually distinct groups. function _kindOf(c) { if (c.contact_type === "legal_role") return "legal"; if (c.contact_type === "advisor") return "advisor"; return c.role === "next_of_kin" ? "nok" : "family"; } function _arcOf(kind) { return kind === "legal" ? "legal" : kind === "advisor" ? "advisor" : "family"; } const _ARC_ORDER = ["family", "legal", "advisor"]; function LifeGraph({ contacts, profileName }) { const people = (contacts || []).filter(c => c && c.name); const W = 620, H = 480, cx = W / 2, cy = H / 2, R = 150; const groups = _ARC_ORDER.map(arc => people.filter(c => _arcOf(_kindOf(c)) === arc)); const arcSpan = (2 * Math.PI) / 3; return (

Your relationships

{people.length === 0 ? (

Add your family, estate roles and advisers in the People tab to build your relationship map.

) : ( {groups.flatMap((arcPeople, arcIdx) => arcPeople.map((c, i) => { const arcStart = -Math.PI / 2 + arcIdx * arcSpan; const ang = arcStart + (arcSpan * (i + 1)) / (arcPeople.length + 1); const x = cx + R * Math.cos(ang), y = cy + R * Math.sin(ang); const kind = _kindOf(c); const role = _roleLabel(c.role); const needed = Math.max(_halfTextW(c.name, 11), _halfTextW(role, 9)) + 8; const r = Math.min(Math.max(kind === "nok" ? 42 : 36, needed), 60); return ( {c.name} {role} ); }))} {profileName || "You"} )}
); } // ─── People tab ──────────────────────────────────────────────────────────── // Top-level "People" nav tab: the relationship graph plus the same // people-editing sections DiscoveryWizard renders in edit mode (about_you, // connected_people, estate_roles, advisors). Replaces the old "People & // relationships" card under Manual Entry (see WizardHub, Task 8). function PeopleTab({ data, onSubmit }) { return (

People

Your people

Family, estate roles and professional advisers.

); } if (typeof window !== "undefined") window.PeopleTab = PeopleTab; // ─── Personal details (edit) ────────────────────────────────────────────────── // Focused editor for name / date of birth / address. Lives ONLY here — these fields // are not asked in the linear discovery wizard. Saves via the same POST /wizard/answers // as the wizard (a partial answers object is fine; keys match the hidden // personal_details YAML section). Validation is client-side: // - first name + surname required (middle name optional) // - date of birth must give an age between 18 and 120 // - postcode validated via postcodes.io ("Look up" button), proxied through // GET /wizard/address-lookup; a valid postcode auto-fills town/county, the user // types their own street line. Lookup failure falls back to manual entry. function _ageFromDob(dob) { if (!dob) return null; const d = new Date(dob); if (isNaN(d.getTime())) return null; const now = new Date(); let a = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) a -= 1; return a; } // onboarding=true is the first-run capture step shown before the discovery wizard // (blocking, "Continue"); false is the editable card in the wizard hub ("Save"). function PersonalDetailsSection({ data, onSubmit, addressApi, onboarding = false }) { const { useState } = React; const profile = data.profile || {}; const [f, setF] = useState({ first_name: profile.first_name || "", middle_name: profile.middle_name || "", surname: profile.surname || "", dob: profile.dob || "", address_line1: profile.address_line1 || "", town: profile.town || "", county: profile.county || "", postcode: profile.postcode || "", }); const [looking, setLooking] = useState(false); const [lookupMsg, setLookupMsg] = useState(null); const [busy, setBusy] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); const set = (k, v) => { setSaved(false); setF(s => ({ ...s, [k]: v })); }; const age = _ageFromDob(f.dob); const dobOk = age !== null && age >= 18 && age <= 120; const today = new Date().toISOString().slice(0, 10); async function lookup() { const pc = f.postcode.trim(); if (!pc) return; setLooking(true); setLookupMsg(null); try { const res = await addressApi.lookup(pc); if (!res.valid) { setLookupMsg("That postcode wasn't recognised — please check it or type your address below."); } else { // Auto-fill town/county; the user types their own street line. setSaved(false); setF(s => ({ ...s, town: res.town || s.town, county: res.county || s.county, postcode: res.postcode || s.postcode, })); setLookupMsg("Postcode found — town and county filled in below."); } } catch (e) { // Service down. Let the user type the address manually rather than block them. setLookupMsg("Postcode lookup is unavailable — please type your address below."); } finally { setLooking(false); } } const canSave = !!f.first_name.trim() && !!f.surname.trim() && dobOk && !!f.address_line1.trim() && !!f.postcode.trim() && !busy; async function save() { setBusy(true); setError(null); try { await onSubmit(f); setSaved(true); } catch (e) { setError(e.message || "Could not save"); } finally { setBusy(false); } } const invalidStyle = { borderColor: "#c0532f" }; return (

{onboarding ? "Tell us about you" : "Edit personal details"}

{onboarding ? "First, a few details about you — your name, date of birth and address." : "Your name, date of birth and address."}

set("first_name", e.target.value)} />
set("middle_name", e.target.value)} />
set("surname", e.target.value)} />
set("dob", e.target.value)} /> {f.dob && !dobOk &&

Age must be between 18 and 120.

}
{ set("postcode", e.target.value); setLookupMsg(null); }} />
{lookupMsg &&

{lookupMsg}

}
set("address_line1", e.target.value)} />
set("town", e.target.value)} />
set("county", e.target.value)} />
{error &&

{error}

}
{saved && !onboarding && Saved ✓}
); } function WizardHub({ data, onSubmit, resourcesApi, gapsApi, addressApi, onResourcesChanged }) { const { useState } = React; const [view, setView] = useState(null); const back = ; if (view === "details") { return (
{back}
); } if (view === "add") { return (
{back}
); } if (view === "edit") { return (
{back}
); } // Note: "Fill AI gaps" lives ONLY in the post-ingest popup (app.jsx renders // window.GapsSection there). The GapsSection component is still defined below. const cards = [ { id: "details", title: "Personal details", sub: "Your name, age and address." }, { id: "add", title: "Add resources", sub: "Add accounts and assets from preset categories." }, { id: "edit", title: "Manage resources", sub: "Review and update resources you've added or confirmed." }, ]; return (

Discovery wizard

Your handover

Tell us about you, add your accounts, and fill anything our AI missed.

{cards.map(c => ( ))}
); } if (typeof window !== "undefined") window.WizardHub = WizardHub; if (typeof window !== "undefined") window.PersonalDetailsSection = PersonalDetailsSection; // ─── Manual resources section ───────────────────────────────────────────────── // Predefined categories (from seeded resource_types) → fields (from resources.yaml). // Saves to the SAME user_resources tables AI ingestion writes to — source=MANUAL is // the only difference. Props: api = { load, add, remove }. // Per-family example brand for the add-resource placeholders (so it's not always // "Barclays"). Falls back to a neutral hint for unmapped families. const _FAMILY_EG = { banking: "Barclays", payments: "Revolut", insurance: "Aviva", investment: "Vanguard", trading: "Trading 212", streaming: "Netflix", ecommerce: "Amazon", retail: "Amazon", telecoms: "Vodafone", energy: "British Gas", utilities: "British Gas", water: "Thames Water", travel: "British Airways", transport: "Trainline", fitness: "PureGym", food_delivery: "Deliveroo", government: "HMRC", social: "Facebook", gaming: "Steam", property: "Rightmove", automotive: "DVLA", healthcare: "Bupa", education: "Coursera", charity: "Oxfam", membership: "National Trust", professional: "LinkedIn", digital: "iCloud", employment_benefit: "your employer", }; // Best-effort favicon for a manual resource: guess a domain from the provider // name (first word + .com) and hit Google's favicon service. Falls back to the // monogram if the image errors. function resFaviconUrl(brand) { const s = (brand || "").trim().toLowerCase(); if (!s) return ""; const token = s.split(/\s+/)[0].replace(/[^a-z0-9]/g, ""); if (!token) return ""; return `https://www.google.com/s2/favicons?domain=${token}.com&sz=128`; } // ─── ResEditCard — one card in the "Edit existing resources" grid ──────────── function ResEditCard({ it, family, typeLabel, pending, onEdit, onDelete, busy }) { const [favOk, setFavOk] = React.useState(true); const brand = it.organisation || it.name || ""; const mono = (brand || "?").trim().charAt(0).toUpperCase(); const fav = resFaviconUrl(brand); return (
{fav && favOk && ( setFavOk(false)} /> )}
{typeLabel}
{it.name || "Untitled"}
); } function ResourcesSection({ api, onChanged, mode = "add" }) { const { useState, useEffect } = React; const isEdit = mode === "edit"; const [state, setState] = useState(null); // { categories, items } | null const [family, setFamily] = useState(null); const [typeId, setTypeId] = useState(""); const [name, setName] = useState(""); const [organisation, setOrganisation] = useState(""); const [fields, setFields] = useState({}); const [editId, setEditId] = useState(null); // resource_id being edited (null = add mode) const [filter, setFilter] = useState("all"); // edit view: "all" | family — which pill is active const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let on = true; api.load().then(d => on && setState(d)).catch(e => on && setError(e.message)); return () => { on = false; }; }, []); if (error && !state) return

{error}

; if (!state) return

Loading…

; const category = state.categories.find(c => c.family === family); const eg = _FAMILY_EG[family] || "your provider"; // ONE org field, relabelled per family. Families not listed have no provider concept // (cars, devices, valuables) and show no org field at all. const ORG_LABELS = { financial: "Provider", investment: "Provider", insurance: "Insurer", subscription: "Provider", digital: "Service", membership: "Organisation", document: "Issuer", employment_benefit: "Employer" }; const orgLabel = ORG_LABELS[family]; const showOrg = !!orgLabel; // Options filtered by the exact SELECTED TYPE: a Video Streaming Subscription shows // streaming orgs only; a Credit Card Account shows banking/payments — never a SaaS org. const selType = category?.types.find(t => String(t.resource_type_id) === String(typeId)); const orgOptions = (selType && state.organisations?.[selType.type_name]) || []; // Keep an already-set value selectable on edit even if it's outside the filtered list // (e.g. a user-added org not in the intelligence layer). const orgChoices = organisation && !orgOptions.includes(organisation) ? [organisation, ...orgOptions] : orgOptions; const typeName = (id) => { for (const c of state.categories) { const t = c.types.find(t => t.resource_type_id === id); if (t) return t.type_name; } return ""; }; const familyOf = (rtId) => { for (const c of state.categories) { if (c.types.some(t => t.resource_type_id === rtId)) return c.family; } return null; }; const resetForm = () => { setFamily(null); setTypeId(""); setName(""); setOrganisation(""); setFields({}); setEditId(null); setError(null); }; // Open the add form pre-filled with an existing resource → save patches it (and // flips its source to MANUAL). Works for confirmed (MANUAL/DISCOVERY_WIZARD) rows. const startEdit = (it) => { setEditId(it.id); setFamily(familyOf(it.resource_type_id)); setTypeId(String(it.resource_type_id)); setName(it.name || ""); const d = it.details || {}; setOrganisation(it.organisation || ""); // prefill the org/provider/issuer so it can be edited setFields(d); setError(null); }; async function save() { if (!typeId || !name.trim() || (showOrg && !organisation.trim())) { setError(showOrg ? `Pick a type and enter a name and ${orgLabel.toLowerCase()}.` : "Pick a type and enter a name."); return; } setBusy(true); setError(null); try { if (editId) { setState(await api.edit(editId, { resource_type_id: Number(typeId), name: name.trim(), organisation: organisation.trim(), fields })); } else { setState(await api.add({ resource_type_id: Number(typeId), name: name.trim(), organisation: organisation.trim(), fields })); } resetForm(); onChanged?.(); // tell the dashboard/report to reload — no page refresh needed } catch (e) { setError(e.message); } finally { setBusy(false); } } async function remove(id) { setBusy(true); setError(null); try { setState(await api.remove(id)); onChanged?.(); } catch (e) { setError(e.message); } finally { setBusy(false); } } return (

{isEdit ? "Edit existing resources" : "Add your resources"}

{isEdit ? "Review and update resources you've added or confirmed." : "Add accounts, policies and assets by category."}

{!isEdit && !category && (
{state.categories.map(c => ( ))}
)} {category && (
setName(e.target.value)} placeholder={`e.g. ${eg}${category && category.types.length ? " " + (typeName(Number(typeId)) || category.title).toLowerCase() : ""}`} />
{showOrg && (
)} {category.fields.map(f => (
setFields(s => ({ ...s, [f.id]: e.target.value }))} />
))} {error &&

{error}

}
)} {/* Edit view: grouped cards. Hidden while the edit form is open (category set). */} {isEdit && !category && state.items.length === 0 &&

No resources yet — add some in “Add your resources”.

} {isEdit && !category && state.items.length > 0 && (() => { // Bucket items by family using familyOf(), then order the groups by the // categories list so the layout is stable. Unknown families fall to the end. const byFamily = {}; for (const it of state.items) { const fam = familyOf(it.resource_type_id) || "unknown"; (byFamily[fam] ||= []).push(it); } const groups = state.categories .filter(c => byFamily[c.family]) .map(c => ({ family: c.family, title: c.title, items: byFamily[c.family] })); const known = new Set(groups.map(g => g.family)); for (const fam of Object.keys(byFamily)) { if (!known.has(fam)) groups.push({ family: fam, title: fam, items: byFamily[fam] }); } const total = state.items.length; const shown = groups.filter(g => filter === "all" || g.family === filter); return (
{groups.map(g => ( ))}
{shown.map(g => (

{g.title}

{g.items.length} {g.items.length === 1 ? "item" : "items"}
{g.items.map(it => { const pending = it.source === "AI_INGESTION"; return ( startEdit(it)} onDelete={() => remove(it.id)} /> ); })}
))}
); })()}
); } if (typeof window !== "undefined") window.ResourcesSection = ResourcesSection; // ─── Fill AI gaps section ───────────────────────────────────────────────────── // Three kinds of gap, all surfaced in the post-ingest popup (app.jsx): // 1. Missing details — complete fields AI left blank (writes user_resources). // 2. "You might also have" — inferred likely resources (YAML rules engine). // 3. Open-ended — a free-text "anything else we missed?" box (saved to profile). // Props: api = { load, fill, saveOpen }; resourcesApi = manual-add API (load/add/ // remove/edit); onChanged = () => reload the dashboard/report after a change. function GapsSection({ api, resourcesApi, onChanged }) { const { useState, useEffect } = React; const [gaps, setGaps] = useState(null); const [adding, setAdding] = useState(false); // true = show the manual add form in place const [active, setActive] = useState(null); // resource_id whose form is open const [values, setValues] = useState({}); const [openNotes, setOpenNotes] = useState(""); const [openSaved, setOpenSaved] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let on = true; api.load().then(d => { if (on) { setGaps(d); setOpenNotes(d.open_notes || ""); } }) .catch(e => on && setError(e.message)); return () => { on = false; }; }, []); if (error && !gaps) return

{error}

; if (!gaps) return

Loading…

; // "Add" on a gap opens the manual add form IN PLACE — the popup stays open, and // "← Back to gaps" returns to the list. onChanged keeps the dashboard live. if (adding) { return (
); } const inferred = gaps.inferred || []; const nothing = gaps.missing_details.length === 0 && gaps.coverage.length === 0 && inferred.length === 0; async function fill(resourceId) { setBusy(true); setError(null); try { setGaps(await api.fill({ resource_id: resourceId, fields: values })); setActive(null); setValues({}); } catch (e) { setError(e.message); } finally { setBusy(false); } } async function saveOpen() { if (!api.saveOpen) return; setBusy(true); setError(null); setOpenSaved(false); try { setGaps(await api.saveOpen({ notes: openNotes })); setOpenSaved(true); } catch (e) { setError(e.message); } finally { setBusy(false); } } return (

Fill AI gaps

Things ingestion couldn't complete, what you might also have, and anything else we missed.

{nothing &&

No gaps — you're all caught up ✓

} {gaps.missing_details.length > 0 && (

Missing details

{gaps.missing_details.map(m => (
{m.name}
Missing: {m.fields.map(f => f.label).join(", ")}
{active !== m.resource_id && ( )}
{active === m.resource_id && (
{m.fields.map(f => (
setValues(s => ({ ...s, [f.id]: e.target.value }))} />
))}
)}
))}
)} {inferred.length > 0 && (

You might also have

{inferred.map(i => (
{i.title}
{i.reason &&
{i.reason}
}
))}
)} {gaps.coverage.length > 0 && (

Anything we missed?

{gaps.coverage.map(c => (
No {c.title.toLowerCase()} found
))}
)}

Anything else we missed?