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.
+
+ {/* 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" ? (
+