diff --git a/frontend/src/api/canonicalSystems.ts b/frontend/src/api/canonicalSystems.ts new file mode 100644 index 0000000..d7c2649 --- /dev/null +++ b/frontend/src/api/canonicalSystems.ts @@ -0,0 +1,81 @@ +/** + * Canonical systems — the GLOBAL area vocabulary every project's Systems can + * map onto (milestone 307). + * + * The mapping is an ASSOCIATION, never a rename: a project's System keeps the + * name the project gave it, and `canonical_id` only records which shared area + * it is an instance of. An unmapped System is fully usable — the catalog is a + * convergence aid, not a gate. + */ +import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client"; + +export interface CanonicalSystem { + id: number; + name: string; + /** The match key: lowercase, "&" folded to "and", punctuation collapsed. */ + slug: string; + description: string | null; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +/** + * A suggested mapping. `basis` is the whole point of showing it: + * - `exact` — the names differ only in spelling. Mechanical. + * - `overlap` — they share a meaningful word. A judgment call the reviewer is + * making, and it must never be presented as if it were the first. + */ +export interface CanonicalMatch { + id: number; + name: string; + basis: "exact" | "overlap"; + score?: number; +} + +export interface MappingProposal { + system_id: number; + system_name: string; + canonical_id: number; + canonical_name: string; + basis: "exact" | "overlap"; + score: number; +} + +export async function listCanonicalSystems(): Promise { + const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>( + "/api/canonical-systems", + ); + return data.canonical_systems; +} + +/** Admin only — a global list anyone can extend stops being shared. */ +export async function createCanonicalSystem(data: { + name: string; + description?: string; +}): Promise { + return apiPost("/api/canonical-systems", data); +} + +export async function updateCanonicalSystem( + id: number, + data: Partial<{ name: string; description: string; order_index: number }>, +): Promise { + return apiPatch(`/api/canonical-systems/${id}`, data); +} + +/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */ +export async function proposeMappings(projectId: number): Promise { + const data = await apiGet<{ proposals: MappingProposal[] }>( + `/api/projects/${projectId}/canonical-proposals`, + ); + return data.proposals; +} + +/** Apply or clear one mapping. `null` unmaps. */ +export async function mapSystem( + systemId: number, + canonicalId: number | null, +): Promise<{ id: number; canonical_id: number | null }> { + return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId }); +} diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index 1fa194d..24d3637 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -1,9 +1,15 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +import type { CanonicalMatch } from "@/api/canonicalSystems"; export interface System { id: number; project_id: number; name: string; + /** + * The global area this System is an instance of, or null. Null is a valid + * resting state — a project-specific area should stay unmapped. + */ + canonical_id: number | null; description: string; color: string | null; status: "active" | "archived"; @@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise { return data.systems; } +/** + * A created System, plus the catalog's answer about its name. An `exact` + * catalog hit is applied by the server and arrives as a populated + * `canonical_id`; an `overlap` is only OFFERED, and comes back here for the + * caller to accept or ignore. + * + * A same-named System in this project is a 409 ApiError carrying + * `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482). + */ +export interface CreatedSystem extends System { + canonical_suggestion?: CanonicalMatch; +} + export async function createSystem( projectId: number, - data: { name: string; description?: string; color?: string }, -): Promise { + data: { name: string; description?: string; color?: string; canonical_id?: number }, +): Promise { return apiPost(`/api/projects/${projectId}/systems`, data); } diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 6bf72ae..fbc7e77 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -1,14 +1,18 @@