feat: Dispo V2 import — API router + admin UI

API Router (packages/api/src/router/dispo.ts):
- 12 adminProcedure endpoints wired to existing application layer
- stageImportBatch, validateImportBatch, commitImportBatch
- listImportBatches, getImportBatch, cancelImportBatch
- listStagedResources/Projects/Assignments/Vacations/Unresolved
- resolveStagedRecord (APPROVE/REJECT/SKIP actions)

Admin UI:
- /admin/dispo-imports — batch list with status filter, new import modal
- /admin/dispo-imports/[batchId] — detail with 6 tabs:
  Summary, Resources, Projects, Assignments, Vacations, Unresolved
- Unresolved review queue with approve/skip per-record actions
- Commit workflow with pre-validation and progress indicator
- Sidebar nav link under Admin

Also fixes: timeline filter dropdown z-index (toolbar relative z-20)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-22 19:07:20 +01:00
parent 7a57b5e649
commit 7e4b21afe9
7 changed files with 1877 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
import {
ImportBatchStatus,
StagedRecordStatus,
DispoStagedRecordType,
} from "@planarchy/db";
import {
assessDispoImportReadiness,
commitDispoImportBatch,
stageDispoImportBatch,
} from "@planarchy/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter } from "../trpc.js";
// ─── Shared schemas ──────────────────────────────────────────────────────────
const paginationSchema = z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(200).default(50),
});
const importBatchStatusSchema = z.nativeEnum(ImportBatchStatus);
const stagedRecordStatusSchema = z.nativeEnum(StagedRecordStatus);
const stagedRecordTypeSchema = z.nativeEnum(DispoStagedRecordType);
// ─── Router ──────────────────────────────────────────────────────────────────
export const dispoRouter = createTRPCRouter({
// ── 1. stageImportBatch ──────────────────────────────────────────────────
stageImportBatch: adminProcedure
.input(
z.object({
chargeabilityWorkbookPath: z.string(),
costWorkbookPath: z.string().optional(),
notes: z.string().nullish(),
planningWorkbookPath: z.string(),
referenceWorkbookPath: z.string(),
rosterWorkbookPath: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
return stageDispoImportBatch(ctx.db, {
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
planningWorkbookPath: input.planningWorkbookPath,
referenceWorkbookPath: input.referenceWorkbookPath,
...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
});
}),
// ── 2. validateImportBatch ───────────────────────────────────────────────
validateImportBatch: adminProcedure
.input(
z.object({
chargeabilityWorkbookPath: z.string(),
costWorkbookPath: z.string().optional(),
importBatchId: z.string().optional(),
notes: z.string().nullish(),
planningWorkbookPath: z.string(),
referenceWorkbookPath: z.string(),
rosterWorkbookPath: z.string().optional(),
}),
)
.query(async ({ input }) => {
return assessDispoImportReadiness({
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
planningWorkbookPath: input.planningWorkbookPath,
referenceWorkbookPath: input.referenceWorkbookPath,
...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.importBatchId !== undefined ? { importBatchId: input.importBatchId } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
});
}),
// ── 3. listImportBatches ─────────────────────────────────────────────────
listImportBatches: adminProcedure
.input(
paginationSchema.extend({
status: importBatchStatusSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, limit, status } = input;
const items = await ctx.db.importBatch.findMany({
where: {
...(status !== undefined ? { status } : {}),
},
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 4. getImportBatch ────────────────────────────────────────────────────
getImportBatch: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const batch = await ctx.db.importBatch.findUnique({
where: { id: input.id },
});
if (!batch) {
throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` });
}
const [
resourceCount,
clientCount,
projectCount,
assignmentCount,
vacationCount,
availabilityRuleCount,
unresolvedCount,
] = await Promise.all([
ctx.db.stagedResource.count({ where: { importBatchId: input.id } }),
ctx.db.stagedClient.count({ where: { importBatchId: input.id } }),
ctx.db.stagedProject.count({ where: { importBatchId: input.id } }),
ctx.db.stagedAssignment.count({ where: { importBatchId: input.id } }),
ctx.db.stagedVacation.count({ where: { importBatchId: input.id } }),
ctx.db.stagedAvailabilityRule.count({ where: { importBatchId: input.id } }),
ctx.db.stagedUnresolvedRecord.count({ where: { importBatchId: input.id } }),
]);
return {
...batch,
counts: {
assignmentCount,
availabilityRuleCount,
clientCount,
projectCount,
resourceCount,
unresolvedCount,
vacationCount,
},
};
}),
// ── 5. cancelImportBatch ─────────────────────────────────────────────────
cancelImportBatch: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const batch = await ctx.db.importBatch.findUnique({
where: { id: input.id },
select: { id: true, status: true },
});
if (!batch) {
throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` });
}
const terminalStatuses: ImportBatchStatus[] = [
ImportBatchStatus.COMMITTED,
ImportBatchStatus.CANCELLED,
];
if (terminalStatuses.includes(batch.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot cancel batch in status "${batch.status}"`,
});
}
return ctx.db.importBatch.update({
where: { id: input.id },
data: { status: ImportBatchStatus.CANCELLED },
});
}),
// ── 6. listStagedResources ───────────────────────────────────────────────
listStagedResources: adminProcedure
.input(
paginationSchema.extend({
importBatchId: z.string(),
status: stagedRecordStatusSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, importBatchId, limit, status } = input;
const items = await ctx.db.stagedResource.findMany({
where: {
importBatchId,
...(status !== undefined ? { status } : {}),
},
orderBy: { canonicalExternalId: "asc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 7. listStagedProjects ────────────────────────────────────────────────
listStagedProjects: adminProcedure
.input(
paginationSchema.extend({
importBatchId: z.string(),
isTbd: z.boolean().optional(),
status: stagedRecordStatusSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, importBatchId, isTbd, limit, status } = input;
const items = await ctx.db.stagedProject.findMany({
where: {
importBatchId,
...(status !== undefined ? { status } : {}),
...(isTbd !== undefined ? { isTbd } : {}),
},
orderBy: { projectKey: "asc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 8. listStagedAssignments ─────────────────────────────────────────────
listStagedAssignments: adminProcedure
.input(
paginationSchema.extend({
importBatchId: z.string(),
resourceExternalId: z.string().optional(),
status: stagedRecordStatusSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, importBatchId, limit, resourceExternalId, status } = input;
const items = await ctx.db.stagedAssignment.findMany({
where: {
importBatchId,
...(status !== undefined ? { status } : {}),
...(resourceExternalId !== undefined ? { resourceExternalId } : {}),
},
orderBy: { createdAt: "asc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 9. listStagedVacations ───────────────────────────────────────────────
listStagedVacations: adminProcedure
.input(
paginationSchema.extend({
importBatchId: z.string(),
resourceExternalId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, importBatchId, limit, resourceExternalId } = input;
const items = await ctx.db.stagedVacation.findMany({
where: {
importBatchId,
...(resourceExternalId !== undefined ? { resourceExternalId } : {}),
},
orderBy: { startDate: "asc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 10. listStagedUnresolvedRecords ──────────────────────────────────────
listStagedUnresolvedRecords: adminProcedure
.input(
paginationSchema.extend({
importBatchId: z.string(),
recordType: stagedRecordTypeSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, importBatchId, limit, recordType } = input;
const items = await ctx.db.stagedUnresolvedRecord.findMany({
where: {
importBatchId,
...(recordType !== undefined ? { recordType } : {}),
},
orderBy: { createdAt: "asc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}),
// ── 11. resolveStagedRecord ──────────────────────────────────────────────
resolveStagedRecord: adminProcedure
.input(
z.object({
action: z.enum(["APPROVE", "REJECT", "SKIP"]),
id: z.string(),
recordType: stagedRecordTypeSchema,
}),
)
.mutation(async ({ ctx, input }) => {
const statusMap: Record<string, StagedRecordStatus> = {
APPROVE: StagedRecordStatus.APPROVED,
REJECT: StagedRecordStatus.REJECTED,
SKIP: StagedRecordStatus.REJECTED,
};
const nextStatus = statusMap[input.action]!;
// Delegate table lookup based on record type
switch (input.recordType) {
case DispoStagedRecordType.RESOURCE:
return ctx.db.stagedResource.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.CLIENT:
return ctx.db.stagedClient.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.PROJECT:
return ctx.db.stagedProject.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.ASSIGNMENT:
return ctx.db.stagedAssignment.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.VACATION:
return ctx.db.stagedVacation.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.AVAILABILITY_RULE:
return ctx.db.stagedAvailabilityRule.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.UNRESOLVED:
return ctx.db.stagedUnresolvedRecord.update({
where: { id: input.id },
data: { status: nextStatus },
});
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown record type: ${input.recordType as string}`,
});
}
}),
// ── 12. commitImportBatch ────────────────────────────────────────────────
commitImportBatch: adminProcedure
.input(
z.object({
allowTbdUnresolved: z.boolean().optional(),
importBatchId: z.string(),
importTbdProjects: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
return commitDispoImportBatch(ctx.db, {
importBatchId: input.importBatchId,
...(input.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: input.allowTbdUnresolved } : {}),
...(input.importTbdProjects !== undefined ? { importTbdProjects: input.importTbdProjects } : {}),
});
}),
});
+2
View File
@@ -9,6 +9,7 @@ import { clientRouter } from "./client.js";
import { commentRouter } from "./comment.js";
import { countryRouter } from "./country.js";
import { dashboardRouter } from "./dashboard.js";
import { dispoRouter } from "./dispo.js";
import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
@@ -36,6 +37,7 @@ import { webhookRouter } from "./webhook.js";
export const appRouter = createTRPCRouter({
assistant: assistantRouter,
dashboard: dashboardRouter,
dispo: dispoRouter,
effortRule: effortRuleRouter,
experienceMultiplier: experienceMultiplierRouter,
estimate: estimateRouter,