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,10 @@
|
||||
import { DispoImportDetailClient } from "~/components/admin/DispoImportDetailClient.js";
|
||||
|
||||
export default async function DispoImportDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ batchId: string }>;
|
||||
}) {
|
||||
const { batchId } = await params;
|
||||
return <DispoImportDetailClient batchId={batchId} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DispoImportClient } from "~/components/admin/DispoImportClient.js";
|
||||
|
||||
export default function DispoImportsPage() {
|
||||
return <DispoImportClient />;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { Button } from "@planarchy/ui";
|
||||
import { Badge } from "@planarchy/ui";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { ShimmerSkeleton } from "~/components/ui/ShimmerSkeleton.js";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types (mirrors API output) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type BatchStatus =
|
||||
| "DRAFT"
|
||||
| "STAGING"
|
||||
| "STAGED"
|
||||
| "REVIEW_READY"
|
||||
| "APPROVED"
|
||||
| "COMMITTING"
|
||||
| "COMMITTED"
|
||||
| "FAILED"
|
||||
| "CANCELLED";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Status badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_BADGE: Record<BatchStatus, { label: string; variant: "default" | "success" | "warning" | "danger" | "info" }> = {
|
||||
DRAFT: { label: "Draft", variant: "default" },
|
||||
STAGING: { label: "Staging", variant: "info" },
|
||||
APPROVED: { label: "Approved", variant: "success" },
|
||||
STAGED: { label: "Staged", variant: "info" },
|
||||
REVIEW_READY: { label: "Review Ready", variant: "warning" },
|
||||
COMMITTING: { label: "Committing", variant: "info" },
|
||||
COMMITTED: { label: "Committed", variant: "success" },
|
||||
FAILED: { label: "Failed", variant: "danger" },
|
||||
CANCELLED: { label: "Cancelled", variant: "default" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: BatchStatus }) {
|
||||
const cfg = STATUS_BADGE[status] ?? STATUS_BADGE.DRAFT;
|
||||
return <Badge variant={cfg.variant}>{cfg.label}</Badge>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Truncate ID helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function truncateId(id: string) {
|
||||
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* New Import Modal */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const WORKBOOK_LABELS: { key: string; label: string; placeholder: string }[] = [
|
||||
{ key: "resources", label: "Resources Workbook", placeholder: "/data/dispo/resources.xlsx" },
|
||||
{ key: "projects", label: "Projects Workbook", placeholder: "/data/dispo/projects.xlsx" },
|
||||
{ key: "assignments", label: "Assignments Workbook", placeholder: "/data/dispo/assignments.xlsx" },
|
||||
{ key: "vacations", label: "Vacations Workbook", placeholder: "/data/dispo/vacations.xlsx" },
|
||||
{ key: "roles", label: "Roles Workbook", placeholder: "/data/dispo/roles.xlsx" },
|
||||
];
|
||||
|
||||
function NewImportModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [filePaths, setFilePaths] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const stageMutation = trpc.dispo.stageImportBatch.useMutation({
|
||||
onSuccess: () => {
|
||||
onCreated();
|
||||
onClose();
|
||||
setFilePaths({});
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
setError(null);
|
||||
const nonEmpty = Object.fromEntries(
|
||||
Object.entries(filePaths).filter(([, v]) => v.trim().length > 0),
|
||||
);
|
||||
if (Object.keys(nonEmpty).length === 0) {
|
||||
setError("Provide at least one workbook path.");
|
||||
return;
|
||||
}
|
||||
stageMutation.mutate({
|
||||
referenceWorkbookPath: (nonEmpty as Record<string, string>).referenceWorkbookPath ?? "",
|
||||
planningWorkbookPath: (nonEmpty as Record<string, string>).planningWorkbookPath ?? "",
|
||||
chargeabilityWorkbookPath: (nonEmpty as Record<string, string>).chargeabilityWorkbookPath ?? "",
|
||||
...(nonEmpty.rosterWorkbookPath ? { rosterWorkbookPath: nonEmpty.rosterWorkbookPath } : {}),
|
||||
...(nonEmpty.costWorkbookPath ? { costWorkbookPath: nonEmpty.costWorkbookPath } : {}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose} maxWidth="max-w-lg">
|
||||
<div className="px-6 py-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
New Dispo Import
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{WORKBOOK_LABELS.map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={placeholder}
|
||||
value={filePaths[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilePaths((prev) => ({ ...prev, [key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={stageMutation.isPending}
|
||||
>
|
||||
{stageMutation.isPending ? "Staging..." : "Stage Import"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function DispoImportClient() {
|
||||
const [statusFilter, setStatusFilter] = useState<BatchStatus | "">("");
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: batches, isLoading } = trpc.dispo.listImportBatches.useQuery(
|
||||
{ status: statusFilter || undefined },
|
||||
{ staleTime: 10_000 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Dispo Import
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage V2 data imports from Dispo workbooks
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewModal(true)}>New Import</Button>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Status:
|
||||
</label>
|
||||
<select
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as BatchStatus | "")}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{Object.keys(STATUS_BADGE).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_BADGE[s as BatchStatus].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<ShimmerSkeleton key={i} height={48} rounded="lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !batches?.items || batches.items.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
No import batches found.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/60">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Source Files
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Staged
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Updated
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
{batches.items.map((batch: any) => (
|
||||
<tr
|
||||
key={batch.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/dispo-imports/${batch.id}`}
|
||||
className="text-sm font-mono text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 underline decoration-dotted"
|
||||
>
|
||||
{truncateId(batch.id)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={batch.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{batch.sourceFiles
|
||||
? Object.keys(batch.sourceFiles).join(", ")
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{batch.stagedCounts ? (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{batch.stagedCounts.resources} res</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>{batch.stagedCounts.projects} proj</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>{batch.stagedCounts.assignments} asgn</span>
|
||||
{batch.stagedCounts.unresolved > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">
|
||||
{batch.stagedCounts.unresolved} unresolved
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(batch.createdAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(batch.updatedAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Import Modal */}
|
||||
<NewImportModal
|
||||
open={showNewModal}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onCreated={() => utils.dispo.listImportBatches.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -189,6 +189,7 @@ const adminNavEntries: AdminEntry[] = [
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||
{ href: "/admin/webhooks", label: "Webhooks", icon: <AdminIcon /> },
|
||||
{ href: "/admin/dispo-imports", label: "Dispo Import", icon: <AdminIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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