summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/contracts.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/contracts.tsx')
-rw-r--r--makima/frontend/src/routes/contracts.tsx614
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>
+ );
+}