Files
CapaKraken/apps/web/src/components/blueprints/BlueprintsClient.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

512 lines
22 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, MouseEvent } from "react";
import { BlueprintTarget } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/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 INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
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.");
}
}
function handleBackdropClick(e: MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" onClick={handleBackdropClick}>
<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={INPUT_CLS} 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={`${INPUT_CLS} 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={INPUT_CLS}>
<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="text-xs text-red-500 hover:text-red-700 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("@capakraken/shared").StaffingRequirement[]) : []}
initialTab={editingTab}
onClose={() => setEditingBlueprint(null)}
/>
)}
</div>
);
}