"use client"; import { useState, useMemo, useCallback } from "react"; import { FieldType } from "@planarchy/shared"; import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { RolePresetsEditor } from "./RolePresetsEditor.js"; import { FieldCard } from "./FieldCard.js"; import type { FieldOverrides } from "./FieldCard.js"; import { getCatalogForTarget, getCategoriesForTarget, findCatalogField, } from "~/lib/blueprint-field-catalog.js"; import type { CatalogField } from "~/lib/blueprint-field-catalog.js"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- 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"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type BlueprintTargetValue = "RESOURCE" | "PROJECT"; /** Internal state for a field: catalog index or custom definition */ interface FieldState { /** Catalog key (undefined for custom fields) */ catalogKey: string | undefined; overrides: FieldOverrides; /** For custom fields only */ custom?: { key: string; label: string; type: FieldType; options: FieldOption[]; }; } // --------------------------------------------------------------------------- // Helpers: Convert between FieldState and BlueprintFieldDefinition // --------------------------------------------------------------------------- function fieldDefToState( def: BlueprintFieldDefinition, target: BlueprintTargetValue, ): FieldState { const catalogField = findCatalogField(target, def.key); if (catalogField) { return { catalogKey: catalogField.key, overrides: { enabled: true, required: def.required, showInList: def.showInList ?? false, defaultValue: def.defaultValue, description: def.description ?? "", }, }; } // Custom field -- not in catalog return { catalogKey: undefined, overrides: { enabled: true, required: def.required, showInList: def.showInList ?? false, defaultValue: def.defaultValue, description: def.description ?? "", }, custom: { key: def.key, label: def.label, type: def.type, options: def.options ?? [], }, }; } function stateToFieldDef( state: FieldState, order: number, target: BlueprintTargetValue, ): BlueprintFieldDefinition | null { if (!state.overrides.enabled) return null; if (state.catalogKey) { const catalogField = findCatalogField(target, state.catalogKey); if (!catalogField) return null; const desc = state.overrides.description || catalogField.description; return { id: catalogField.key, key: catalogField.key, label: catalogField.label, type: catalogField.type, required: state.overrides.required, order, ...(state.overrides.showInList ? { showInList: true } : {}), ...(desc ? { description: desc } : {}), defaultValue: state.overrides.defaultValue, ...(catalogField.options ? { options: catalogField.options } : {}), }; } // Custom field if (!state.custom) return null; const customDesc = state.overrides.description || undefined; return { id: state.custom.key, key: state.custom.key, label: state.custom.label, type: state.custom.type, required: state.overrides.required, order, ...(state.overrides.showInList ? { showInList: true } : {}), ...(customDesc !== undefined ? { description: customDesc } : {}), defaultValue: state.overrides.defaultValue, ...(state.custom.options.length > 0 ? { options: state.custom.options } : {}), }; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface BlueprintFieldCatalogProps { blueprintId: string; blueprintName: string; blueprintTarget: BlueprintTargetValue; initialFieldDefs: BlueprintFieldDefinition[]; initialRolePresets?: StaffingRequirement[]; initialTab?: "fields" | "presets"; onClose: () => void; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const FIELD_TYPES: { value: FieldType; label: string }[] = [ { value: FieldType.TEXT, label: "Text" }, { value: FieldType.TEXTAREA, label: "Textarea" }, { value: FieldType.NUMBER, label: "Number" }, { value: FieldType.BOOLEAN, label: "Boolean" }, { value: FieldType.DATE, label: "Date" }, { value: FieldType.SELECT, label: "Select" }, { value: FieldType.MULTI_SELECT, label: "Multi-Select" }, { value: FieldType.URL, label: "URL" }, { value: FieldType.EMAIL, label: "Email" }, ]; export function BlueprintFieldCatalog({ blueprintId, blueprintName, blueprintTarget, initialFieldDefs, initialRolePresets = [], initialTab = "fields", onClose, }: BlueprintFieldCatalogProps) { const utils = trpc.useUtils(); const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab); const [searchQuery, setSearchQuery] = useState(""); const [activeCategory, setActiveCategory] = useState(null); const [saveError, setSaveError] = useState(null); const [presetSaveError, setPresetSaveError] = useState(null); // -- Custom field form state -- const [showCustomForm, setShowCustomForm] = useState(false); const [customKey, setCustomKey] = useState(""); const [customLabel, setCustomLabel] = useState(""); const [customType, setCustomType] = useState(FieldType.TEXT); const catalog = useMemo(() => getCatalogForTarget(blueprintTarget), [blueprintTarget]); const categories = useMemo(() => getCategoriesForTarget(blueprintTarget), [blueprintTarget]); // --------------------------------------------------------------------------- // Build initial state from existing fieldDefs + catalog // --------------------------------------------------------------------------- const [catalogOverrides, setCatalogOverrides] = useState< Record >(() => { const map: Record = {}; // Start with all catalog fields disabled for (const cf of catalog) { map[cf.key] = { enabled: false, required: false, showInList: false, defaultValue: cf.defaultValue, description: "", }; } // Override from existing fieldDefs for (const def of initialFieldDefs) { const state = fieldDefToState(def, blueprintTarget); if (state.catalogKey && map[state.catalogKey]) { map[state.catalogKey] = state.overrides; } } return map; }); const [customFields, setCustomFields] = useState(() => { return initialFieldDefs .map((def) => fieldDefToState(def, blueprintTarget)) .filter((s) => !s.catalogKey); }); // --------------------------------------------------------------------------- // Mutations // --------------------------------------------------------------------------- const updateMutation = trpc.blueprint.update.useMutation(); const presetMutation = trpc.blueprint.updateRolePresets.useMutation(); // --------------------------------------------------------------------------- // Derived data // --------------------------------------------------------------------------- const allCategoryNames = useMemo( () => [...categories.map((c) => c.name), "Custom Fields"], [categories], ); const filteredCatalog = useMemo(() => { if (!searchQuery.trim()) return catalog; const q = searchQuery.toLowerCase(); return catalog.filter( (f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q) || f.description.toLowerCase().includes(q) || f.category.toLowerCase().includes(q), ); }, [catalog, searchQuery]); const fieldsByCategory = useMemo(() => { const map = new Map(); for (const cat of categories) { map.set(cat.name, []); } for (const f of filteredCatalog) { const list = map.get(f.category); if (list) list.push(f); } return map; }, [filteredCatalog, categories]); const enabledCount = useMemo(() => { let count = 0; for (const ov of Object.values(catalogOverrides)) { if (ov.enabled) count++; } count += customFields.filter((f) => f.overrides.enabled).length; return count; }, [catalogOverrides, customFields]); // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- const handleCatalogFieldChange = useCallback( (key: string, overrides: FieldOverrides) => { setCatalogOverrides((prev) => ({ ...prev, [key]: overrides })); }, [], ); const handleCustomFieldChange = useCallback( (idx: number, overrides: FieldOverrides) => { setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)), ); }, [], ); function removeCustomField(idx: number) { setCustomFields((prev) => prev.filter((_, i) => i !== idx)); } function addCustomField() { if (!customKey.trim() || !customLabel.trim()) return; // Check for duplicate key const allKeys = new Set([ ...catalog.map((f) => f.key), ...customFields.map((f) => f.custom?.key).filter(Boolean), ]); if (allKeys.has(customKey.trim())) return; setCustomFields((prev) => [ ...prev, { catalogKey: undefined, overrides: { enabled: true, required: false, showInList: false, defaultValue: undefined, description: "", }, custom: { key: customKey.trim(), label: customLabel.trim(), type: customType, options: [], }, }, ]); setCustomKey(""); setCustomLabel(""); setCustomType(FieldType.TEXT); setShowCustomForm(false); } function handleSave() { setSaveError(null); const defs: BlueprintFieldDefinition[] = []; let order = 0; // Catalog fields first (in catalog order) for (const cf of catalog) { const ov = catalogOverrides[cf.key]; if (!ov?.enabled) continue; const state: FieldState = { catalogKey: cf.key, overrides: ov }; const def = stateToFieldDef(state, order, blueprintTarget); if (def) { defs.push(def); order++; } } // Custom fields for (const cf of customFields) { if (!cf.overrides.enabled) continue; const def = stateToFieldDef(cf, order, blueprintTarget); if (def) { defs.push(def); order++; } } updateMutation.mutate( { id: blueprintId, data: { fieldDefs: defs } }, { onSuccess: async () => { await utils.blueprint.list.invalidate(); onClose(); }, onError: (err) => setSaveError(err.message), }, ); } function handleBackdropClick(e: React.MouseEvent) { if (e.target === e.currentTarget) onClose(); } // --------------------------------------------------------------------------- // Collapsed categories // --------------------------------------------------------------------------- const [collapsedCategories, setCollapsedCategories] = useState>( new Set(), ); function toggleCategory(name: string) { setCollapsedCategories((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return (
{/* Header */}

