- Timestamp:
- 06/22/26 19:00:39 (2 weeks ago)
- Branches:
- master
- Children:
- c0086c0
- Parents:
- 194c016
- Location:
- frontend
- Files:
-
- 2 added
- 5 edited
-
Dockerfile (added)
-
nginx.conf (added)
-
src/api/discipline.js (modified) (1 diff)
-
src/pages/Dashboard/pages/CustomCategory/CustomCategoryKanban.jsx (modified) (8 diffs)
-
src/pages/Dashboard/pages/CustomCategory/components/CustomCategoryKanbanBoard.jsx (modified) (1 diff)
-
src/pages/Dashboard/pages/CustomCategory/components/KanbanColumn.jsx (modified) (4 diffs)
-
src/pages/Dashboard/pages/CustomCategory/components/TaskTicketCard.jsx (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/api/discipline.js
r194c016 r545738f 81 81 } 82 82 83 export async function updateCustomCategoryTaskStatus(customTrackingId, taskId, status) { 84 const res = await api.patch( 85 `/discipline/custom-tracking-categories/${customTrackingId}/tasks/${taskId}/status`, 86 { status } 87 ); 88 return res?.data; 89 } 90 83 91 export async function deleteCustomCategoryTask(customTrackingId, taskId) { 84 92 return api.delete( -
frontend/src/pages/Dashboard/pages/CustomCategory/CustomCategoryKanban.jsx
r194c016 r545738f 8 8 getCustomTrackingCategories, 9 9 updateCustomCategoryTask, 10 updateCustomCategoryTask Finished,10 updateCustomCategoryTaskStatus, 11 11 } from "../../../../api/discipline.js"; 12 12 … … 23 23 24 24 const lanes = useMemo(() => { 25 const notDone = (tasks ?? []).filter((t) => !t.isFinished); 26 const done = (tasks ?? []).filter((t) => t.isFinished); 27 return { notDone, done }; 25 const all = tasks ?? []; 26 return { 27 notStarted: all.filter((t) => t.status === "NOT_STARTED"), 28 inProgress: all.filter((t) => t.status === "IN_PROGRESS"), 29 finished: all.filter((t) => t.status === "FINISHED"), 30 }; 28 31 }, [tasks]); 29 32 … … 36 39 getCustomCategoryTasks(customTrackingId), 37 40 ]); 38 39 41 const cats = catsRes?.items ?? []; 40 42 const found = cats.find((c) => Number(c.customTrackingId) === customTrackingId); 41 43 setCategoryName(found?.name ?? "Custom Category"); 42 43 44 setTasks(tasksRes?.tasks ?? []); 44 45 } catch (e) { … … 59 60 }, [customTrackingId]); 60 61 61 async function handleAddTask( name) {62 const created = await createCustomCategoryTask(customTrackingId, { name });62 async function handleAddTask(payload) { 63 const created = await createCustomCategoryTask(customTrackingId, payload); 63 64 setTasks((prev) => [created, ...(prev ?? [])]); 64 65 } 65 66 66 async function handleEditTask(taskId, name) {67 const updated = await updateCustomCategoryTask(customTrackingId, taskId, { name });67 async function handleEditTask(taskId, payload) { 68 const updated = await updateCustomCategoryTask(customTrackingId, taskId, payload); 68 69 setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? updated : t))); 69 70 } … … 74 75 } 75 76 76 async function handleMoveTask(taskId, toFinished) { 77 // optimistic update 77 async function handleMoveTask(taskId, toStatus) { 78 78 setTasks((prev) => 79 (prev ?? []).map((t) => 80 t.taskId === taskId ? { ...t, isFinished: toFinished } : t 81 ) 79 (prev ?? []).map((t) => (t.taskId === taskId ? { ...t, status: toStatus } : t)) 82 80 ); 83 84 81 try { 85 const saved = await updateCustomCategoryTaskFinished( 86 customTrackingId, 87 taskId, 88 toFinished 89 ); 82 const saved = await updateCustomCategoryTaskStatus(customTrackingId, taskId, toStatus); 90 83 setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? saved : t))); 91 } catch (e) { 92 // revert by refetching (simple + correct) 84 } catch { 93 85 await refresh(); 94 throw e;95 86 } 96 87 } … … 102 93 <h1 className="text-2xl font-bold">{categoryName}</h1> 103 94 <p className="text-sm opacity-80"> 104 Drag tickets between columns to mark them done / not done.95 Drag tickets between columns or use the edit form to update status. 105 96 </p> 106 97 </div> … … 115 106 <CustomCategoryKanbanBoard 116 107 loading={loading} 117 notDone={lanes.notDone} 118 done={lanes.done} 108 notStarted={lanes.notStarted} 109 inProgress={lanes.inProgress} 110 finished={lanes.finished} 119 111 onAddTask={handleAddTask} 120 112 onEditTask={handleEditTask} … … 125 117 ); 126 118 } 127 128 -
frontend/src/pages/Dashboard/pages/CustomCategory/components/CustomCategoryKanbanBoard.jsx
r194c016 r545738f 1 1 import React, { useMemo, useState } from "react"; 2 3 import { 4 DndContext, 5 PointerSensor, 6 closestCenter, 7 useSensor, 8 useSensors, 9 } from "@dnd-kit/core"; 10 import { 11 SortableContext, 12 arrayMove, 13 rectSortingStrategy, 14 } from "@dnd-kit/sortable"; 2 import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core"; 3 import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable"; 4 import { MdAdd, MdSort } from "react-icons/md"; 15 5 16 6 import KanbanColumn from "./KanbanColumn"; 17 7 import TaskTicketCard from "./TaskTicketCard"; 8 import TaskFormModal from "./TaskFormModal"; 18 9 19 function laneIdForTask(task) { 20 return task?.isFinished ? "done" : "notDone"; 10 const LANES = [ 11 { id: "NOT_STARTED", droppableId: "lane:NOT_STARTED", title: "Not Started" }, 12 { id: "IN_PROGRESS", droppableId: "lane:IN_PROGRESS", title: "In Progress" }, 13 { id: "FINISHED", droppableId: "lane:FINISHED", title: "Finished" }, 14 ]; 15 16 const PRIORITY_ORDER = { HIGH: 0, MEDIUM: 1, LOW: 2, null: 3, undefined: 3 }; 17 18 function sortTasks(tasks, sortBy) { 19 if (!tasks || tasks.length === 0) return tasks; 20 const arr = [...tasks]; 21 if (sortBy === "dueDate") { 22 arr.sort((a, b) => { 23 if (!a.dueDate && !b.dueDate) return 0; 24 if (!a.dueDate) return 1; 25 if (!b.dueDate) return -1; 26 return new Date(a.dueDate) - new Date(b.dueDate); 27 }); 28 } else if (sortBy === "priority") { 29 arr.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3)); 30 } 31 return arr; 32 } 33 34 function statusForDropId(dropId) { 35 return dropId.replace("lane:", ""); 21 36 } 22 37 23 38 export default function CustomCategoryKanbanBoard({ 24 loading, 25 notDone, 26 done, 27 onAddTask, 28 onEditTask, 29 onDeleteTask, 30 onMoveTask, 39 loading, notStarted, inProgress, finished, 40 onAddTask, onEditTask, onDeleteTask, onMoveTask, 31 41 }) { 32 const [newName, setNewName] = useState(""); 33 const [submitting, setSubmitting] = useState(false); 42 const [modalOpen, setModalOpen] = useState(false); 43 const [editingTask, setEditingTask] = useState(null); 44 const [modalLoading, setModalLoading] = useState(false); 45 const [sortBy, setSortBy] = useState("default"); 34 46 35 47 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); 36 48 37 // Keep a local order per lane for nicer UX, but still driven by server state. 38 const [notDoneOrder, setNotDoneOrder] = useState([]); 39 const [doneOrder, setDoneOrder] = useState([]); 49 const tasksByLane = useMemo(() => ({ 50 NOT_STARTED: sortTasks(notStarted ?? [], sortBy), 51 IN_PROGRESS: sortTasks(inProgress ?? [], sortBy), 52 FINISHED: sortTasks(finished ?? [], sortBy), 53 }), [notStarted, inProgress, finished, sortBy]); 40 54 41 const orderedNotDone = useMemo(() => { 42 const byId = new Map((notDone ?? []).map((t) => [String(t.taskId), t])); 43 const ids = [ 44 ...notDoneOrder.filter((id) => byId.has(id)), 45 ...Array.from(byId.keys()).filter((id) => !notDoneOrder.includes(id)), 46 ]; 47 return ids.map((id) => byId.get(id)); 48 }, [notDone, notDoneOrder]); 55 const allTasks = useMemo(() => [ 56 ...(notStarted ?? []), 57 ...(inProgress ?? []), 58 ...(finished ?? []), 59 ], [notStarted, inProgress, finished]); 49 60 50 const orderedDone = useMemo(() => { 51 const byId = new Map((done ?? []).map((t) => [String(t.taskId), t])); 52 const ids = [ 53 ...doneOrder.filter((id) => byId.has(id)), 54 ...Array.from(byId.keys()).filter((id) => !doneOrder.includes(id)), 55 ]; 56 return ids.map((id) => byId.get(id)); 57 }, [done, doneOrder]); 61 function findTask(id) { 62 return allTasks.find((t) => String(t.taskId) === String(id)); 63 } 58 64 59 async function submitNewTask(e) { 60 e.preventDefault(); 61 const name = newName.trim(); 62 if (!name || submitting) return; 63 setSubmitting(true); 64 try { 65 await onAddTask(name); 66 setNewName(""); 67 } finally { 68 setSubmitting(false); 65 async function handleDragEnd({ active, over }) { 66 if (!over) return; 67 const activeId = String(active.id); 68 const overId = String(over.id); 69 if (activeId === overId) return; 70 71 const task = findTask(activeId); 72 if (!task) return; 73 74 if (overId.startsWith("lane:")) { 75 const toStatus = statusForDropId(overId); 76 if (task.status !== toStatus) await onMoveTask(task.taskId, toStatus); 77 return; 78 } 79 80 const overTask = findTask(overId); 81 if (!overTask) return; 82 83 if (task.status !== overTask.status) { 84 await onMoveTask(task.taskId, overTask.status); 69 85 } 70 86 } 71 87 72 function findTaskById(id) { 73 const all = [...(notDone ?? []), ...(done ?? [])]; 74 return all.find((t) => String(t.taskId) === String(id)); 75 } 76 77 async function handleDragEnd(event) { 78 const { active, over } = event; 79 if (!over) return; 80 81 const activeId = String(active.id); 82 const overId = String(over.id); 83 84 if (activeId === overId) return; 85 86 const task = findTaskById(activeId); 87 if (!task) return; 88 89 // Moving over a column dropzone 90 if (overId === "lane:notDone" || overId === "lane:done") { 91 const toFinished = overId === "lane:done"; 92 if (Boolean(task.isFinished) === toFinished) return; 93 await onMoveTask(task.taskId, toFinished); 94 return; 95 } 96 97 // Sorting within a lane: keep it frontend-only. 98 const overTask = findTaskById(overId); 99 if (!overTask) return; 100 101 const fromLane = laneIdForTask(task); 102 const toLane = laneIdForTask(overTask); 103 104 if (fromLane !== toLane) { 105 // if dropped onto a card in the other lane => treat as lane move 106 await onMoveTask(task.taskId, toLane === "done"); 107 return; 108 } 109 110 if (fromLane === "notDone") { 111 const ids = orderedNotDone.map((t) => String(t.taskId)); 112 const oldIndex = ids.indexOf(activeId); 113 const newIndex = ids.indexOf(overId); 114 if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { 115 setNotDoneOrder(arrayMove(ids, oldIndex, newIndex)); 88 async function handleModalSubmit(payload) { 89 setModalLoading(true); 90 try { 91 if (editingTask) { 92 await onEditTask(editingTask.taskId, payload); 93 } else { 94 await onAddTask(payload); 116 95 } 117 } else { 118 const ids = orderedDone.map((t) => String(t.taskId)); 119 const oldIndex = ids.indexOf(activeId); 120 const newIndex = ids.indexOf(overId); 121 if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { 122 setDoneOrder(arrayMove(ids, oldIndex, newIndex)); 123 } 96 setModalOpen(false); 97 setEditingTask(null); 98 } finally { 99 setModalLoading(false); 124 100 } 125 101 } 126 102 103 const total = allTasks.length; 104 127 105 return ( 128 <div className="space-y-6"> 129 <div className="rounded-xl border border-white/10 bg-neutral-900/40 p-4"> 130 <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> 131 <div> 132 <div className="text-sm font-semibold text-white/90"> 133 Tickets 134 <span className="ml-2 text-xs font-normal text-white/60"> 135 Drag cards left/right to change status. 136 </span> 137 </div> 138 <div className="mt-1 text-xs text-white/55"> 139 Tip: grab the <span className="font-semibold text-white/80">drag</span> handle. 140 </div> 106 <div className="space-y-4"> 107 <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3"> 108 <div className="flex flex-wrap items-center gap-3 text-xs text-white/70"> 109 <span className="rounded-full bg-slate-500/20 px-2 py-1 text-slate-200"> 110 Not started: {(notStarted ?? []).length} 111 </span> 112 <span className="rounded-full bg-amber-500/15 px-2 py-1 text-amber-200"> 113 In progress: {(inProgress ?? []).length} 114 </span> 115 <span className="rounded-full bg-emerald-500/15 px-2 py-1 text-emerald-200"> 116 Finished: {(finished ?? []).length} 117 </span> 118 <span className="text-white/40">/ {total} total</span> 119 </div> 120 121 <div className="flex items-center gap-2"> 122 <div className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-black/30 px-2 py-1.5"> 123 <MdSort className="size-4 text-white/50" /> 124 <span className="text-xs text-white/50">Sort:</span> 125 <select 126 value={sortBy} 127 onChange={(e) => setSortBy(e.target.value)} 128 className="bg-transparent text-xs text-white/80 outline-none cursor-pointer" 129 > 130 <option value="default">Default</option> 131 <option value="dueDate">Due date</option> 132 <option value="priority">Priority</option> 133 </select> 141 134 </div> 142 135 143 <div className="flex items-center gap-3 text-xs text-white/70"> 144 <span className="rounded-full bg-sky-500/15 px-2 py-1 text-sky-200"> 145 Not done: {(notDone ?? []).length} 146 </span> 147 <span className="rounded-full bg-emerald-500/15 px-2 py-1 text-emerald-200"> 148 Done: {(done ?? []).length} 149 </span> 150 </div> 136 <button 137 onClick={() => { setEditingTask(null); setModalOpen(true); }} 138 className="flex items-center gap-1.5 rounded-lg bg-green-400 px-3 py-1.5 text-sm font-medium text-black hover:bg-green-300" 139 > 140 <MdAdd className="size-4" /> 141 New ticket 142 </button> 151 143 </div> 152 153 <form onSubmit={submitNewTask} className="mt-4 flex flex-col gap-3 md:flex-row">154 <input155 value={newName}156 onChange={(e) => setNewName(e.target.value)}157 placeholder="Add a new ticket (e.g. Fix bug #123)"158 maxLength={200}159 className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-green-400"160 />161 <button162 className="rounded-lg bg-green-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-60"163 type="submit"164 disabled={!newName.trim() || submitting}165 >166 {submitting ? "Adding..." : "Add"}167 </button>168 </form>169 144 </div> 170 145 171 <DndContext 172 sensors={sensors} 173 collisionDetection={closestCenter} 174 onDragEnd={handleDragEnd} 175 > 176 <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> 177 <KanbanColumn 178 title="Not done" 179 droppableId="lane:notDone" 180 count={orderedNotDone.length} 181 > 182 <SortableContext 183 items={orderedNotDone.map((t) => String(t.taskId))} 184 strategy={rectSortingStrategy} 185 > 186 <div className="space-y-3"> 187 {loading ? ( 188 <div className="text-sm opacity-70">Loading...</div> 189 ) : orderedNotDone.length === 0 ? ( 190 <div className="text-sm opacity-70">No tickets yet.</div> 191 ) : ( 192 orderedNotDone.map((t) => ( 193 <TaskTicketCard 194 key={t.taskId} 195 task={t} 196 onEdit={(name) => onEditTask(t.taskId, name)} 197 onDelete={() => onDeleteTask(t.taskId)} 198 /> 199 )) 200 )} 201 </div> 202 </SortableContext> 203 </KanbanColumn> 204 205 <KanbanColumn 206 title="Done" 207 droppableId="lane:done" 208 count={orderedDone.length} 209 > 210 <SortableContext 211 items={orderedDone.map((t) => String(t.taskId))} 212 strategy={rectSortingStrategy} 213 > 214 <div className="space-y-3"> 215 {loading ? ( 216 <div className="text-sm opacity-70">Loading...</div> 217 ) : orderedDone.length === 0 ? ( 218 <div className="text-sm opacity-70">Nothing done yet.</div> 219 ) : ( 220 orderedDone.map((t) => ( 221 <TaskTicketCard 222 key={t.taskId} 223 task={t} 224 onEdit={(name) => onEditTask(t.taskId, name)} 225 onDelete={() => onDeleteTask(t.taskId)} 226 /> 227 )) 228 )} 229 </div> 230 </SortableContext> 231 </KanbanColumn> 146 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> 147 <div className="grid grid-cols-1 gap-4 md:grid-cols-3"> 148 {LANES.map((lane) => { 149 const tasks = tasksByLane[lane.id] ?? []; 150 return ( 151 <KanbanColumn 152 key={lane.id} 153 laneId={lane.id} 154 title={lane.title} 155 droppableId={lane.droppableId} 156 count={tasks.length} 157 > 158 <SortableContext 159 items={tasks.map((t) => String(t.taskId))} 160 strategy={rectSortingStrategy} 161 > 162 <div className="space-y-3"> 163 {loading ? ( 164 <div className="text-sm opacity-70">Loading...</div> 165 ) : tasks.length === 0 ? ( 166 <div className="text-sm opacity-40">No tickets.</div> 167 ) : ( 168 tasks.map((t) => ( 169 <TaskTicketCard 170 key={t.taskId} 171 task={t} 172 onEdit={() => { setEditingTask(t); setModalOpen(true); }} 173 onDelete={() => onDeleteTask(t.taskId)} 174 /> 175 )) 176 )} 177 </div> 178 </SortableContext> 179 </KanbanColumn> 180 ); 181 })} 232 182 </div> 233 183 </DndContext> 184 185 <TaskFormModal 186 isOpen={modalOpen} 187 onClose={() => { setModalOpen(false); setEditingTask(null); }} 188 onSubmit={handleModalSubmit} 189 task={editingTask} 190 isLoading={modalLoading} 191 /> 234 192 </div> 235 193 ); 236 194 } 237 238 -
frontend/src/pages/Dashboard/pages/CustomCategory/components/KanbanColumn.jsx
r194c016 r545738f 1 1 import React, { useMemo } from "react"; 2 2 import { useDroppable } from "@dnd-kit/core"; 3 import { MdCheckCircle, MdRadioButtonUnchecked } from "react-icons/md";3 import { MdCheckCircle, MdRadioButtonUnchecked, MdAutorenew } from "react-icons/md"; 4 4 5 export default function KanbanColumn({ title, droppableId, count, children }) { 5 const VARIANTS = { 6 NOT_STARTED: { 7 Icon: MdRadioButtonUnchecked, 8 header: "from-slate-500/20 via-slate-500/8 to-transparent", 9 badge: "bg-slate-500/15 text-slate-200 border-slate-500/30", 10 ring: "group-hover:ring-slate-500/25", 11 drop: "border-slate-400/60 bg-slate-400/10", 12 border: "border-slate-500/20", 13 }, 14 IN_PROGRESS: { 15 Icon: MdAutorenew, 16 header: "from-amber-500/20 via-amber-500/8 to-transparent", 17 badge: "bg-amber-500/15 text-amber-200 border-amber-500/30", 18 ring: "group-hover:ring-amber-500/25", 19 drop: "border-amber-400/60 bg-amber-400/10", 20 border: "border-amber-500/20", 21 }, 22 FINISHED: { 23 Icon: MdCheckCircle, 24 header: "from-emerald-500/25 via-emerald-500/10 to-transparent", 25 badge: "bg-emerald-500/15 text-emerald-200 border-emerald-500/30", 26 ring: "group-hover:ring-emerald-500/25", 27 drop: "border-emerald-400/60 bg-emerald-400/10", 28 border: "border-emerald-500/20", 29 }, 30 }; 31 32 export default function KanbanColumn({ laneId, title, droppableId, count, children }) { 6 33 const { setNodeRef, isOver } = useDroppable({ id: droppableId }); 7 8 const variant = useMemo(() => { 9 if (droppableId === "lane:done") { 10 return { 11 Icon: MdCheckCircle, 12 header: "from-emerald-500/25 via-emerald-500/10 to-transparent", 13 badge: "bg-emerald-500/15 text-emerald-200 border-emerald-500/30", 14 ring: "group-hover:ring-emerald-500/25", 15 drop: "border-emerald-400/60 bg-emerald-400/10", 16 border: "border-emerald-500/20", 17 }; 18 } 19 20 return { 21 Icon: MdRadioButtonUnchecked, 22 header: "from-sky-500/25 via-sky-500/10 to-transparent", 23 badge: "bg-sky-500/15 text-sky-200 border-sky-500/30", 24 ring: "group-hover:ring-sky-500/25", 25 drop: "border-sky-400/60 bg-sky-400/10", 26 border: "border-sky-500/20", 27 }; 28 }, [droppableId]); 34 const v = VARIANTS[laneId] ?? VARIANTS.NOT_STARTED; 29 35 30 36 return ( … … 32 38 className={ 33 39 "group overflow-hidden rounded-xl border bg-neutral-900/40 shadow-sm ring-1 ring-white/5 transition " + 34 variant.border + 35 " " + 36 variant.ring 40 v.border + " " + v.ring 37 41 } 38 42 > 39 <div className={"bg-gradient-to-b " + v ariant.header + " p-4"}>43 <div className={"bg-gradient-to-b " + v.header + " p-4"}> 40 44 <div className="flex items-center justify-between gap-3"> 41 45 <div className="flex items-center gap-2"> 42 <variant.Icon className="size-5 text-white/80" /> 43 <h2 className="text-sm font-semibold tracking-wide text-white/90"> 44 {title} 45 </h2> 46 <v.Icon className="size-5 text-white/80" /> 47 <h2 className="text-sm font-semibold tracking-wide text-white/90">{title}</h2> 46 48 </div> 47 <span 48 className={ 49 "rounded-full border px-2 py-0.5 text-xs font-medium " + 50 variant.badge 51 } 52 > 49 <span className={"rounded-full border px-2 py-0.5 text-xs font-medium " + v.badge}> 53 50 {count} 54 51 </span> … … 60 57 className={ 61 58 "min-h-[220px] p-4 transition-colors " + 62 (isOver ? v ariant.drop : "border-t border-white/10")59 (isOver ? v.drop : "border-t border-white/10") 63 60 } 64 61 > … … 68 65 ); 69 66 } 70 -
frontend/src/pages/Dashboard/pages/CustomCategory/components/TaskTicketCard.jsx
r194c016 r545738f 1 import React, { useMemo , useState} from "react";1 import React, { useMemo } from "react"; 2 2 import { useSortable } from "@dnd-kit/sortable"; 3 3 import { CSS } from "@dnd-kit/utilities"; 4 import { MdDelete, MdDragIndicator, MdEdit, MdSave } from "react-icons/md"; 4 import { MdDelete, MdDragIndicator, MdEdit, MdCalendarToday } from "react-icons/md"; 5 6 const PRIORITY_STYLES = { 7 HIGH: "bg-red-500/15 text-red-300 border-red-500/30", 8 MEDIUM: "bg-amber-500/15 text-amber-300 border-amber-500/30", 9 LOW: "bg-sky-500/15 text-sky-300 border-sky-500/30", 10 }; 11 12 const STATUS_DOT = { 13 NOT_STARTED: "bg-slate-400", 14 IN_PROGRESS: "bg-amber-400", 15 FINISHED: "bg-emerald-400", 16 }; 17 18 function formatDate(dateStr) { 19 if (!dateStr) return null; 20 const d = new Date(dateStr); 21 return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); 22 } 23 24 function isOverdue(dateStr) { 25 if (!dateStr) return false; 26 return new Date(dateStr) < new Date(new Date().toDateString()); 27 } 5 28 6 29 export default function TaskTicketCard({ task, onEdit, onDelete }) { 7 30 const id = String(task.taskId); 8 const { 9 attributes, 10 listeners, 11 setNodeRef, 12 transform, 31 const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); 32 33 const style = useMemo(() => ({ 34 transform: CSS.Transform.toString(transform), 13 35 transition, 14 isDragging, 15 } = useSortable({ id }); 36 }), [transform, transition]); 16 37 17 const style = useMemo( 18 () => ({ 19 transform: CSS.Transform.toString(transform), 20 transition, 21 }), 22 [transform, transition] 23 ); 24 25 const [editing, setEditing] = useState(false); 26 const [draft, setDraft] = useState(task.name ?? ""); 27 28 async function submitEdit(e) { 29 e.preventDefault(); 30 const next = draft.trim(); 31 if (!next) return; 32 await onEdit(next); 33 setEditing(false); 34 } 38 const overdue = task.status !== "FINISHED" && isOverdue(task.dueDate); 35 39 36 40 return ( … … 40 44 className={ 41 45 "group rounded-lg border border-white/10 bg-black/30 p-3 shadow-sm transition hover:border-white/20 hover:bg-black/35 " + 42 (task. isFinished ? "opacity-90" : "") +43 (isDragging ? " opacity- 60" : "")46 (task.status === "FINISHED" ? "opacity-80" : "") + 47 (isDragging ? " opacity-50 scale-95" : "") 44 48 } 45 49 > 46 <div className="flex items-start justify-between gap-3"> 50 {/* Header row */} 51 <div className="flex items-start justify-between gap-2"> 47 52 <div className="min-w-0 flex-1"> 48 {editing ? ( 49 <form onSubmit={submitEdit} className="flex gap-2"> 50 <input 51 value={draft} 52 onChange={(e) => setDraft(e.target.value)} 53 maxLength={200} 54 className="w-full rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm outline-none focus:border-green-400" 55 autoFocus 56 /> 57 <button 58 type="submit" 59 className="inline-flex items-center gap-1 rounded-md bg-green-400 px-2 py-1 text-sm font-medium text-black" 60 > 61 <MdSave className="size-4" /> 62 Save 63 </button> 64 </form> 65 ) : ( 66 <div className="flex items-center gap-2"> 67 <span 68 className={ 69 "h-2 w-2 shrink-0 rounded-full " + 70 (task.isFinished ? "bg-emerald-400" : "bg-sky-400") 71 } 72 /> 73 <div className="min-w-0 text-sm font-medium text-white/90"> 74 {task.name} 75 </div> 76 </div> 77 )} 53 <div className="flex items-center gap-2"> 54 <span className={"h-2 w-2 shrink-0 rounded-full " + (STATUS_DOT[task.status] ?? "bg-slate-400")} /> 55 <span className="text-sm font-medium text-white/90 leading-snug">{task.name}</span> 56 </div> 78 57 79 <div className="mt-1 text-xs text-white/50">Ticket #{task.taskId}</div> 58 {task.description ? ( 59 <p className="mt-1.5 text-xs text-white/50 line-clamp-2">{task.description}</p> 60 ) : null} 80 61 </div> 81 62 82 <div className="flex shrink-0 items-center gap-2"> 83 {!editing ? ( 84 <button 85 type="button" 86 onClick={() => { 87 setDraft(task.name ?? ""); 88 setEditing(true); 89 }} 90 className="rounded-md p-1 text-white/70 hover:bg-white/10 hover:text-white" 91 title="Edit" 92 > 93 <MdEdit className="size-5" /> 94 </button> 95 ) : null} 96 63 <div className="flex shrink-0 items-center gap-1"> 64 <button 65 type="button" 66 onClick={onEdit} 67 className="rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white" 68 title="Edit" 69 > 70 <MdEdit className="size-4" /> 71 </button> 97 72 <button 98 73 type="button" 99 74 onClick={onDelete} 100 className="rounded-md p-1 text-white/ 70 hover:bg-white/10 hover:text-white"75 className="rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white" 101 76 title="Delete" 102 77 > 103 <MdDelete className="size- 5" />78 <MdDelete className="size-4" /> 104 79 </button> 105 106 80 <button 107 81 type="button" 108 className=" inline-flex cursor-grab items-center gap-1 rounded-md p-1 text-white/70 hover:bg-white/10 hover:text-white active:cursor-grabbing"82 className="cursor-grab rounded-md p-1 text-white/50 hover:bg-white/10 hover:text-white active:cursor-grabbing" 109 83 title="Drag" 110 84 {...attributes} 111 85 {...listeners} 112 86 > 113 <MdDragIndicator className="size- 5" />87 <MdDragIndicator className="size-4" /> 114 88 </button> 115 89 </div> 90 </div> 91 92 {/* Footer row: priority + due date */} 93 <div className="mt-2 flex flex-wrap items-center gap-2"> 94 <span className="text-xs text-white/30">#{task.taskId}</span> 95 96 {task.priority ? ( 97 <span className={"rounded-full border px-2 py-0.5 text-xs font-medium " + (PRIORITY_STYLES[task.priority] ?? PRIORITY_STYLES.LOW)}> 98 {task.priority.charAt(0) + task.priority.slice(1).toLowerCase()} 99 </span> 100 ) : null} 101 102 {task.dueDate ? ( 103 <span className={"flex items-center gap-1 text-xs " + (overdue ? "text-red-400" : "text-white/40")}> 104 <MdCalendarToday className="size-3" /> 105 {overdue ? "Overdue · " : ""}{formatDate(task.dueDate)} 106 </span> 107 ) : null} 116 108 </div> 117 109 </div> 118 110 ); 119 111 } 120
Note:
See TracChangeset
for help on using the changeset viewer.
