summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DOGList.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-03-07 02:29:19 +0000
committerGitHub <noreply@github.com>2026-03-07 02:29:19 +0000
commitef643072234477685614ed281e34ef77e45caad4 (patch)
tree96562ad1b73efa0f21ea79ae571e1c8674549d31 /makima/frontend/src/components/directives/DOGList.tsx
parent0e30f1790cd3a1717dcb55ae137700de9bb0dfcb (diff)
parentae3bc57de7a240c3c8ab15080b405e8ea3e16ccb (diff)
downloadsoryu-ef643072234477685614ed281e34ef77e45caad4.tar.gz
soryu-ef643072234477685614ed281e34ef77e45caad4.zip
Merge pull request #86 from soryu-co/makima/directive-soryu-co-soryu---makima-19fd3e1d-v1772803139
feat: filter contract phase orbs by type & add DOGs (directive order groups)
Diffstat (limited to 'makima/frontend/src/components/directives/DOGList.tsx')
-rw-r--r--makima/frontend/src/components/directives/DOGList.tsx381
1 files changed, 381 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/DOGList.tsx b/makima/frontend/src/components/directives/DOGList.tsx
new file mode 100644
index 0000000..de59d7d
--- /dev/null
+++ b/makima/frontend/src/components/directives/DOGList.tsx
@@ -0,0 +1,381 @@
+import { useState } from "react";
+import type {
+ DirectiveOrderGroup,
+ DOGStatus,
+ CreateDOGRequest,
+} from "../../lib/api";
+
+const DOG_STATUS_BADGE: Record<DOGStatus, { 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 DOG_STATUS_OPTIONS: DOGStatus[] = ["open", "in_progress", "done", "archived"];
+
+interface DOGListProps {
+ dogs: DirectiveOrderGroup[];
+ loading: boolean;
+ onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>;
+ onUpdateDog: (dogId: string, req: { name?: string; description?: string | null; status?: DOGStatus }) => Promise<void>;
+ onDeleteDog: (dogId: string) => Promise<void>;
+ onPickUpOrders: (dogId: string) => Promise<any>;
+}
+
+export function DOGList({
+ dogs,
+ loading,
+ onCreateDog,
+ onUpdateDog,
+ onDeleteDog,
+ onPickUpOrders,
+}: DOGListProps) {
+ const [showCreate, setShowCreate] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [newDesc, setNewDesc] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const handleCreate = async () => {
+ if (!newName.trim()) return;
+ setCreating(true);
+ try {
+ await onCreateDog({
+ name: newName.trim(),
+ description: newDesc.trim() || null,
+ });
+ setNewName("");
+ setNewDesc("");
+ setShowCreate(false);
+ } catch (e) {
+ console.error("Failed to create DOG:", e);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <span className="text-[10px] font-mono text-[#556677]">Loading DOGs...</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-3">
+ {/* Header + Create button */}
+ <div className="flex items-center justify-between">
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Order Groups ({dogs.length})
+ </span>
+ <button
+ type="button"
+ onClick={() => setShowCreate(!showCreate)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5"
+ >
+ {showCreate ? "Cancel" : "+ New DOG"}
+ </button>
+ </div>
+
+ {/* Create form */}
+ {showCreate && (
+ <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] rounded p-3 flex flex-col gap-2">
+ <div>
+ <label className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide block mb-1">
+ Name *
+ </label>
+ <input
+ value={newName}
+ onChange={(e) => setNewName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && newName.trim()) handleCreate();
+ if (e.key === "Escape") setShowCreate(false);
+ }}
+ placeholder="Group name..."
+ autoFocus
+ 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 placeholder:text-[#445566]"
+ />
+ </div>
+ <div>
+ <label className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide block mb-1">
+ Description
+ </label>
+ <textarea
+ value={newDesc}
+ onChange={(e) => setNewDesc(e.target.value)}
+ placeholder="Optional description..."
+ rows={2}
+ 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 placeholder:text-[#445566]"
+ />
+ </div>
+ <div className="flex gap-1.5">
+ <button
+ type="button"
+ onClick={handleCreate}
+ disabled={!newName.trim() || creating}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5 disabled:opacity-50"
+ >
+ {creating ? "Creating..." : "Create"}
+ </button>
+ <button
+ type="button"
+ onClick={() => setShowCreate(false)}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* DOG list */}
+ {dogs.length === 0 ? (
+ <div className="flex items-center justify-center py-6">
+ <span className="text-[10px] font-mono text-[#556677] italic">
+ No order groups yet
+ </span>
+ </div>
+ ) : (
+ <div className="flex flex-col gap-2">
+ {dogs.map((dog) => (
+ <DOGCard
+ key={dog.id}
+ dog={dog}
+ onUpdate={onUpdateDog}
+ onDelete={onDeleteDog}
+ onPickUpOrders={onPickUpOrders}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+function DOGCard({
+ dog,
+ onUpdate,
+ onDelete,
+ onPickUpOrders,
+}: {
+ dog: DirectiveOrderGroup;
+ onUpdate: (dogId: string, req: { name?: string; description?: string | null; status?: DOGStatus }) => Promise<void>;
+ onDelete: (dogId: string) => Promise<void>;
+ onPickUpOrders: (dogId: string) => Promise<any>;
+}) {
+ const [editingName, setEditingName] = useState(false);
+ const [nameText, setNameText] = useState(dog.name);
+ const [editingDesc, setEditingDesc] = useState(false);
+ const [descText, setDescText] = useState(dog.description || "");
+ const [pickingUp, setPickingUp] = useState(false);
+ const [pickUpResult, setPickUpResult] = useState<string | null>(null);
+ const [deleting, setDeleting] = useState(false);
+
+ const badge = DOG_STATUS_BADGE[dog.status] || DOG_STATUS_BADGE.open;
+
+ const handleNameSave = async () => {
+ if (nameText.trim() && nameText !== dog.name) {
+ await onUpdate(dog.id, { name: nameText.trim() });
+ }
+ setEditingName(false);
+ };
+
+ const handleDescSave = async () => {
+ const newDesc = descText.trim() || null;
+ if (newDesc !== dog.description) {
+ await onUpdate(dog.id, { description: newDesc });
+ }
+ setEditingDesc(false);
+ };
+
+ const handleStatusChange = async (status: DOGStatus) => {
+ await onUpdate(dog.id, { status });
+ };
+
+ const handlePickUpOrders = async () => {
+ setPickingUp(true);
+ setPickUpResult(null);
+ try {
+ const result = await onPickUpOrders(dog.id);
+ if (result) {
+ setPickUpResult(result.message);
+ setTimeout(() => setPickUpResult(null), 5000);
+ }
+ } catch (e) {
+ setPickUpResult(e instanceof Error ? e.message : "Failed to plan orders");
+ setTimeout(() => setPickUpResult(null), 5000);
+ } finally {
+ setPickingUp(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!window.confirm(`Delete DOG "${dog.name}"?`)) return;
+ setDeleting(true);
+ try {
+ await onDelete(dog.id);
+ } catch (e) {
+ console.error("Failed to delete DOG:", e);
+ setDeleting(false);
+ }
+ };
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] rounded">
+ {/* Name + Status */}
+ <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.08)]">
+ <div className="flex items-center justify-between mb-1.5">
+ {editingName ? (
+ <div className="flex-1 flex items-center gap-2 pr-2">
+ <input
+ value={nameText}
+ onChange={(e) => setNameText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleNameSave();
+ if (e.key === "Escape") setEditingName(false);
+ }}
+ autoFocus
+ className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5 text-[12px] font-mono text-white"
+ />
+ <button
+ type="button"
+ onClick={handleNameSave}
+ className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300"
+ >
+ [save]
+ </button>
+ <button
+ type="button"
+ onClick={() => setEditingName(false)}
+ className="text-[9px] font-mono text-[#556677] hover:text-white"
+ >
+ [cancel]
+ </button>
+ </div>
+ ) : (
+ <span
+ className="text-[12px] font-mono text-white font-medium cursor-pointer hover:text-[#9bc3ff] truncate pr-2"
+ onClick={() => {
+ setNameText(dog.name);
+ setEditingName(true);
+ }}
+ >
+ {dog.name}
+ </span>
+ )}
+ <span className={`text-[9px] font-mono ${badge.color} border rounded px-1.5 py-0.5 shrink-0`}>
+ {badge.label}
+ </span>
+ </div>
+
+ {/* Status selector */}
+ <div className="flex gap-1 flex-wrap">
+ {DOG_STATUS_OPTIONS.map((s) => {
+ const sBadge = DOG_STATUS_BADGE[s];
+ return (
+ <button
+ key={s}
+ type="button"
+ onClick={() => handleStatusChange(s)}
+ className={`text-[9px] font-mono border rounded px-1.5 py-0.5 transition-colors ${
+ s === dog.status
+ ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]`
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ >
+ {sBadge.label}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* Description */}
+ <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.08)]">
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-[9px] font-mono text-[#7788aa] uppercase tracking-wide">
+ Description
+ </span>
+ {!editingDesc && (
+ <button
+ type="button"
+ onClick={() => {
+ setDescText(dog.description || "");
+ setEditingDesc(true);
+ }}
+ className="text-[8px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [edit]
+ </button>
+ )}
+ </div>
+ {editingDesc ? (
+ <div className="flex flex-col gap-1">
+ <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 text-[10px] font-mono text-white resize-y min-h-[40px]"
+ rows={2}
+ autoFocus
+ />
+ <div className="flex gap-1">
+ <button
+ type="button"
+ onClick={handleDescSave}
+ className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-1.5 py-0.5"
+ >
+ Save
+ </button>
+ <button
+ type="button"
+ onClick={() => setEditingDesc(false)}
+ className="text-[9px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-1.5 py-0.5"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <p className="text-[10px] font-mono text-[#c0d0e0] whitespace-pre-wrap">
+ {dog.description || <span className="text-[#556677] italic">No description</span>}
+ </p>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="px-3 py-2 flex items-center gap-2">
+ <button
+ type="button"
+ onClick={handlePickUpOrders}
+ disabled={pickingUp}
+ className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-0.5 disabled:opacity-50"
+ >
+ {pickingUp ? "Planning..." : "Plan Orders"}
+ </button>
+ <button
+ type="button"
+ onClick={handleDelete}
+ disabled={deleting}
+ className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-0.5 ml-auto disabled:opacity-50"
+ >
+ {deleting ? "Deleting..." : "Delete"}
+ </button>
+ </div>
+
+ {/* Pick-up result */}
+ {pickUpResult && (
+ <div className="mx-3 mb-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded">
+ <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span>
+ </div>
+ )}
+
+ {/* Metadata */}
+ <div className="px-3 py-1.5 border-t border-[rgba(117,170,252,0.05)]">
+ <span className="text-[8px] font-mono text-[#445566]">
+ Created {new Date(dog.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ </div>
+ );
+}