Эх сурвалжийг харах

feat: adiciona tab de midias

ebagabee 1 долоо хоног өмнө
parent
commit
d661751312

+ 20 - 0
src/api/kanban_media.js

@@ -0,0 +1,20 @@
+import api from "src/api";
+
+export const getKanbanMedias = async (kanbanId) => {
+  const { data } = await api.get(`/kanban/${kanbanId}/medias`);
+  return data.payload;
+};
+
+export const uploadKanbanMedia = async (kanbanId, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/kanban/${kanbanId}/medias`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteKanbanMedia = async (kanbanId, id) => {
+  const { data } = await api.delete(`/kanban/${kanbanId}/medias/${id}`);
+  return data;
+};

+ 100 - 7
src/pages/kanban/components/AddEditKanbanDialog.vue

@@ -132,6 +132,52 @@
                 </div>
                 </div>
               </div>
               </div>
             </div>
             </div>
+
+            <!-- Tab: Mídias -->
+            <div v-show="currentTab === 'midias'">
+              <div class="flex justify-end q-mb-sm">
+                <q-btn
+                  color="primary"
+                  icon="mdi-upload-outline"
+                  unelevated
+                  style="width: 40px; height: 40px"
+                  :loading="uploadingMedia"
+                  @click="triggerFileInput"
+                />
+                <input
+                  ref="fileInputRef"
+                  type="file"
+                  style="display: none"
+                  @change="onFileSelected"
+                />
+              </div>
+              <div
+                style="
+                  height: 100%;
+                  max-height: 380px;
+                  overflow-y: auto;
+                  display: flex;
+                  flex-direction: column;
+                  gap: 8px;
+                "
+              >
+                <template v-if="medias.length">
+                  <KanbanMediaCard
+                    v-for="media in medias"
+                    :key="media.id"
+                    :file-name="media.file_name"
+                    :file-url="media.file_url"
+                    :mime-type="media.mime_type"
+                    :created-at="media.created_at"
+                    :user-name="media.user_name"
+                    @delete="onDeleteMedia(media)"
+                  />
+                </template>
+                <div v-else class="flex flex-center full-height text-grey-5 text-body2">
+                  Nenhuma mídia anexada.
+                </div>
+              </div>
+            </div>
           </q-card-section>
           </q-card-section>
 
 
           <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
           <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
@@ -155,6 +201,7 @@ import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 import UnitSelect from "src/components/selects/UnitSelect.vue";
 import UnitSelect from "src/components/selects/UnitSelect.vue";
 import KanbanCommentCard from "./KanbanCommentCard.vue";
 import KanbanCommentCard from "./KanbanCommentCard.vue";
+import KanbanMediaCard from "./KanbanMediaCard.vue";
 
 
 import { createKanban, updateKanban } from "src/api/kanban";
 import { createKanban, updateKanban } from "src/api/kanban";
 import {
 import {
@@ -163,6 +210,11 @@ import {
   updateKanbanReply,
   updateKanbanReply,
   deleteKanbanReply,
   deleteKanbanReply,
 } from "src/api/kanban_reply";
 } from "src/api/kanban_reply";
+import {
+  getKanbanMedias,
+  uploadKanbanMedia,
+  deleteKanbanMedia,
+} from "src/api/kanban_media";
 
 
 defineEmits([...useDialogPluginComponent.emits]);
 defineEmits([...useDialogPluginComponent.emits]);
 
 
@@ -176,15 +228,19 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 
 
 const $q = useQuasar();
 const $q = useQuasar();
 
 
-const formRef    = ref(null);
-const loading    = ref(false);
-const currentTab = ref("atividade");
-const replies    = ref([]);
+const formRef      = ref(null);
+const loading      = ref(false);
+const currentTab   = ref("atividade");
+const replies      = ref([]);
+const medias       = ref([]);
+const uploadingMedia = ref(false);
+const fileInputRef = ref(null);
 const selectedUnit = ref(card?.target_unit_id ? { value: card.target_unit_id } : null);
 const selectedUnit = ref(card?.target_unit_id ? { value: card.target_unit_id } : null);
 
 
 const tabs = computed(() => [
 const tabs = computed(() => [
-  { name: "atividade",    label: "Atividade"    },
-  { name: "comentarios",  label: "Comentários"  },
+  { name: "atividade",   label: "Atividade"   },
+  { name: "comentarios", label: "Comentários" },
+  { name: "midias",      label: "Mídias"      },
 ]);
 ]);
 
 
 const priorityOptions = [
 const priorityOptions = [
@@ -243,6 +299,40 @@ const loadReplies = async () => {
   replies.value = await getKanbanReplies(card.id);
   replies.value = await getKanbanReplies(card.id);
 };
 };
 
 
+const loadMedias = async () => {
+  if (!card?.id) return;
+  medias.value = await getKanbanMedias(card.id);
+};
+
+const triggerFileInput = () => {
+  fileInputRef.value?.click();
+};
+
+const onFileSelected = async (event) => {
+  const file = event.target.files?.[0];
+  if (!file) return;
+  uploadingMedia.value = true;
+  try {
+    await uploadKanbanMedia(card.id, file);
+    await loadMedias();
+  } finally {
+    uploadingMedia.value = false;
+    event.target.value = "";
+  }
+};
+
+const onDeleteMedia = (media) => {
+  $q.dialog({
+    title:   "Excluir Mídia",
+    message: `Deseja excluir "${media.file_name}"?`,
+    cancel:  { outline: true, color: "primary",  label: "Cancelar" },
+    ok:      {               color: "negative",  label: "Excluir"  },
+  }).onOk(async () => {
+    await deleteKanbanMedia(card.id, media.id);
+    loadMedias();
+  });
+};
+
 const onAddComment = () => {
 const onAddComment = () => {
   $q.dialog({
   $q.dialog({
     title: "Adicionar Comentário",
     title: "Adicionar Comentário",
@@ -296,5 +386,8 @@ const onOKClick = async () => {
   }
   }
 };
 };
 
 
-onMounted(loadReplies);
+onMounted(() => {
+  loadReplies();
+  loadMedias();
+});
 </script>
 </script>

+ 91 - 0
src/pages/kanban/components/KanbanMediaCard.vue

@@ -0,0 +1,91 @@
+<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="display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0">
+        <q-icon :name="mimeIcon" size="28px" :color="mimeColor" style="flex-shrink: 0" />
+        <div style="min-width: 0">
+          <div class="text-body2 text-weight-medium ellipsis" style="max-width: 260px">
+            {{ fileName }}
+          </div>
+          <div class="text-caption text-grey-6 q-mt-xs">
+            {{ createdAt }} &mdash; {{ userName }}
+          </div>
+        </div>
+      </div>
+      <div style="display: flex; gap: 4px; flex-shrink: 0">
+        <q-btn
+          v-if="isPreviewable"
+          flat
+          dense
+          round
+          icon="mdi-eye-outline"
+          size="sm"
+          color="grey-7"
+          tag="a"
+          :href="fileUrl"
+          target="_blank"
+        />
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-download-outline"
+          size="sm"
+          color="primary"
+          tag="a"
+          :href="fileUrl"
+          target="_blank"
+        />
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-trash-can-outline"
+          size="sm"
+          color="negative"
+          @click="$emit('delete')"
+        />
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+defineEmits(["delete"]);
+
+const props = defineProps({
+  fileName:  { type: String, required: true },
+  fileUrl:   { type: String, required: true },
+  mimeType:  { type: String, default: null },
+  createdAt: { type: String, required: true },
+  userName:  { type: String, default: "" },
+});
+
+const isPreviewable = computed(() => {
+  const t = props.mimeType ?? "";
+  return t.startsWith("image/") || t === "application/pdf";
+});
+
+const mimeIcon = computed(() => {
+  const t = props.mimeType ?? "";
+  if (t.startsWith("image/"))       return "mdi-file-image-outline";
+  if (t === "application/pdf")      return "mdi-file-pdf-box";
+  if (t.includes("word"))           return "mdi-file-word-outline";
+  if (t.includes("spreadsheet") || t.includes("excel")) return "mdi-file-excel-outline";
+  if (t.startsWith("video/"))       return "mdi-file-video-outline";
+  if (t.startsWith("audio/"))       return "mdi-file-music-outline";
+  return "mdi-file-outline";
+});
+
+const mimeColor = computed(() => {
+  const t = props.mimeType ?? "";
+  if (t.startsWith("image/"))  return "teal";
+  if (t === "application/pdf") return "red";
+  if (t.includes("word"))      return "blue";
+  if (t.includes("spreadsheet") || t.includes("excel")) return "green";
+  if (t.startsWith("video/"))  return "purple";
+  return "grey-7";
+});
+</script>