diff options
Diffstat (limited to 'makima/frontend/src/routes/contracts.tsx')
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 614 |
1 files changed, 614 insertions, 0 deletions
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx new file mode 100644 index 0000000..8c90804 --- /dev/null +++ b/makima/frontend/src/routes/contracts.tsx @@ -0,0 +1,614 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { ContractList } from "../components/contracts/ContractList"; +import { ContractDetail } from "../components/contracts/ContractDetail"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; +import { useContracts } from "../hooks/useContracts"; +import { useAuth } from "../contexts/AuthContext"; +import { createTask, getDaemonDirectories } from "../lib/api"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + CreateContractRequest, + RepositorySourceType, + DaemonDirectory, +} from "../lib/api"; + +export default function ContractsPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <ContractsPageContent />; +} + +function ContractsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + contracts, + loading, + error, + fetchContract, + saveContract, + editContract, + removeContract, + changePhase, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + removeRepo, + setRepoPrimary, + } = useContracts(); + + const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newContractName, setNewContractName] = useState(""); + const [newContractDescription, setNewContractDescription] = useState(""); + const [initialPhase, setInitialPhase] = useState<ContractPhase>("research"); + const [repoType, setRepoType] = useState<RepositorySourceType>("remote"); + const [repoName, setRepoName] = useState(""); + const [repoUrl, setRepoUrl] = useState(""); + const [repoPath, setRepoPath] = useState(""); + const [createError, setCreateError] = useState<string | null>(null); + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + + // Fetch daemon directories when "local" repo type is selected + useEffect(() => { + if (repoType === "local" && isCreating) { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [repoType, isCreating]); + + // Load contract detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + fetchContract(id).then((contract) => { + setContractDetail(contract); + setDetailLoading(false); + }); + } else { + setContractDetail(null); + } + }, [id, fetchContract]); + + const handleSelect = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/contracts"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + // Validate repository configuration + const isRepoValid = useCallback(() => { + if (!repoName.trim()) return false; + if (repoType === "remote" && !repoUrl.trim()) return false; + if (repoType === "local" && !repoPath.trim()) return false; + return true; + }, [repoType, repoName, repoUrl, repoPath]); + + const handleCreateSubmit = useCallback(async () => { + if (!newContractName.trim()) return; + if (!isRepoValid()) { + setCreateError("Repository configuration is required"); + return; + } + + setCreateError(null); + + const data: CreateContractRequest = { + name: newContractName.trim(), + description: newContractDescription.trim() || undefined, + initialPhase: initialPhase !== "research" ? initialPhase : undefined, + }; + + try { + const contract = await saveContract(data); + if (contract) { + // Add the repository after contract creation + try { + if (repoType === "remote") { + await addRemoteRepo(contract.id, { + name: repoName.trim(), + repositoryUrl: repoUrl.trim(), + isPrimary: true, + }); + } else if (repoType === "local") { + await addLocalRepo(contract.id, { + name: repoName.trim(), + localPath: repoPath.trim(), + isPrimary: true, + }); + } else if (repoType === "managed") { + await createManagedRepo(contract.id, { + name: repoName.trim(), + isPrimary: true, + }); + } + } catch (repoError) { + console.error("Failed to add repository:", repoError); + // Still navigate to the contract - repo can be added later + } + + // Clear form state + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + navigate(`/contracts/${contract.id}`); + } + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Failed to create contract"); + } + }, [ + newContractName, + newContractDescription, + repoType, + repoName, + repoUrl, + repoPath, + isRepoValid, + saveContract, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + navigate, + ]); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + setCreateError(null); + }, []); + + const handleUpdate = useCallback( + async (name: string, description: string) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + name, + description: description || undefined, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + const handleDelete = useCallback(async () => { + if (contractDetail && confirm("Are you sure you want to delete this contract?")) { + const success = await removeContract(contractDetail.id); + if (success) { + navigate("/contracts"); + } + } + }, [contractDetail, removeContract, navigate]); + + const handlePhaseChange = useCallback( + async (phase: ContractPhase) => { + if (contractDetail) { + const updated = await changePhase(contractDetail.id, phase); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, changePhase, fetchContract] + ); + + const handleStatusChange = useCallback( + async (status: ContractStatus) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + status, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + // Repository handlers + const handleAddRemoteRepo = useCallback( + async (name: string, url: string, isPrimary: boolean) => { + if (contractDetail) { + await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addRemoteRepo, fetchContract] + ); + + const handleAddLocalRepo = useCallback( + async (name: string, path: string, isPrimary: boolean) => { + if (contractDetail) { + await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addLocalRepo, fetchContract] + ); + + const handleCreateManagedRepo = useCallback( + async (name: string, isPrimary: boolean) => { + if (contractDetail) { + await createManagedRepo(contractDetail.id, { name, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, createManagedRepo, fetchContract] + ); + + const handleDeleteRepo = useCallback( + async (repoId: string) => { + if (contractDetail) { + await removeRepo(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, removeRepo, fetchContract] + ); + + const handleSetRepoPrimary = useCallback( + async (repoId: string) => { + if (contractDetail) { + await setRepoPrimary(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, setRepoPrimary, fetchContract] + ); + + // Refresh contract detail (used after file/task operations) + const handleRefresh = useCallback(async () => { + if (contractDetail) { + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, [contractDetail, fetchContract]); + + // File/task navigation handlers + const handleFileSelect = useCallback( + (fileId: string) => { + navigate(`/files/${fileId}`); + }, + [navigate] + ); + + const handleTaskSelect = useCallback( + (taskId: string) => { + navigate(`/mesh/${taskId}`); + }, + [navigate] + ); + + // Create task within contract context + const handleTaskCreate = useCallback( + async (name: string, plan: string, repositoryUrl?: string) => { + if (!contractDetail) return; + try { + // Create the task with contract_id (task is automatically associated) + const task = await createTask({ + contractId: contractDetail.id, + name, + plan, + repositoryUrl, + }); + // Refresh contract detail to show new task + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + // Navigate to the new task + navigate(`/mesh/${task.id}`); + } catch (e) { + console.error("Failed to create task:", e); + alert(e instanceof Error ? e.message : "Failed to create task"); + } + }, + [contractDetail, fetchContract, navigate] + ); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create contract modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Contract + </h3> + + {createError && ( + <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {createError} + </div> + )} + + <div className="space-y-4"> + {/* Contract name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Contract Name + </label> + <input + type="text" + value={newContractName} + onChange={(e) => setNewContractName(e.target.value)} + placeholder="Contract name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Description */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Description (optional) + </label> + <textarea + value={newContractDescription} + onChange={(e) => setNewContractDescription(e.target.value)} + placeholder="Describe what this contract is for..." + rows={2} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + {/* Starting Phase */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Starting Phase + </label> + <select + value={initialPhase} + onChange={(e) => setInitialPhase(e.target.value as ContractPhase)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="research">Research</option> + <option value="specify">Specify</option> + <option value="plan">Plan</option> + <option value="execute">Execute</option> + <option value="review">Review</option> + </select> + <p className="mt-1 font-mono text-xs text-[#8b949e]"> + Skip earlier phases if you already have requirements defined + </p> + </div> + + {/* Repository Configuration */} + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3"> + Repository Configuration (Required) + </label> + + {/* Repository type selector */} + <div className="flex gap-2 mb-3"> + <button + type="button" + onClick={() => setRepoType("remote")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "remote" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Remote + </button> + <button + type="button" + onClick={() => setRepoType("local")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "local" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Local + </button> + <button + type="button" + onClick={() => setRepoType("managed")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "managed" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Managed + </button> + </div> + + {/* Repository name */} + <div className="mb-3"> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository Name + </label> + <input + type="text" + value={repoName} + onChange={(e) => setRepoName(e.target.value)} + placeholder="e.g., my-project" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + + {/* Repository URL (for remote) */} + {repoType === "remote" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository URL + </label> + <input + type="text" + value={repoUrl} + onChange={(e) => setRepoUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + )} + + {/* Repository path (for local) */} + {repoType === "local" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Local Path + </label> + <DirectoryInput + value={repoPath} + onChange={setRepoPath} + suggestions={suggestedDirectories} + placeholder="/path/to/repository" + /> + </div> + )} + + {/* Managed description */} + {repoType === "managed" && ( + <p className="font-mono text-xs text-[#8b949e]"> + A managed repository will be created automatically by the daemon. + </p> + )} + </div> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={handleCreateCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateSubmit} + disabled={!newContractName.trim() || !isRepoValid()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Contract list */} + <ContractList + contracts={contracts} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + /> + + {/* Contract detail or empty state */} + {contractDetail ? ( + <ContractDetail + contract={contractDetail} + loading={detailLoading} + onBack={handleBack} + onUpdate={handleUpdate} + onDelete={handleDelete} + onPhaseChange={handlePhaseChange} + onStatusChange={handleStatusChange} + onFileSelect={handleFileSelect} + onTaskSelect={handleTaskSelect} + onTaskCreate={handleTaskCreate} + onRefresh={handleRefresh} + onAddRemoteRepo={handleAddRemoteRepo} + onAddLocalRepo={handleAddLocalRepo} + onCreateManagedRepo={handleCreateManagedRepo} + onDeleteRepo={handleDeleteRepo} + onSetRepoPrimary={handleSetRepoPrimary} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a contract or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Contract + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} |
