AddEditKanbanDialog.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. <template>
  2. <q-dialog ref="dialogRef" @hide="onDialogHide">
  3. <div style="width: 100%; max-width: 1100px">
  4. <q-card style="height: 560px; display: flex; flex-direction: column; overflow: hidden">
  5. <DefaultDialogHeader
  6. :title="card ? 'Editar Atividade' : 'Adicionar Atividade'"
  7. @close="onDialogCancel"
  8. />
  9. <DefaultForm
  10. ref="formRef"
  11. style="flex: 1; display: flex; flex-direction: column; overflow: hidden"
  12. @submit="onOKClick"
  13. >
  14. <q-card-section class="q-pt-sm" style="flex: 1; overflow-y: auto; min-height: 0">
  15. <CustomTabComponent
  16. v-if="card?.id"
  17. v-model:active-tab="currentTab"
  18. :tabs="tabs"
  19. class="q-mb-md"
  20. />
  21. <!-- Tab: Atividade -->
  22. <div v-show="currentTab === 'atividade'">
  23. <div class="row q-col-gutter-sm">
  24. <!-- 1. Título -->
  25. <DefaultInput
  26. v-model="form.title"
  27. label="Título da Tarefa"
  28. class="col-12"
  29. :rules="[val => !!val || 'Campo obrigatório']"
  30. />
  31. <!-- 2. Destino (primeiro campo de configuração) -->
  32. <DefaultSelect
  33. v-model="form.scope"
  34. label="Destino"
  35. :options="scopeOptions"
  36. emit-value
  37. map-options
  38. class="col-6"
  39. :rules="[val => !!val || 'Campo obrigatório']"
  40. />
  41. <!-- Prazo ao lado do Destino -->
  42. <DefaultInputDatePicker
  43. v-model="form.due_date_display"
  44. v-model:untreated-date="form.due_date"
  45. label="Prazo de Entrega"
  46. class="col-6"
  47. />
  48. <!-- Unidade específica — só aparece quando scope = specific -->
  49. <UnitSelect
  50. v-if="form.scope === 'specific'"
  51. v-model="selectedUnit"
  52. class="col-6"
  53. />
  54. <!-- 3. Prioridade + Fase -->
  55. <DefaultSelect
  56. v-model="form.priority"
  57. label="Prioridade"
  58. :options="priorityOptions"
  59. emit-value
  60. map-options
  61. class="col-6"
  62. :rules="[val => !!val || 'Campo obrigatório']"
  63. />
  64. <DefaultSelect
  65. v-model="form.phase"
  66. label="Fase"
  67. :options="phaseOptions"
  68. emit-value
  69. map-options
  70. class="col-6"
  71. :rules="[val => !!val || 'Campo obrigatório']"
  72. />
  73. <!-- 4. Setor -->
  74. <DefaultInput
  75. v-model="form.sector"
  76. label="Setor"
  77. class="col-6"
  78. />
  79. <!-- 5. Descrição -->
  80. <DefaultInput
  81. v-model="form.description"
  82. label="Descrição"
  83. type="textarea"
  84. class="col-12"
  85. />
  86. </div>
  87. </div>
  88. <!-- Tab: Comentários -->
  89. <div v-show="currentTab === 'comentarios'">
  90. <div class="flex justify-end q-mb-sm">
  91. <q-btn
  92. v-if="canAdd"
  93. color="primary"
  94. icon="mdi-plus"
  95. unelevated
  96. style="width: 40px; height: 40px"
  97. @click="onAddComment"
  98. />
  99. </div>
  100. <div
  101. style="
  102. height: 100%;
  103. max-height: 380px;
  104. overflow-y: auto;
  105. display: flex;
  106. flex-direction: column;
  107. gap: 8px;
  108. "
  109. >
  110. <template v-if="replies.length">
  111. <KanbanCommentCard
  112. v-for="reply in replies"
  113. :key="reply.id"
  114. :reply="reply.reply"
  115. :created-at="reply.created_at"
  116. :user-name="reply.user_name"
  117. :can-edit="canEdit"
  118. :can-delete="canDelete"
  119. @edit="onEditComment(reply)"
  120. @delete="onDeleteComment(reply)"
  121. />
  122. </template>
  123. <div v-else class="flex flex-center full-height text-grey-5 text-body2">
  124. Nenhum comentário registrado.
  125. </div>
  126. </div>
  127. </div>
  128. <!-- Tab: Mídias -->
  129. <div v-show="currentTab === 'midias'">
  130. <div class="flex justify-end q-mb-sm">
  131. <q-btn
  132. v-if="canAdd"
  133. color="primary"
  134. icon="mdi-upload-outline"
  135. unelevated
  136. style="width: 40px; height: 40px"
  137. :loading="uploadingMedia"
  138. @click="triggerFileInput"
  139. />
  140. <input
  141. ref="fileInputRef"
  142. type="file"
  143. style="display: none"
  144. @change="onFileSelected"
  145. />
  146. </div>
  147. <div
  148. style="
  149. height: 100%;
  150. max-height: 380px;
  151. overflow-y: auto;
  152. display: flex;
  153. flex-direction: column;
  154. gap: 8px;
  155. "
  156. >
  157. <template v-if="medias.length">
  158. <KanbanMediaCard
  159. v-for="media in medias"
  160. :key="media.id"
  161. :file-name="media.file_name"
  162. :file-url="media.file_url"
  163. :mime-type="media.mime_type"
  164. :created-at="media.created_at"
  165. :user-name="media.user_name"
  166. :can-delete="canDelete"
  167. @delete="onDeleteMedia(media)"
  168. />
  169. </template>
  170. <div v-else class="flex flex-center full-height text-grey-5 text-body2">
  171. Nenhuma mídia anexada.
  172. </div>
  173. </div>
  174. </div>
  175. </q-card-section>
  176. <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
  177. <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
  178. <q-btn v-if="card?.id ? canEdit : canAdd" color="primary" label="Salvar" type="submit" :loading="loading" />
  179. </q-card-actions>
  180. </DefaultForm>
  181. </q-card>
  182. </div>
  183. </q-dialog>
  184. </template>
  185. <script setup>
  186. import { ref, computed, onMounted } from "vue";
  187. import { useDialogPluginComponent, useQuasar } from "quasar";
  188. import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
  189. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  190. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  191. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  192. import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
  193. import UnitSelect from "src/components/selects/UnitSelect.vue";
  194. import KanbanCommentCard from "./KanbanCommentCard.vue";
  195. import KanbanMediaCard from "./KanbanMediaCard.vue";
  196. import { createKanban, updateKanban } from "src/api/kanban";
  197. import {
  198. getKanbanReplies,
  199. createKanbanReply,
  200. updateKanbanReply,
  201. deleteKanbanReply,
  202. } from "src/api/kanban_reply";
  203. import {
  204. getKanbanMedias,
  205. uploadKanbanMedia,
  206. deleteKanbanMedia,
  207. } from "src/api/kanban_media";
  208. import { permissionStore } from "src/stores/permission";
  209. defineEmits([...useDialogPluginComponent.emits]);
  210. const { card, initialPhase } = defineProps({
  211. card: { type: Object, default: null },
  212. initialPhase: { type: String, default: "a_fazer" },
  213. });
  214. const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
  215. useDialogPluginComponent();
  216. const $q = useQuasar();
  217. const permissions = permissionStore();
  218. const canAdd = permissions.getAccess("franchisor_activities", "add");
  219. const canEdit = permissions.getAccess("franchisor_activities", "edit");
  220. const canDelete = permissions.getAccess("franchisor_activities", "delete");
  221. const formRef = ref(null);
  222. const loading = ref(false);
  223. const currentTab = ref("atividade");
  224. const replies = ref([]);
  225. const medias = ref([]);
  226. const uploadingMedia = ref(false);
  227. const fileInputRef = ref(null);
  228. const selectedUnit = ref(card?.target_unit_id ? { value: card.target_unit_id } : null);
  229. const tabs = computed(() => [
  230. { name: "atividade", label: "Atividade" },
  231. { name: "comentarios", label: "Comentários" },
  232. { name: "midias", label: "Mídias" },
  233. ]);
  234. const priorityOptions = [
  235. { label: "Alta", value: "alta" },
  236. { label: "Normal", value: "normal" },
  237. { label: "Baixa", value: "baixa" },
  238. ];
  239. const phaseOptions = [
  240. { label: "A Fazer", value: "a_fazer" },
  241. { label: "Em Progresso", value: "em_progresso" },
  242. { label: "Em Revisão", value: "em_revisao" },
  243. { label: "Concluído", value: "concluido" },
  244. { label: "Demandas Especiais", value: "demandas_especiais" },
  245. ];
  246. const scopeOptions = [
  247. { label: "Interno", value: "internal" },
  248. { label: "Todas as Unidades", value: "all" },
  249. { label: "Unidade Específica", value: "specific" },
  250. ];
  251. const formatDisplayDate = (rawDate) => {
  252. if (!rawDate) return null;
  253. const [y, m, d] = rawDate.split("-");
  254. return `${d}/${m}/${y}`;
  255. };
  256. const form = ref({
  257. title: card?.title ?? null,
  258. priority: card?.priority ?? "normal",
  259. phase: card?.phase ?? initialPhase,
  260. scope: card?.scope ?? "internal",
  261. sector: card?.sector ?? null,
  262. description: card?.description ?? null,
  263. due_date: card?.due_date ?? null,
  264. due_date_display: formatDisplayDate(card?.due_date),
  265. });
  266. const buildPayload = () => ({
  267. title: form.value.title,
  268. priority: form.value.priority,
  269. phase: form.value.phase,
  270. scope: form.value.scope,
  271. sector: form.value.sector || null,
  272. description: form.value.description || null,
  273. due_date: form.value.due_date || null,
  274. responsible_user_id: null, // Matriz never assigns a responsible
  275. target_unit_id: form.value.scope === "specific"
  276. ? (selectedUnit.value?.value ?? null)
  277. : null,
  278. });
  279. const loadReplies = async () => {
  280. if (!card?.id) return;
  281. replies.value = await getKanbanReplies(card.id);
  282. };
  283. const loadMedias = async () => {
  284. if (!card?.id) return;
  285. medias.value = await getKanbanMedias(card.id);
  286. };
  287. const triggerFileInput = () => {
  288. fileInputRef.value?.click();
  289. };
  290. const onFileSelected = async (event) => {
  291. const file = event.target.files?.[0];
  292. if (!file) return;
  293. uploadingMedia.value = true;
  294. try {
  295. await uploadKanbanMedia(card.id, file);
  296. await loadMedias();
  297. } finally {
  298. uploadingMedia.value = false;
  299. event.target.value = "";
  300. }
  301. };
  302. const onDeleteMedia = (media) => {
  303. $q.dialog({
  304. title: "Excluir Mídia",
  305. message: `Deseja excluir "${media.file_name}"?`,
  306. cancel: { outline: true, color: "primary", label: "Cancelar" },
  307. ok: { color: "negative", label: "Excluir" },
  308. }).onOk(async () => {
  309. await deleteKanbanMedia(card.id, media.id);
  310. loadMedias();
  311. });
  312. };
  313. const onAddComment = () => {
  314. $q.dialog({
  315. title: "Adicionar Comentário",
  316. prompt: { model: "", type: "textarea", label: "Comentário" },
  317. ok: { label: "Salvar", color: "primary" },
  318. cancel: { label: "Cancelar", color: "primary", outline: true },
  319. }).onOk(async (text) => {
  320. if (!text?.trim()) return;
  321. await createKanbanReply(card.id, { reply: text.trim() });
  322. loadReplies();
  323. });
  324. };
  325. const onEditComment = (reply) => {
  326. $q.dialog({
  327. title: "Editar Comentário",
  328. prompt: { model: reply.reply, type: "textarea", label: "Comentário" },
  329. ok: { label: "Salvar", color: "primary" },
  330. cancel: { label: "Cancelar", color: "primary", outline: true },
  331. }).onOk(async (text) => {
  332. if (!text?.trim()) return;
  333. await updateKanbanReply(card.id, reply.id, { reply: text.trim() });
  334. loadReplies();
  335. });
  336. };
  337. const onDeleteComment = (reply) => {
  338. $q.dialog({
  339. title: "Excluir Comentário",
  340. message: "Tem certeza que deseja excluir este comentário?",
  341. cancel: { outline: true, color: "primary", label: "Cancelar" },
  342. ok: { color: "negative", label: "Excluir" },
  343. }).onOk(async () => {
  344. await deleteKanbanReply(card.id, reply.id);
  345. loadReplies();
  346. });
  347. };
  348. const onOKClick = async () => {
  349. loading.value = true;
  350. try {
  351. const payload = buildPayload();
  352. if (card?.id) {
  353. await updateKanban(card.id, payload);
  354. } else {
  355. await createKanban(payload);
  356. }
  357. onDialogOK(true);
  358. } finally {
  359. loading.value = false;
  360. }
  361. };
  362. onMounted(() => {
  363. loadReplies();
  364. loadMedias();
  365. });
  366. </script>