Configure Fields:{" "} {blueprintName}

{enabledCount} field{enabledCount !== 1 ? "s" : ""} enabled

{/* Tabs */}
{(["fields", "presets"] as const).map((tab) => ( ))}
{activeTab === "fields" ? ( <> {/* Search + category sidebar layout */}
{/* Category sidebar */}
{/* Main content */}
{/* Search bar */}
setSearchQuery(e.target.value)} placeholder="Search fields..." className={`${INPUT_CLS} w-full`} autoFocus />
{/* Field cards */}
{categories .filter( (cat) => activeCategory === null || activeCategory === cat.name, ) .map((cat) => { const fields = fieldsByCategory.get(cat.name) ?? []; if (fields.length === 0 && searchQuery.trim()) return null; if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null; const isCollapsed = collapsedCategories.has(cat.name); return (
{!isCollapsed && (
{fields.map((field) => ( handleCatalogFieldChange(field.key, ov) } /> ))} {fields.length === 0 && (

No fields in this category.

)}
)}
); })} {/* Custom Fields section */} {(activeCategory === null || activeCategory === "Custom Fields") && (
{!collapsedCategories.has("Custom Fields") && (
{customFields.map((cf, idx) => { if (!cf.custom) return null; // Build a pseudo CatalogField for the FieldCard const pseudoCatalog: CatalogField = { key: cf.custom.key, label: cf.custom.label, type: cf.custom.type, category: "Custom Fields", description: cf.overrides.description || "Custom field", ...(cf.custom.options.length > 0 ? { options: cf.custom.options } : {}), builtIn: false, }; return (
handleCustomFieldChange(idx, ov) } />
); })} {/* Add custom field */} {showCustomForm ? (
setCustomKey( e.target.value.replace( /[^a-zA-Z0-9_]/g, "", ), ) } placeholder="field_key" className={`${INPUT_CLS} font-mono`} />
setCustomLabel(e.target.value) } placeholder="Display Label" className={INPUT_CLS} />
) : ( )}
)}
)}
{/* Error */} {saveError && (
{saveError}
)} {/* Footer */}
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
) : (

Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.

presetMutation.mutate( { id: blueprintId, rolePresets: presets }, { onSuccess: async () => { await utils.blueprint.list.invalidate(); setPresetSaveError(null); onClose(); }, onError: (err) => { setPresetSaveError(err.message); }, }, ) } isSaving={presetMutation.isPending} saveError={presetSaveError} />
)}
); }