diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/templates/TemplateEditor.tsx | 31 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 120 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 46 | ||||
| -rw-r--r-- | makima/frontend/src/routes/templates.tsx | 248 |
4 files changed, 328 insertions, 117 deletions
diff --git a/makima/frontend/src/components/templates/TemplateEditor.tsx b/makima/frontend/src/components/templates/TemplateEditor.tsx index 03382f3..c8e1f98 100644 --- a/makima/frontend/src/components/templates/TemplateEditor.tsx +++ b/makima/frontend/src/components/templates/TemplateEditor.tsx @@ -5,9 +5,10 @@ interface Props { template: ContractTemplate; onSave: (template: ContractTemplate) => void; onCancel: () => void; + readOnly?: boolean; } -export function TemplateEditor({ template, onSave, onCancel }: Props) { +export function TemplateEditor({ template, onSave, onCancel, readOnly = false }: Props) { const [editedTemplate, setEditedTemplate] = useState<ContractTemplate>({ ...template, phases: template.phases.map((p) => ({ @@ -106,11 +107,16 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { {/* Header */} <div className="mb-6 pb-4 border-b border-[rgba(117,170,252,0.15)]"> <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-1"> - Edit Template: {template.name} + {readOnly ? "View" : "Edit"} Template: {template.name} </h2> <p className="text-xs font-mono text-[#75aafc] opacity-70"> {template.description} </p> + {readOnly && ( + <p className="text-xs font-mono text-amber-400 mt-2"> + Built-in templates are read-only + </p> + )} </div> {/* Phases */} @@ -127,10 +133,11 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { </span> <input type="text" - className="flex-1 px-3 py-1.5 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]" + className="flex-1 px-3 py-1.5 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-60" value={phase.name} onChange={(e) => handlePhaseNameChange(phase.id, e.target.value)} placeholder="Phase name" + disabled={readOnly} /> {!template.isBuiltIn && ( <button @@ -233,15 +240,17 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { onClick={onCancel} className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" > - Cancel - </button> - <button - type="button" - onClick={() => onSave(editedTemplate)} - className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" - > - Save Changes + {readOnly ? "Close" : "Cancel"} </button> + {!readOnly && ( + <button + type="button" + onClick={() => onSave(editedTemplate)} + className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + > + Save Changes + </button> + )} </div> </div> ); diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 8838dbd..e8b3d8a 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1645,6 +1645,56 @@ export interface ListContractTypesResponse { contractTypes: ContractTypeTemplate[]; } +/** Phase definition for custom templates */ +export interface PhaseDefinition { + id: string; + name: string; + order: number; +} + +/** Deliverable definition for custom templates */ +export interface DeliverableDefinition { + id: string; + name: string; + priority: "required" | "recommended" | "optional"; +} + +/** Request to create a custom contract type template */ +export interface CreateTemplateRequest { + name: string; + description?: string; + phases: PhaseDefinition[]; + defaultPhase: string; + deliverables?: Record<string, DeliverableDefinition[]>; +} + +/** Request to update a custom contract type template */ +export interface UpdateTemplateRequest { + name?: string; + description?: string; + phases?: PhaseDefinition[]; + defaultPhase?: string; + deliverables?: Record<string, DeliverableDefinition[]>; + version?: number; +} + +/** Custom template record from the API */ +export interface ContractTypeTemplateRecord { + id: string; + name: string; + description: string | null; + phases: PhaseDefinition[]; + defaultPhase: string; + isBuiltin: boolean; + version: number; + createdAt: string; +} + +/** Response for single template operations */ +export interface TemplateResponse { + template: ContractTypeTemplateRecord; +} + /** * List available contract types/templates. * Returns built-in types (simple, specification) and any custom types. @@ -1657,6 +1707,66 @@ export async function listContractTypes(): Promise<ListContractTypesResponse> { return res.json(); } +/** + * Create a new custom contract type template. + */ +export async function createContractTemplate( + req: CreateTemplateRequest +): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err.message || `Failed to create template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get a custom contract type template by ID. + */ +export async function getContractTemplate(id: string): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`); + if (!res.ok) { + throw new Error(`Failed to get template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Update a custom contract type template. + */ +export async function updateContractTemplate( + id: string, + req: UpdateTemplateRequest +): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err.message || `Failed to update template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Delete a custom contract type template. + */ +export async function deleteContractTemplate(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete template: ${res.statusText}`); + } +} + export interface ContractRepository { id: string; contractId: string; @@ -1741,10 +1851,12 @@ export interface ContractListResponse { export interface CreateContractRequest { name: string; description?: string; - /** Contract type: "simple" (default) or "specification" */ - contractType?: ContractType; - /** Initial phase to start in (defaults based on contract type) */ - initialPhase?: ContractPhase; + /** Contract type: "simple" (default), "specification", "execute", or custom template name */ + contractType?: ContractType | string; + /** UUID of a custom template to use. If provided, takes precedence over contractType. */ + templateId?: string; + /** Initial phase to start in (defaults based on contract type or template) */ + initialPhase?: ContractPhase | string; /** When true, tasks won't auto-push or create PRs - use patch files instead */ localOnly?: boolean; /** When true, spawn a red team task to monitor work output */ diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index bb66215..fc76f60 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -96,49 +96,19 @@ function ContractsPageContent() { const [redTeamEnabled, setRedTeamEnabled] = useState(false); const [redTeamPrompt, setRedTeamPrompt] = useState(""); - // Fetch contract types when modal opens - merges built-in types with user templates + // Fetch contract types when modal opens - API returns both built-in and custom templates useEffect(() => { if (isCreating) { setContractTypesLoading(true); - // Load user templates from localStorage - const loadUserTemplates = (): ContractTypeTemplate[] => { - try { - const saved = localStorage.getItem("makima_contract_templates"); - if (saved) { - const templates = JSON.parse(saved); - // Convert user templates to ContractTypeTemplate format, excluding built-ins - return templates - .filter((t: { isBuiltIn?: boolean }) => !t.isBuiltIn) - .map((t: { id: string; name: string; description: string; phases: { id: string; name: string }[] }) => ({ - id: t.id, - name: t.name, - description: t.description, - phases: t.phases.map((p: { id: string }) => p.id) as ContractPhase[], - phaseNames: Object.fromEntries(t.phases.map((p: { id: string; name: string }) => [p.id, p.name])), - defaultPhase: (t.phases[0]?.id || "execute") as ContractPhase, - isBuiltin: false, - })); - } - } catch { - // Ignore localStorage errors - } - return []; - }; - listContractTypes() .then((res) => { - // Merge built-in types from API with user templates from localStorage - const userTemplates = loadUserTemplates(); - // Filter out any user templates that have the same ID as built-in types - const builtinIds = new Set(res.contractTypes.map(t => t.id)); - const uniqueUserTemplates = userTemplates.filter(t => !builtinIds.has(t.id)); - setContractTypes([...res.contractTypes, ...uniqueUserTemplates]); + setContractTypes(res.contractTypes); setContractTypesLoading(false); }) .catch((err) => { console.error("Failed to fetch contract types:", err); - // Fall back to built-in types + user templates + // Fall back to built-in types const builtinTypes: ContractTypeTemplate[] = [ { id: "simple", @@ -165,10 +135,7 @@ function ContractsPageContent() { isBuiltin: true, }, ]; - const userTemplates = loadUserTemplates(); - const builtinIds = new Set(builtinTypes.map(t => t.id)); - const uniqueUserTemplates = userTemplates.filter(t => !builtinIds.has(t.id)); - setContractTypes([...builtinTypes, ...uniqueUserTemplates]); + setContractTypes(builtinTypes); setContractTypesLoading(false); }); } @@ -261,11 +228,14 @@ function ContractsPageContent() { // Get default phase from contract types or fall back to static function const selectedType = contractTypes.find((t) => t.id === contractType); const defaultPhaseForType = selectedType?.defaultPhase || (contractType === "simple" ? "plan" : "research"); + const isCustomTemplate = selectedType && !selectedType.isBuiltin; const data: CreateContractRequest = { name: newContractName.trim(), description: newContractDescription.trim() || undefined, - contractType: contractType, + // For custom templates, send templateId instead of contractType + contractType: isCustomTemplate ? undefined : contractType, + templateId: isCustomTemplate ? contractType : undefined, initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, localOnly: localOnly || undefined, redTeamEnabled: redTeamEnabled || undefined, diff --git a/makima/frontend/src/routes/templates.tsx b/makima/frontend/src/routes/templates.tsx index 15bf95c..b2c9974 100644 --- a/makima/frontend/src/routes/templates.tsx +++ b/makima/frontend/src/routes/templates.tsx @@ -1,28 +1,27 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { TemplateEditor } from "../components/templates/TemplateEditor"; import { useAuth } from "../contexts/AuthContext"; import type { ContractTemplate } from "../types/templates"; import { DEFAULT_TEMPLATES } from "../types/templates"; - -const STORAGE_KEY = "makima_contract_templates"; +import { + listContractTypes, + createContractTemplate, + updateContractTemplate, + deleteContractTemplate, + type PhaseDefinition, + type DeliverableDefinition, +} from "../lib/api"; export default function TemplatesPage() { const navigate = useNavigate(); const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const [templates, setTemplates] = useState<ContractTemplate[]>(() => { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return DEFAULT_TEMPLATES; - } - } - return DEFAULT_TEMPLATES; - }); + const [templates, setTemplates] = useState<ContractTemplate[]>(DEFAULT_TEMPLATES); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [saving, setSaving] = useState(false); const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>( null @@ -38,63 +37,161 @@ export default function TemplatesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - const saveTemplates = (newTemplates: ContractTemplate[]) => { - setTemplates(newTemplates); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newTemplates)); - }; + // Fetch templates from API + const fetchTemplates = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await listContractTypes(); - const handleSaveTemplate = (updatedTemplate: ContractTemplate) => { - const newTemplates = templates.map((t) => - t.id === updatedTemplate.id ? updatedTemplate : t - ); - saveTemplates(newTemplates); - setEditingTemplate(null); + // Convert API response to ContractTemplate format + const apiTemplates: ContractTemplate[] = response.contractTypes.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + isBuiltIn: t.isBuiltin, + phases: t.phases.map((phaseId) => ({ + id: phaseId, + name: t.phaseNames?.[phaseId] || phaseId.charAt(0).toUpperCase() + phaseId.slice(1), + deliverables: [], // Deliverables are managed server-side + })), + })); + + // Merge with DEFAULT_TEMPLATES to ensure we have full phase/deliverable info for built-ins + const mergedTemplates = apiTemplates.map((apiTemplate) => { + const defaultTemplate = DEFAULT_TEMPLATES.find((d) => d.id === apiTemplate.id); + if (defaultTemplate && apiTemplate.isBuiltIn) { + return defaultTemplate; // Use the richer default template for built-ins + } + return apiTemplate; + }); + + setTemplates(mergedTemplates); + } catch (err) { + console.error("Failed to fetch templates:", err); + setError(err instanceof Error ? err.message : "Failed to fetch templates"); + // Fall back to default templates + setTemplates(DEFAULT_TEMPLATES); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!authLoading && isAuthenticated) { + fetchTemplates(); + } else if (!authLoading && !isAuthConfigured) { + // No auth configured, just show defaults + setTemplates(DEFAULT_TEMPLATES); + setLoading(false); + } + }, [authLoading, isAuthenticated, isAuthConfigured, fetchTemplates]); + + const handleSaveTemplate = async (updatedTemplate: ContractTemplate) => { + if (updatedTemplate.isBuiltIn) { + // Built-in templates are read-only, just close the editor + setEditingTemplate(null); + return; + } + + try { + setSaving(true); + setError(null); + + // Convert to API format + const phases: PhaseDefinition[] = updatedTemplate.phases.map((p, index) => ({ + id: p.id, + name: p.name, + order: index, + })); + + const deliverables: Record<string, DeliverableDefinition[]> = {}; + for (const phase of updatedTemplate.phases) { + if (phase.deliverables.length > 0) { + deliverables[phase.id] = phase.deliverables.map((d) => ({ + id: d.id, + name: d.name, + priority: "required" as const, + })); + } + } + + await updateContractTemplate(updatedTemplate.id, { + name: updatedTemplate.name, + description: updatedTemplate.description, + phases, + defaultPhase: phases[0]?.id || "execute", + deliverables: Object.keys(deliverables).length > 0 ? deliverables : undefined, + }); + + // Refresh templates from server + await fetchTemplates(); + setEditingTemplate(null); + } catch (err) { + console.error("Failed to update template:", err); + setError(err instanceof Error ? err.message : "Failed to update template"); + } finally { + setSaving(false); + } }; - const handleCreateTemplate = () => { + const handleCreateTemplate = async () => { if (!newTemplateName.trim()) return; - const newTemplate: ContractTemplate = { - id: `custom-${Date.now()}`, - name: newTemplateName.trim(), - description: newTemplateDescription.trim() || "Custom contract template", - isBuiltIn: false, - phases: [ - { - id: `phase-${Date.now()}`, - name: "Execute", - deliverables: [], - }, - ], - }; - - saveTemplates([...templates, newTemplate]); - setNewTemplateName(""); - setNewTemplateDescription(""); - setShowNewTemplateForm(false); + try { + setSaving(true); + setError(null); + + const phases: PhaseDefinition[] = [ + { id: "execute", name: "Execute", order: 0 }, + ]; + + await createContractTemplate({ + name: newTemplateName.trim(), + description: newTemplateDescription.trim() || "Custom contract template", + phases, + defaultPhase: "execute", + }); + + // Refresh templates from server + await fetchTemplates(); + + setNewTemplateName(""); + setNewTemplateDescription(""); + setShowNewTemplateForm(false); + } catch (err) { + console.error("Failed to create template:", err); + setError(err instanceof Error ? err.message : "Failed to create template"); + } finally { + setSaving(false); + } }; - const handleDeleteTemplate = (templateId: string) => { + const handleDeleteTemplate = async (templateId: string) => { const template = templates.find((t) => t.id === templateId); if (template?.isBuiltIn) return; if (window.confirm(`Are you sure you want to delete "${template?.name}"?`)) { - saveTemplates(templates.filter((t) => t.id !== templateId)); + try { + setSaving(true); + setError(null); + await deleteContractTemplate(templateId); + await fetchTemplates(); + } catch (err) { + console.error("Failed to delete template:", err); + setError(err instanceof Error ? err.message : "Failed to delete template"); + } finally { + setSaving(false); + } } }; - const handleResetToDefaults = () => { - if ( - window.confirm( - "Reset all templates to defaults? This will remove any custom templates." - ) - ) { - saveTemplates(DEFAULT_TEMPLATES); - } + const handleRefresh = () => { + fetchTemplates(); }; // Show loading state - if (authLoading) { + if (authLoading || loading) { return ( <div className="relative z-10 min-h-screen flex items-center justify-center bg-[#0a1628]"> <div className="text-[#75aafc] font-mono text-sm animate-pulse"> @@ -114,6 +211,7 @@ export default function TemplatesPage() { template={editingTemplate} onSave={handleSaveTemplate} onCancel={() => setEditingTemplate(null)} + readOnly={editingTemplate.isBuiltIn} /> </main> </div> @@ -124,6 +222,20 @@ export default function TemplatesPage() { <div className="relative z-10 min-h-screen bg-[#0a1628]"> <Masthead /> <main className="max-w-6xl mx-auto px-4 py-6"> + {/* Error display */} + {error && ( + <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {error} + <button + type="button" + onClick={() => setError(null)} + className="ml-2 text-red-400/70 hover:text-red-400" + > + Dismiss + </button> + </div> + )} + {/* Header */} <div className="flex justify-between items-start mb-6 pb-4 border-b border-[rgba(117,170,252,0.15)]"> <div> @@ -137,15 +249,17 @@ export default function TemplatesPage() { <div className="flex gap-3"> <button type="button" - onClick={handleResetToDefaults} - className="px-3 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" + onClick={handleRefresh} + disabled={saving} + className="px-3 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50" > - Reset to Defaults + Refresh </button> <button type="button" onClick={() => setShowNewTemplateForm(true)} - className="px-3 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + disabled={saving} + className="px-3 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors disabled:opacity-50" > + New Template </button> @@ -162,6 +276,7 @@ export default function TemplatesPage() { placeholder="Template name..." value={newTemplateName} onChange={(e) => setNewTemplateName(e.target.value)} + disabled={saving} /> <input type="text" @@ -169,18 +284,21 @@ export default function TemplatesPage() { placeholder="Description (optional)..." value={newTemplateDescription} onChange={(e) => setNewTemplateDescription(e.target.value)} + disabled={saving} /> <button type="button" onClick={handleCreateTemplate} - className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + disabled={saving || !newTemplateName.trim()} + className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors disabled:opacity-50" > - Create + {saving ? "Creating..." : "Create"} </button> <button type="button" onClick={() => setShowNewTemplateForm(false)} - className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" + disabled={saving} + className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50" > Cancel </button> @@ -245,15 +363,17 @@ export default function TemplatesPage() { <button type="button" onClick={() => setEditingTemplate(template)} - className="flex-1 px-3 py-1.5 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors" + disabled={saving} + className="flex-1 px-3 py-1.5 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50" > - Edit + {template.isBuiltIn ? "View" : "Edit"} </button> {!template.isBuiltIn && ( <button type="button" onClick={() => handleDeleteTemplate(template.id)} - className="px-3 py-1.5 border border-[rgba(255,100,100,0.25)] text-[#ff6464] font-mono text-xs uppercase tracking-wide hover:border-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.05)] transition-colors" + disabled={saving} + className="px-3 py-1.5 border border-[rgba(255,100,100,0.25)] text-[#ff6464] font-mono text-xs uppercase tracking-wide hover:border-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.05)] transition-colors disabled:opacity-50" > Delete </button> |
