Przeglądaj źródła

Merge branch 'feature/GINC-GAB-fluxo-kanban-franqueadora-franquia' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development

Gabriel Alves 2 tygodni temu
rodzic
commit
916181087a

+ 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",

+ 30 - 0
src/api/kanban.js

@@ -0,0 +1,30 @@
+import api from "src/api";
+
+export const getKanbans = async () => {
+  const { data } = await api.get("/kanban");
+  return data.payload;
+};
+
+export const createKanban = async (payload) => {
+  const { data } = await api.post("/kanban", payload);
+  return data.payload;
+};
+
+export const updateKanban = async (id, payload) => {
+  const { data } = await api.put(`/kanban/${id}`, payload);
+  return data.payload;
+};
+
+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;
+};

+ 21 - 0
src/api/kanban_reply.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getKanbanReplies = async (kanbanId) => {
+  const { data } = await api.get(`/kanban/${kanbanId}/replies`);
+  return data.payload;
+};
+
+export const createKanbanReply = async (kanbanId, payload) => {
+  const { data } = await api.post(`/kanban/${kanbanId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateKanbanReply = async (kanbanId, id, payload) => {
+  const { data } = await api.put(`/kanban/${kanbanId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteKanbanReply = async (kanbanId, id) => {
+  const { data } = await api.delete(`/kanban/${kanbanId}/replies/${id}`);
+  return data;
+};

+ 197 - 0
src/pages/kanban/KanbanPage.vue

@@ -0,0 +1,197 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Atividades" :show-filter-icon="false" />
+
+    <div v-if="loading" class="flex flex-center q-pa-xl">
+      <q-spinner color="primary" size="48px" />
+    </div>
+
+    <div
+      v-else
+      class="kanban-board q-px-md q-pb-md"
+      style="
+        display: flex;
+        gap: 16px;
+        overflow-x: auto;
+        align-items: flex-start;
+        min-height: calc(100vh - 120px);
+      "
+    >
+      <div
+        v-for="column in columns"
+        :key="column.phase"
+        style="
+          min-width: 280px;
+          max-width: 320px;
+          flex: 1;
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+        "
+      >
+        <!-- Column header -->
+        <div
+          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">
+            {{ column.label }}
+          </span>
+          <q-badge
+            color="white"
+            :text-color="column.badgeTextColor"
+            :label="columnMap[column.phase].length"
+            style="font-size: 11px"
+          />
+        </div>
+
+        <!-- Draggable card list -->
+        <draggable
+          :list="columnMap[column.phase]"
+          :data-phase="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: 48px; flex: 1"
+          @end="onDragEnd"
+        >
+          <template #item="{ element }">
+            <KanbanCard
+              :card="element"
+              @edit="openDialog(element, column.phase)"
+              @delete="removeCard($event, column.phase)"
+            />
+          </template>
+        </draggable>
+
+        <!-- Add button -->
+        <q-btn
+          flat
+          color="grey-6"
+          icon="mdi-plus"
+          label="Adicionar"
+          class="q-mt-xs full-width"
+          style="border-radius: 8px; border: 1px dashed #ccc"
+          @click="openDialog(null, column.phase)"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, 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, reorderKanbans } from "src/api/kanban";
+
+const AddEditKanbanDialog = defineAsyncComponent(
+  () => import("./components/AddEditKanbanDialog.vue"),
+);
+
+const $q = useQuasar();
+const loading = ref(false);
+
+const columns = [
+  { 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" },
+];
+
+// Each phase has its own reactive array — required for vuedraggable cross-list DnD
+const columnMap = reactive({
+  a_fazer: [],
+  em_progresso: [],
+  em_revisao: [],
+  concluido: [],
+  demandas_especiais: [],
+});
+
+const loadCards = async () => {
+  loading.value = true;
+  try {
+    const data = await getKanbans();
+
+    // Reset all columns before filling
+    Object.keys(columnMap).forEach((k) => (columnMap[k] = []));
+
+    data.forEach((card) => {
+      if (columnMap[card.phase]) {
+        columnMap[card.phase].push(card);
+      }
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+
+/**
+ * Called once when drag ends.
+ * By this point vuedraggable has already moved the item between the two arrays.
+ * We read source/destination phases from the DOM data-phase attribute and persist.
+ */
+const onDragEnd = async (evt) => {
+  const sourcePhase = evt.from.dataset.phase;
+  const targetPhase = evt.to.dataset.phase;
+
+  // Collect phases to update (may be the same if reordering in-column)
+  const phasesToUpdate = new Set([sourcePhase, targetPhase].filter(Boolean));
+
+  const items = [];
+  phasesToUpdate.forEach((phase) => {
+    columnMap[phase].forEach((card, idx) => {
+      // Sync the card's phase in memory too
+      card.phase = phase;
+      items.push({ id: card.id, phase, order: idx });
+    });
+  });
+
+  try {
+    await reorderKanbans(items);
+  } catch {
+    // On failure restore from server
+    await loadCards();
+  }
+};
+
+const removeCard = (cardId, phase) => {
+  const idx = columnMap[phase].findIndex((c) => c.id === cardId);
+  if (idx !== -1) columnMap[phase].splice(idx, 1);
+};
+
+const openDialog = (card = null, phase = "a_fazer") => {
+  $q.dialog({
+    component: AddEditKanbanDialog,
+    componentProps: { card, initialPhase: phase },
+  }).onOk(() => {
+    loadCards();
+  });
+};
+
+onMounted(loadCards);
+</script>
+
+<style scoped>
+.kanban-board {
+  padding-top: 12px;
+}
+
+:deep(.drag-ghost) {
+  opacity: 0.4;
+  border: 2px dashed #aaa;
+  border-radius: 10px;
+}
+
+:deep(.drag-active) {
+  opacity: 0.95;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+  transform: rotate(1.5deg);
+}
+</style>

+ 300 - 0
src/pages/kanban/components/AddEditKanbanDialog.vue

@@ -0,0 +1,300 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 1100px">
+      <q-card style="height: 560px; display: flex; flex-direction: column; overflow: hidden">
+        <DefaultDialogHeader
+          :title="card ? 'Editar Atividade' : 'Adicionar Atividade'"
+          @close="onDialogCancel"
+        />
+
+        <q-form
+          ref="formRef"
+          style="flex: 1; display: flex; flex-direction: column; overflow: hidden"
+          @submit="onOKClick"
+        >
+          <q-card-section class="q-pt-sm" style="flex: 1; overflow-y: auto; min-height: 0">
+            <CustomTabComponent
+              v-if="card?.id"
+              v-model:active-tab="currentTab"
+              :tabs="tabs"
+              class="q-mb-md"
+            />
+
+            <!-- Tab: Atividade -->
+            <div v-show="currentTab === 'atividade'">
+              <div class="row q-col-gutter-sm">
+                <!-- 1. Título -->
+                <DefaultInput
+                  v-model="form.title"
+                  label="Título da Tarefa"
+                  class="col-12"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <!-- 2. Destino (primeiro campo de configuração) -->
+                <DefaultSelect
+                  v-model="form.scope"
+                  label="Destino"
+                  :options="scopeOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <!-- Prazo ao lado do Destino -->
+                <DefaultInputDatePicker
+                  v-model="form.due_date_display"
+                  v-model:untreated-date="form.due_date"
+                  label="Prazo de Entrega"
+                  class="col-6"
+                />
+
+                <!-- Unidade específica — só aparece quando scope = specific -->
+                <UnitSelect
+                  v-if="form.scope === 'specific'"
+                  v-model="selectedUnit"
+                  class="col-6"
+                />
+
+                <!-- 3. Prioridade + Fase -->
+                <DefaultSelect
+                  v-model="form.priority"
+                  label="Prioridade"
+                  :options="priorityOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <DefaultSelect
+                  v-model="form.phase"
+                  label="Fase"
+                  :options="phaseOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <!-- 4. Setor -->
+                <DefaultInput
+                  v-model="form.sector"
+                  label="Setor"
+                  class="col-6"
+                />
+
+                <!-- 5. Descrição -->
+                <DefaultInput
+                  v-model="form.description"
+                  label="Descrição"
+                  type="textarea"
+                  class="col-12"
+                />
+              </div>
+            </div>
+
+            <!-- Tab: Comentários -->
+            <div v-show="currentTab === 'comentarios'">
+              <div class="flex justify-end q-mb-sm">
+                <q-btn
+                  color="primary"
+                  icon="mdi-plus"
+                  unelevated
+                  style="width: 40px; height: 40px"
+                  @click="onAddComment"
+                />
+              </div>
+              <div
+                style="
+                  height: 100%;
+                  max-height: 380px;
+                  overflow-y: auto;
+                  display: flex;
+                  flex-direction: column;
+                  gap: 8px;
+                "
+              >
+                <template v-if="replies.length">
+                  <KanbanCommentCard
+                    v-for="reply in replies"
+                    :key="reply.id"
+                    :reply="reply.reply"
+                    :created-at="reply.created_at"
+                    :user-name="reply.user_name"
+                    @edit="onEditComment(reply)"
+                    @delete="onDeleteComment(reply)"
+                  />
+                </template>
+                <div v-else class="flex flex-center full-height text-grey-5 text-body2">
+                  Nenhum comentário registrado.
+                </div>
+              </div>
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
+            <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
+            <q-btn color="primary" label="Salvar" type="submit" :loading="loading" />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import UnitSelect from "src/components/selects/UnitSelect.vue";
+import KanbanCommentCard from "./KanbanCommentCard.vue";
+
+import { createKanban, updateKanban } from "src/api/kanban";
+import {
+  getKanbanReplies,
+  createKanbanReply,
+  updateKanbanReply,
+  deleteKanbanReply,
+} from "src/api/kanban_reply";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { card, initialPhase } = defineProps({
+  card:         { type: Object, default: null },
+  initialPhase: { type: String, default: "a_fazer" },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const formRef    = ref(null);
+const loading    = ref(false);
+const currentTab = ref("atividade");
+const replies    = ref([]);
+const selectedUnit = ref(card?.target_unit_id ? { value: card.target_unit_id } : null);
+
+const tabs = computed(() => [
+  { name: "atividade",    label: "Atividade"    },
+  { name: "comentarios",  label: "Comentários"  },
+]);
+
+const priorityOptions = [
+  { label: "Alta",   value: "alta"   },
+  { label: "Normal", value: "normal" },
+  { label: "Baixa",  value: "baixa"  },
+];
+
+const phaseOptions = [
+  { label: "A Fazer",            value: "a_fazer"            },
+  { label: "Em Progresso",       value: "em_progresso"       },
+  { label: "Em Revisão",         value: "em_revisao"         },
+  { label: "Concluído",          value: "concluido"          },
+  { label: "Demandas Especiais", value: "demandas_especiais" },
+];
+
+const scopeOptions = [
+  { label: "Interno",            value: "internal" },
+  { label: "Todas as Unidades",  value: "all"      },
+  { label: "Unidade Específica", value: "specific" },
+];
+
+const formatDisplayDate = (rawDate) => {
+  if (!rawDate) return null;
+  const [y, m, d] = rawDate.split("-");
+  return `${d}/${m}/${y}`;
+};
+
+const form = ref({
+  title:            card?.title         ?? null,
+  priority:         card?.priority      ?? "normal",
+  phase:            card?.phase         ?? initialPhase,
+  scope:            card?.scope         ?? "internal",
+  sector:           card?.sector        ?? null,
+  description:      card?.description   ?? null,
+  due_date:         card?.due_date      ?? null,
+  due_date_display: formatDisplayDate(card?.due_date),
+});
+
+const buildPayload = () => ({
+  title:               form.value.title,
+  priority:            form.value.priority,
+  phase:               form.value.phase,
+  scope:               form.value.scope,
+  sector:              form.value.sector       || null,
+  description:         form.value.description  || null,
+  due_date:            form.value.due_date      || null,
+  responsible_user_id: null,   // Matriz never assigns a responsible
+  target_unit_id: form.value.scope === "specific"
+    ? (selectedUnit.value?.value ?? null)
+    : null,
+});
+
+const loadReplies = async () => {
+  if (!card?.id) return;
+  replies.value = await getKanbanReplies(card.id);
+};
+
+const onAddComment = () => {
+  $q.dialog({
+    title: "Adicionar Comentário",
+    prompt: { model: "", type: "textarea", label: "Comentário" },
+    ok:     { label: "Salvar",   color: "primary" },
+    cancel: { label: "Cancelar", color: "primary", outline: true },
+  }).onOk(async (text) => {
+    if (!text?.trim()) return;
+    await createKanbanReply(card.id, { reply: text.trim() });
+    loadReplies();
+  });
+};
+
+const onEditComment = (reply) => {
+  $q.dialog({
+    title: "Editar Comentário",
+    prompt: { model: reply.reply, type: "textarea", label: "Comentário" },
+    ok:     { label: "Salvar",   color: "primary" },
+    cancel: { label: "Cancelar", color: "primary", outline: true },
+  }).onOk(async (text) => {
+    if (!text?.trim()) return;
+    await updateKanbanReply(card.id, reply.id, { reply: text.trim() });
+    loadReplies();
+  });
+};
+
+const onDeleteComment = (reply) => {
+  $q.dialog({
+    title:   "Excluir Comentário",
+    message: "Tem certeza que deseja excluir este comentário?",
+    cancel:  { outline: true, color: "primary",  label: "Cancelar" },
+    ok:      {               color: "negative",  label: "Excluir"  },
+  }).onOk(async () => {
+    await deleteKanbanReply(card.id, reply.id);
+    loadReplies();
+  });
+};
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    const payload = buildPayload();
+    if (card?.id) {
+      await updateKanban(card.id, payload);
+    } else {
+      await createKanban(payload);
+    }
+    onDialogOK(true);
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(loadReplies);
+</script>

+ 135 - 0
src/pages/kanban/components/KanbanCard.vue

@@ -0,0 +1,135 @@
+<template>
+  <q-card
+    class="kanban-card cursor-pointer"
+    :style="{ borderLeft: `4px solid ${phaseColor}` }"
+    @click="$emit('edit', card)"
+  >
+    <!-- Header: Creator name + Priority badge + Delete button -->
+    <div class="row items-center justify-between q-px-md q-pt-md">
+      <span class="text-body2 text-weight-medium text-grey-8 ellipsis" style="max-width: 55%">
+        {{ card.created_by_user_name ?? '—' }}
+      </span>
+
+      <div class="row items-center" style="gap: 6px">
+        <q-badge
+          :color="priorityColor"
+          :label="priorityLabel"
+          style="padding: 4px 8px; font-size: 11px"
+        />
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-trash-can-outline"
+          size="xs"
+          color="grey-5"
+          @click.stop="onDelete"
+        />
+      </div>
+    </div>
+
+    <!-- De / Para -->
+    <div class="q-px-md q-mt-xs" style="display: flex; gap: 10px">
+      <span class="text-caption text-grey-6">
+        <span class="text-weight-medium text-grey-7">De:</span>
+        {{ card.applicant_unit_name ?? 'Matriz' }}
+      </span>
+      <span class="text-caption text-grey-6">
+        <span class="text-weight-medium text-grey-7">Para:</span>
+        {{ paraLabel }}
+      </span>
+    </div>
+
+    <!-- Responsável -->
+    <div v-if="card.responsible_user_name" class="q-px-md q-mt-xs text-caption text-grey-6">
+      <span class="text-weight-medium text-grey-7">Resp.:</span>
+      {{ card.responsible_user_name }}
+    </div>
+
+    <!-- Description truncated -->
+    <div
+      v-if="card.description"
+      class="q-px-md q-mt-sm text-body2 text-grey-7"
+      style="line-height: 1.4"
+    >
+      {{ truncated }}
+    </div>
+
+    <!-- Footer: Sector + Comments count -->
+    <div class="row items-center justify-between q-px-md q-pb-md q-mt-sm">
+      <span class="text-caption text-grey-6">{{ card.sector ?? '—' }}</span>
+      <div class="row items-center" style="gap: 4px">
+        <q-icon name="mdi-comment-outline" color="grey-5" size="16px" />
+        <span class="text-caption text-grey-6">{{ card.replies_count ?? 0 }}</span>
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useQuasar } from "quasar";
+import { deleteKanban } from "src/api/kanban";
+
+const emit = defineEmits(["edit", "delete"]);
+
+const { card } = defineProps({
+  card: { type: Object, required: true },
+});
+
+const $q = useQuasar();
+
+const truncated = computed(() => {
+  const desc = card.description ?? "";
+  return desc.length > 50 ? desc.slice(0, 50) + "…" : desc;
+});
+
+const paraLabel = computed(() => {
+  if (card.scope === "internal") return "Interno";
+  return card.target_unit_name ?? "—";
+});
+
+const priorityColor = computed(() => {
+  const map = { alta: "negative", normal: "warning", baixa: "positive" };
+  return map[card.priority] ?? "grey";
+});
+
+const priorityLabel = computed(() => {
+  const map = { alta: "Alta", normal: "Normal", baixa: "Baixa" };
+  return map[card.priority] ?? card.priority;
+});
+
+const phaseColor = computed(() => {
+  const map = {
+    a_fazer:            "#9E9E9E",
+    em_progresso:       "#1976D2",
+    em_revisao:         "#FF9800",
+    concluido:          "#4CAF50",
+    demandas_especiais: "#FFC107",
+  };
+  return map[card.phase] ?? "#9E9E9E";
+});
+
+const onDelete = () => {
+  $q.dialog({
+    title:   "Excluir Atividade",
+    message: `Tem certeza que deseja excluir "${card.title}"?`,
+    cancel:  { outline: true, color: "primary",  label: "Cancelar" },
+    ok:      {               color: "negative",  label: "Excluir"  },
+  }).onOk(async () => {
+    await deleteKanban(card.id);
+    emit("delete", card.id);
+  });
+};
+</script>
+
+<style scoped>
+.kanban-card {
+  border-radius: 10px;
+  transition: box-shadow 0.2s;
+}
+
+.kanban-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+}
+</style>

+ 53 - 0
src/pages/kanban/components/KanbanCommentCard.vue

@@ -0,0 +1,53 @@
+<template>
+  <q-card flat bordered class="q-pa-md">
+    <div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 8px">
+      <div style="flex: 1">
+        <div class="text-body2">
+          <span class="text-weight-medium">Comentário:</span> {{ reply }}
+        </div>
+        <div class="text-caption text-grey-6 q-mt-xs">
+          {{ createdAt }} &mdash; {{ userName }}
+        </div>
+      </div>
+      <div style="display: flex; gap: 4px; flex-shrink: 0">
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-pencil-outline"
+          size="sm"
+          color="primary"
+          @click="$emit('edit')"
+        />
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-trash-can-outline"
+          size="sm"
+          color="negative"
+          @click="$emit('delete')"
+        />
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+defineEmits(["edit", "delete"]);
+
+defineProps({
+  reply: {
+    type: String,
+    required: true,
+  },
+  createdAt: {
+    type: String,
+    required: true,
+  },
+  userName: {
+    type: String,
+    required: true,
+  },
+});
+</script>

+ 21 - 0
src/router/routes/kanban.route.js

@@ -0,0 +1,21 @@
+export default [
+  {
+    path: "/kanban",
+    name: "KanbanPage",
+    component: () => import("pages/kanban/KanbanPage.vue"),
+    meta: {
+      title: {
+        value: "Atividades",
+        translate: false,
+      },
+      requireAuth: true,
+      requiredPermission: "dashboard",
+      breadcrumbs: [
+        {
+          name: "KanbanPage",
+          title: "Atividades",
+        },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -58,6 +58,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "dashboard",
     },
+    {
+      type: "single",
+      title: "Atividades",
+      name: "KanbanPage",
+      icon: "mdi-clipboard-list-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard",
+    },
     {
       type: "single",
       title: "Cadastros",