Procházet zdrojové kódy

feat(kanban): add kanban board page to franchisor

- api/kanban.js + api/kanban_reply.js
- router/routes/kanban.route.js
- pages/kanban/KanbanPage.vue (5-column board)
- pages/kanban/components/KanbanCard.vue
- pages/kanban/components/KanbanCommentCard.vue
- pages/kanban/components/AddEditKanbanDialog.vue
ebagabee před 2 týdny
rodič
revize
092819bef5

+ 21 - 0
src/api/kanban.js

@@ -0,0 +1,21 @@
+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;
+};

+ 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;
+};

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

@@ -0,0 +1,134 @@
+<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"
+        class="kanban-column"
+        style="
+          min-width: 280px;
+          max-width: 320px;
+          flex: 1;
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+        "
+      >
+        <!-- Column header -->
+        <div
+          class="kanban-column-header 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.textColor"
+            :label="cardsForPhase(column.phase).length"
+            style="font-size: 11px"
+          />
+        </div>
+
+        <!-- Cards -->
+        <div
+          style="
+            display: flex;
+            flex-direction: column;
+            gap: 8px;
+            flex: 1;
+          "
+        >
+          <KanbanCard
+            v-for="card in cardsForPhase(column.phase)"
+            :key="card.id"
+            :card="card"
+            @edit="openDialog(card, column.phase)"
+          />
+        </div>
+
+        <!-- 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, defineAsyncComponent, onMounted } from "vue";
+import { useQuasar } from "quasar";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import KanbanCard from "./components/KanbanCard.vue";
+import { getKanbans } from "src/api/kanban";
+
+const AddEditKanbanDialog = defineAsyncComponent(
+  () => import("./components/AddEditKanbanDialog.vue"),
+);
+
+const $q = useQuasar();
+
+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"},
+];
+
+const cardsForPhase = (phase) =>
+  cards.value.filter((c) => c.phase === phase);
+
+const loadCards = async () => {
+  loading.value = true;
+  try {
+    cards.value = await getKanbans();
+  } finally {
+    loading.value = false;
+  }
+};
+
+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;
+}
+</style>

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

@@ -0,0 +1,352 @@
+<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">
+            <!-- Tabs: only shown when editing -->
+            <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">
+                <DefaultInput
+                  v-model="form.title"
+                  label="Título da Tarefa"
+                  class="col-12"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="form.due_date_display"
+                  v-model:untreated-date="form.due_date"
+                  label="Prazo de Entrega"
+                  class="col-6"
+                />
+
+                <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.responsible_user_id"
+                  label="Responsável"
+                  :options="userOptions"
+                  emit-value
+                  map-options
+                  class="col-12"
+                />
+
+                <DefaultSelect
+                  v-model="form.phase"
+                  label="Fase"
+                  :options="phaseOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <DefaultInput
+                  v-model="form.sector"
+                  label="Setor"
+                  class="col-6"
+                />
+
+                <DefaultSelect
+                  v-model="form.scope"
+                  label="Destino"
+                  :options="scopeOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                  :rules="[val => !!val || 'Campo obrigatório']"
+                />
+
+                <UnitSelect
+                  v-if="form.scope === 'specific'"
+                  v-model="selectedUnit"
+                  class="col-6"
+                />
+
+                <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";
+import { getUsers } from "src/api/user";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { card, initialPhase } = defineProps({
+  card: {
+    type: Object,
+    default: null,
+  },
+  /** Phase pre-selected when opening the dialog from a column's "add" button */
+  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 selectedUnit = ref(card?.target_unit_id ? { value: card.target_unit_id } : null);
+const userOptions = ref([]);
+const replies = ref([]);
+
+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" },
+];
+
+// Format date from YYYY-MM-DD to DD/MM/YYYY for display
+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),
+  responsible_user_id: card?.responsible_user_id ?? null,
+});
+
+const buildPayload = () => {
+  const payload = {
+    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: form.value.responsible_user_id || null,
+  };
+
+  if (form.value.scope === "specific") {
+    payload.target_unit_id = selectedUnit.value?.value ?? null;
+  } else {
+    payload.target_unit_id = null;
+  }
+
+  return payload;
+};
+
+const loadUsers = async () => {
+  try {
+    const users = await getUsers();
+    userOptions.value = users.map((u) => ({ label: u.name, value: u.id }));
+  } catch {}
+};
+
+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(() => {
+  loadUsers();
+  loadReplies();
+});
+</script>

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

@@ -0,0 +1,92 @@
+<template>
+  <q-card
+    class="kanban-card cursor-pointer"
+    :style="{ borderLeft: `4px solid ${phaseColor}` }"
+    @click="$emit('edit', card)"
+  >
+    <!-- Header: Creator name + Priority badge -->
+    <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: 60%">
+        {{ card.created_by_user_name ?? '—' }}
+      </span>
+      <q-badge
+        :color="priorityColor"
+        :label="priorityLabel"
+        style="padding: 4px 8px; font-size: 11px"
+      />
+    </div>
+
+    <!-- Unit name of responsible -->
+    <div class="q-px-md q-mt-xs text-caption text-grey-6">
+      {{ card.applicant_unit_name ?? 'Matriz' }}
+    </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 gap-xs" 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";
+
+defineEmits(["edit"]);
+
+const { card } = defineProps({
+  card: {
+    type: Object,
+    required: true,
+  },
+});
+
+const truncated = computed(() => {
+  const desc = card.description ?? "";
+  return desc.length > 50 ? desc.slice(0, 50) + "…" : desc;
+});
+
+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";
+});
+</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",
+        },
+      ],
+    },
+  },
+];