Changeset 545738f for frontend


Ignore:
Timestamp:
06/22/26 19:00:39 (2 weeks ago)
Author:
Andrej <asumanovski@…>
Branches:
master
Children:
c0086c0
Parents:
194c016
Message:

Switched to dockerized db and improved Task model and Task Ui

Location:
frontend
Files:
2 added
5 edited

Legend:

Unmodified
Added
Removed
  • frontend/src/api/discipline.js

    r194c016 r545738f  
    8181}
    8282
     83export 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
    8391export async function deleteCustomCategoryTask(customTrackingId, taskId) {
    8492  return api.delete(
  • frontend/src/pages/Dashboard/pages/CustomCategory/CustomCategoryKanban.jsx

    r194c016 r545738f  
    88  getCustomTrackingCategories,
    99  updateCustomCategoryTask,
    10   updateCustomCategoryTaskFinished,
     10  updateCustomCategoryTaskStatus,
    1111} from "../../../../api/discipline.js";
    1212
     
    2323
    2424  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    };
    2831  }, [tasks]);
    2932
     
    3639        getCustomCategoryTasks(customTrackingId),
    3740      ]);
    38 
    3941      const cats = catsRes?.items ?? [];
    4042      const found = cats.find((c) => Number(c.customTrackingId) === customTrackingId);
    4143      setCategoryName(found?.name ?? "Custom Category");
    42 
    4344      setTasks(tasksRes?.tasks ?? []);
    4445    } catch (e) {
     
    5960  }, [customTrackingId]);
    6061
    61   async function handleAddTask(name) {
    62     const created = await createCustomCategoryTask(customTrackingId, { name });
     62  async function handleAddTask(payload) {
     63    const created = await createCustomCategoryTask(customTrackingId, payload);
    6364    setTasks((prev) => [created, ...(prev ?? [])]);
    6465  }
    6566
    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);
    6869    setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? updated : t)));
    6970  }
     
    7475  }
    7576
    76   async function handleMoveTask(taskId, toFinished) {
    77     // optimistic update
     77  async function handleMoveTask(taskId, toStatus) {
    7878    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))
    8280    );
    83 
    8481    try {
    85       const saved = await updateCustomCategoryTaskFinished(
    86         customTrackingId,
    87         taskId,
    88         toFinished
    89       );
     82      const saved = await updateCustomCategoryTaskStatus(customTrackingId, taskId, toStatus);
    9083      setTasks((prev) => (prev ?? []).map((t) => (t.taskId === taskId ? saved : t)));
    91     } catch (e) {
    92       // revert by refetching (simple + correct)
     84    } catch {
    9385      await refresh();
    94       throw e;
    9586    }
    9687  }
     
    10293          <h1 className="text-2xl font-bold">{categoryName}</h1>
    10394          <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.
    10596          </p>
    10697        </div>
     
    115106      <CustomCategoryKanbanBoard
    116107        loading={loading}
    117         notDone={lanes.notDone}
    118         done={lanes.done}
     108        notStarted={lanes.notStarted}
     109        inProgress={lanes.inProgress}
     110        finished={lanes.finished}
    119111        onAddTask={handleAddTask}
    120112        onEditTask={handleEditTask}
     
    125117  );
    126118}
    127 
    128 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/CustomCategoryKanbanBoard.jsx

    r194c016 r545738f  
    11import 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";
     2import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
     3import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable";
     4import { MdAdd, MdSort } from "react-icons/md";
    155
    166import KanbanColumn from "./KanbanColumn";
    177import TaskTicketCard from "./TaskTicketCard";
     8import TaskFormModal from "./TaskFormModal";
    189
    19 function laneIdForTask(task) {
    20   return task?.isFinished ? "done" : "notDone";
     10const 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
     16const PRIORITY_ORDER = { HIGH: 0, MEDIUM: 1, LOW: 2, null: 3, undefined: 3 };
     17
     18function 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
     34function statusForDropId(dropId) {
     35  return dropId.replace("lane:", "");
    2136}
    2237
    2338export 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,
    3141}) {
    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");
    3446
    3547  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
    3648
    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]);
    4054
    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]);
    4960
    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  }
    5864
    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);
    6985    }
    7086  }
    7187
    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);
    11695      }
    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);
    124100    }
    125101  }
    126102
     103  const total = allTasks.length;
     104
    127105  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>
    141134          </div>
    142135
    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>
    151143        </div>
    152 
    153         <form onSubmit={submitNewTask} className="mt-4 flex flex-col gap-3 md:flex-row">
    154           <input
    155             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           <button
    162             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>
    169144      </div>
    170145
    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          })}
    232182        </div>
    233183      </DndContext>
     184
     185      <TaskFormModal
     186        isOpen={modalOpen}
     187        onClose={() => { setModalOpen(false); setEditingTask(null); }}
     188        onSubmit={handleModalSubmit}
     189        task={editingTask}
     190        isLoading={modalLoading}
     191      />
    234192    </div>
    235193  );
    236194}
    237 
    238 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/KanbanColumn.jsx

    r194c016 r545738f  
    11import React, { useMemo } from "react";
    22import { useDroppable } from "@dnd-kit/core";
    3 import { MdCheckCircle, MdRadioButtonUnchecked } from "react-icons/md";
     3import { MdCheckCircle, MdRadioButtonUnchecked, MdAutorenew } from "react-icons/md";
    44
    5 export default function KanbanColumn({ title, droppableId, count, children }) {
     5const 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
     32export default function KanbanColumn({ laneId, title, droppableId, count, children }) {
    633  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;
    2935
    3036  return (
     
    3238      className={
    3339        "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
    3741      }
    3842    >
    39       <div className={"bg-gradient-to-b " + variant.header + " p-4"}>
     43      <div className={"bg-gradient-to-b " + v.header + " p-4"}>
    4044        <div className="flex items-center justify-between gap-3">
    4145          <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>
    4648          </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}>
    5350            {count}
    5451          </span>
     
    6057        className={
    6158          "min-h-[220px] p-4 transition-colors " +
    62           (isOver ? variant.drop : "border-t border-white/10")
     59          (isOver ? v.drop : "border-t border-white/10")
    6360        }
    6461      >
     
    6865  );
    6966}
    70 
  • frontend/src/pages/Dashboard/pages/CustomCategory/components/TaskTicketCard.jsx

    r194c016 r545738f  
    1 import React, { useMemo, useState } from "react";
     1import React, { useMemo } from "react";
    22import { useSortable } from "@dnd-kit/sortable";
    33import { CSS } from "@dnd-kit/utilities";
    4 import { MdDelete, MdDragIndicator, MdEdit, MdSave } from "react-icons/md";
     4import { MdDelete, MdDragIndicator, MdEdit, MdCalendarToday } from "react-icons/md";
     5
     6const 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
     12const STATUS_DOT = {
     13  NOT_STARTED: "bg-slate-400",
     14  IN_PROGRESS: "bg-amber-400",
     15  FINISHED:    "bg-emerald-400",
     16};
     17
     18function 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
     24function isOverdue(dateStr) {
     25  if (!dateStr) return false;
     26  return new Date(dateStr) < new Date(new Date().toDateString());
     27}
    528
    629export default function TaskTicketCard({ task, onEdit, onDelete }) {
    730  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),
    1335    transition,
    14     isDragging,
    15   } = useSortable({ id });
     36  }), [transform, transition]);
    1637
    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);
    3539
    3640  return (
     
    4044      className={
    4145        "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" : "")
    4448      }
    4549    >
    46       <div className="flex items-start justify-between gap-3">
     50      {/* Header row */}
     51      <div className="flex items-start justify-between gap-2">
    4752        <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>
    7857
    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}
    8061        </div>
    8162
    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>
    9772          <button
    9873            type="button"
    9974            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"
    10176            title="Delete"
    10277          >
    103             <MdDelete className="size-5" />
     78            <MdDelete className="size-4" />
    10479          </button>
    105 
    10680          <button
    10781            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"
    10983            title="Drag"
    11084            {...attributes}
    11185            {...listeners}
    11286          >
    113             <MdDragIndicator className="size-5" />
     87            <MdDragIndicator className="size-4" />
    11488          </button>
    11589        </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}
    116108      </div>
    117109    </div>
    118110  );
    119111}
    120 
Note: See TracChangeset for help on using the changeset viewer.