diff options
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/orders/OrderDetail.tsx | 530 | ||||
| -rw-r--r-- | makima/frontend/src/components/orders/OrderList.tsx | 188 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useOrders.ts | 123 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 135 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/routes/orders.tsx | 238 |
7 files changed, 1216 insertions, 2 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 9bb7777..5aba6a3 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -12,7 +12,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Board", href: "/workflow", requiresAuth: true }, + { label: "Orders", href: "/orders", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx new file mode 100644 index 0000000..7f8a95d --- /dev/null +++ b/makima/frontend/src/components/orders/OrderDetail.tsx @@ -0,0 +1,530 @@ +import { useState } from "react"; +import type { + Order, + OrderStatus, + OrderPriority, + OrderType, + UpdateOrderRequest, + DirectiveSummary, +} from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_OPTIONS: { value: OrderPriority; color: string; label: string }[] = [ + { value: "critical", color: "text-red-400 border-red-800", label: "Critical" }, + { value: "high", color: "text-orange-400 border-orange-800", label: "High" }, + { value: "medium", color: "text-yellow-400 border-yellow-800", label: "Medium" }, + { value: "low", color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "Low" }, + { value: "none", color: "text-[#556677] border-[#2a3a5a]", label: "None" }, +]; + +const TYPE_OPTIONS: { value: OrderType; color: string; label: string }[] = [ + { value: "feature", color: "text-[#75aafc]", label: "Feature" }, + { value: "bug", color: "text-red-400", label: "Bug" }, + { value: "spike", color: "text-yellow-400", label: "Spike" }, + { value: "chore", color: "text-[#7788aa]", label: "Chore" }, + { value: "improvement", color: "text-emerald-400", label: "Improvement" }, +]; + +const STATUS_OPTIONS: OrderStatus[] = ["open", "in_progress", "done", "archived"]; + +interface OrderDetailProps { + order: Order; + directives: DirectiveSummary[]; + onUpdate: (req: UpdateOrderRequest) => Promise<void>; + onDelete: () => void; + onLinkDirective: (directiveId: string) => Promise<void>; + onLinkContract: (contractId: string) => Promise<void>; + onConvertToStep: (directiveId: string) => Promise<void>; + onRefresh: () => void; +} + +export function OrderDetail({ + order, + directives, + onUpdate, + onDelete, + onLinkDirective, + onLinkContract, + onConvertToStep, + onRefresh, +}: OrderDetailProps) { + const [editingTitle, setEditingTitle] = useState(false); + const [titleText, setTitleText] = useState(order.title); + const [editingDesc, setEditingDesc] = useState(false); + const [descText, setDescText] = useState(order.description || ""); + const [editingLabels, setEditingLabels] = useState(false); + const [labelsText, setLabelsText] = useState(order.labels.join(", ")); + const [showLinkDirective, setShowLinkDirective] = useState(false); + const [showLinkContract, setShowLinkContract] = useState(false); + const [contractIdInput, setContractIdInput] = useState(""); + const [showConvertToStep, setShowConvertToStep] = useState(false); + + const badge = STATUS_BADGE[order.status] || STATUS_BADGE.open; + const currentPriority = PRIORITY_OPTIONS.find((p) => p.value === order.priority) || PRIORITY_OPTIONS[4]; + const currentType = TYPE_OPTIONS.find((t) => t.value === order.orderType) || TYPE_OPTIONS[0]; + + const handleTitleSave = async () => { + if (titleText.trim() && titleText !== order.title) { + await onUpdate({ title: titleText.trim() }); + } + setEditingTitle(false); + }; + + const handleDescSave = async () => { + const newDesc = descText.trim() || null; + if (newDesc !== order.description) { + await onUpdate({ description: newDesc }); + } + setEditingDesc(false); + }; + + const handleLabelsSave = async () => { + const newLabels = labelsText + .split(",") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + await onUpdate({ labels: newLabels }); + setEditingLabels(false); + }; + + const handleStatusChange = async (status: OrderStatus) => { + await onUpdate({ status }); + }; + + const handlePriorityChange = async (priority: OrderPriority) => { + await onUpdate({ priority }); + }; + + const handleTypeChange = async (orderType: OrderType) => { + await onUpdate({ orderType }); + }; + + const handleLinkDirective = async (directiveId: string) => { + await onLinkDirective(directiveId); + setShowLinkDirective(false); + }; + + const handleLinkContract = async () => { + if (!contractIdInput.trim()) return; + await onLinkContract(contractIdInput.trim()); + setContractIdInput(""); + setShowLinkContract(false); + }; + + const handleConvertToStep = async (directiveId: string) => { + await onConvertToStep(directiveId); + setShowConvertToStep(false); + }; + + return ( + <div className="flex flex-col h-full overflow-y-auto"> + {/* Header */} + <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-2"> + {editingTitle ? ( + <div className="flex-1 flex items-center gap-2 pr-2"> + <input + value={titleText} + onChange={(e) => setTitleText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleTitleSave(); + if (e.key === "Escape") setEditingTitle(false); + }} + autoFocus + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[14px] font-mono text-white" + /> + <button + type="button" + onClick={handleTitleSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300" + > + [save] + </button> + <button + type="button" + onClick={() => setEditingTitle(false)} + className="text-[10px] font-mono text-[#556677] hover:text-white" + > + [cancel] + </button> + </div> + ) : ( + <h2 + className="text-[14px] font-mono text-white font-medium truncate pr-2 cursor-pointer hover:text-[#9bc3ff]" + onClick={() => { + setTitleText(order.title); + setEditingTitle(true); + }} + > + {order.title} + </h2> + )} + <div className="flex items-center gap-2 shrink-0"> + <span + className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`} + > + {badge.label} + </span> + <button + type="button" + onClick={onRefresh} + className="text-[10px] font-mono text-[#7788aa] hover:text-white" + title="Refresh" + > + [refresh] + </button> + </div> + </div> + + {/* Type + Priority inline */} + <div className="flex items-center gap-3 mb-2"> + <span className={`text-[10px] font-mono ${currentType.color}`}> + {currentType.label} + </span> + <span className="text-[10px] font-mono text-[#2a3a5a]">/</span> + <span className={`text-[10px] font-mono ${currentPriority.color} border rounded px-1.5 py-0.5`}> + {currentPriority.label} + </span> + </div> + + {/* Linked entities */} + {order.directiveId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Directive: <a href={`/directives/${order.directiveId}`} className="text-[#75aafc] hover:text-white underline">{order.directiveId.slice(0, 8)}...</a> + </div> + )} + {order.contractId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Contract: <a href={`/contracts/${order.contractId}`} className="text-[#75aafc] hover:text-white underline">{order.contractId.slice(0, 8)}...</a> + </div> + )} + {order.directiveStepId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Step: <span className="text-[#7788aa]">{order.directiveStepId.slice(0, 8)}...</span> + </div> + )} + + {/* Controls */} + <div className="flex flex-wrap gap-2 mt-2"> + <button + type="button" + onClick={onDelete} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto" + > + Delete + </button> + </div> + </div> + + {/* Status selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Status + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {STATUS_OPTIONS.map((s) => { + const sBadge = STATUS_BADGE[s]; + return ( + <button + key={s} + type="button" + onClick={() => handleStatusChange(s)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + s === order.status + ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {sBadge.label} + </button> + ); + })} + </div> + </div> + + {/* Priority selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Priority + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {PRIORITY_OPTIONS.map((p) => ( + <button + key={p.value} + type="button" + onClick={() => handlePriorityChange(p.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + p.value === order.priority + ? `${p.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {p.label} + </button> + ))} + </div> + </div> + + {/* Type selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Type + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {TYPE_OPTIONS.map((t) => ( + <button + key={t.value} + type="button" + onClick={() => handleTypeChange(t.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + t.value === order.orderType + ? `${t.color} border-current bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {t.label} + </button> + ))} + </div> + </div> + + {/* Description */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Description + </span> + {!editingDesc && ( + <button + type="button" + onClick={() => { + setDescText(order.description || ""); + setEditingDesc(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingDesc ? ( + <div className="flex flex-col gap-1.5"> + <textarea + value={descText} + onChange={(e) => setDescText(e.target.value)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[80px]" + rows={4} + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleDescSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingDesc(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap"> + {order.description || <span className="text-[#556677] italic">No description</span>} + </p> + )} + </div> + + {/* Labels */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Labels + </span> + {!editingLabels && ( + <button + type="button" + onClick={() => { + setLabelsText(order.labels.join(", ")); + setEditingLabels(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingLabels ? ( + <div className="flex flex-col gap-1.5"> + <input + value={labelsText} + onChange={(e) => setLabelsText(e.target.value)} + placeholder="label1, label2, ..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white" + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleLabelsSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingLabels(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="flex gap-1 flex-wrap"> + {order.labels.length > 0 ? ( + order.labels.map((l) => ( + <span + key={l} + className="text-[10px] font-mono text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5" + > + {l} + </span> + )) + ) : ( + <span className="text-[10px] font-mono text-[#556677] italic">No labels</span> + )} + </div> + )} + </div> + + {/* Actions */} + <div className="px-4 py-3 flex-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + Actions + </span> + + <div className="flex flex-col gap-2"> + {/* Link to Directive */} + <div> + <button + type="button" + onClick={() => setShowLinkDirective(!showLinkDirective)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left" + > + Link to Directive + </button> + {showLinkDirective && ( + <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded"> + {directives.length === 0 ? ( + <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> + No directives available + </div> + ) : ( + directives.map((d) => ( + <button + key={d.id} + type="button" + onClick={() => handleLinkDirective(d.id)} + className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + {d.title} + </button> + )) + )} + </div> + )} + </div> + + {/* Link to Contract */} + <div> + <button + type="button" + onClick={() => setShowLinkContract(!showLinkContract)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left" + > + Link to Contract + </button> + {showLinkContract && ( + <div className="mt-1 flex gap-1.5"> + <input + value={contractIdInput} + onChange={(e) => setContractIdInput(e.target.value)} + placeholder="Contract ID..." + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white" + autoFocus + /> + <button + type="button" + onClick={handleLinkContract} + disabled={!contractIdInput.trim()} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50" + > + Link + </button> + </div> + )} + </div> + + {/* Convert to Directive Step */} + {!order.directiveStepId && ( + <div> + <button + type="button" + onClick={() => setShowConvertToStep(!showConvertToStep)} + className="text-[10px] font-mono text-yellow-400 hover:text-yellow-300 border border-yellow-800 rounded px-2 py-1 w-full text-left" + > + Convert to Directive Step + </button> + {showConvertToStep && ( + <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded"> + {directives.length === 0 ? ( + <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> + No directives available + </div> + ) : ( + directives.map((d) => ( + <button + key={d.id} + type="button" + onClick={() => handleConvertToStep(d.id)} + className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-yellow-400 hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + {d.title} + </button> + )) + )} + </div> + )} + </div> + )} + </div> + </div> + + {/* Metadata */} + <div className="px-4 py-2 border-t border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between text-[9px] font-mono text-[#556677]"> + <span>Created {new Date(order.createdAt).toLocaleDateString()}</span> + <span>Updated {new Date(order.updatedAt).toLocaleDateString()}</span> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/orders/OrderList.tsx b/makima/frontend/src/components/orders/OrderList.tsx new file mode 100644 index 0000000..76ac7a7 --- /dev/null +++ b/makima/frontend/src/components/orders/OrderList.tsx @@ -0,0 +1,188 @@ +import { useState, useMemo } from "react"; +import type { Order, OrderStatus, OrderPriority, OrderType } from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_COLOR: Record<OrderPriority, string> = { + critical: "bg-red-400", + high: "bg-orange-400", + medium: "bg-yellow-400", + low: "bg-[#75aafc]", + none: "bg-[#556677]", +}; + +const TYPE_BADGE: Record<OrderType, { color: string; label: string }> = { + feature: { color: "text-[#75aafc] border-[rgba(117,170,252,0.3)]", label: "FEAT" }, + bug: { color: "text-red-400 border-red-800", label: "BUG" }, + spike: { color: "text-yellow-400 border-yellow-800", label: "SPIKE" }, + chore: { color: "text-[#7788aa] border-[#2a3a5a]", label: "CHORE" }, + improvement: { color: "text-emerald-400 border-emerald-800", label: "IMPROVE" }, +}; + +interface OrderListProps { + orders: Order[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; + statusFilter: OrderStatus | undefined; + onStatusFilter: (s: OrderStatus | undefined) => void; + typeFilter: OrderType | undefined; + onTypeFilter: (t: OrderType | undefined) => void; +} + +const STATUS_OPTIONS: (OrderStatus | "all")[] = ["all", "open", "in_progress", "done", "archived"]; +const TYPE_OPTIONS: (OrderType | "all")[] = ["all", "feature", "bug", "spike", "chore", "improvement"]; + +export function OrderList({ + orders, + selectedId, + onSelect, + onCreate, + statusFilter, + onStatusFilter, + typeFilter, + onTypeFilter, +}: OrderListProps) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search.trim()) return orders; + const q = search.toLowerCase(); + return orders.filter( + (o) => + o.title.toLowerCase().includes(q) || + (o.description && o.description.toLowerCase().includes(q)) || + o.labels.some((l) => l.toLowerCase().includes(q)), + ); + }, [orders, search]); + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Orders + </span> + <button + type="button" + onClick={onCreate} + className="text-[11px] font-mono text-[#75aafc] hover:text-white bg-transparent border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5 hover:border-[rgba(117,170,252,0.6)] transition-colors" + > + + New + </button> + </div> + + {/* Search */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)]"> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search orders..." + className="w-full bg-transparent border-none outline-none text-[11px] font-mono text-white placeholder:text-[#556677]" + /> + </div> + + {/* Filters */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)] flex flex-col gap-1"> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Status + </span> + {STATUS_OPTIONS.map((s) => ( + <button + key={s} + type="button" + onClick={() => onStatusFilter(s === "all" ? undefined : s)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (s === "all" && !statusFilter) || s === statusFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {s === "all" ? "ALL" : s === "in_progress" ? "WIP" : s.toUpperCase()} + </button> + ))} + </div> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Type + </span> + {TYPE_OPTIONS.map((t) => ( + <button + key={t} + type="button" + onClick={() => onTypeFilter(t === "all" ? undefined : t)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (t === "all" && !typeFilter) || t === typeFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {t === "all" ? "ALL" : t.toUpperCase()} + </button> + ))} + </div> + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto"> + {filtered.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + No orders found + </div> + ) : ( + filtered.map((o) => { + const statusBadge = STATUS_BADGE[o.status] || STATUS_BADGE.open; + const typeBadge = TYPE_BADGE[o.orderType] || TYPE_BADGE.feature; + const priorityColor = PRIORITY_COLOR[o.priority] || PRIORITY_COLOR.none; + + return ( + <button + key={o.id} + type="button" + onClick={() => onSelect(o.id)} + className={`w-full text-left px-3 py-2.5 border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + selectedId === o.id ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-start gap-2 mb-1"> + {/* Priority dot */} + <span + className={`w-2 h-2 rounded-full ${priorityColor} shrink-0 mt-[3px]`} + title={o.priority} + /> + <span className="text-[12px] font-mono text-white truncate flex-1"> + {o.title} + </span> + </div> + <div className="flex items-center gap-1.5 pl-4"> + <span + className={`text-[9px] font-mono ${statusBadge.color} border rounded px-1.5 py-0.5`} + > + {statusBadge.label} + </span> + <span + className={`text-[9px] font-mono ${typeBadge.color} border rounded px-1.5 py-0.5`} + > + {typeBadge.label} + </span> + {o.labels.length > 0 && ( + <span className="text-[9px] font-mono text-[#556677] truncate"> + {o.labels.slice(0, 2).join(", ")} + {o.labels.length > 2 && ` +${o.labels.length - 2}`} + </span> + )} + </div> + </button> + ); + }) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/hooks/useOrders.ts b/makima/frontend/src/hooks/useOrders.ts new file mode 100644 index 0000000..2dd20bb --- /dev/null +++ b/makima/frontend/src/hooks/useOrders.ts @@ -0,0 +1,123 @@ +import { useState, useEffect, useCallback } from "react"; +import { + type Order, + type OrderStatus, + type OrderType, + type OrderPriority, + type CreateOrderRequest, + type UpdateOrderRequest, + listOrders, + createOrder, + getOrder, + updateOrder, + deleteOrder, + linkOrderToDirective, + linkOrderToContract, + convertOrderToStep, +} from "../lib/api"; + +export function useOrders( + statusFilter?: OrderStatus, + typeFilter?: OrderType, + priorityFilter?: OrderPriority, +) { + const [orders, setOrders] = useState<Order[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await listOrders(statusFilter, typeFilter, priorityFilter); + setOrders(res.orders); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load orders"); + } finally { + setLoading(false); + } + }, [statusFilter, typeFilter, priorityFilter]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = useCallback(async (req: CreateOrderRequest) => { + const o = await createOrder(req); + await refresh(); + return o; + }, [refresh]); + + const remove = useCallback(async (id: string) => { + await deleteOrder(id); + await refresh(); + }, [refresh]); + + return { orders, loading, error, refresh, create, remove }; +} + +export function useOrder(id: string | undefined) { + const [order, setOrder] = useState<Order | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const o = await getOrder(id); + setOrder(o); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load order"); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + setOrder(null); + setError(null); + setLoading(true); + refresh(); + }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + + const update = useCallback(async (req: UpdateOrderRequest) => { + if (!id) return; + const o = await updateOrder(id, req); + setOrder(o); + return o; + }, [id]); + + const remove = useCallback(async () => { + if (!id) return; + await deleteOrder(id); + }, [id]); + + const linkDirective = useCallback(async (directiveId: string) => { + if (!id) return; + const o = await linkOrderToDirective(id, directiveId); + setOrder(o); + return o; + }, [id]); + + const linkContract = useCallback(async (contractId: string) => { + if (!id) return; + const o = await linkOrderToContract(id, contractId); + setOrder(o); + return o; + }, [id]); + + const convertToStep = useCallback(async (directiveId: string) => { + if (!id) return; + const step = await convertOrderToStep(id, directiveId); + await refresh(); + return step; + }, [id, refresh]); + + return { + order, loading, error, refresh, + update, remove, + linkDirective, linkContract, convertToStep, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 480041c..6adc4d4 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3246,3 +3246,138 @@ export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: numb return res.json(); } +// ============================================================================= +// Orders API +// ============================================================================= + +export type OrderPriority = "critical" | "high" | "medium" | "low" | "none"; +export type OrderStatus = "open" | "in_progress" | "done" | "archived"; +export type OrderType = "feature" | "bug" | "spike" | "chore" | "improvement"; + +export interface Order { + id: string; + ownerId: string; + title: string; + description: string | null; + priority: OrderPriority; + status: OrderStatus; + orderType: OrderType; + labels: string[]; + directiveId: string | null; + directiveStepId: string | null; + contractId: string | null; + repositoryUrl: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OrderListResponse { + orders: Order[]; + total: number; +} + +export interface CreateOrderRequest { + title: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export interface UpdateOrderRequest { + title?: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + directiveStepId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export async function listOrders( + status?: OrderStatus, + type?: OrderType, + priority?: OrderPriority, + directiveId?: string, + contractId?: string, +): Promise<OrderListResponse> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (type) params.set("type", type); + if (priority) params.set("priority", priority); + if (directiveId) params.set("directiveId", directiveId); + if (contractId) params.set("contractId", contractId); + const qs = params.toString(); + const res = await authFetch(`${API_BASE}/api/v1/orders${qs ? `?${qs}` : ""}`); + if (!res.ok) throw new Error(`Failed to list orders: ${res.statusText}`); + return res.json(); +} + +export async function createOrder(req: CreateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create order: ${res.statusText}`); + return res.json(); +} + +export async function getOrder(id: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`); + if (!res.ok) throw new Error(`Failed to get order: ${res.statusText}`); + return res.json(); +} + +export async function updateOrder(id: string, req: UpdateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update order: ${res.statusText}`); + return res.json(); +} + +export async function deleteOrder(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete order: ${res.statusText}`); +} + +export async function linkOrderToDirective(orderId: string, directiveId: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-directive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directiveId }), + }); + if (!res.ok) throw new Error(`Failed to link order to directive: ${res.statusText}`); + return res.json(); +} + +export async function linkOrderToContract(orderId: string, contractId: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-contract`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contractId }), + }); + if (!res.ok) throw new Error(`Failed to link order to contract: ${res.statusText}`); + return res.json(); +} + +export async function convertOrderToStep(orderId: string, directiveId: string): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/convert-to-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directiveId }), + }); + if (!res.ok) throw new Error(`Failed to convert order to step: ${res.statusText}`); + return res.json(); +} + diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 3dc68f5..1134bd3 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,7 +12,7 @@ import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; -import WorkflowPage from "./routes/workflow"; +import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx new file mode 100644 index 0000000..456e1a7 --- /dev/null +++ b/makima/frontend/src/routes/orders.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { OrderList } from "../components/orders/OrderList"; +import { OrderDetail } from "../components/orders/OrderDetail"; +import { useOrders, useOrder } from "../hooks/useOrders"; +import { useDirectives } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; +import type { OrderStatus, OrderType, OrderPriority } from "../lib/api"; + +export default function OrdersPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + + const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined); + const [typeFilter, setTypeFilter] = useState<OrderType | undefined>(undefined); + const { orders, loading: listLoading, create, remove: removeFromList, refresh: refreshList } = useOrders(statusFilter, typeFilter); + const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, linkContract, convertToStep } = useOrder(selectedId); + const { directives } = useDirectives(); + + const [showCreate, setShowCreate] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [newPriority, setNewPriority] = useState<OrderPriority>("medium"); + const [newType, setNewType] = useState<OrderType>("feature"); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + 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> + ); + } + + const handleCreate = async () => { + if (!newTitle.trim()) return; + try { + const o = await create({ + title: newTitle.trim(), + description: newDesc.trim() || undefined, + priority: newPriority, + orderType: newType, + }); + setShowCreate(false); + setNewTitle(""); + setNewDesc(""); + setNewPriority("medium"); + setNewType("feature"); + navigate(`/orders/${o.id}`); + } catch (e) { + console.error("Failed to create order:", e); + } + }; + + const handleDelete = async () => { + if (!selectedId) return; + if (!window.confirm("Delete this order?")) return; + try { + await removeOrder(); + await refreshList(); + navigate("/orders"); + } catch (e) { + console.error("Failed to delete:", e); + } + }; + + const handleUpdate = async (req: Parameters<typeof update>[0]) => { + await update(req); + await refreshList(); + }; + + const handleLinkDirective = async (directiveId: string) => { + await linkDirective(directiveId); + await refreshList(); + }; + + const handleLinkContract = async (contractId: string) => { + await linkContract(contractId); + await refreshList(); + }; + + const handleConvertToStep = async (directiveId: string) => { + await convertToStep(directiveId); + await refreshList(); + }; + + const priorityOptions: { value: OrderPriority; label: string }[] = [ + { value: "critical", label: "Critical" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + { value: "none", label: "None" }, + ]; + + const typeOptions: { value: OrderType; label: string }[] = [ + { value: "feature", label: "Feature" }, + { value: "bug", label: "Bug" }, + { value: "spike", label: "Spike" }, + { value: "chore", label: "Chore" }, + { value: "improvement", label: "Improvement" }, + ]; + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> + {/* Left: List */} + <div className="w-[280px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> + <OrderList + orders={orders} + selectedId={selectedId ?? null} + onSelect={(id) => navigate(`/orders/${id}`)} + onCreate={() => setShowCreate(true)} + statusFilter={statusFilter} + onStatusFilter={setStatusFilter} + typeFilter={typeFilter} + onTypeFilter={setTypeFilter} + /> + </div> + + {/* Right: Detail or Create */} + <div className="flex-1 overflow-hidden"> + {showCreate ? ( + <div className="p-4 max-w-lg"> + <h2 className="text-[14px] font-mono text-white font-medium mb-4"> + New Order + </h2> + <div className="flex flex-col gap-3"> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Title + </label> + <input + value={newTitle} + onChange={(e) => setNewTitle(e.target.value)} + placeholder="Order title..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + onKeyDown={(e) => { + if (e.key === "Enter" && newTitle.trim()) handleCreate(); + }} + /> + </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Description (optional) + </label> + <textarea + value={newDesc} + onChange={(e) => setNewDesc(e.target.value)} + placeholder="Describe the order..." + rows={4} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white resize-y" + /> + </div> + <div className="flex gap-4"> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Priority + </label> + <select + value={newPriority} + onChange={(e) => setNewPriority(e.target.value as OrderPriority)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + > + {priorityOptions.map((p) => ( + <option key={p.value} value={p.value}>{p.label}</option> + ))} + </select> + </div> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Type + </label> + <select + value={newType} + onChange={(e) => setNewType(e.target.value as OrderType)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + > + {typeOptions.map((t) => ( + <option key={t.value} value={t.value}>{t.label}</option> + ))} + </select> + </div> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleCreate} + disabled={!newTitle.trim()} + className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-3 py-1 disabled:opacity-50" + > + Create + </button> + <button + type="button" + onClick={() => setShowCreate(false)} + className="text-[11px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-3 py-1" + > + Cancel + </button> + </div> + </div> + </div> + ) : selectedId && order ? ( + <OrderDetail + order={order} + directives={directives} + onUpdate={handleUpdate} + onDelete={handleDelete} + onLinkDirective={handleLinkDirective} + onLinkContract={handleLinkContract} + onConvertToStep={handleConvertToStep} + onRefresh={refreshDetail} + /> + ) : ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]"> + {listLoading + ? "Loading..." + : "Select an order or create a new one"} + </p> + </div> + )} + </div> + </main> + </div> + ); +} |
