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:
@@ -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 } : {}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user