Просмотр исходного кода

feat(kanban): drag-and-drop with order + phase persistence (franchisor)

- Install vuedraggable@next
- KanbanPage: wrap columns with <draggable group=kanban>
- onDragEnd: rebuild order for affected phases, call reorderKanbans()
- api/kanban: add reorderKanbans()
- Drag visual: ghost + active CSS classes
ebagabee 2 недель назад
Родитель
Сommit
71caf77152
4 измененных файлов с 115 добавлено и 29 удалено
  1. 20 1
      package-lock.json
  2. 3 2
      package.json
  3. 9 0
      src/api/kanban.js
  4. 83 26
      src/pages/kanban/KanbanPage.vue

+ 20 - 1
package-lock.json

@@ -23,7 +23,8 @@
         "vue-chartjs": "^5.3.3",
         "vue-currency-input": "^3.2.1",
         "vue-i18n": "^11.1.4",
-        "vue-router": "^4.6.4"
+        "vue-router": "^4.6.4",
+        "vuedraggable": "^4.1.0"
       },
       "devDependencies": {
         "@bufbuild/buf": "^1.61.0",
@@ -9827,6 +9828,12 @@
         "node": ">=10.0.0"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
+      "license": "MIT"
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -10798,6 +10805,18 @@
       "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
       "license": "MIT"
     },
