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 /> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user