diff --git a/apps/web/app/admin/courses/[code]/error.tsx b/apps/web/app/admin/courses/[code]/error.tsx new file mode 100644 index 00000000..35c8f33e --- /dev/null +++ b/apps/web/app/admin/courses/[code]/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/courses/error.tsx b/apps/web/app/admin/courses/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/courses/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/courses/imports/error.tsx b/apps/web/app/admin/courses/imports/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/courses/imports/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/courses/imports/loading.tsx b/apps/web/app/admin/courses/imports/loading.tsx index 5cdacf36..49ab30e6 100644 --- a/apps/web/app/admin/courses/imports/loading.tsx +++ b/apps/web/app/admin/courses/imports/loading.tsx @@ -1,5 +1,5 @@ -import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; +import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/courses/imports/page.tsx b/apps/web/app/admin/courses/imports/page.tsx index 8f662b1b..2eb638ee 100644 --- a/apps/web/app/admin/courses/imports/page.tsx +++ b/apps/web/app/admin/courses/imports/page.tsx @@ -1,7 +1,8 @@ -import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages"; +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; +import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages"; export const dynamic = "force-dynamic"; -export default function Page() { - return ; +export default function Page({ searchParams }: { searchParams: SearchParams }) { + return ; } diff --git a/apps/web/app/admin/courses/loading.tsx b/apps/web/app/admin/courses/loading.tsx index 733032a1..6a058ea0 100644 --- a/apps/web/app/admin/courses/loading.tsx +++ b/apps/web/app/admin/courses/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/majors/[code]/error.tsx b/apps/web/app/admin/majors/[code]/error.tsx new file mode 100644 index 00000000..35c8f33e --- /dev/null +++ b/apps/web/app/admin/majors/[code]/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/majors/error.tsx b/apps/web/app/admin/majors/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/majors/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/majors/imports/error.tsx b/apps/web/app/admin/majors/imports/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/majors/imports/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/majors/imports/loading.tsx b/apps/web/app/admin/majors/imports/loading.tsx index 5cdacf36..49ab30e6 100644 --- a/apps/web/app/admin/majors/imports/loading.tsx +++ b/apps/web/app/admin/majors/imports/loading.tsx @@ -1,5 +1,5 @@ -import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; +import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/majors/imports/page.tsx b/apps/web/app/admin/majors/imports/page.tsx index 8bcef622..aeea62a0 100644 --- a/apps/web/app/admin/majors/imports/page.tsx +++ b/apps/web/app/admin/majors/imports/page.tsx @@ -1,7 +1,8 @@ -import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages"; +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; +import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages"; export const dynamic = "force-dynamic"; -export default function Page() { - return ; +export default function Page({ searchParams }: { searchParams: SearchParams }) { + return ; } diff --git a/apps/web/app/admin/majors/loading.tsx b/apps/web/app/admin/majors/loading.tsx index 11736eb2..9e4d6df0 100644 --- a/apps/web/app/admin/majors/loading.tsx +++ b/apps/web/app/admin/majors/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/minors/[code]/error.tsx b/apps/web/app/admin/minors/[code]/error.tsx new file mode 100644 index 00000000..35c8f33e --- /dev/null +++ b/apps/web/app/admin/minors/[code]/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/minors/error.tsx b/apps/web/app/admin/minors/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/minors/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/minors/imports/error.tsx b/apps/web/app/admin/minors/imports/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/minors/imports/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/minors/imports/loading.tsx b/apps/web/app/admin/minors/imports/loading.tsx index 5cdacf36..49ab30e6 100644 --- a/apps/web/app/admin/minors/imports/loading.tsx +++ b/apps/web/app/admin/minors/imports/loading.tsx @@ -1,5 +1,5 @@ -import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; +import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/minors/imports/page.tsx b/apps/web/app/admin/minors/imports/page.tsx index 5646d9fc..49e8131c 100644 --- a/apps/web/app/admin/minors/imports/page.tsx +++ b/apps/web/app/admin/minors/imports/page.tsx @@ -1,7 +1,8 @@ -import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages"; +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; +import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages"; export const dynamic = "force-dynamic"; -export default function Page() { - return ; +export default function Page({ searchParams }: { searchParams: SearchParams }) { + return ; } diff --git a/apps/web/app/admin/minors/loading.tsx b/apps/web/app/admin/minors/loading.tsx index c58558fd..02210f76 100644 --- a/apps/web/app/admin/minors/loading.tsx +++ b/apps/web/app/admin/minors/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/programmes/[code]/error.tsx b/apps/web/app/admin/programmes/[code]/error.tsx new file mode 100644 index 00000000..35c8f33e --- /dev/null +++ b/apps/web/app/admin/programmes/[code]/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/programmes/error.tsx b/apps/web/app/admin/programmes/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/programmes/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/programmes/imports/error.tsx b/apps/web/app/admin/programmes/imports/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/programmes/imports/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/programmes/imports/loading.tsx b/apps/web/app/admin/programmes/imports/loading.tsx index 5cdacf36..49ab30e6 100644 --- a/apps/web/app/admin/programmes/imports/loading.tsx +++ b/apps/web/app/admin/programmes/imports/loading.tsx @@ -1,5 +1,5 @@ -import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; +import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/programmes/imports/page.tsx b/apps/web/app/admin/programmes/imports/page.tsx index 2f6352ca..33900b37 100644 --- a/apps/web/app/admin/programmes/imports/page.tsx +++ b/apps/web/app/admin/programmes/imports/page.tsx @@ -1,7 +1,8 @@ -import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages"; +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; +import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages"; export const dynamic = "force-dynamic"; -export default function Page() { - return ; +export default function Page({ searchParams }: { searchParams: SearchParams }) { + return ; } diff --git a/apps/web/app/admin/programmes/loading.tsx b/apps/web/app/admin/programmes/loading.tsx index a5f35265..09a1b0f9 100644 --- a/apps/web/app/admin/programmes/loading.tsx +++ b/apps/web/app/admin/programmes/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/specialisations/[code]/error.tsx b/apps/web/app/admin/specialisations/[code]/error.tsx new file mode 100644 index 00000000..35c8f33e --- /dev/null +++ b/apps/web/app/admin/specialisations/[code]/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/specialisations/error.tsx b/apps/web/app/admin/specialisations/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/specialisations/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/specialisations/imports/error.tsx b/apps/web/app/admin/specialisations/imports/error.tsx new file mode 100644 index 00000000..22a72703 --- /dev/null +++ b/apps/web/app/admin/specialisations/imports/error.tsx @@ -0,0 +1,2 @@ +"use client"; +export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error"; diff --git a/apps/web/app/admin/specialisations/imports/loading.tsx b/apps/web/app/admin/specialisations/imports/loading.tsx index 5cdacf36..49ab30e6 100644 --- a/apps/web/app/admin/specialisations/imports/loading.tsx +++ b/apps/web/app/admin/specialisations/imports/loading.tsx @@ -1,5 +1,5 @@ -import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; +import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/app/admin/specialisations/imports/page.tsx b/apps/web/app/admin/specialisations/imports/page.tsx index acd6a9fd..2bc631d2 100644 --- a/apps/web/app/admin/specialisations/imports/page.tsx +++ b/apps/web/app/admin/specialisations/imports/page.tsx @@ -1,7 +1,10 @@ -import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages"; +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; +import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages"; export const dynamic = "force-dynamic"; -export default function Page() { - return ; +export default function Page({ searchParams }: { searchParams: SearchParams }) { + return ( + + ); } diff --git a/apps/web/app/admin/specialisations/loading.tsx b/apps/web/app/admin/specialisations/loading.tsx index bc284a53..b90ba01d 100644 --- a/apps/web/app/admin/specialisations/loading.tsx +++ b/apps/web/app/admin/specialisations/loading.tsx @@ -1,5 +1,5 @@ import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading"; export default function Loading() { - return ; + return ; } diff --git a/apps/web/lib/catalogue-import/changes.ts b/apps/web/lib/catalogue-import/changes.ts index a0fe6896..a53e1375 100644 --- a/apps/web/lib/catalogue-import/changes.ts +++ b/apps/web/lib/catalogue-import/changes.ts @@ -30,6 +30,7 @@ const COURSE_COLLECTIONS = [ ] as const; const STRUCTURE_COLLECTIONS = [ + "summaryFields", "sections", "learningOutcomes", "fees", diff --git a/apps/web/lib/catalogue-import/kind-adapter.ts b/apps/web/lib/catalogue-import/kind-adapter.ts index f7e7eae2..23e0ea89 100644 --- a/apps/web/lib/catalogue-import/kind-adapter.ts +++ b/apps/web/lib/catalogue-import/kind-adapter.ts @@ -30,6 +30,14 @@ export type MergeOutcome = { warningCount: number; errorCount: number; report: unknown; + /** + * Set when the model output was discarded. The processor records it on the + * target so a snapshot built from deterministic parsing alone says so, + * rather than finishing `ready` with no error at all. + */ + errorCode?: string | null; + /** The reason, for `catalogue_extractions.error_summary`. */ + errorSummary?: string | null; }; /** @@ -75,6 +83,8 @@ export type CatalogueKindAdapter = { modelValid: boolean; modelInput: string; responseError: string | null; + /** The provider's stop reason; `length` means the response was truncated. */ + finishReason: string | null; }): MergeOutcome; project(extraction: Extraction): CatalogueSnapshotWrite; }; diff --git a/apps/web/lib/catalogue-import/kinds/course/adapter.ts b/apps/web/lib/catalogue-import/kinds/course/adapter.ts index e5d73d12..fc1a312f 100644 --- a/apps/web/lib/catalogue-import/kinds/course/adapter.ts +++ b/apps/web/lib/catalogue-import/kinds/course/adapter.ts @@ -113,6 +113,10 @@ export const courseKindAdapter: CatalogueKindAdapter = { modelValid, warningCount, errorCount, + errorCode: modelValid ? null : "MODEL_OUTPUT_REJECTED", + errorSummary: modelValid + ? null + : "The model response failed the strict course extraction contract; only deterministic parsing reached this snapshot.", report: { schemaValid: modelValid, modelValidationIssues: result.modelValidationIssues, diff --git a/apps/web/lib/catalogue-import/kinds/structure/adapter.ts b/apps/web/lib/catalogue-import/kinds/structure/adapter.ts index 7d07d9a2..c13041f7 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/adapter.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/adapter.ts @@ -12,10 +12,13 @@ import { convertAcademicStructureHtmlToMarkdown, } from "./markdown.ts"; import { + ACADEMIC_STRUCTURE_MODEL_FIELDS, academicStructureModelEvidenceIssues, + academicStructureModelFieldRoot, mergeAcademicStructureExtractions, normaliseAcademicStructureModelExtraction, } from "./merge.ts"; +import { academicStructureModelResponseError } from "./model-response-error.ts"; import { projectAcademicStructureSnapshot } from "./project.ts"; import { ACADEMIC_STRUCTURE_IMPORT_MAX_OUTPUT_TOKENS, @@ -103,6 +106,7 @@ export const structureKindAdapter: CatalogueKindAdapter( + discarded + ? modelFields + : evidenceIssues + .map(({ fieldKey }) => academicStructureModelFieldRoot(fieldKey)) + .filter((field) => modelFields.includes(field)), + ); + const extraction = + discarded || !validation.success + ? structuredClone(deterministic) + : mergeAcademicStructureExtractions({ + deterministic, + model: validation.data, + rejectedFields, + }); + + if (discarded) { + extraction.reviewItems.push({ + fieldKey: "modelExtraction", + kind: "invalid", + severity: "error", + message: + responseCause ?? + "The model response failed the strict academic structure extraction contract; only deterministic parsing reached this snapshot.", + }); + } else { + for (const field of rejectedFields) { + extraction.reviewItems.push({ + fieldKey: field, + kind: "evidence_missing", + severity: "warning", + message: `The model supplied ${field} without wording from the selected-year source; the deterministic value was kept.`, + }); + } + } const warningCount = extraction.reviewItems.filter( ({ severity }) => severity === "warning", ).length; @@ -133,17 +174,28 @@ export const structureKindAdapter: CatalogueKindAdapter !rejectedFields.has(field)), }, }; }, diff --git a/apps/web/lib/catalogue-import/kinds/structure/merge.ts b/apps/web/lib/catalogue-import/kinds/structure/merge.ts index 692fdbcb..caa63f42 100644 Binary files a/apps/web/lib/catalogue-import/kinds/structure/merge.ts and b/apps/web/lib/catalogue-import/kinds/structure/merge.ts differ diff --git a/apps/web/lib/catalogue-import/persist-snapshot.ts b/apps/web/lib/catalogue-import/persist-snapshot.ts index c00ba082..98ee3652 100644 --- a/apps/web/lib/catalogue-import/persist-snapshot.ts +++ b/apps/web/lib/catalogue-import/persist-snapshot.ts @@ -246,6 +246,16 @@ async function insertStructureContent( ${details.contactText} ) `; + for (const field of content.summaryFields) { + await tx` + insert into public.structure_snapshot_summary_fields ( + snapshot_id, position, value_position, field_key, label, field_value, source_text + ) values ( + ${snapshotId}, ${field.position}, ${field.valuePosition}, ${field.fieldKey}, + ${field.label}, ${field.fieldValue}, ${field.sourceText} + ) + `; + } for (const section of content.sections) { await tx` insert into public.academic_structure_snapshot_sections ( @@ -634,6 +644,16 @@ export async function persistSnapshotCandidate( set draft_snapshot_id = ${snapshotId} where id = ${claim.itemYearId} `; + // A first import has nothing to compare against, so its changes are + // recorded as already accepted and the candidate becomes the draft + // without anyone pressing Apply. Recording that here keeps the target + // honest: it was applied, and leaving applied_snapshot_id null made a + // published record still read "Ready for review". + await tx` + update public.catalogue_import_targets + set applied_snapshot_id = ${snapshotId}, applied_at = now() + where id = ${claim.targetId}::uuid + `; } const changeKind: SnapshotChangeKind = becameDraft ? "new" : "changed"; return { diff --git a/apps/web/lib/catalogue-import/process-target.ts b/apps/web/lib/catalogue-import/process-target.ts index 9a610aa8..f3e8f7ad 100644 --- a/apps/web/lib/catalogue-import/process-target.ts +++ b/apps/web/lib/catalogue-import/process-target.ts @@ -499,6 +499,7 @@ async function processClaimedTarget({ modelValid: modelValidation.success, modelInput: userPrompt, responseError: modelResult.result.responseError, + finishReason: modelResult.result.finishReason, }); const validated = await persistArtifact({ stageId, @@ -528,9 +529,10 @@ async function processClaimedTarget({ warningCount: outcome.warningCount, errorCount: outcome.errorCount, errorSummary: - outcome.modelValid && outcome.errorCount === 0 + outcome.errorSummary ?? + (outcome.modelValid && outcome.errorCount === 0 ? null - : "The model response failed strict extraction validation; deterministic data was retained.", + : "The model response failed strict extraction validation; deterministic data was retained."), }); return outcome; }); @@ -572,6 +574,12 @@ async function processClaimedTarget({ changeKind: persisted.changeKind, sourcePageId, candidateSnapshotId: persisted.candidateSnapshotId, + // Discarding the model extraction used to be silent: the target ended + // `ready` with no error code, and only catalogue_extractions recorded + // it. The blocking flag the merge emitted holds publication; this says + // why on the target itself. + errorCode: merged.errorCode ?? null, + errorMessage: merged.errorSummary ?? null, }); } catch (error) { const code = importErrorCode(error); diff --git a/apps/web/lib/catalogue-import/snapshot-read.ts b/apps/web/lib/catalogue-import/snapshot-read.ts index 95c6bc22..11496cf4 100644 --- a/apps/web/lib/catalogue-import/snapshot-read.ts +++ b/apps/web/lib/catalogue-import/snapshot-read.ts @@ -307,12 +307,14 @@ async function readStructureContent( select * from public.structure_snapshot_details where snapshot_id = ${snapshotId} `; if (!details) return null; - const [sections, outcomes, fees, relationships] = await Promise.all([ - sql`select section_key, heading, markdown, source_text, source_locator, position from public.academic_structure_snapshot_sections where snapshot_id = ${snapshotId} order by position`, - sql`select position, outcome_text, source_text, source_locator from public.academic_structure_learning_outcomes where snapshot_id = ${snapshotId} order by position`, - sql`select * from public.academic_structure_fees where snapshot_id = ${snapshotId} order by position`, - sql`select * from public.academic_structure_snapshot_relationships where snapshot_id = ${snapshotId} order by position`, - ]); + const [summaryFields, sections, outcomes, fees, relationships] = + await Promise.all([ + sql`select position, value_position, field_key, label, field_value, source_text from public.structure_snapshot_summary_fields where snapshot_id = ${snapshotId} order by position, value_position`, + sql`select section_key, heading, markdown, source_text, source_locator, position from public.academic_structure_snapshot_sections where snapshot_id = ${snapshotId} order by position`, + sql`select position, outcome_text, source_text, source_locator from public.academic_structure_learning_outcomes where snapshot_id = ${snapshotId} order by position`, + sql`select * from public.academic_structure_fees where snapshot_id = ${snapshotId} order by position`, + sql`select * from public.academic_structure_snapshot_relationships where snapshot_id = ${snapshotId} order by position`, + ]); return { details: { name: String(details.name), @@ -336,6 +338,14 @@ async function readStructureContent( studyAs: str(details.study_as), contactText: str(details.contact_text), }, + summaryFields: summaryFields.map((row) => ({ + position: Number(row.position), + valuePosition: Number(row.value_position), + fieldKey: String(row.field_key), + label: String(row.label), + fieldValue: String(row.field_value), + sourceText: String(row.source_text), + })), sections: sections.map((row) => ({ position: Number(row.position), sectionKey: String(row.section_key), diff --git a/apps/web/lib/catalogue-import/snapshot-write.ts b/apps/web/lib/catalogue-import/snapshot-write.ts index 65d7676d..6750d07c 100644 --- a/apps/web/lib/catalogue-import/snapshot-write.ts +++ b/apps/web/lib/catalogue-import/snapshot-write.ts @@ -165,6 +165,7 @@ export type StructureContentWrite = { studyAs: string | null; contactText: string | null; }; + summaryFields: AcademicStructureSnapshotProjection["summaryFields"]; sections: AcademicStructureSnapshotProjection["sections"]; learningOutcomes: AcademicStructureSnapshotProjection["learningOutcomes"]; fees: AcademicStructureSnapshotProjection["fees"]; @@ -395,6 +396,7 @@ export function structureSnapshotWrite({ studyAs: projection.snapshot.studyAs, contactText: projection.snapshot.contactText, }, + summaryFields: projection.summaryFields, sections: projection.sections, learningOutcomes: projection.learningOutcomes, fees: projection.fees, diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 5f9fb699..1e870525 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -28,6 +28,16 @@ function failure(error: unknown, fallback: string): ActionResult { }; } +/** + * The record page identifies itself with a URL carrying the academic year, but + * revalidatePath matches a route path. Passing the query string made every + * revalidation silently miss, so an accepted change only appeared after a + * manual reload. + */ +function revalidateRecord(path: string) { + revalidatePath(path.split("?")[0] ?? path); +} + export async function resolveReviewEntryAction({ entryId, status, @@ -48,43 +58,60 @@ export async function resolveReviewEntryAction({ p_note: note ?? undefined, }); if (error) return { ok: false, error: error.message }; - revalidatePath(path); + revalidateRecord(path); return { ok: true }; } -export async function resolveAllChangesAction({ - targetId, +/** + * Several review entries at once: one group of changes, one kind of flag, or + * everything still open. The reviewer already has the entries on screen, so + * the ids come with the request rather than being looked up again, which keeps + * a bulk decision to exactly the rows the reviewer was shown. + */ +export async function resolveReviewEntriesAction({ + entryIds, status, + note, path, }: { - targetId: string; - status: "accepted" | "rejected"; + entryIds: number[]; + status: "open" | "accepted" | "rejected" | "acknowledged"; + note?: string; path: string; }): Promise { if (!(await canManageCourseImports())) return { ok: false, error: "Import permission is required." }; + if (entryIds.length === 0) + return { ok: false, error: "There was nothing to decide." }; const supabase = await createClient(); - const { data: entries, error } = await supabase - .from("catalogue_import_changes") - .select("id") - .eq("target_id", targetId) - .eq("entry_kind", "change") - .eq("status", "open"); - if (error) return { ok: false, error: error.message }; - for (const entry of entries ?? []) { - const { error: resolveError } = await supabase.rpc( - "resolve_catalogue_import_change", - { - p_change_id: entry.id, - p_status: status, - }, - ); - if (resolveError) return { ok: false, error: resolveError.message }; + let resolved = 0; + for (const entryId of entryIds) { + const { error } = await supabase.rpc("resolve_catalogue_import_change", { + p_change_id: entryId, + p_status: status, + p_note: note ?? undefined, + }); + // Report what did land, so a partial failure is not read as none at all. + if (error) + return { + ok: false, + error: + resolved === 0 + ? error.message + : `${resolved} of ${entryIds.length} were saved, then: ${error.message}`, + }; + resolved += 1; } - revalidatePath(path); + revalidateRecord(path); + const verb = + status === "acknowledged" + ? "acknowledged" + : status === "open" + ? "reopened" + : status; return { ok: true, - message: `${entries?.length ?? 0} change${entries?.length === 1 ? "" : "s"} ${status}.`, + message: `${resolved} ${resolved === 1 ? "entry" : "entries"} ${verb}.`, }; } @@ -101,7 +128,7 @@ export async function applyReviewAction({ if (!viewer) return { ok: false, error: "Authentication is required." }; try { const result = await applyImportReview({ targetId, userId: viewer.id }); - revalidatePath(path); + revalidateRecord(path); return { ok: true, message: result.reusedCandidate @@ -129,7 +156,7 @@ export async function publishDraftAction({ p_item_year_id: itemYearId, }); if (error) return { ok: false, error: error.message }; - revalidatePath(path); + revalidateRecord(path); return { ok: true, message: "Published. Students now see this version." }; } @@ -147,7 +174,7 @@ export async function unpublishAction({ p_item_year_id: itemYearId, }); if (error) return { ok: false, error: error.message }; - revalidatePath(path); + revalidateRecord(path); return { ok: true, message: "Unpublished. Students no longer see this record for the year.", @@ -176,7 +203,7 @@ export async function saveManualSnapshotAction({ write, userId: viewer.id, }); - revalidatePath(path); + revalidateRecord(path); return { ok: true, snapshotId: result.snapshotId, @@ -210,7 +237,7 @@ export async function restoreSnapshotAction({ snapshotId, userId: viewer.id, }); - revalidatePath(path); + revalidateRecord(path); return { ok: true, message: result.unchanged @@ -238,7 +265,7 @@ export async function discardDraftAction({ p_item_year_id: itemYearId, }); if (error) return { ok: false, error: error.message }; - revalidatePath(path); + revalidateRecord(path); return { ok: true, message: "Draft discarded. The snapshot stays in history.", diff --git a/apps/web/lib/coursemap/admin-catalogue.ts b/apps/web/lib/coursemap/admin-catalogue.ts index 660ecedb..00df75a8 100644 --- a/apps/web/lib/coursemap/admin-catalogue.ts +++ b/apps/web/lib/coursemap/admin-catalogue.ts @@ -1,4 +1,5 @@ import "server-only"; +import type { PostgrestError } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/server"; import { type AdminCatalogueSummary, @@ -6,9 +7,13 @@ import { type CatalogueDirectoryPage, type CatalogueDirectoryRecord, type CatalogueKind, + DEFAULT_IMPORT_RECORD_SORT, type DirectoryFilter, type DirectoryWorkflowStatus, - type ImportRunSummary, + type ImportRecordSort, + type ImportRecordsPage, + type ImportRunProgress, + type ImportRunRow, type ImportTargetDetail, } from "./catalogue-kinds"; @@ -46,16 +51,38 @@ function emptyCounts(): Record { }; } +/** + * The years an administrator can choose. Every seeded academic year from 2020 + * to 2030 used to be offered, so the picker listed eleven years of which only + * one held any catalogue, tall enough to cover the page tabs when it opened. + * It now offers the years whose listing has been fetched, plus this year and + * next so the coming handbook can always be imported before it has any rows. + * The fetched years come from the directory statuses, one row per year and + * kind, rather than from the entries themselves, which run to thousands. + */ export async function loadCatalogueYears() { const supabase = await createClient(); - const { data, error } = await supabase - .from("academic_years") - .select("year") - .gte("year", 2020) - .lte("year", 2030) - .order("year", { ascending: false }); - if (error) throw error; - return (data ?? []).map((row) => row.year); + const [yearsResult, statusesResult] = await Promise.all([ + supabase + .from("academic_years") + .select("id,year") + .gte("year", 2020) + .lte("year", 2030), + supabase.from("catalogue_directory_statuses").select("academic_year_id"), + ]); + if (yearsResult.error) throw yearsResult.error; + if (statusesResult.error) throw statusesResult.error; + const current = new Date().getFullYear(); + const fetched = new Set( + (statusesResult.data ?? []).map((row) => row.academic_year_id), + ); + return (yearsResult.data ?? []) + .filter( + (row) => + fetched.has(row.id) || row.year === current || row.year === current + 1, + ) + .map((row) => row.year) + .sort((left, right) => right - left); } /** @@ -81,6 +108,32 @@ export async function defaultCatalogueYear( return years[0] ?? current; } +const ROW_PAGE_SIZE = 1000; + +/** + * PostgREST answers every request with at most 1,000 rows, whatever the query + * asks for, and does so without saying anything. The directory reads a whole + * year's listing to filter it in memory, so one request stopped at the + * thousandth code: everything after EMET1001, about two thirds of the course + * catalogue, could not be found, filtered or imported from the directory, and + * the footer reported a total of exactly 1,000. This reads every page. Each + * caller must order by a unique key so a row cannot move between pages. + */ +async function readAllRows( + readPage: ( + from: number, + to: number, + ) => PromiseLike<{ data: Row[] | null; error: PostgrestError | null }>, +): Promise<{ data: Row[]; error: PostgrestError | null }> { + const rows: Row[] = []; + for (let from = 0; ; from += ROW_PAGE_SIZE) { + const { data, error } = await readPage(from, from + ROW_PAGE_SIZE - 1); + if (error) return { data: rows, error }; + rows.push(...(data ?? [])); + if ((data?.length ?? 0) < ROW_PAGE_SIZE) return { data: rows, error: null }; + } +} + /** * Directory entries for one kind and year with each record's workflow state. * Filtering by workflow happens in memory because the state derives from @@ -127,28 +180,42 @@ export async function loadCatalogueDirectoryPage({ .eq("academic_year_id", yearRow.id) .eq("kind", kind) .maybeSingle(), - supabase - .from("catalogue_directory_entries") - .select("code,title,summary,item_id") - .eq("academic_year_id", yearRow.id) - .eq("kind", kind) - .eq("is_current", true) - .order("code"), - supabase - .from("catalogue_item_years") - .select( - "item_id,public_id,draft_snapshot_id,published_snapshot_id,archived_at", - ) - .eq("academic_year_id", yearRow.id) - .eq("kind", kind), - supabase - .from("catalogue_import_targets") - .select( - "id,run_id,item_id,status,change_kind,error_message,completed_at,created_at", - ) - .eq("academic_year_id", yearRow.id) - .eq("kind", kind) - .order("created_at", { ascending: false }), + // Codes are unique within a kind and year, so code alone orders the pages. + readAllRows((from, to) => + supabase + .from("catalogue_directory_entries") + .select("code,title,summary,item_id") + .eq("academic_year_id", yearRow.id) + .eq("kind", kind) + .eq("is_current", true) + .order("code") + .range(from, to), + ), + readAllRows((from, to) => + supabase + .from("catalogue_item_years") + .select( + "item_id,public_id,draft_snapshot_id,published_snapshot_id,archived_at", + ) + .eq("academic_year_id", yearRow.id) + .eq("kind", kind) + .order("item_id") + .range(from, to), + ), + // Newest first so the first target seen per item is its latest; id breaks + // ties between targets created in the same instant. + readAllRows((from, to) => + supabase + .from("catalogue_import_targets") + .select( + "id,run_id,item_id,status,change_kind,error_message,completed_at,created_at", + ) + .eq("academic_year_id", yearRow.id) + .eq("kind", kind) + .order("created_at", { ascending: false }) + .order("id") + .range(from, to), + ), ]); if (statusResult.error) throw statusResult.error; if (entriesResult.error) throw entriesResult.error; @@ -170,10 +237,10 @@ export async function loadCatalogueDirectoryPage({ // Items imported directly (without a directory row) still appear so the // administrator can see everything the year holds. - const entryCodes = new Set((entriesResult.data ?? []).map((row) => row.code)); + const entryCodes = new Set(entriesResult.data.map((row) => row.code)); + const entryItemIds = new Set(entriesResult.data.map((row) => row.item_id)); const extraItemIds = [...itemYearByItem.keys()].filter( - (itemId) => - !(entriesResult.data ?? []).some((entry) => entry.item_id === itemId), + (itemId) => !entryItemIds.has(itemId), ); const { data: extraItems } = extraItemIds.length ? await supabase @@ -282,51 +349,29 @@ export async function loadCatalogueDirectoryPage({ }; } -export async function loadCatalogueImportRuns({ - kind, - limit = 25, -}: { - kind: CatalogueKind; - limit?: number; -}): Promise { - const supabase = await createClient(); - const { data: runs, error } = await supabase - .from("catalogue_import_runs") - .select( - "id,run_number,kind,status,requested_model,target_count,completed_count,failed_count,cost_usd,created_at,completed_at,academic_years(year)", - ) - .eq("kind", kind) - .order("created_at", { ascending: false }) - .limit(limit); - if (error) throw error; - const runIds = (runs ?? []).map((run) => run.id); - const { data: targets, error: targetsError } = runIds.length - ? await supabase - .from("catalogue_import_targets") - .select( - "id,run_id,code,status,change_kind,attempt_count,error_code,error_message,candidate_snapshot_id,catalogue_item_years(public_id)", - ) - .in("run_id", runIds) - .order("code") - : { data: [], error: null }; - if (targetsError) throw targetsError; - const targetsByRun = new Map(); - for (const target of targets ?? []) { - const list = targetsByRun.get(target.run_id) ?? []; - list.push({ - id: target.id, - code: target.code, - status: target.status, - changeKind: target.change_kind, - attemptCount: target.attempt_count, - errorCode: target.error_code, - errorMessage: target.error_message, - candidateSnapshotId: target.candidate_snapshot_id, - itemYearPublicId: target.catalogue_item_years?.public_id ?? null, - }); - targetsByRun.set(target.run_id, list); - } - return (runs ?? []).map((run) => ({ +const IMPORT_RECORD_PAGE_SIZE = 25; +const RECENT_RUN_LIMIT = 20; + +const RUN_COLUMNS = + "id,run_number,kind,status,requested_model,target_count,completed_count,failed_count,cost_usd,created_at,completed_at,academic_years(year)"; + +type RunRow = { + id: string; + run_number: number; + kind: string; + status: string; + requested_model: string; + target_count: number; + completed_count: number; + failed_count: number; + cost_usd: number | string; + created_at: string; + completed_at: string | null; + academic_years: { year: number } | null; +}; + +function runRow(run: RunRow): ImportRunRow { + return { id: run.id, runNumber: run.run_number, kind: run.kind as CatalogueKind, @@ -339,8 +384,274 @@ export async function loadCatalogueImportRuns({ costUsd: Number(run.cost_usd), createdAt: run.created_at, completedAt: run.completed_at, - targets: targetsByRun.get(run.id) ?? [], - })); + }; +} + +// The year is taken from the target's own column and resolved through the +// academic year table, rather than reached by a nested embed through the run: +// PostgREST resolves `catalogue_import_targets -> catalogue_item_years` by two +// foreign keys of the same name, so the embedded shape is not typed. +const RECORD_COLUMNS = + "id,code,academic_year_id,status,change_kind,attempt_count,error_code,error_message,applied_snapshot_id,created_at,completed_at,run_id,directory_entry_id,item_year_id"; + +type RecordRow = { + id: string; + code: string; + academic_year_id: number; + status: string; + change_kind: string | null; + attempt_count: number; + error_code: string | null; + error_message: string | null; + applied_snapshot_id: number | null; + created_at: string; + completed_at: string | null; + run_id: string; + directory_entry_id: number | null; + item_year_id: number; +}; + +/** + * A PostgREST `or` list is comma separated and parenthesised, so a needle + * carrying either character would change the shape of the filter rather than + * be matched by it. + */ +function safeNeedle(query: string) { + return query + .trim() + .replace(/[(),*]/g, " ") + .trim(); +} + +/** + * One page of imported records for a kind: a flat history across every run, + * narrowed by a search over code and title, by the record's own outcome and + * by the run that produced it. The run travels on each row, so a reader never + * has to pick a batch before seeing what was imported. + * + * The recent runs come back with the page because they are the run filter's + * options and, when one is chosen, the strip that carries its progress and + * its Stop control. + */ +export async function loadCatalogueImportRecords({ + kind, + query = "", + status = "", + runId = null, + sort = DEFAULT_IMPORT_RECORD_SORT, + page = 1, +}: { + kind: CatalogueKind; + query?: string; + status?: string; + runId?: string | null; + sort?: ImportRecordSort; + page?: number; +}): Promise { + const supabase = await createClient(); + const needle = safeNeedle(query); + + const { data: runData, error: runError } = await supabase + .from("catalogue_import_runs") + .select(RUN_COLUMNS) + .eq("kind", kind) + .order("created_at", { ascending: false }) + .limit(RECENT_RUN_LIMIT); + if (runError) throw runError; + const runs = ((runData ?? []) as RunRow[]).map(runRow); + + // A run chosen from an older page is not in the recent list, so it is read + // on its own rather than silently dropping the filter the reader applied. + let run = runs.find((candidate) => candidate.id === runId) ?? null; + if (runId && !run) { + const { data: single } = await supabase + .from("catalogue_import_runs") + .select(RUN_COLUMNS) + .eq("id", runId) + .eq("kind", kind) + .maybeSingle(); + run = single ? runRow(single as RunRow) : null; + } + + const empty: ImportRecordsPage = { + records: [], + page: 1, + pageSize: IMPORT_RECORD_PAGE_SIZE, + total: 0, + sort, + runs, + run, + }; + + let recordsQuery = supabase + .from("catalogue_import_targets") + .select(RECORD_COLUMNS, { count: "exact" }) + .eq("kind", kind); + if (status) recordsQuery = recordsQuery.eq("status", status); + if (runId) { + // An unknown run id would otherwise return every record, which reads as + // though the filter had been ignored. + if (!run) return empty; + recordsQuery = recordsQuery.eq("run_id", runId); + } + + if (needle) { + // The title lives on the directory entry, which PostgREST cannot reach + // from inside an `or`, so matching titles are resolved to entry ids first. + const { data: titleMatches, error: titleError } = await supabase + .from("catalogue_directory_entries") + .select("id") + .eq("kind", kind) + .ilike("title", `%${needle}%`) + .limit(1000); + if (titleError) throw titleError; + const clauses = [`code.ilike.*${needle}*`]; + const entryIds = (titleMatches ?? []).map((match) => match.id); + if (entryIds.length) + clauses.push(`directory_entry_id.in.(${entryIds.join(",")})`); + recordsQuery = recordsQuery.or(clauses.join(",")); + } + + recordsQuery = + sort === "oldest" + ? recordsQuery.order("created_at", { ascending: true }) + : sort === "code-asc" + ? recordsQuery.order("code", { ascending: true }) + : sort === "code-desc" + ? recordsQuery.order("code", { ascending: false }) + : recordsQuery.order("created_at", { ascending: false }); + // Ties on code or on a shared run timestamp would otherwise order + // differently per page, so rows could repeat or go missing across pages. + recordsQuery = recordsQuery.order("id", { ascending: true }); + + const { count, error: countError } = await recordsQuery.range(0, 0); + if (countError) throw countError; + const total = count ?? 0; + const safePage = Math.max( + 1, + Math.min(page, Math.ceil(total / IMPORT_RECORD_PAGE_SIZE) || 1), + ); + const from = (safePage - 1) * IMPORT_RECORD_PAGE_SIZE; + const { data, error } = await recordsQuery.range( + from, + from + IMPORT_RECORD_PAGE_SIZE - 1, + ); + if (error) throw error; + const rows = (data ?? []) as RecordRow[]; + + // The labels a row needs are resolved by id over the page rather than by + // embedding them in the select above. One page is twenty-five rows, so this + // is four small reads, and it keeps the ambiguous embeds out: PostgREST + // reaches `catalogue_item_years` from a target through two foreign keys of + // the same name, which it will not resolve and cannot type. + const distinct = (values: Value[]) => [...new Set(values)]; + const entryIds = distinct( + rows.map((row) => row.directory_entry_id).filter((id) => id !== null), + ); + const yearIds = distinct(rows.map((row) => row.academic_year_id)); + const itemYearIds = distinct(rows.map((row) => row.item_year_id)); + // The recent runs are already loaded, so only a row from an older run than + // the list offers costs a read. + const olderRunIds = distinct(rows.map((row) => row.run_id)).filter( + (id) => !runs.some((candidate) => candidate.id === id), + ); + + const [entries, years, itemYears, olderRuns] = await Promise.all([ + entryIds.length + ? supabase + .from("catalogue_directory_entries") + .select("id,title") + .in("id", entryIds) + : null, + yearIds.length + ? supabase.from("academic_years").select("id,year").in("id", yearIds) + : null, + itemYearIds.length + ? supabase + .from("catalogue_item_years") + .select("id,public_id") + .in("id", itemYearIds) + : null, + olderRunIds.length + ? supabase + .from("catalogue_import_runs") + .select("id,run_number") + .in("id", olderRunIds) + : null, + ]); + if (entries?.error) throw entries.error; + if (years?.error) throw years.error; + if (itemYears?.error) throw itemYears.error; + if (olderRuns?.error) throw olderRuns.error; + + const titleById = new Map( + (entries?.data ?? []).map((entry) => [entry.id, entry.title]), + ); + const yearById = new Map( + (years?.data ?? []).map((year) => [year.id, year.year]), + ); + const publicIdById = new Map( + (itemYears?.data ?? []).map((itemYear) => [ + itemYear.id, + itemYear.public_id, + ]), + ); + const runNumberById = new Map(runs.map((row) => [row.id, row.runNumber])); + for (const row of olderRuns?.data ?? []) + runNumberById.set(row.id, row.run_number); + + return { + records: rows.map((row) => ({ + id: row.id, + code: row.code, + title: + row.directory_entry_id === null + ? null + : (titleById.get(row.directory_entry_id) ?? null), + academicYear: yearById.get(row.academic_year_id) ?? 0, + status: row.status, + changeKind: row.change_kind, + attemptCount: row.attempt_count, + errorCode: row.error_code, + errorMessage: row.error_message, + appliedSnapshotId: row.applied_snapshot_id, + itemYearPublicId: publicIdById.get(row.item_year_id) ?? null, + createdAt: row.created_at, + completedAt: row.completed_at, + runId: row.run_id, + runNumber: runNumberById.get(row.run_id) ?? 0, + })), + page: safePage, + pageSize: IMPORT_RECORD_PAGE_SIZE, + total, + sort, + runs, + run, + }; +} + +/** + * The counters of one run and nothing else. An active run is watched through + * this rather than by refetching the page, which previously reread every run + * and every target every four seconds to learn that one number had moved. + */ +export async function loadImportRunProgress( + runId: string, +): Promise { + const supabase = await createClient(); + const { data, error } = await supabase + .from("catalogue_import_runs") + .select("status,target_count,completed_count,failed_count") + .eq("id", runId) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + return { + status: data.status, + targetCount: data.target_count, + completedCount: data.completed_count, + failedCount: data.failed_count, + }; } export async function loadImportTargetDetail( diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index d5ba2dd2..b74ed6fd 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -88,12 +88,14 @@ export type ImportRunSummary = { targets: Array<{ id: string; code: string; + title: string | null; status: string; changeKind: string | null; attemptCount: number; errorCode: string | null; errorMessage: string | null; candidateSnapshotId: number | null; + appliedSnapshotId: number | null; itemYearPublicId: string | null; }>; }; @@ -208,3 +210,87 @@ export const FIELD_LABELS: Record = { export function fieldLabel(fieldPath: string) { return FIELD_LABELS[fieldPath] ?? fieldPath; } + +/** + * A readable name for a key the label map does not carry, such as a field + * inside a collection row. Prefer FIELD_LABELS; this is the fallback, not a + * substitute for naming a field properly. + */ +export function humaniseKey(key: string) { + const known = FIELD_LABELS[key]; + if (known) return known; + const spaced = key + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .toLowerCase() + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * The imports list is read on the server and driven from the client, so its + * vocabulary lives here rather than beside the loader, which is server-only + * and would pull the Supabase client into the browser bundle. + */ +export const IMPORT_RECORD_SORTS = [ + "newest", + "oldest", + "code-asc", + "code-desc", +] as const; +export type ImportRecordSort = (typeof IMPORT_RECORD_SORTS)[number]; +export const DEFAULT_IMPORT_RECORD_SORT: ImportRecordSort = "newest"; + +/** `catalogue_import_targets.status`, as the check constraint defines it. */ +export const IMPORT_RECORD_STATUSES = [ + "queued", + "running", + "ready", + "unchanged", + "failed", + "cancelled", +] as const; + +/** A run as it appears beside the records it produced: counters, no targets. */ +export type ImportRunRow = Omit; + +/** + * One imported record. The run that produced it is carried on the row, because + * the list is a flat history of records rather than a list of batches. + */ +export type ImportRecordRow = { + id: string; + code: string; + title: string | null; + academicYear: number; + status: string; + changeKind: string | null; + attemptCount: number; + errorCode: string | null; + errorMessage: string | null; + appliedSnapshotId: number | null; + itemYearPublicId: string | null; + createdAt: string; + completedAt: string | null; + runId: string; + runNumber: number; +}; + +export type ImportRecordsPage = { + records: ImportRecordRow[]; + page: number; + pageSize: number; + total: number; + sort: ImportRecordSort; + /** Recent runs, offered as the run filter's options. */ + runs: ImportRunRow[]; + /** The run the list is narrowed to, when the reader has chosen one. */ + run: ImportRunRow | null; +}; + +export type ImportRunProgress = { + status: string; + targetCount: number; + completedCount: number; + failedCount: number; +}; diff --git a/apps/web/playwright/catalogue-admin.spec.ts b/apps/web/playwright/catalogue-admin.spec.ts index 5a7e11f2..f7d0fcf0 100644 --- a/apps/web/playwright/catalogue-admin.spec.ts +++ b/apps/web/playwright/catalogue-admin.spec.ts @@ -43,17 +43,19 @@ test("administrators browse the programme directory and import runs", async ({ page.getByText(/Directory refreshed: 1 programmes/), ).toBeVisible(); - await page.getByRole("tab", { name: "Import runs" }).click(); + await page.getByRole("tab", { name: "Imports" }).click(); await expect(page).toHaveURL(/\/admin\/programmes\/imports/); await expect( page.getByRole("heading", { - name: "Programme import runs", + name: "Programme imports", level: 1, exact: true, }), ).toBeAttached(); + // The page is one table of imported records, so its search box is there + // whether or not anything has been imported yet. await expect( - page.getByText(/No programme imports yet|Run #/).first(), + page.getByPlaceholder("Search imported programmes by code or title"), ).toBeVisible(); await page.goto("/admin/courses?status=published"); diff --git a/apps/web/playwright/catalogue-review.spec.ts b/apps/web/playwright/catalogue-review.spec.ts index 8373d64c..6d17a891 100644 --- a/apps/web/playwright/catalogue-review.spec.ts +++ b/apps/web/playwright/catalogue-review.spec.ts @@ -92,37 +92,48 @@ test("administrators review, apply and publish an import candidate", async ({ await expect(page.getByRole("heading", { level: 1 })).toContainText( seed.title, ); - const publish = page.getByRole("button", { name: "Publish draft" }); - await expect(publish).toBeDisabled(); + // The verdict leads with what is left, and offers no publish control while + // the reviewer still owes the record a decision. + await expect(page.getByText("2 decisions outstanding")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Publish draft" }), + ).toHaveCount(0); // Accept the title, reject the description. const titleRow = page - .getByRole("listitem") + .getByRole("row") .filter({ hasText: "Title" }) .filter({ hasText: "(revised)" }); await titleRow.getByRole("button", { name: "Accept" }).click(); - await expect(titleRow.getByText("accepted")).toBeVisible(); + await expect(titleRow.getByText("Accepted")).toBeVisible(); const descriptionRow = page - .getByRole("listitem") + .getByRole("row") .filter({ hasText: "A revised description for review." }); await descriptionRow.getByRole("button", { name: "Reject" }).click(); - await expect(descriptionRow.getByText("rejected")).toBeVisible(); + await expect(descriptionRow.getByText("Rejected")).toBeVisible(); // A blocking flag needs a note before it is acknowledged. const flagRow = page .getByRole("listitem") - .filter({ hasText: "blocks publication" }); + .filter({ hasText: "Blocks publication" }); await flagRow.getByRole("button", { name: "Acknowledge" }).click(); await flagRow .getByLabel(/Why publication may proceed/) .fill("Checked units on the ANU page."); await flagRow.getByRole("button", { name: "Save and acknowledge" }).click(); - await expect(flagRow.getByText("acknowledged")).toBeVisible(); + await expect(flagRow.getByText("Acknowledged")).toBeVisible(); await page.getByRole("button", { name: "Apply to draft" }).click(); await expect(page.getByText(/new draft combines/)).toBeVisible(); - await expect(publish).toBeEnabled(); - await publish.click(); + + // Only once nothing holds the record back does publishing become the + // next step, and it confirms before students see anything. + await expect(page.getByText("Ready to publish")).toBeVisible(); + await page.getByRole("button", { name: "Publish draft" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Publish", exact: true }) + .click(); await expect( page.getByText("Published. Students now see this version."), ).toBeVisible(); diff --git a/apps/web/tests/catalogue-review-state.test.ts b/apps/web/tests/catalogue-review-state.test.ts new file mode 100644 index 00000000..365638b8 --- /dev/null +++ b/apps/web/tests/catalogue-review-state.test.ts @@ -0,0 +1,272 @@ +import { expect, test } from "vitest"; +import type { + CatalogueRecord, + ReviewEntry, + ReviewTarget, +} from "@/lib/coursemap/admin-catalogue-record"; +import { anuSourceLocation } from "@/ui/admin/catalogue/anu-source"; +import { + flagFieldLabel, + groupChanges, + groupFlags, + recordNextStep, + reviewSummary, +} from "@/ui/admin/catalogue/review-state"; + +let nextId = 1; + +function entry(overrides: Partial): ReviewEntry { + return { + id: nextId++, + entryKind: "change", + fieldPath: "course.details.title", + oldValue: null, + newValue: null, + severity: null, + isBlocking: false, + issueCode: null, + summary: null, + sourceLocator: null, + sourceExcerpt: null, + status: "open", + resolutionNote: null, + resolvedAt: null, + ...overrides, + }; +} + +function target(overrides: Partial = {}): ReviewTarget { + return { + id: "target-1", + runId: "run-1", + runNumber: 3, + status: "ready", + changeKind: "changed", + createdAt: "2026-03-01T00:00:00Z", + completedAt: "2026-03-01T00:01:00Z", + appliedAt: null, + baselineSnapshotId: 10, + candidateSnapshotId: 11, + entries: [], + ...overrides, + }; +} + +function record(overrides: Partial = {}): CatalogueRecord { + return { + kind: "course", + code: "COMP3600", + academicYear: 2026, + itemId: 1, + itemYearId: 1, + itemYearPublicId: "iy_1", + title: "Algorithms", + draftSnapshotId: null, + publishedSnapshotId: null, + archivedAt: null, + publishBlockers: [], + snapshots: [], + publications: [], + reviews: [], + ...overrides, + }; +} + +test("a flag's leaf key is named from the field label map", () => { + // Changes carry a full path, flags only the key the extractor used. + expect(flagFieldLabel("eftsl")).toBe("EFTSL"); + expect(flagFieldLabel("prescribedTexts")).toBe("Prescribed texts"); + expect(flagFieldLabel("course.details.units")).toBe("Units"); + // Nothing in the map ends in this key, so the key itself is made readable. + expect(flagFieldLabel("requisites.prerequisiteText")).toBe( + "Prerequisite text", + ); +}); + +test("blocking flags stay separate and repeated warnings collapse by kind", () => { + const flags = [ + entry({ entryKind: "flag", issueCode: "INVALID", isBlocking: true }), + ...["introduction", "description", "fees"].map((fieldPath) => + entry({ entryKind: "flag", issueCode: "CONFLICT", fieldPath }), + ), + entry({ entryKind: "flag", issueCode: "EVIDENCE_MISSING" }), + ]; + const { blocking, groups } = groupFlags(flags); + + expect(blocking).toHaveLength(1); + expect(groups.map((group) => [group.label, group.entries.length])).toEqual([ + ["Model disagreed with the parser", 3], + ["No supporting excerpt", 1], + ]); + expect(groups[0]?.open).toHaveLength(3); +}); + +test("changes group by the part of the record they belong to", () => { + const groups = groupChanges([ + entry({ fieldPath: "course.details.title" }), + entry({ fieldPath: "course.sessions" }), + entry({ fieldPath: "requirements.prerequisite" }), + entry({ fieldPath: "course.details.units", status: "accepted" }), + ]); + + expect(groups.map((group) => group.label)).toEqual([ + "Details", + "Requirements", + "Lists", + ]); + expect(groups[0]?.entries).toHaveLength(2); + expect(groups[0]?.open).toHaveLength(1); + // The whole-collection constraint is stated once, above the rows it binds. + expect(groups[2]?.note).toContain("whole list"); +}); + +test("a run reports what is left rather than what it contains", () => { + const summary = reviewSummary( + target({ + entries: [ + entry({ status: "accepted" }), + entry({ fieldPath: "course.fees" }), + entry({ entryKind: "flag", issueCode: "CONFLICT" }), + ], + }), + ); + + expect(summary.headline).toBe("1 of 2 changes still to decide"); + expect(summary.decided).toBe(1); + expect(summary.actionable).toBe(true); +}); + +test("a settled run asks for nothing and collapses", () => { + const summary = reviewSummary( + target({ + appliedAt: "2026-03-02T00:00:00Z", + entries: [entry({ status: "accepted" })], + }), + ); + + expect(summary.actionable).toBe(false); + expect(summary.reviewable).toBe(false); + expect(summary.headline).toBe("Applied to the draft"); +}); + +test("the record's next step walks from decisions to published", () => { + const open = entry({}); + const reviews = [target({ entries: [open, entry({ status: "accepted" })] })]; + + const outstanding = recordNextStep( + record({ + draftSnapshotId: 5, + reviews, + publishBlockers: ["The import review still has open changes."], + }), + ); + expect(outstanding.headline).toBe("1 decision outstanding"); + expect(outstanding.next).toBe("review"); + expect(outstanding.decided).toBe(1); + + const decided = recordNextStep( + record({ + draftSnapshotId: 5, + reviews: [target({ entries: [entry({ status: "accepted" })] })], + }), + ); + expect(decided.headline).toBe("Every change is decided"); + expect(decided.next).toBe("review"); + + const ready = recordNextStep( + record({ + draftSnapshotId: 5, + reviews: [ + target({ + appliedAt: "2026-03-02T00:00:00Z", + entries: [entry({ status: "accepted" })], + }), + ], + }), + ); + expect(ready.headline).toBe("Ready to publish"); + expect(ready.next).toBe("publish"); + + const live = recordNextStep(record({ publishedSnapshotId: 5 })); + expect(live.headline).toBe("Published"); + expect(live.next).toBe("none"); +}); + +test("a blocking flag holds publication and names itself", () => { + const step = recordNextStep( + record({ + draftSnapshotId: 5, + publishBlockers: ["A blocking flag on the import review is still open."], + reviews: [ + target({ + entries: [ + entry({ + entryKind: "flag", + isBlocking: true, + issueCode: "INVALID", + }), + ], + }), + ], + }), + ); + + expect(step.headline).toBe("1 flag blocking publication"); + expect(step.tone).toBe("warning"); +}); + +test("decisions from a superseded import do not hold a record back", () => { + // catalogue_publish_blockers reads the newest finished review and the one + // behind the draft, so the interface counts the same two and no others. + const step = recordNextStep( + record({ + draftSnapshotId: 5, + snapshots: [ + { + id: 5, + publicId: "sn_5", + origin: "import", + createdAt: "2026-03-02T00:00:00Z", + sealedAt: null, + basedOnSnapshotId: null, + importTargetId: "target-1", + contentHash: "abc", + }, + ], + reviews: [ + target({ + id: "target-1", + appliedAt: "2026-03-02T00:00:00Z", + entries: [entry({ status: "accepted" })], + }), + target({ id: "target-0", status: "failed", entries: [entry({})] }), + ], + }), + ); + + expect(step.headline).toBe("Ready to publish"); +}); + +test("an id locator deep-links the ANU page and a selector does not", () => { + const page = "https://programsandcourses.anu.edu.au/2026/course/COMP3600"; + + expect(anuSourceLocation(page, "#learning-outcomes")).toEqual({ + label: "Learning outcomes", + href: `${page}#learning-outcomes`, + }); + // A class or attribute selector names markup, which tells a reviewer nothing. + expect(anuSourceLocation(page, ".degree-summary")).toEqual({ + label: null, + href: page, + }); + expect(anuSourceLocation(page, 'meta[name="course-name"]')).toEqual({ + label: null, + href: page, + }); + // Structure evidence names a heading in the page's own words. + expect(anuSourceLocation(page, "Admission Requirements")).toEqual({ + label: "Admission Requirements", + href: page, + }); + expect(anuSourceLocation(page, null)).toEqual({ label: null, href: page }); +}); diff --git a/apps/web/tests/value-diff.test.tsx b/apps/web/tests/value-diff.test.tsx new file mode 100644 index 00000000..c2c7d336 --- /dev/null +++ b/apps/web/tests/value-diff.test.tsx @@ -0,0 +1,69 @@ +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ValueDiff } from "@/ui/admin/catalogue/value-diff"; + +test("a scalar change reads as one pair of values", () => { + render( + , + ); + expect(screen.getByText("Algorithms")).toBeInTheDocument(); + expect(screen.getByText("Advanced Algorithms")).toBeInTheDocument(); +}); + +test("only the changed field of a collection row is shown", () => { + const before = [ + { classNumber: "1", deliveryMode: "In person", location: "Manning Clark" }, + { classNumber: "2", deliveryMode: "In person", location: "Hanna Neumann" }, + ]; + const after = [ + { classNumber: "1", deliveryMode: "Online", location: "Manning Clark" }, + { classNumber: "2", deliveryMode: "In person", location: "Hanna Neumann" }, + ]; + render( + , + ); + + // The one altered field is named, with its own before and after. + expect(screen.getByText("Item 1 · Delivery mode")).toBeInTheDocument(); + expect(screen.getByText("In person")).toBeInTheDocument(); + expect(screen.getByText("Online")).toBeInTheDocument(); + + // The untouched row never appears, and neither does a JSON dump of it. + expect(screen.queryByText(/Item 2/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Hanna Neumann/)).not.toBeInTheDocument(); +}); + +test("an added row is labelled as added", () => { + render( + , + ); + expect(screen.getByText("Item 2 (added)")).toBeInTheDocument(); + expect(screen.getByText("International")).toBeInTheDocument(); +}); + +test("a value the walk cannot reduce falls back to the raw values", () => { + const wide = Array.from({ length: 80 }, (_, index) => ({ + key: `value-${index}`, + })); + render( + , + ); + // Nothing was dropped: the reviewer still sees both sides in full. + expect(screen.getByLabelText("Current course.sessions")).toBeInTheDocument(); + expect(screen.getByLabelText("Imported course.sessions")).toBeInTheDocument(); +}); diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index 0e8fc014..318e46f4 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -3264,6 +3264,51 @@ export type Database = { }, ] } + structure_snapshot_summary_fields: { + Row: { + field_key: string + field_value: string + label: string + position: number + snapshot_id: number + source_text: string + value_position: number + } + Insert: { + field_key: string + field_value: string + label: string + position: number + snapshot_id: number + source_text: string + value_position: number + } + Update: { + field_key?: string + field_value?: string + label?: string + position?: number + snapshot_id?: number + source_text?: string + value_position?: number + } + Relationships: [ + { + foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" + columns: ["snapshot_id"] + isOneToOne: false + referencedRelation: "catalogue_snapshots" + referencedColumns: ["id"] + }, + { + foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" + columns: ["snapshot_id"] + isOneToOne: false + referencedRelation: "published_course_summaries" + referencedColumns: ["snapshot_id"] + }, + ] + } university_calendar_events: { Row: { academic_year_id: number @@ -3552,6 +3597,10 @@ export type Database = { Args: { required_permission: string } Returns: boolean } + delete_catalogue_item: { + Args: { p_code: string; p_kind: string } + Returns: undefined + } discard_catalogue_draft: { Args: { p_item_year_id: number } Returns: undefined @@ -3594,6 +3643,17 @@ export type Database = { to_is_available: boolean }[] } + published_structure_detail: { + Args: { p_academic_year: number; p_structure_code: string } + Returns: Json + } + published_structure_years: { + Args: { p_structure_code: string } + Returns: { + academic_year: number + structure_kind: string + }[] + } record_current_user_course_attempt: { Args: { p_attempt_mark?: number @@ -3603,6 +3663,11 @@ export type Database = { } Returns: string } + recover_catalogue_import_targets: { Args: never; Returns: number } + release_catalogue_import_target: { + Args: { p_target_id: string } + Returns: undefined + } remove_current_user_plan_item: { Args: { p_plan_item_id: string } Returns: boolean diff --git a/apps/web/ui/admin/catalogue-table/catalogue-error.tsx b/apps/web/ui/admin/catalogue-table/catalogue-error.tsx index a1bb1f9f..b1a04522 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-error.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-error.tsx @@ -2,6 +2,8 @@ import { Button } from "@coursemap/ui/primitives/button"; import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { CATALOGUE_KIND_LABELS } from "@/lib/coursemap/catalogue-kinds"; import { ErrorState } from "@/ui/common/error-state"; import { AppShell } from "@/ui/shell"; @@ -30,3 +32,45 @@ export function CatalogueError({ ); } + +const LIST_LABELS = Object.fromEntries( + Object.values(CATALOGUE_KIND_LABELS).map((labels) => [ + labels.segment, + labels.plural.toLowerCase(), + ]), +); + +/** + * One record failed, not the list. The way out is the list it came from, which + * is the segment above it, so the reader is not sent back to the overview to + * find their place again. + */ +export function CatalogueRecordError({ + error, + reset, +}: { + error?: Error & { digest?: string }; + reset: () => void; +}) { + const pathname = usePathname(); + const segment = pathname.split("/")[2] ?? ""; + const listPath = `/admin/${segment}`; + const listLabel = LIST_LABELS[segment] ?? "catalogue"; + return ( + + + + + + + ); +} diff --git a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx index 22527776..b2aa4392 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx +++ b/apps/web/ui/admin/catalogue-table/catalogue-loading.tsx @@ -12,96 +12,198 @@ import { TableCaption, } from "./catalogue-table"; -export function CatalogueLoading({ +/** + * Every grid `DataTableShell` lays out. The skeleton has to name the same one + * the page will, or the placeholder is a different table from the one that + * replaces it and the whole list reflows on arrival. + */ +export type CatalogueLoadingLayout = + "public-courses" | "users" | "directory" | "import-records"; + +/** + * A skeleton cell per real cell. The kind decides the shape, so a placeholder + * takes the space its content will. + */ +type Column = { + label: string; + kind: "select" | "identity" | "text" | "actions"; +}; + +function columnsFor(noun: string, layout: CatalogueLoadingLayout): Column[] { + if (layout === "public-courses") + return [ + { label: "Course", kind: "identity" }, + { label: "Year", kind: "text" }, + { label: "Requisites", kind: "text" }, + { label: "Available", kind: "text" }, + { label: "Units", kind: "text" }, + { label: "Actions", kind: "actions" }, + ]; + if (layout === "users") + return [ + { label: "User", kind: "identity" }, + { label: "Role", kind: "text" }, + { label: "Joined", kind: "text" }, + { label: "Updated", kind: "text" }, + { label: "Actions", kind: "actions" }, + ]; + if (layout === "import-records") + return [ + { label: "Import", kind: "identity" }, + { label: "Year", kind: "text" }, + { label: "Outcome", kind: "text" }, + { label: "Change", kind: "text" }, + { label: "Run", kind: "text" }, + { label: "Started", kind: "text" }, + { label: "Actions", kind: "actions" }, + ]; + return [ + { label: "Select", kind: "select" }, + { label: noun, kind: "identity" }, + { label: "Details", kind: "text" }, + { label: "Status", kind: "text" }, + { label: "Latest import", kind: "text" }, + { label: "Actions", kind: "actions" }, + ]; +} + +/** + * The table skeleton without a page shell, so a `Suspense` fallback inside a + * page that has already rendered shows the same shape `loading.tsx` did rather + * than a second, different one. + */ +export function CatalogueTableLoading({ noun, - imports = false, layout, + rows = 7, }: { noun: string; - imports?: boolean; - layout?: "public-courses" | "users"; + layout: CatalogueLoadingLayout; + rows?: number; }) { - const columns = - layout === "public-courses" - ? ["Course", "Year", "Requisites", "Available", "Units", "Actions"] - : layout === "users" - ? ["User", "Role", "Joined", "Updated", "Actions"] - : imports - ? ["Import", "Year", "Outcome", "Change", "Started", "Actions"] - : ["Select", noun, "Year", "Status", "Units", "Actions"]; + const columns = columnsFor(noun, layout); + const imports = layout === "import-records"; return ( - -
-

Loading {noun}

- {!imports && !layout ? ( -
- - +
+ {layout === "directory" ? ( + // The year picker, its status line and the refresh action sit above the + // directory's filter bar, so the skeleton holds that row open too. +
+
+ + +
+
+ + +
+
+ ) : null} +
+ + {imports ? : null} +
+ + +
- ) : null} - - - - -
- } - > - - Loading {noun} - - + } + > +
+ Loading {noun} + + + {columns.map((column) => ( + + {column.label} + {column.kind !== "actions" ? ( + + ) : null} + + ))} + + + + {Array.from({ length: rows }, (_, row) => ( + {columns.map((column) => ( - - {column} - {column !== "Actions" ? ( + + {column.kind === "identity" ? ( +
+ +
+ + +
+
+ ) : ( - ) : null} -
+ )} + ))}
- - - {Array.from({ length: 7 }, (_, row) => ( - - {columns.map((column, index) => ( - - {index === (imports || layout ? 0 : 1) ? ( -
- -
- - -
-
- ) : ( - - )} -
- ))} -
- ))} -
-
- -
+ ))} + + + + + ); +} + +/** A whole `loading.tsx` route: the admin shell around the table skeleton. */ +export function CatalogueLoading({ + noun, + layout, +}: { + noun: string; + layout: CatalogueLoadingLayout; +}) { + return ( + +

Loading {noun}

+
); } + +/** A whole `loading.tsx` route for the imports page. */ +export function ImportRecordsLoading({ noun }: { noun: string }) { + return ( + +

Loading {noun}

+ +
+ ); +} + +/** + * The imports page is one table of records with a filter bar above it, so its + * skeleton is the record table and nothing else. It previously drew a run + * table and a card beneath it, which is the stacked layout the page no longer + * has; the route skeleton and the in-page Suspense boundary share this shape. + */ +export function ImportRecordsSkeleton() { + return ( + + ); +} diff --git a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css index 142d7721..82f100f2 100644 --- a/apps/web/ui/admin/catalogue-table/catalogue-table.module.css +++ b/apps/web/ui/admin/catalogue-table/catalogue-table.module.css @@ -53,9 +53,12 @@ .shell[data-selectable="false"] tr { grid-template-columns: minmax(240px, 1fr) 88px 180px 76px 60px; } +/* Imported records: identity, year, outcome, change, run, started, actions. + The run is a column on the record rather than a pane above the table, so + this grid carries one more column than the other admin layouts. */ .shell[data-imports="true"] tr { - min-width: 850px; - grid-template-columns: minmax(240px, 1fr) 88px 168px 96px 165px 60px; + min-width: 940px; + grid-template-columns: minmax(240px, 1fr) 76px 150px 96px 84px 160px 60px; } .viewport thead tr { min-height: 49px; @@ -173,12 +176,14 @@ a.title:focus-visible { min-width: 700px; grid-template-columns: minmax(260px, 1fr) 150px 120px 120px 60px; } -/* Catalogue directory: select, identity, details, workflow status, latest import. */ +/* Catalogue directory: select, identity, details, workflow status, latest + import, actions. The actions column matches the width the sibling layouts + reserve, so the menu button lands in the same place on every admin table. */ .shell[data-layout="directory"] tr { - min-width: 820px; + min-width: 880px; grid-template-columns: 48px minmax(280px, 1.4fr) minmax(160px, 1fr) - 170px 170px; + 170px 170px 60px; } @media (pointer: coarse) { diff --git a/apps/web/ui/admin/catalogue/anu-source.ts b/apps/web/ui/admin/catalogue/anu-source.ts new file mode 100644 index 00000000..c514b6ea --- /dev/null +++ b/apps/web/ui/admin/catalogue/anu-source.ts @@ -0,0 +1,66 @@ +import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; + +const PROGRAMS_AND_COURSES = "https://programsandcourses.anu.edu.au"; + +/** + * The ANU Programs and Courses page a record was imported from. The site names + * its own segments, so "programme" and "specialisation" cannot be used + * verbatim. Kept here because the directory, the record header and the review + * all need to point a reader back at the source they are judging. + */ +export function anuSourceUrl({ + kind, + code, + academicYear, +}: { + kind: CatalogueKind; + code: string; + academicYear: number; +}) { + const segment = + kind === "programme" + ? "program" + : kind === "specialisation" + ? "specialisation" + : kind; + return `${PROGRAMS_AND_COURSES}/${academicYear}/${segment}/${code}`; +} + +/** An element id the ANU page also uses as an anchor, so it can be linked to. */ +const ANCHOR = /^#[A-Za-z][\w-]*$/; +/** Anything with selector punctuation names markup, not a place on the page. */ +const SELECTOR = /[.#[\]=>,:]/; + +export type SourceLocation = { + /** Where on the ANU page the value was read, in the page's own words. */ + label: string | null; + /** The page, deep-linked to that place where the locator allows it. */ + href: string; +}; + +/** + * The pipeline records where it read a value as a CSS selector, which is how + * the extractor found it rather than anything a reviewer can act on. An id + * selector doubles as the page's own anchor, so it becomes a deep link and a + * section name; a class or attribute selector names markup and is dropped + * rather than printed at someone who is checking a value against the page. + */ +export function anuSourceLocation( + sourceHref: string, + locator: string | null, +): SourceLocation { + const first = locator?.split(",")[0]?.trim(); + if (!first) return { label: null, href: sourceHref }; + if (ANCHOR.test(first)) + return { + label: humaniseAnchor(first.slice(1)), + href: `${sourceHref}${first}`, + }; + if (SELECTOR.test(first)) return { label: null, href: sourceHref }; + return { label: first, href: sourceHref }; +} + +function humaniseAnchor(id: string) { + const words = id.replace(/[-_]+/g, " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} diff --git a/apps/web/ui/admin/catalogue/artefact-data.ts b/apps/web/ui/admin/catalogue/artefact-data.ts new file mode 100644 index 00000000..09ae1590 --- /dev/null +++ b/apps/web/ui/admin/catalogue/artefact-data.ts @@ -0,0 +1,49 @@ +export type ImportArtefact = { + id: string; + kind: string; + attemptNumber: number; + mediaType: string; +}; + +export const importArtefactLabels: Record = { + raw_html: "Raw HTML", + normalised_markdown: "Markdown", + model_input: "Model input", + deterministic_output: "Deterministic output", + model_request: "Model request", + model_response: "Model response", + validated_json: "Validated JSON", + validation_report: "Validation", + change_set: "Persistence decision", +}; + +export function groupImportArtefacts(artifacts: ImportArtefact[]) { + const order = Object.keys(importArtefactLabels); + const groups = new Map(); + // Projections live in Database rows, with the persisted records they describe. + for (const artifact of artifacts.filter( + (entry) => entry.kind !== "database_projection", + )) { + const group = groups.get(artifact.kind) ?? []; + group.push(artifact); + groups.set(artifact.kind, group); + } + return [...groups] + .map(([kind, attempts]) => ({ + kind, + attempts: attempts.sort((a, b) => b.attemptNumber - a.attemptNumber), + })) + .sort((a, b) => { + const position = (kind: string) => + order.includes(kind) ? order.indexOf(kind) : order.length; + return position(a.kind) - position(b.kind); + }); +} + +export function parseImportArtefact(content: string): unknown { + try { + return JSON.parse(content) as unknown; + } catch { + return null; + } +} diff --git a/apps/web/ui/admin/catalogue/artefact-navigation.module.css b/apps/web/ui/admin/catalogue/artefact-navigation.module.css new file mode 100644 index 00000000..da5f8e15 --- /dev/null +++ b/apps/web/ui/admin/catalogue/artefact-navigation.module.css @@ -0,0 +1,25 @@ +/* This vertical navigation must not inherit the Nova segmented-tab track. */ +.list { + background: transparent !important; + box-shadow: none !important; + border: 0; +} + +.list [role="tab"] { + flex: none; + height: 36px; + border: 0; + box-shadow: none; + background: transparent !important; +} +.list [role="tab"]:hover { + background: var(--accent) !important; +} +.list [role="tab"][data-state="active"] { + background: color-mix( + in oklab, + var(--primary) 12%, + var(--background) + ) !important; + color: var(--primary) !important; +} diff --git a/apps/web/ui/admin/catalogue/artefact-viewer.tsx b/apps/web/ui/admin/catalogue/artefact-viewer.tsx new file mode 100644 index 00000000..1f10c0f1 --- /dev/null +++ b/apps/web/ui/admin/catalogue/artefact-viewer.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { LoaderCircle } from "lucide-react"; +import { Alert, AlertDescription } from "@coursemap/ui/components/alert"; +import { Button } from "@coursemap/ui/primitives/button"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@coursemap/ui/primitives/tabs"; +import { OptionPicker } from "@/ui/common/option-picker"; +import { JsonCode } from "@/ui/common/json-code"; +import { ArtefactViewport } from "./artefact-viewport"; +import { + groupImportArtefacts, + importArtefactLabels, + parseImportArtefact, + type ImportArtefact, +} from "./artefact-data"; +import { useImportArtefact } from "./use-artefact"; +import { SourceCode } from "./source-code"; +import navigationStyles from "./artefact-navigation.module.css"; + +export function ArtefactViewer({ + artifacts, + endpoint, +}: { + artifacts: ImportArtefact[]; + endpoint: string; +}) { + const grouped = useMemo(() => groupImportArtefacts(artifacts), [artifacts]); + const [activeKind, setActiveKind] = useState(""); + const [attempts, setAttempts] = useState>({}); + const group = + grouped.find((entry) => entry.kind === activeKind) ?? grouped[0]; + const artifact = + group?.attempts.find((entry) => entry.id === attempts[group.kind]) ?? + group?.attempts[0] ?? + null; + const { content, loading, error, retry } = useImportArtefact( + artifact, + endpoint, + ); + const label = artifact + ? (importArtefactLabels[artifact.kind] ?? + artifact.kind.replaceAll("_", " ")) + : "Artefact"; + const parsed = + content !== undefined && artifact?.mediaType === "application/json" + ? parseImportArtefact(content) + : null; + + if (!group || !artifact) + return ( +

+ This attempt stored no artefacts. +

+ ); + + return ( + +
+
+ ({ + value: entry.kind, + label: + importArtefactLabels[entry.kind] ?? + entry.kind.replaceAll("_", " "), + }))} + /> +
+ +
+ +
+ {group.attempts.length > 1 && ( +
+ + setAttempts((current) => ({ ...current, [group.kind]: id })) + } + aria-label={`Choose ${label} attempt`} + className="w-44" + items={group.attempts.map((entry, index) => ({ + value: entry.id, + label: `Attempt ${entry.attemptNumber}${index === 0 ? " (latest)" : ""}`, + }))} + /> +
+ )} + + {error ? ( +
+ + {error} + + +
+ ) : loading ? ( +
+
+ ) : parsed !== null ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/ui/admin/catalogue/artefact-viewport.tsx b/apps/web/ui/admin/catalogue/artefact-viewport.tsx new file mode 100644 index 00000000..bc4d71a1 --- /dev/null +++ b/apps/web/ui/admin/catalogue/artefact-viewport.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; + +export function ArtefactViewport({ + children, + label, + toolbar, +}: { + children: ReactNode; + label: string; + toolbar?: ReactNode; +}) { + return ( +
+ {toolbar ? ( +
+ {toolbar} +
+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/apps/web/ui/admin/catalogue/catalogue-directory.tsx b/apps/web/ui/admin/catalogue/catalogue-directory.tsx index 447ad4ff..8fe462a1 100644 --- a/apps/web/ui/admin/catalogue/catalogue-directory.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-directory.tsx @@ -7,7 +7,7 @@ import { TooltipContent, TooltipTrigger, } from "@coursemap/ui/primitives/tooltip"; -import { History, LoaderCircle, RefreshCw, Upload, X } from "lucide-react"; +import { LoaderCircle, RefreshCw, Upload, X } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; @@ -31,9 +31,12 @@ import { TableRow, } from "@/ui/admin/catalogue-table/catalogue-table"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { CatalogueRowActions } from "@/ui/admin/catalogue-table/catalogue-row-actions"; import { FilterBar } from "@/ui/common/filter-bar"; +import { LinkedTableRow } from "@/ui/common/linked-table-row"; import { Pagination } from "@/ui/common/pagination"; import { YearPicker } from "@/ui/common/year-picker"; +import { anuSourceUrl } from "./anu-source"; import { readImportStream } from "./import-stream"; import { WORKFLOW_LABELS, WorkflowBadge } from "./workflow-badge"; @@ -232,32 +235,28 @@ export function CatalogueDirectory({ years={page.years} onChange={(year) => navigate({ year: String(year) })} /> -

- {page.status.state === "never" - ? "The listing has not been fetched yet." - : page.status.state === "failed" - ? `Last refresh failed${page.status.message ? `: ${page.status.message}` : "."}` - : `${page.status.entryCount} listed${refreshedAt ? `, refreshed ${refreshedAt}` : ""}.`} - {page.status.state === "available" && page.status.message ? ( - - {" "} - {page.status.message} - - ) : null} -

+ {/* The row count is already in the table footer, so only the state + of the listing itself is worth a line here. */} + {page.status.state === "never" ? ( +

Not fetched yet.

+ ) : page.status.state === "failed" ? ( +

+ Last refresh failed + {page.status.message ? `: ${page.status.message}` : "."} +

+ ) : page.status.message ? ( +

+ {page.status.message} +

+ ) : null}
- - - ); - } + {page.records.length === 0 ? ( + + + + ) : ( + + + {labels.singular} imports, {SORT_CAPTIONS[page.sort]} + + + + Import + Year + Outcome + Change + Run + Started + + Actions + + + + + {page.records.map((record) => ( + select({ target: record.id })} + onSelectRun={() => + select({ run: record.runId, page: null, target: null }) + } + record={record} + /> + ))} + +
+ )} + - return ( -
- + { + if (!open) select({ target: null }); + }} + > + + {visibleDetail ? ( + + ) : ( +
+ Loading the pipeline + + + {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ )} +
+
+
+ ); +} - {run ? ( -
- - -
- -

- Run #{run.runNumber} · {run.academicYear}{" "} - {labels.plural.toLowerCase()} -

-
- - {run.requestedModel} · {formatCost(run.costUsd)} · started{" "} - {formatDateTime(run.createdAt)} - {run.completedAt - ? `, finished ${formatDateTime(run.completedAt)}` - : ""} - -
- {run.status === "queued" || run.status === "running" ? ( - - ) : null} -
- -
    - {run.targets.map((target) => { - const current = target.id === selectedTargetId; - return ( -
  • - - - {target.changeKind ? ( - - {target.changeKind === "new" - ? "First snapshot" - : target.changeKind === "changed" - ? "Content changed" - : "No change"} - - ) : null} - {target.attemptCount > 1 ? ( - - {target.attemptCount} attempts - - ) : null} - {target.errorMessage ? ( - - {target.errorMessage} - - ) : null} - {target.status === "ready" && target.itemYearPublicId ? ( - - ) : null} -
  • - ); - })} -
-
-
+const SORT_CAPTIONS: Record = { + newest: "newest first", + oldest: "oldest first", + "code-asc": "by code, A to Z", + "code-desc": "by code, Z to A", +}; - {selectedTargetId ? ( - visibleDetail ? ( - - ) : ( -

Loading target…

- ) +function RecordRow({ + basePath, + kind, + onOpenPipeline, + onSelectRun, + record, +}: { + basePath: string; + kind: CatalogueKind; + onOpenPipeline: () => void; + onSelectRun: () => void; + record: ImportRecordRow; +}) { + const failure = + record.errorMessage || + (record.errorCode ? readable(record.errorCode) : null); + const reviewHref = + record.status === "ready" && record.itemYearPublicId + ? `${basePath}/${record.code}?year=${record.academicYear}&tab=review` + : undefined; + return ( + + + + + + {record.academicYear} + + +
+ + {/* Why it failed, so a column of "Failed" badges can be told apart + without opening each pipeline in turn. The message is written for + a reader; the code is a machine token, so it is only the fallback + when nothing wrote a message. */} + {failure ? ( + + {failure} + ) : null}
- ) : null} -
+ + + {record.changeKind ? ( + readable(record.changeKind) + ) : ( + None + )} + + + {/* The run narrows the same list rather than opening a second one. */} + + + + + + + , + onSelect: onOpenPipeline, + }, + ]} + links={[ + ...(reviewHref + ? [{ label: "Review import", href: reviewHref }] + : []), + ...(record.itemYearPublicId + ? [ + { + label: "Import history", + href: `${basePath}/${record.code}?year=${record.academicYear}&tab=history`, + icon: "history" as const, + }, + ] + : []), + { + label: "Find in directory", + href: `${basePath}?q=${encodeURIComponent(record.code)}&year=${record.academicYear}`, + }, + ]} + /> + + + ); +} + +/** + * The chosen run, one line: what it cost, how far it got and how to stop it. + * It replaces the run table, and appears only while a run is the filter, so an + * unfiltered list is a single table rather than a master and a detail. + */ +function RunStrip({ + cancelling, + live, + onCancel, + onClear, + run, +}: { + cancelling: boolean; + live: ImportRunProgress | null; + onCancel: () => void; + onClear: () => void; + run: ImportRunRow; +}) { + const completed = live?.completedCount ?? run.completedCount; + const failed = live?.failedCount ?? run.failedCount; + const total = live?.targetCount ?? run.targetCount; + const status = live?.status ?? run.status; + return ( +
+

Run #{run.runNumber}

+ + + {completed}/{total} done + {failed ? ( + · {failed} failed + ) : null} + + + {run.requestedModel} + + + {formatCost(run.costUsd)} + + +
+ {status === "queued" || status === "running" ? ( + + ) : null} + +
+
); } @@ -353,101 +608,175 @@ function TargetDetail({ detail }: { detail: ImportTargetDetail }) { const stages = detail.stages.filter( (stage) => stage.attemptNumber === latestAttempt, ); - const artifactsByStage = new Map(); - for (const artifact of detail.artifacts) { - const list = artifactsByStage.get(artifact.stageId) ?? []; - list.push(artifact); - artifactsByStage.set(artifact.stageId, list); - } return ( - - - -

- {detail.code} · attempt {latestAttempt} -

-
- - {detail.extraction - ? `${detail.extraction.resolvedModel ?? "model"} · ${detail.extraction.inputTokens} in / ${detail.extraction.outputTokens} out · ${formatCost(detail.extraction.costUsd)} · ${detail.extraction.warningCount} warnings, ${detail.extraction.errorCount} errors` - : "No model extraction recorded."} - {detail.extraction?.errorSummary - ? ` ${detail.extraction.errorSummary}` - : ""} - {detail.extraction?.finishReason === "length" - ? " The model ran out of output tokens; choose a model with a larger output budget for this record." + <> + + {detail.code} pipeline + + Attempt {latestAttempt} + {detail.extraction?.resolvedModel + ? ` · ${detail.extraction.resolvedModel}` : ""} - -
- -
    - {stages.map((stage) => { - const duration = durationMs(stage.startedAt, stage.completedAt); - const artifacts = artifactsByStage.get(stage.id) ?? []; - return ( -
  1. -
    - {stage.status === "completed" ? ( -
    - {stage.errorSummary ? ( -

    - {stage.errorCode ? `${stage.errorCode}: ` : ""} - {stage.errorSummary} -

    - ) : null} - {artifacts.length ? ( - + + +
    + {detail.extraction ? ( +
    +
    +
    Input
    +
    + {detail.extraction.inputTokens.toLocaleString("en-AU")} +
    +
    +
    +
    Output
    +
    + {detail.extraction.outputTokens.toLocaleString("en-AU")} +
    +
    +
    +
    Cost
    +
    + {formatCost(detail.extraction.costUsd)} +
    +
    +
    +
    Latency
    +
    + {detail.extraction.latencyMs === null + ? "-" + : elapsed(detail.extraction.latencyMs)} +
    +
    +
    +
    Diagnostics
    +
    + {detail.extraction.warningCount} warnings ·{" "} + {detail.extraction.errorCount} errors +
    +
    +
    +
    Validation
    +
    + + {readable(detail.extraction.validationStatus)} + +
    +
    +
    + ) : ( +

    + No model extraction recorded. +

    + )} + {detail.extraction?.finishReason === "length" ? ( + + + ) : null} + {detail.extraction?.errorSummary ? ( +
    + Validation details +

    {detail.extraction.errorSummary}

    +
    + ) : null} +
    + + + + Import pipeline stages + + + + Step + Stage + Status + + Duration + + Error + + + + {stages.length === 0 ? ( + + + No stages were recorded for this attempt. + + ) : null} -
  2. - ); - })} -
-
-
+ {stages.map((stage, index) => ( + + + {index + 1} + + + {STAGE_LABELS[stage.name] ?? readable(stage.name)} + + + + {readable(stage.status)} + + + + {duration(stage.startedAt, stage.completedAt)} + + + {stage.errorSummary ? ( + + {stage.errorCode ? `${stage.errorCode}: ` : ""} + {stage.errorSummary} + + ) : ( + {"-"} + )} + + + ))} + + + +
+
+

Artefacts

+ artifact.attemptNumber === latestAttempt, + )} + endpoint="/api/admin/catalogue-imports/artifacts" + /> +
+ + ); } diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index 9158a1e1..cc57eaef 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -1,8 +1,24 @@ "use client"; +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@coursemap/ui/components/alert"; import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; -import { ExternalLink, LoaderCircle, Send, Undo2 } from "lucide-react"; +import { Progress } from "@coursemap/ui/primitives/progress"; +import { + CircleCheck, + ExternalLink, + Info, + ListChecks, + LoaderCircle, + Send, + TriangleAlert, + Undo2, +} from "lucide-react"; import Link from "next/link"; import { useTransition } from "react"; import { toast } from "sonner"; @@ -14,22 +30,11 @@ import { import type { CatalogueRecord } from "@/lib/coursemap/admin-catalogue-record"; import { CATALOGUE_KIND_LABELS } from "@/lib/coursemap/catalogue-kinds"; import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { anuSourceUrl } from "./anu-source"; +import { type RecordStep, recordNextStep } from "./review-state"; import { WorkflowBadge } from "./workflow-badge"; -function sourceUrl(record: CatalogueRecord) { - const base = "https://programsandcourses.anu.edu.au"; - if (record.kind === "course") - return `${base}/${record.academicYear}/course/${record.code}`; - const segment = - record.kind === "programme" - ? "program" - : record.kind === "specialisation" - ? "specialisation" - : record.kind; - return `${base}/${record.academicYear}/${segment}/${record.code}`; -} - -/** Title, pointers and the publish controls for one record and year. */ +/** Title, pointers, the verdict and the publish controls for one record and year. */ export function RecordHeader({ record, path, @@ -39,8 +44,7 @@ export function RecordHeader({ }) { const [pending, startTransition] = useTransition(); const labels = CATALOGUE_KIND_LABELS[record.kind]; - const canPublish = - record.publishBlockers.length === 0 && record.draftSnapshotId !== null; + const step = recordNextStep(record); const workflow = record.publishedSnapshotId && record.draftSnapshotId ? "published_with_draft" @@ -61,83 +65,163 @@ export function RecordHeader({ } return ( -
-
-

- {labels.singular} · {record.code} · {record.academicYear} -

-

- {record.title} -

-
- - {record.archivedAt ? Archived : null} - - ANU page -
+ + + ); +} + +const STEP_VARIANTS = { + success: "success", + warning: "warning", + info: "info", + neutral: "default", +} as const; + +/** + * The verdict on the record and the one control that moves it forward. It + * carries the reason a draft is held back as well, because why a record cannot + * go live is the most important thing on the page and used to be the faintest + * line of text on it. + */ +function NextStep({ + pending, + path, + record, + run, + step, +}: { + pending: boolean; + path: string; + record: CatalogueRecord; + run: ( + action: () => Promise<{ ok: boolean; error?: string; message?: string }>, + ) => void; + step: RecordStep; +}) { + const Icon = + step.tone === "success" + ? CircleCheck + : step.tone === "warning" + ? TriangleAlert + : step.tone === "info" + ? Info + : ListChecks; + return ( + + ); } diff --git a/apps/web/ui/admin/catalogue/record-history.tsx b/apps/web/ui/admin/catalogue/record-history.tsx index d73fc3e4..7100b8aa 100644 --- a/apps/web/ui/admin/catalogue/record-history.tsx +++ b/apps/web/ui/admin/catalogue/record-history.tsx @@ -2,7 +2,7 @@ import { Badge } from "@coursemap/ui/components/badge"; import { Button } from "@coursemap/ui/primitives/button"; -import { History, Trash2 } from "lucide-react"; +import { Download, History, Pencil, Trash2, Undo2, Upload } from "lucide-react"; import { useTransition } from "react"; import { toast } from "sonner"; @@ -43,132 +43,161 @@ export function RecordHistory({ else toast.error(result.error ?? "The action failed."); }); } + // Snapshots and publications are one story, so they share a timeline rather + // than sitting in two lists the reader has to interleave by timestamp. + type HistoryEvent = { + id: string; + at: string; + kind: "snapshot" | "published" | "unpublished"; + origin?: string; + title: string; + detail: string | null; + snapshot: CatalogueRecord["snapshots"][number] | null; + }; + + const events: HistoryEvent[] = [ + ...record.snapshots.map((snapshot) => { + const runNumber = snapshot.importTargetId + ? targetRun.get(snapshot.importTargetId) + : undefined; + const detail = [ + runNumber ? `Run #${runNumber}` : null, + snapshot.basedOnSnapshotId + ? `based on #${snapshot.basedOnSnapshotId}` + : null, + ] + .filter(Boolean) + .join(" · "); + return { + id: `snapshot-${snapshot.id}`, + at: snapshot.createdAt, + kind: "snapshot" as const, + origin: snapshot.origin, + title: + snapshot.origin === "import" + ? `Imported as #${snapshot.id}` + : `Edited by hand as #${snapshot.id}`, + detail: detail || null, + snapshot, + }; + }), + ...record.publications.map((publication, index) => ({ + id: `publication-${publication.publishedAt}-${index}`, + at: publication.publishedAt, + kind: publication.snapshotId + ? ("published" as const) + : ("unpublished" as const), + title: publication.snapshotId ? "Published" : "Withdrawn from students", + detail: publication.snapshotId + ? `Snapshot #${publication.snapshotId}` + : null, + snapshot: null, + })), + ].sort((left, right) => Date.parse(right.at) - Date.parse(left.at)); + + if (events.length === 0) + return ( +

+ Nothing has been imported, edited or published for this record yet. +

+ ); + return ( -
-
-

- Snapshots{" "} - - {record.snapshots.length} - -

-
    - {record.snapshots.map((snapshot) => ( -
  1. - - #{snapshot.id} - - {formatDateTime(snapshot.createdAt)} - {snapshot.origin} - {snapshot.importTargetId && - targetRun.has(snapshot.importTargetId) ? ( - - run #{targetRun.get(snapshot.importTargetId)} - - ) : null} - {snapshot.basedOnSnapshotId ? ( - - based on #{snapshot.basedOnSnapshotId} - +
      + {events.map((event) => { + const Icon = + event.kind === "published" + ? Upload + : event.kind === "unpublished" + ? Undo2 + : event.origin === "import" + ? Download + : Pencil; + const snapshot = event.snapshot; + return ( +
    1. + + +
      +
      +
      +

      {event.title}

      + {snapshot?.id === record.publishedSnapshotId ? ( + Published + ) : snapshot?.id === record.draftSnapshotId ? ( + Current draft + ) : null} + {snapshot && !snapshot.sealedAt ? ( + Unsealed + ) : null} +
      +

      + {event.detail ? `${event.detail} · ` : null} + +

      +
      + {snapshot ? ( +
      + {snapshot.id === record.draftSnapshotId ? ( + + run(() => + discardDraftAction({ + itemYearId: record.itemYearId, + path, + }), + ) + } + trigger={ + + } + /> + ) : snapshot.id !== record.publishedSnapshotId || + record.draftSnapshotId ? ( + + ) : null} +
      ) : null} - - {snapshot.id === record.publishedSnapshotId ? ( - Published - ) : null} - {snapshot.id === record.draftSnapshotId ? ( - Draft - ) : null} - {!snapshot.sealedAt ? ( - Unsealed - ) : null} - {snapshot.id === record.draftSnapshotId ? ( - - run(() => - discardDraftAction({ - itemYearId: record.itemYearId, - path, - }), - ) - } - trigger={ - - } - /> - ) : snapshot.id !== record.publishedSnapshotId || - record.draftSnapshotId ? ( - - ) : null} - -
    2. - ))} -
    -
-
-

- Publications{" "} - - {record.publications.length} - -

- {record.publications.length === 0 ? ( -

Never published.

- ) : ( -
    - {record.publications.map((publication, index) => ( -
  1. - {formatDateTime(publication.publishedAt)} - - {publication.snapshotId - ? ` · snapshot #${publication.snapshotId}` - : " · unpublished"} - -
  2. - ))} -
- )} -
-
+ + + ); + })} + ); } diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 14af505b..cde0d96f 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -1,9 +1,5 @@ -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@coursemap/ui/primitives/tabs"; +import { TabsContent } from "@coursemap/ui/primitives/tabs"; +import { ClipboardCheck, Eye, History, SquarePen } from "lucide-react"; import Link from "next/link"; import { canManageCourseImports } from "@/lib/auth/viewer"; import { @@ -18,8 +14,11 @@ import { } from "@/lib/coursemap/catalogue-kinds"; import { AccessDeniedError } from "@/ui/errors/access-denied-error"; import { AppShell } from "@/ui/shell"; +import { SectionTabs } from "@/ui/common/section-tabs"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { anuSourceUrl } from "./anu-source"; import { RecordHeader } from "./record-header"; +import { RecordTabs } from "./record-tabs"; import { RecordHistory } from "./record-history"; import { ReviewPanel } from "./review-panel"; import { SnapshotEditor } from "./snapshot-editor"; @@ -68,51 +67,62 @@ export async function CatalogueRecordPage({ ]) : [null, null]; + // Each tab carries an icon rather than a count. A badge on Review reported + // the open flags a second time, under a label that already says what the + // tab is for, and it moved the tab's width every time one was resolved. + const sectionTabs = record ? ( +