import { useEffect, useState, useCallback, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { Plus, ChevronUp, ChevronDown } from "lucide-react"; import { get, post, put, del } from "../api/client"; import { useAuth } from "../hooks/useAuth"; import type { Project, Item, CreateProjectRequest, UpdateProjectRequest, } from "../api/types"; type Mode = "list" | "create" | "edit" | "delete"; interface ProjectWithCount extends Project { itemCount: number; } export function ProjectsPage() { const { user } = useAuth(); const navigate = useNavigate(); const isEditor = user?.role === "admin" || user?.role === "editor"; const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mode, setMode] = useState("list"); const [editingProject, setEditingProject] = useState( null, ); // Form state const [formCode, setFormCode] = useState(""); const [formName, setFormName] = useState(""); const [formDesc, setFormDesc] = useState(""); const [formError, setFormError] = useState(""); const [formSubmitting, setFormSubmitting] = useState(false); // Sort state const [sortKey, setSortKey] = useState< "code" | "name" | "itemCount" | "created_at" >("code"); const [sortAsc, setSortAsc] = useState(true); const loadProjects = useCallback(async () => { try { const list = await get("/api/projects"); const withCounts = await Promise.all( list.map(async (p) => { try { const items = await get(`/api/projects/${p.code}/items`); return { ...p, itemCount: items.length }; } catch { return { ...p, itemCount: 0 }; } }), ); setProjects(withCounts); setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load projects"); } finally { setLoading(false); } }, []); useEffect(() => { loadProjects(); }, [loadProjects]); const openCreate = () => { setFormCode(""); setFormName(""); setFormDesc(""); setFormError(""); setMode("create"); }; const openEdit = (p: ProjectWithCount) => { setEditingProject(p); setFormCode(p.code); setFormName(p.name ?? ""); setFormDesc(p.description ?? ""); setFormError(""); setMode("edit"); }; const openDelete = (p: ProjectWithCount) => { setEditingProject(p); setMode("delete"); }; const cancel = () => { setMode("list"); setEditingProject(null); setFormError(""); }; const handleCreate = async (e: FormEvent) => { e.preventDefault(); setFormError(""); setFormSubmitting(true); try { const req: CreateProjectRequest = { code: formCode.toUpperCase(), name: formName || undefined, description: formDesc || undefined, }; await post("/api/projects", req); cancel(); await loadProjects(); } catch (e) { setFormError(e instanceof Error ? e.message : "Failed to create project"); } finally { setFormSubmitting(false); } }; const handleEdit = async (e: FormEvent) => { e.preventDefault(); if (!editingProject) return; setFormError(""); setFormSubmitting(true); try { const req: UpdateProjectRequest = { name: formName, description: formDesc, }; await put(`/api/projects/${editingProject.code}`, req); cancel(); await loadProjects(); } catch (e) { setFormError(e instanceof Error ? e.message : "Failed to update project"); } finally { setFormSubmitting(false); } }; const handleDelete = async () => { if (!editingProject) return; setFormSubmitting(true); try { await del(`/api/projects/${editingProject.code}`); cancel(); await loadProjects(); } catch (e) { setFormError(e instanceof Error ? e.message : "Failed to delete project"); } finally { setFormSubmitting(false); } }; const handleSort = (key: typeof sortKey) => { if (sortKey === key) { setSortAsc(!sortAsc); } else { setSortKey(key); setSortAsc(true); } }; const sorted = [...projects].sort((a, b) => { let cmp = 0; if (sortKey === "code") cmp = a.code.localeCompare(b.code); else if (sortKey === "name") cmp = (a.name ?? "").localeCompare(b.name ?? ""); else if (sortKey === "itemCount") cmp = a.itemCount - b.itemCount; else if (sortKey === "created_at") cmp = a.created_at.localeCompare(b.created_at); return sortAsc ? cmp : -cmp; }); const formatDate = (s: string) => { const d = new Date(s); return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); }; const sortArrow = (key: typeof sortKey) => sortKey === key ? ( {sortAsc ? : } ) : null; if (loading) return

Loading projects...

; if (error) return

Error: {error}

; return (
{/* Header */}

Projects ({projects.length})

{isEditor && mode === "list" && ( )}
{/* Create / Edit form */} {(mode === "create" || mode === "edit") && (
{mode === "create" ? "New Project" : `Edit ${editingProject?.code}`}
{formError &&
{formError}
} {mode === "create" && (
setFormCode(e.target.value)} placeholder="e.g., PROJ-A" required minLength={2} maxLength={10} style={{ ...inputStyle, textTransform: "uppercase" }} />
)}
setFormName(e.target.value)} placeholder="Project name" style={inputStyle} />
setFormDesc(e.target.value)} placeholder="Project description" style={inputStyle} />
)} {/* Delete confirmation */} {mode === "delete" && editingProject && (
Delete Project
{formError &&
{formError}
}

Are you sure you want to permanently delete project{" "} {editingProject.code} ?

This action cannot be undone.

)} {/* Table */}
{isEditor && } {sorted.length === 0 ? ( ) : ( sorted.map((p, i) => ( {isEditor && ( )} )) )}
handleSort("code")}> Code{sortArrow("code")} handleSort("name")}> Name{sortArrow("name")} Description handleSort("itemCount")}> Items{sortArrow("itemCount")} handleSort("created_at")}> Created{sortArrow("created_at")} Actions
No projects found. Create your first project to start organizing items.
navigate(`/?project=${encodeURIComponent(p.code)}`) } style={{ color: "var(--ctp-peach)", fontFamily: "'JetBrains Mono', monospace", fontWeight: 500, cursor: "pointer", }} > {p.code} {p.name || "-"} {p.description || "-"} {p.itemCount} {formatDate(p.created_at)}
); } // Styles const btnPrimaryStyle: React.CSSProperties = { padding: "0.5rem 1rem", borderRadius: "0.25rem", border: "none", backgroundColor: "var(--ctp-mauve)", color: "var(--ctp-crust)", fontWeight: 500, fontSize: "0.75rem", cursor: "pointer", }; const btnSecondaryStyle: React.CSSProperties = { padding: "0.5rem 1rem", borderRadius: "0.25rem", border: "none", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-text)", fontWeight: 500, fontSize: "0.75rem", cursor: "pointer", }; const btnDangerStyle: React.CSSProperties = { padding: "0.5rem 1rem", borderRadius: "0.25rem", border: "none", backgroundColor: "var(--ctp-red)", color: "var(--ctp-crust)", fontWeight: 500, fontSize: "0.75rem", cursor: "pointer", }; const btnSmallStyle: React.CSSProperties = { padding: "0.25rem 0.5rem", borderRadius: "0.25rem", border: "none", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-text)", fontWeight: 500, fontSize: "0.75rem", cursor: "pointer", }; const formPaneStyle: React.CSSProperties = { backgroundColor: "var(--ctp-surface0)", borderRadius: "0.5rem", marginBottom: "1rem", overflow: "hidden", }; const formHeaderStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem 1rem", color: "var(--ctp-crust)", fontSize: "var(--font-body)", }; const formCloseStyle: React.CSSProperties = { background: "none", border: "none", color: "inherit", cursor: "pointer", fontSize: "0.75rem", fontWeight: 500, borderRadius: "0.25rem", }; const errorBannerStyle: React.CSSProperties = { color: "var(--ctp-red)", background: "rgba(243, 139, 168, 0.1)", border: "1px solid rgba(243, 139, 168, 0.2)", padding: "0.5rem 0.75rem", borderRadius: "0.5rem", marginBottom: "0.75rem", fontSize: "var(--font-body)", }; const fieldStyle: React.CSSProperties = { marginBottom: "0.75rem", }; const labelStyle: React.CSSProperties = { display: "block", marginBottom: "0.25rem", fontWeight: 500, color: "var(--ctp-subtext1)", fontSize: "var(--font-body)", }; const inputStyle: React.CSSProperties = { width: "100%", padding: "0.5rem 0.75rem", backgroundColor: "var(--ctp-base)", border: "1px solid var(--ctp-surface1)", borderRadius: "0.5rem", color: "var(--ctp-text)", fontSize: "var(--font-body)", boxSizing: "border-box", }; const tableContainerStyle: React.CSSProperties = { backgroundColor: "var(--ctp-surface0)", borderRadius: "0.75rem", padding: "0.5rem", overflowX: "auto", }; const thStyle: React.CSSProperties = { padding: "0.5rem 0.75rem", textAlign: "left", borderBottom: "1px solid var(--ctp-surface1)", color: "var(--ctp-overlay1)", fontWeight: 600, fontSize: "var(--font-table)", textTransform: "uppercase", letterSpacing: "0.05em", cursor: "pointer", userSelect: "none", }; const tdStyle: React.CSSProperties = { padding: "0.25rem 0.75rem", borderBottom: "1px solid var(--ctp-surface1)", fontSize: "var(--font-body)", };