+    "node_modules/vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "license": "MIT",
+      "dependencies": {
+        "sortablejs": "1.14.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
     "node_modules/webpack-merge": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz",

+ 3 - 2
package.json

@@ -30,14 +30,15 @@
     "vue-chartjs": "^5.3.3",
     "vue-currency-input": "^3.2.1",
     "vue-i18n": "^11.1.4",
-    "vue-router": "^4.6.4"
+    "vue-router": "^4.6.4",
+    "vuedraggable": "^4.1.0"
   },
   "devDependencies": {
     "@bufbuild/buf": "^1.61.0",
     "@bufbuild/protoc-gen-es": "^2.10.2",
+    "@eslint/js": "^9.27.0",
     "@intlify/eslint-plugin-vue-i18n": "^4.0.1",
     "@intlify/unplugin-vue-i18n": "^6.0.8",
-    "@eslint/js": "^9.27.0",
     "@quasar/app-vite": "^2.4.0",
     "@vue/eslint-config-prettier": "^10.2.0",
     "autoprefixer": "^10.4.21",

+ 9 - 0
src/api/kanban.js

@@ -19,3 +19,12 @@ export const deleteKanban = async (id) => {
   const { data } = await api.delete(`/kanban/${id}`);
   return data;
 };
+
+/**
+ * Persists drag-and-drop reorder.
+ * @param {Array<{id: number, phase: string, order: number}>} items
+ */
+export const reorderKanbans = async (items) => {
+  const { data } = await api.post("/kanban/reorder", { items });
+  return data;
+};

+ 83 - 26
src/pages/kanban/KanbanPage.vue

@@ -20,7 +20,6 @@
       <div
         v-for="column in columns"
         :key="column.phase"
-        class="kanban-column"
         style="
           min-width: 280px;
           max-width: 320px;
@@ -32,7 +31,7 @@
       >
         <!-- Column header -->
         <div
-          class="kanban-column-header row items-center justify-between q-px-md q-py-sm"
+          class="row items-center justify-between q-px-md q-py-sm"
           :style="{ backgroundColor: column.color, borderRadius: '8px' }"
         >
           <span class="text-weight-bold text-white" style="font-size: 14px">
@@ -40,28 +39,30 @@
           </span>
           <q-badge
             color="white"
-            :text-color="column.textColor"
-            :label="cardsForPhase(column.phase).length"
+            :text-color="column.badgeTextColor"
+            :label="columnCards(column.phase).length"
             style="font-size: 11px"
           />
         </div>
 
-        <!-- Cards -->
-        <div
-          style="
-            display: flex;
-            flex-direction: column;
-            gap: 8px;
-            flex: 1;
-          "
+        <!-- Draggable card list -->
+        <draggable
+          :list="columnCards(column.phase)"
+          group="kanban"
+          item-key="id"
+          :animation="180"
+          ghost-class="drag-ghost"
+          drag-class="drag-active"
+          style="display: flex; flex-direction: column; gap: 8px; min-height: 8px; flex: 1"
+          @end="onDragEnd($event, column.phase)"
         >
-          <KanbanCard
-            v-for="card in cardsForPhase(column.phase)"
-            :key="card.id"
-            :card="card"
-            @edit="openDialog(card, column.phase)"
-          />
-        </div>
+          <template #item="{ element }">
+            <KanbanCard
+              :card="element"
+              @edit="openDialog(element, column.phase)"
+            />
+          </template>
+        </draggable>
 
         <!-- Add button -->
         <q-btn
@@ -81,10 +82,11 @@
 <script setup>
 import { ref, defineAsyncComponent, onMounted } from "vue";
 import { useQuasar } from "quasar";
+import draggable from "vuedraggable";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import KanbanCard from "./components/KanbanCard.vue";
-import { getKanbans } from "src/api/kanban";
+import { getKanbans, reorderKanbans } from "src/api/kanban";
 
 const AddEditKanbanDialog = defineAsyncComponent(
   () => import("./components/AddEditKanbanDialog.vue"),
@@ -96,14 +98,15 @@ const loading = ref(false);
 const cards = ref([]);
 
 const columns = [
-  { phase: "a_fazer",           label: "A Fazer",           color: "#757575", textColor: "grey-9" },
-  { phase: "em_progresso",      label: "Em Progresso",      color: "#1976D2", textColor: "blue-9"  },
-  { phase: "em_revisao",        label: "Em Revisão",        color: "#F57C00", textColor: "orange-9"},
-  { phase: "concluido",         label: "Concluído",         color: "#388E3C", textColor: "green-9" },
-  { phase: "demandas_especiais",label: "Demandas Especiais",color: "#F9A825", textColor: "yellow-9"},
+  { phase: "a_fazer",            label: "A Fazer",            color: "#757575", badgeTextColor: "grey-9"   },
+  { phase: "em_progresso",       label: "Em Progresso",       color: "#1976D2", badgeTextColor: "blue-9"   },
+  { phase: "em_revisao",         label: "Em Revisão",         color: "#F57C00", badgeTextColor: "orange-9" },
+  { phase: "concluido",          label: "Concluído",          color: "#388E3C", badgeTextColor: "green-9"  },
+  { phase: "demandas_especiais", label: "Demandas Especiais", color: "#F9A825", badgeTextColor: "yellow-9" },
 ];
 
-const cardsForPhase = (phase) =>
+/** Returns a reactive slice of cards for the given phase, in order. */
+const columnCards = (phase) =>
   cards.value.filter((c) => c.phase === phase);
 
 const loadCards = async () => {
@@ -115,6 +118,49 @@ const loadCards = async () => {
   }
 };
 
+/**
+ * Called when any drag ends.
+ * Rebuilds orders for every column that was affected and persists to the API.
+ */
+const onDragEnd = async (evt, targetPhase) => {
+  // Identify the card that was moved
+  const movedCard = evt.item?.__draggable_context?.element;
+  if (!movedCard) return;
+
+  // Update the in-memory phase for the moved card
+  movedCard.phase = targetPhase;
+
+  // Collect all cards from source and destination columns (may be the same)
+  const affectedPhases = new Set([targetPhase]);
+  if (evt.from !== evt.to) {
+    // The original phase is stored on the element before the move;
+    // we resolve it from the source list id (set as data-phase attr) or
+    // simply by iterating all columns — safest approach.
+    columns.forEach((col) => {
+      const colCards = columnCards(col.phase);
+      if (colCards.some((c) => c.id === movedCard.id)) {
+        affectedPhases.add(col.phase);
+      }
+    });
+  }
+
+  // Build the reorder payload for each affected phase
+  const items = [];
+  affectedPhases.forEach((phase) => {
+    columnCards(phase).forEach((card, idx) => {
+      card.phase = phase; // ensure phase is in sync
+      items.push({ id: card.id, phase, order: idx });
+    });
+  });
+
+  try {
+    await reorderKanbans(items);
+  } catch {
+    // On failure reload from server to restore consistent state
+    await loadCards();
+  }
+};
+
 const openDialog = (card = null, phase = "a_fazer") => {
   $q.dialog({
     component: AddEditKanbanDialog,
@@ -131,4 +177,15 @@ onMounted(loadCards);
 .kanban-board {
   padding-top: 12px;
 }
+
+:deep(.drag-ghost) {
+  opacity: 0.4;
+  border: 2px dashed #aaa;
+}
+
+:deep(.drag-active) {
+  opacity: 0.95;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+  transform: rotate(1.5deg);
+}
 </style>