Files
CapaKraken/apps/web/src/components/blueprints/BlueprintsClient.tsx
T

511 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 "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.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 animate-pulse 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} />
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
<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 last:border-b-0 hover:bg-gray-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">{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" : "bg-blue-50 text-blue-700"}`}>
{bp.target}
</span>
</td>
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
<td className="px-3 py-3 text-center text-gray-600">{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 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 && (
<BlueprintFieldEditor
blueprintId={editingBlueprint.id}
blueprintName={editingBlueprint.name}
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
initialTab={editingTab}
onClose={() => setEditingBlueprint(null)}
/>
)}
</div>
);
}