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
@@ -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 /> },
];
/**
+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,