Files
Nexus/apps/web/src/components/blueprints/BlueprintsClient.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

650 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import type { FormEvent } from "react";
import type { BlueprintTarget } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const BTN_PRIMARY =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
const BTN_SECONDARY =
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
interface NewBlueprintModalProps {
onClose: () => void;
onCreated: () => void;
}
type BlueprintTargetValue = "RESOURCE" | "PROJECT";
type BlueprintSortField = "name" | "target" | "fieldCount" | "presetCount" | "global";
function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
const utils = trpc.useUtils();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [target, setTarget] = useState<BlueprintTargetValue>("RESOURCE");
const [error, setError] = useState<string | null>(null);
const createMutation = trpc.blueprint.create.useMutation();
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Name is required.");
return;
}
try {
await createMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
target: target as BlueprintTarget,
fieldDefs: [],
defaults: {},
validationRules: [],
});
await utils.blueprint.list.invalidate();
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create blueprint.");
}
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Resource Extended Fields"
className="app-input"
autoFocus
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className="app-input resize-none"
rows={2}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Target</label>
<select
value={target}
onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)}
className="app-input"
>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
Cancel
</button>
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
</button>
</div>
</form>
</div>
</div>
);
}
interface BlueprintRow {
id: string;
name: string;
description: string | null;
target: BlueprintTargetValue;
fieldDefs: unknown;
rolePresets: unknown;
isGlobal?: boolean;
}
interface BlueprintCardProps {
blueprint: BlueprintRow;
onEditFields: () => void;
onEditStaffing: () => void;
onToggleGlobal: () => void;
onDelete: () => void;
isSelected: boolean;
onToggleSelect: () => void;
}
function BlueprintCard({
blueprint,
onEditFields,
onEditStaffing,
onToggleGlobal,
onDelete,
isSelected,
onToggleSelect,
}: BlueprintCardProps) {
const fieldDefs = Array.isArray(blueprint.fieldDefs)
? (blueprint.fieldDefs as BlueprintFieldDefinition[])
: [];
const rolePresets = Array.isArray(blueprint.rolePresets)
? (blueprint.rolePresets as unknown[])
: [];
const fieldCount = fieldDefs.length;
const presetCount = rolePresets.length;
const isProject = blueprint.target === "PROJECT";
return (
<div
className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="mt-0.5 rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{blueprint.name}</h3>
{blueprint.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{blueprint.description}</p>
)}
</div>
</div>
<span
className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}
>
{blueprint.target}
</span>
</div>
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
<span>
{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
</span>
{isProject && (
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
{presetCount === 0
? "No staffing presets"
: `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
</span>
)}
{blueprint.isGlobal && (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
Global
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
<button
type="button"
onClick={onEditFields}
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
Edit Fields
</button>
{isProject && (
<button
type="button"
onClick={onEditStaffing}
className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors"
>
Edit Staffing Presets
</button>
)}
<button
type="button"
onClick={onToggleGlobal}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
blueprint.isGlobal
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
title={
blueprint.isGlobal
? "Remove from global columns"
: "Make fields available as global columns"
}
>
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${blueprint.name}"?`)) {
onDelete();
}
}}
className="px-3 py-1.5 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
>
Delete
</button>
</div>
</div>
);
}
export function BlueprintsClient() {
const [showNewModal, setShowNewModal] = useState(false);
const [editingBlueprint, setEditingBlueprint] = useState<BlueprintRow | null>(null);
const [editingTab, setEditingTab] = useState<"fields" | "presets">("fields");
const [targetFilter, setTargetFilter] = useState<string>("");
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { data, isLoading, isError } = trpc.blueprint.list.useQuery({
target: (targetFilter as BlueprintTarget) || undefined,
});
const batchDeleteMutation = trpc.blueprint.batchDelete.useMutation();
const deleteMutation = trpc.blueprint.delete.useMutation();
const setGlobalMutation = trpc.blueprint.setGlobal.useMutation();
const viewPrefs = useViewPrefs("blueprints");
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetFilter]);
const blueprints: BlueprintRow[] = data ?? [];
const {
sorted: sortedBlueprints,
sortField,
sortDir,
toggle,
} = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const blueprintIds = sortedBlueprints.map((b) => b.id);
function handleSort(field: BlueprintSortField) {
switch (field) {
case "fieldCount":
toggle(field, (row) => (Array.isArray(row.fieldDefs) ? row.fieldDefs.length : 0));
return;
case "presetCount":
toggle(field, (row) => (Array.isArray(row.rolePresets) ? row.rolePresets.length : 0));
return;
case "global":
toggle(field, (row) => (row.isGlobal ? 0 : 1));
return;
default:
toggle(field);
}
}
function handleSortRequest(field: string) {
handleSort(field as BlueprintSortField);
}
async function handleDelete(id: string) {
await deleteMutation.mutateAsync({ id });
await utils.blueprint.list.invalidate();
if (selection.selectedIds.has(id)) {
selection.toggle(id);
}
if (editingBlueprint?.id === id) {
setEditingBlueprint(null);
}
}
async function handleToggleGlobal(id: string, isGlobal: boolean | undefined) {
await setGlobalMutation.mutateAsync({ id, isGlobal: !isGlobal });
await utils.blueprint.list.invalidate();
}
async function handleBatchDelete(ids: string[]) {
await batchDeleteMutation.mutateAsync({ ids });
await utils.blueprint.list.invalidate();
selection.clear();
}
return (
<div className="p-6 pb-24">
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
<p className="text-gray-500 text-sm mt-1">
Configure dynamic fields for resources and projects
</p>
</div>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
<FilterBar hasActiveFilters={!!targetFilter} onClearFilters={() => setTargetFilter("")}>
<select
value={targetFilter}
onChange={(e) => setTargetFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Targets</option>
<option value="RESOURCE">Resource</option>
<option value="PROJECT">Project</option>
</select>
</FilterBar>
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36"
/>
))}
</div>
)}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
Failed to load blueprints. Please refresh the page.
</div>
)}
{!isLoading && !isError && blueprints.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-gray-400 text-sm mb-4">
No blueprints yet. Create one to start defining dynamic fields.
</p>
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
+ New Blueprint
</button>
</div>
)}
{!isLoading && !isError && sortedBlueprints.length > 0 && (
<>
<div className="hidden md:block bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-12 px-3 py-3">
<input
type="checkbox"
checked={selection.isAllSelected(blueprintIds)}
onChange={() => selection.toggleAll(blueprintIds)}
className="rounded border-gray-300"
aria-label="Select all blueprints"
/>
</th>
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects."
/>
<SortableColumnHeader
label="Target"
field="target"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
tooltip="Whether this blueprint applies to Resource or Project entities."
/>
<SortableColumnHeader
label="Fields"
field="fieldCount"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Number of custom dynamic fields defined in this blueprint."
/>
<SortableColumnHeader
label="Staffing Presets"
field="presetCount"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints."
/>
<SortableColumnHeader
label="Global"
field="global"
sortField={sortField}
sortDir={sortDir}
onSort={handleSortRequest}
align="center"
tooltip="Global blueprints expose their fields as columns across all entities of the target type."
/>
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody>
{sortedBlueprints.map((bp) => {
const fieldCount = Array.isArray(bp.fieldDefs) ? bp.fieldDefs.length : 0;
const presetCount = Array.isArray(bp.rolePresets) ? bp.rolePresets.length : 0;
const isProject = bp.target === "PROJECT";
return (
<tr
key={bp.id}
className="border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<td className="px-3 py-3">
<input
type="checkbox"
checked={selection.selectedIds.has(bp.id)}
onChange={() => selection.toggle(bp.id)}
className="rounded border-gray-300"
/>
</td>
<td className="px-3 py-3">
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100">
{bp.name}
</div>
{bp.description && (
<div className="text-xs text-gray-500 mt-0.5 truncate">
{bp.description}
</div>
)}
</div>
</td>
<td className="px-3 py-3">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" : "bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"}`}
>
{bp.target}
</span>
</td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
{fieldCount}
</td>
<td className="px-3 py-3 text-center text-gray-600 dark:text-gray-400">
{isProject ? presetCount : "—"}
</td>
<td className="px-3 py-3 text-center">
{bp.isGlobal ? (
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium">
Global
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-3 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setEditingTab("fields");
setEditingBlueprint(bp);
}}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit Fields
</button>
{isProject && (
<button
type="button"
onClick={() => {
setEditingTab("presets");
setEditingBlueprint(bp);
}}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Presets
</button>
)}
<button
type="button"
onClick={() => handleToggleGlobal(bp.id, bp.isGlobal)}
disabled={setGlobalMutation.isPending}
className="text-xs text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
{bp.isGlobal ? "Unglobalize" : "Make Global"}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete blueprint "${bp.name}"?`)) {
handleDelete(bp.id);
}
}}
disabled={deleteMutation.isPending}
className="app-action-delete disabled:opacity-50"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="grid grid-cols-1 gap-4 md:hidden">
{sortedBlueprints.map((bp) => (
<BlueprintCard
key={bp.id}
blueprint={bp}
onEditFields={() => {
setEditingTab("fields");
setEditingBlueprint(bp);
}}
onEditStaffing={() => {
setEditingTab("presets");
setEditingBlueprint(bp);
}}
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
onDelete={() => handleDelete(bp.id)}
isSelected={selection.selectedIds.has(bp.id)}
onToggleSelect={() => selection.toggle(bp.id)}
/>
))}
</div>
</>
)}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{
label: `Delete (${selection.count})`,
variant: "danger",
onClick: () => setConfirmBatchDelete(selection.selectedArray),
disabled: batchDeleteMutation.isPending,
},
]}
/>
{confirmBatchDelete && (
<ConfirmDialog
title="Delete Blueprints"
message={`Delete ${confirmBatchDelete.length} selected blueprint${confirmBatchDelete.length !== 1 ? "s" : ""}? They will be marked as inactive.`}
confirmLabel="Delete All"
variant="danger"
onConfirm={() => {
void handleBatchDelete(confirmBatchDelete);
setConfirmBatchDelete(null);
}}
onCancel={() => setConfirmBatchDelete(null)}
/>
)}
{showNewModal && (
<NewBlueprintModal
onClose={() => setShowNewModal(false)}
onCreated={() => setShowNewModal(false)}
/>
)}
{editingBlueprint && (
<BlueprintFieldCatalog
blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name}
blueprintTarget={editingBlueprint.target}
initialFieldDefs={
Array.isArray(editingBlueprint.fieldDefs)
? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[])
: []
}
initialRolePresets={
Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
: []
}
initialTab={editingTab}
onClose={() => setEditingBlueprint(null)}
/>
)}
</div>
);
}