a53858f920
Adds a "People with no sex set" section to the Cleanup page — lists everyone whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to their page). Refreshes after the source-match and first-name guess passes, so it's the manual mop-up for whatever those leave behind. Frontend only (reuses person list + PATCH) — no migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useParams } from "next/navigation";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import type { components } from "@/lib/api/schema";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
|
||
type Deceased = components["schemas"]["DeceasedCandidate"];
|
||
type GenderProp = components["schemas"]["GenderProposal"];
|
||
type NameIssue = components["schemas"]["NameIssue"];
|
||
type Person = components["schemas"]["PersonRead"];
|
||
|
||
const ISSUE_LABEL: Record<string, string> = {
|
||
date_in_surname: "date in surname",
|
||
date_in_given: "date in given name",
|
||
no_surname: "no surname",
|
||
packed_given: "long given name",
|
||
};
|
||
|
||
export default function CleanupPage() {
|
||
const params = useParams<{ id: string }>();
|
||
const treeId = params.id;
|
||
|
||
// 1) Deceased by birth year
|
||
const [year, setYear] = useState(1930);
|
||
const [deceased, setDeceased] = useState<Deceased[] | null>(null);
|
||
const [decSel, setDecSel] = useState<Set<string>>(new Set());
|
||
const [decMsg, setDecMsg] = useState<string | null>(null);
|
||
|
||
// 2) Gender from source GEDCOM
|
||
const [gender, setGender] = useState<GenderProp[] | null>(null);
|
||
const [genSel, setGenSel] = useState<Set<string>>(new Set());
|
||
const [genMsg, setGenMsg] = useState<string | null>(null);
|
||
const genFile = useRef<HTMLInputElement>(null);
|
||
|
||
// People still missing a sex (manual mop-up)
|
||
const [unset, setUnset] = useState<Person[] | null>(null);
|
||
|
||
// 3) Name issues
|
||
const [issues, setIssues] = useState<NameIssue[] | null>(null);
|
||
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
|
||
const [nameMsg, setNameMsg] = useState<string | null>(null);
|
||
|
||
async function previewDeceased() {
|
||
setDecMsg(null);
|
||
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased", {
|
||
params: { path: { tree_id: treeId }, query: { born_on_or_before: year } },
|
||
});
|
||
setDeceased(data ?? []);
|
||
setDecSel(new Set((data ?? []).map((d) => d.person_id)));
|
||
}
|
||
async function applyDeceased() {
|
||
const ids = [...decSel];
|
||
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
|
||
params: { path: { tree_id: treeId } },
|
||
body: { person_ids: ids },
|
||
});
|
||
setDecMsg(`Marked ${data?.updated ?? 0} people deceased.`);
|
||
setDeceased(null);
|
||
}
|
||
|
||
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (genFile.current) genFile.current.value = "";
|
||
if (!file) return;
|
||
setGenMsg(null);
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const resp = await fetch(`/api/v1/trees/${treeId}/cleanup/gender/preview`, {
|
||
method: "POST",
|
||
body: fd,
|
||
credentials: "include",
|
||
});
|
||
if (resp.ok) {
|
||
const data: GenderProp[] = await resp.json();
|
||
setGender(data);
|
||
setGenSel(new Set(data.map((g) => g.person_id)));
|
||
}
|
||
}
|
||
const loadUnset = useCallback(async () => {
|
||
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
setUnset(
|
||
(data ?? [])
|
||
.filter((p) => !p.gender)
|
||
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")),
|
||
);
|
||
}, [treeId]);
|
||
|
||
async function setSex(personId: string, gender: "male" | "female") {
|
||
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||
params: { path: { tree_id: treeId, person_id: personId } },
|
||
body: { gender },
|
||
});
|
||
setUnset((prev) => (prev ? prev.filter((p) => p.id !== personId) : prev));
|
||
}
|
||
|
||
async function guessGender() {
|
||
setGenMsg(null);
|
||
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/guess", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
setGender(data ?? []);
|
||
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
||
}
|
||
|
||
async function applyGender() {
|
||
const updates = (gender ?? [])
|
||
.filter((g) => genSel.has(g.person_id))
|
||
.map((g) => ({ person_id: g.person_id, gender: g.proposed_gender }));
|
||
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/gender", {
|
||
params: { path: { tree_id: treeId } },
|
||
body: { updates },
|
||
});
|
||
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
|
||
setGender(null);
|
||
loadUnset();
|
||
}
|
||
|
||
const loadNames = useCallback(async () => {
|
||
setNameMsg(null);
|
||
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/names", {
|
||
params: { path: { tree_id: treeId } },
|
||
});
|
||
setIssues(data ?? []);
|
||
const init: Record<string, { given: string; surname: string; on: boolean }> = {};
|
||
for (const i of data ?? []) {
|
||
init[i.name_id] = { given: i.given ?? "", surname: i.surname ?? "", on: false };
|
||
}
|
||
setEdits(init);
|
||
}, [treeId]);
|
||
|
||
useEffect(() => {
|
||
loadNames();
|
||
loadUnset();
|
||
}, [loadNames, loadUnset]);
|
||
|
||
async function applyNames() {
|
||
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
|
||
const body = {
|
||
edits: chosen.map((i) => ({
|
||
name_id: i.name_id,
|
||
given: edits[i.name_id].given,
|
||
surname: edits[i.name_id].surname,
|
||
})),
|
||
};
|
||
if (!body.edits.length) return;
|
||
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/names", {
|
||
params: { path: { tree_id: treeId } },
|
||
body,
|
||
});
|
||
setNameMsg(`Fixed ${data?.updated ?? 0} names.`);
|
||
loadNames();
|
||
}
|
||
|
||
const toggle = (set: Set<string>, id: string, setter: (s: Set<string>) => void) => {
|
||
const n = new Set(set);
|
||
if (n.has(id)) n.delete(id);
|
||
else n.add(id);
|
||
setter(n);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold">Cleanup</h1>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||
Fix common import messes in bulk. Each tool previews its changes — nothing is saved
|
||
until you apply.
|
||
</p>
|
||
</div>
|
||
|
||
{/* 1) Deceased by year */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Mark deceased by birth year</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex flex-wrap items-end gap-2">
|
||
<label className="flex flex-col gap-1 text-sm">
|
||
<span className="text-xs text-[var(--muted)]">Born on or before</span>
|
||
<Input
|
||
type="number"
|
||
className="w-28"
|
||
value={year}
|
||
onChange={(e) => setYear(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
<Button variant="outline" onClick={previewDeceased}>
|
||
Preview
|
||
</Button>
|
||
</div>
|
||
{decMsg && <p className="text-sm text-bronze">{decMsg}</p>}
|
||
{deceased && (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
{deceased.length} people born ≤ {year} (not already marked deceased).
|
||
</p>
|
||
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||
{deceased.map((d) => (
|
||
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={decSel.has(d.person_id)}
|
||
onChange={() => toggle(decSel, d.person_id, setDecSel)}
|
||
/>
|
||
<span className="flex-1">{d.name}</span>
|
||
<span className="text-xs text-[var(--muted)]">b. {d.birth_year}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{deceased.length > 0 && (
|
||
<Button onClick={applyDeceased}>Mark {decSel.size} deceased</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 2) Gender from source */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Set sex from a source GEDCOM</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
Upload your source <code>.ged</code> (it carries each person’s sex). We match by
|
||
name and propose sex only for people who don’t have it set.
|
||
</p>
|
||
<input
|
||
ref={genFile}
|
||
type="file"
|
||
accept=".ged,.gedcom,text/plain"
|
||
onChange={previewGender}
|
||
className="hidden"
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => genFile.current?.click()}>
|
||
Choose source GEDCOM
|
||
</Button>
|
||
<Button variant="outline" onClick={guessGender}>
|
||
Guess from first name
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-[var(--muted)]">
|
||
“Guess from first name” uses a built-in name dictionary for people with no sex set;
|
||
ambiguous names (Marion, Frances, …) are left for you to decide.
|
||
</p>
|
||
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
||
{gender && (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-[var(--muted)]">{gender.length} matches with a sex to set.</p>
|
||
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||
{gender.map((g) => (
|
||
<li key={g.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={genSel.has(g.person_id)}
|
||
onChange={() => toggle(genSel, g.person_id, setGenSel)}
|
||
/>
|
||
<span className="flex-1">{g.name}</span>
|
||
<span
|
||
className="text-xs"
|
||
style={{
|
||
color:
|
||
g.proposed_gender === "male"
|
||
? "rgb(120,159,172)"
|
||
: "rgb(196,138,146)",
|
||
}}
|
||
>
|
||
{g.proposed_gender}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{gender.length > 0 && (
|
||
<Button onClick={applyGender}>Set sex on {genSel.size} people</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* People still missing a sex */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
People with no sex set{unset ? ` (${unset.length})` : ""}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{unset === null ? (
|
||
<p className="text-sm text-[var(--muted)]">Loading…</p>
|
||
) : unset.length === 0 ? (
|
||
<p className="text-sm text-[var(--muted)]">Everyone has a sex set. 🎉</p>
|
||
) : (
|
||
<ul className="max-h-80 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||
{unset.map((p) => (
|
||
<li key={p.id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||
<a
|
||
href={`/trees/${treeId}/persons/${p.id}`}
|
||
className="flex-1 truncate hover:underline"
|
||
>
|
||
{p.primary_name ?? "Unnamed"}
|
||
</a>
|
||
<button
|
||
onClick={() => setSex(p.id, "male")}
|
||
className="rounded px-2 py-0.5 text-xs"
|
||
style={{ color: "rgb(120,159,172)", border: "1px solid rgb(120,159,172)" }}
|
||
>
|
||
♂ Male
|
||
</button>
|
||
<button
|
||
onClick={() => setSex(p.id, "female")}
|
||
className="rounded px-2 py-0.5 text-xs"
|
||
style={{ color: "rgb(196,138,146)", border: "1px solid rgb(196,138,146)" }}
|
||
>
|
||
♀ Female
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 3) Name issues */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Names that look broken</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{nameMsg && <p className="text-sm text-bronze">{nameMsg}</p>}
|
||
{issues === null ? (
|
||
<p className="text-sm text-[var(--muted)]">Scanning…</p>
|
||
) : issues.length === 0 ? (
|
||
<p className="text-sm text-[var(--muted)]">No obvious name problems found.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
{issues.length} flagged. Edit given/surname, tick the ones to fix, then apply.
|
||
</p>
|
||
<ul className="space-y-2">
|
||
{issues.map((i) => {
|
||
const e = edits[i.name_id] ?? { given: "", surname: "", on: false };
|
||
return (
|
||
<li key={i.name_id} className="flex flex-wrap items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={e.on}
|
||
onChange={() =>
|
||
setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } }))
|
||
}
|
||
/>
|
||
<Input
|
||
className="h-9 w-40"
|
||
placeholder="Given"
|
||
value={e.given}
|
||
onChange={(ev) =>
|
||
setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } }))
|
||
}
|
||
/>
|
||
<Input
|
||
className="h-9 w-40"
|
||
placeholder="Surname"
|
||
value={e.surname}
|
||
onChange={(ev) =>
|
||
setEdits((p) => ({
|
||
...p,
|
||
[i.name_id]: { ...e, surname: ev.target.value },
|
||
}))
|
||
}
|
||
/>
|
||
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
|
||
{ISSUE_LABEL[i.issue] ?? i.issue}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<Button onClick={applyNames}>Fix selected</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|