summaryrefslogblamecommitdiff
path: root/makima/frontend/src/routes/templates.tsx
blob: b2c99746c69057648cf78a16dff35cd6df589562 (plain) (tree)
1
2
3
4
5
6
7
                                                         





                                                                        







                             




                                                                                  



                                                                                    














                                                                                  





                                                  
 
























































































                                                                                                 

    
                                            

                                        


























                                                                                 

    
                                                              



                                                                                 










                                                                                   


     

                               


                       
                               
            
                                                                                                









                                                                        
                                                               





                                                      
                                                






               
                                                             

                                                    













                                                                                                          












                                                                                                             


                                                                                                                                                                                                
             
                     



                                                          

                                                                                                                                                                                                                        















                                                                                                                                                                                               
                                 






                                                                                                                                                                                               
                                 



                                              

                                                                                                                                                                                                                          
               
                                                   



                                                             

                                                                                                                                                                                                  































































                                                                                                                                         

                                                                                                                                                                                                                                               
                 
                                                        




                                                                     

                                                                                                                                                                                                                                                        











                           
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";
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[]>(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
  );
  const [showNewTemplateForm, setShowNewTemplateForm] = useState(false);
  const [newTemplateName, setNewTemplateName] = useState("");
  const [newTemplateDescription, setNewTemplateDescription] = useState("");

  // Redirect to login if not authenticated
  useEffect(() => {
    if (!authLoading && isAuthConfigured && !isAuthenticated) {
      navigate("/login");
    }
  }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);

  // Fetch templates from API
  const fetchTemplates = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const response = await listContractTypes();

      // 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 = async () => {
    if (!newTemplateName.trim()) return;

    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 = 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}"?`)) {
      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 handleRefresh = () => {
    fetchTemplates();
  };

  // Show loading state
  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">
          Loading...
        </div>
      </div>
    );
  }

  // Editor view
  if (editingTemplate) {
    return (
      <div className="relative z-10 min-h-screen bg-[#0a1628]">
        <Masthead />
        <main className="max-w-4xl mx-auto px-4 py-6">
          <TemplateEditor
            template={editingTemplate}
            onSave={handleSaveTemplate}
            onCancel={() => setEditingTemplate(null)}
            readOnly={editingTemplate.isBuiltIn}
          />
        </main>
      </div>
    );
  }

  return (
    <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>
            <h1 className="text-lg font-mono uppercase tracking-wide text-[#9bc3ff] mb-1">
              Contract Templates
            </h1>
            <p className="text-xs font-mono text-[#75aafc] opacity-70">
              Manage contract types and their phase deliverables
            </p>
          </div>
          <div className="flex gap-3">
            <button
              type="button"
              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"
            >
              Refresh
            </button>
            <button
              type="button"
              onClick={() => setShowNewTemplateForm(true)}
              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>
          </div>
        </div>

        {/* New Template Form */}
        {showNewTemplateForm && (
          <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-4 mb-6">
            <div className="flex gap-3 items-center">
              <input
                type="text"
                className="flex-1 px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]"
                placeholder="Template name..."
                value={newTemplateName}
                onChange={(e) => setNewTemplateName(e.target.value)}
                disabled={saving}
              />
              <input
                type="text"
                className="flex-1 px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]"
                placeholder="Description (optional)..."
                value={newTemplateDescription}
                onChange={(e) => setNewTemplateDescription(e.target.value)}
                disabled={saving}
              />
              <button
                type="button"
                onClick={handleCreateTemplate}
                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"
              >
                {saving ? "Creating..." : "Create"}
              </button>
              <button
                type="button"
                onClick={() => setShowNewTemplateForm(false)}
                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>
            </div>
          </div>
        )}

        {/* Templates Grid */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {templates.map((template) => (
            <div
              key={template.id}
              className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] hover:border-[rgba(117,170,252,0.45)] transition-colors"
            >
              {/* Card Header */}
              <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.15)] flex items-center justify-between">
                <h3 className="font-mono text-sm text-white">{template.name}</h3>
                {template.isBuiltIn ? (
                  <span className="px-2 py-0.5 bg-[rgba(117,170,252,0.15)] text-[#75aafc] font-mono text-[10px] uppercase tracking-wide">
                    Built-in
                  </span>
                ) : (
                  <span className="px-2 py-0.5 bg-[rgba(100,200,100,0.15)] text-[#7bc97b] font-mono text-[10px] uppercase tracking-wide">
                    Custom
                  </span>
                )}
              </div>

              {/* Card Body */}
              <div className="px-4 py-3">
                <p className="text-xs font-mono text-[#75aafc] opacity-70 mb-4 min-h-[2.5rem]">
                  {template.description}
                </p>

                {/* Phases */}
                <div className="space-y-2 mb-4">
                  {template.phases.map((phase, index) => (
                    <div key={phase.id} className="flex items-start gap-2">
                      <div className="flex flex-col items-center">
                        <span className="w-2 h-2 rounded-full bg-[#3f6fb3]" />
                        {index < template.phases.length - 1 && (
                          <span className="w-px h-4 bg-[rgba(117,170,252,0.25)]" />
                        )}
                      </div>
                      <div className="flex-1 min-w-0">
                        <span className="text-xs font-mono text-[#9bc3ff]">
                          {phase.name}
                        </span>
                        <span className="text-[10px] font-mono text-[#556677] ml-2">
                          {phase.deliverables.length === 0
                            ? "(no deliverables)"
                            : phase.deliverables.map((d) => d.name).join(", ")}
                        </span>
                      </div>
                    </div>
                  ))}
                </div>
              </div>

              {/* Card Footer */}
              <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.15)] flex gap-2">
                <button
                  type="button"
                  onClick={() => setEditingTemplate(template)}
                  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"
                >
                  {template.isBuiltIn ? "View" : "Edit"}
                </button>
                {!template.isBuiltIn && (
                  <button
                    type="button"
                    onClick={() => handleDeleteTemplate(template.id)}
                    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>
                )}
              </div>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}