AddEditKanbanDialog.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. <template>
  2. <q-dialog ref="dialogRef" @hide="onDialogHide">
  3. <div style="width: 100%; max-width: 1100px">
  4. <q-card
  5. style="
  6. height: 560px;
  7. display: flex;
  8. flex-direction: column;
  9. overflow: hidden;
  10. "
  11. >
  12. <DefaultDialogHeader
  13. :title="card ? 'Editar Atividade' : 'Adicionar Atividade'"
  14. @close="onDialogCancel"
  15. />
  16. <DefaultForm
  17. ref="formRef"
  18. style="
  19. flex: 1;
  20. display: flex;
  21. flex-direction: column;
  22. overflow: hidden;
  23. "
  24. @submit="onOKClick"
  25. @validation-error="handleValidationError"
  26. >
  27. <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
  28. <q-card-section class="row q-col-gutter-md q-pt-none">
  29. <CustomTabComponent
  30. v-if="card?.id"
  31. v-model:active-tab="currentTab"
  32. class="col-12 q-mb-md"
  33. :tabs="tabs"
  34. />
  35. <div
  36. v-show="currentTab === 'atividade'"
  37. class="col-12"
  38. >
  39. <div class="row q-col-gutter-md">
  40. <DefaultInput
  41. v-model="form.title"
  42. v-model:error="validationErrors.title"
  43. class="col-12"
  44. label="Título da Tarefa"
  45. :error-message="validationErrors.title"
  46. :rules="[inputRules.required, maxLengthRule(255)]"
  47. />
  48. <DefaultSelect
  49. v-model="form.scope"
  50. v-model:error="validationErrors.scope"
  51. class="col-12 col-sm-6"
  52. emit-value
  53. label="Destino"
  54. map-options
  55. :error-message="validationErrors.scope"
  56. :options="scopeOptions"
  57. :rules="[inputRules.required]"
  58. />
  59. <DefaultInputDatePicker
  60. v-model="form.due_date_display"
  61. v-model:untreated-date="form.due_date"
  62. v-model:error="validationErrors.due_date"
  63. class="col-12 col-sm-6"
  64. label="Prazo de Entrega"
  65. :error-message="validationErrors.due_date"
  66. :rules="[dateRule]"
  67. />
  68. <UnitSelect
  69. v-if="form.scope === 'specific'"
  70. v-model="selectedUnit"
  71. v-model:error="validationErrors.target_unit_id"
  72. class="col-12 col-sm-6"
  73. :error-message="validationErrors.target_unit_id"
  74. />
  75. <DefaultSelect
  76. v-model="form.priority"
  77. v-model:error="validationErrors.priority"
  78. class="col-12 col-sm-6"
  79. emit-value
  80. label="Prioridade"
  81. map-options
  82. :error-message="validationErrors.priority"
  83. :options="priorityOptions"
  84. :rules="[inputRules.required]"
  85. />
  86. <DefaultSelect
  87. v-model="form.phase"
  88. v-model:error="validationErrors.phase"
  89. class="col-12 col-sm-6"
  90. emit-value
  91. label="Fase"
  92. map-options
  93. :error-message="validationErrors.phase"
  94. :options="phaseOptions"
  95. :rules="[inputRules.required]"
  96. />
  97. <DefaultInput
  98. v-model="form.sector"
  99. v-model:error="validationErrors.sector"
  100. class="col-12 col-sm-6"
  101. label="Setor"
  102. :error-message="validationErrors.sector"
  103. :rules="[maxLengthRule(255)]"
  104. />
  105. <DefaultInput
  106. v-model="form.description"
  107. v-model:error="validationErrors.description"
  108. class="col-12"
  109. label="Descrição"
  110. type="textarea"
  111. :error-message="validationErrors.description"
  112. />
  113. </div>
  114. </div>
  115. <div
  116. v-show="currentTab === 'comentarios'"
  117. class="col-12"
  118. >
  119. <div class="flex justify-end q-mb-sm">
  120. <q-btn
  121. v-if="canAdd"
  122. color="primary"
  123. icon="mdi-plus"
  124. style="width: 40px; height: 40px"
  125. unelevated
  126. @click="onAddComment"
  127. />
  128. </div>
  129. <div
  130. style="
  131. display: flex;
  132. flex-direction: column;
  133. gap: 8px;
  134. "
  135. >
  136. <template v-if="replies.length">
  137. <KanbanCommentCard
  138. v-for="reply in replies"
  139. :key="reply.id"
  140. :can-delete="canDelete"
  141. :can-edit="canEdit"
  142. :created-at="reply.created_at"
  143. :reply="reply.reply"
  144. :user-name="reply.user_name"
  145. @delete="onDeleteComment(reply)"
  146. @edit="onEditComment(reply)"
  147. />
  148. </template>
  149. <div
  150. v-else
  151. class="flex flex-center full-height text-grey-5 text-body2"
  152. >
  153. Nenhum comentário registrado.
  154. </div>
  155. </div>
  156. </div>
  157. <div
  158. v-show="currentTab === 'midias'"
  159. class="col-12"
  160. >
  161. <div class="flex justify-end q-mb-sm">
  162. <q-btn
  163. v-if="canAdd"
  164. color="primary"
  165. icon="mdi-upload-outline"
  166. style="width: 40px; height: 40px"
  167. unelevated
  168. :loading="uploadingMedia"
  169. @click="triggerFileInput"
  170. />
  171. <input
  172. ref="fileInputRef"
  173. style="display: none"
  174. type="file"
  175. @change="onFileSelected"
  176. />
  177. </div>
  178. <div
  179. style="
  180. display: flex;
  181. flex-direction: column;
  182. gap: 8px;
  183. "
  184. >
  185. <template v-if="medias.length">
  186. <KanbanMediaCard
  187. v-for="media in medias"
  188. :key="media.id"
  189. :can-delete="canDelete"
  190. :created-at="media.created_at"
  191. :file-name="media.file_name"
  192. :file-url="media.file_url"
  193. :mime-type="media.mime_type"
  194. :user-name="media.user_name"
  195. @delete="onDeleteMedia(media)"
  196. />
  197. </template>
  198. <div
  199. v-else
  200. class="flex flex-center full-height text-grey-5 text-body2"
  201. >
  202. Nenhuma mídia anexada.
  203. </div>
  204. </div>
  205. </div>
  206. </q-card-section>
  207. </q-scroll-area>
  208. <q-separator />
  209. <q-card-actions
  210. align="right"
  211. class="q-pa-md q-gutter-sm"
  212. style="flex-shrink: 0"
  213. >
  214. <q-btn
  215. color="negative"
  216. label="Cancelar"
  217. no-caps
  218. outline
  219. @click="onDialogCancel"
  220. />
  221. <q-btn
  222. v-if="card?.id ? canEdit : canAdd"
  223. color="primary"
  224. label="Salvar"
  225. no-caps
  226. type="submit"
  227. unelevated
  228. :loading="loading"
  229. />
  230. </q-card-actions>
  231. </DefaultForm>
  232. </q-card>
  233. </div>
  234. </q-dialog>
  235. </template>
  236. <script setup>
  237. import {
  238. createKanban,
  239. updateKanban,
  240. } from "src/api/kanban";
  241. import {
  242. createKanbanReply,
  243. deleteKanbanReply,
  244. getKanbanReplies,
  245. updateKanbanReply,
  246. } from "src/api/kanban_reply";
  247. import {
  248. deleteKanbanMedia,
  249. getKanbanMedias,
  250. uploadKanbanMedia,
  251. } from "src/api/kanban_media";
  252. import { computed, nextTick, onMounted, ref, watch } from "vue";
  253. import { permissionStore } from "src/stores/permission";
  254. import {
  255. useDialogPluginComponent,
  256. useQuasar,
  257. } from "quasar";
  258. import { useForm } from "src/composables/useForm";
  259. import { useInputRules } from "src/composables/useInputRules";
  260. import { useScroll } from "src/composables/useScroll";
  261. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  262. import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
  263. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  264. import DefaultForm from "src/components/defaults/DefaultForm.vue";
  265. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  266. import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
  267. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  268. import KanbanCommentCard from "./KanbanCommentCard.vue";
  269. import KanbanMediaCard from "./KanbanMediaCard.vue";
  270. import UnitSelect from "src/components/selects/UnitSelect.vue";
  271. defineEmits([...useDialogPluginComponent.emits]);
  272. const { card, initialPhase } = defineProps({
  273. card: {
  274. default: null,
  275. type: Object,
  276. },
  277. initialPhase: {
  278. default: "a_fazer",
  279. type: String,
  280. },
  281. });
  282. const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
  283. useDialogPluginComponent();
  284. const $q = useQuasar();
  285. const { inputRules } = useInputRules();
  286. const { scrollToComponent } = useScroll();
  287. const permissions = permissionStore();
  288. const fileInputRef = ref(null);
  289. const formRef = ref(null);
  290. const scrollAreaRef = ref(null);
  291. const formatDisplayDate = (rawDate) => {
  292. if (!rawDate) return null;
  293. const [year, month, day] = rawDate.split("-");
  294. return `${day}/${month}/${year}`;
  295. };
  296. const { form, getUpdatedFields } = useForm(
  297. {
  298. description: card?.description ?? null,
  299. due_date: card?.due_date ?? null,
  300. due_date_display: formatDisplayDate(card?.due_date),
  301. phase: card?.phase ?? initialPhase,
  302. priority: card?.priority ?? "normal",
  303. responsible_user_id: card?.responsible_user_id ?? null,
  304. scope: card?.scope ?? "internal",
  305. sector: card?.sector ?? null,
  306. target_unit_id: card?.target_unit_id ?? null,
  307. title: card?.title ?? null,
  308. },
  309. { containerRef: scrollAreaRef },
  310. );
  311. const scrollToActivityComponent = async (
  312. component,
  313. containerRef,
  314. options,
  315. ) => {
  316. currentTab.value = "atividade";
  317. await nextTick();
  318. return scrollToComponent(component, containerRef, options);
  319. };
  320. const {
  321. loading,
  322. validationErrors,
  323. execute,
  324. } = useSubmitHandler({
  325. containerRef: scrollAreaRef,
  326. formRef,
  327. onSuccess: () => onDialogOK(true),
  328. scrollFn: scrollToActivityComponent,
  329. });
  330. const selectedUnit = ref(
  331. card?.target_unit_id
  332. ? {
  333. value: card.target_unit_id,
  334. }
  335. : null,
  336. );
  337. const currentTab = ref("atividade");
  338. const medias = ref([]);
  339. const replies = ref([]);
  340. const uploadingMedia = ref(false);
  341. const tabs = computed(() => [
  342. {
  343. label: "Atividade",
  344. name: "atividade",
  345. },
  346. {
  347. label: "Comentários",
  348. name: "comentarios",
  349. },
  350. {
  351. label: "Mídias",
  352. name: "midias",
  353. },
  354. ]);
  355. const canAdd = permissions.getAccess("franchisor_activities", "add");
  356. const canDelete = permissions.getAccess("franchisor_activities", "delete");
  357. const canEdit = permissions.getAccess("franchisor_activities", "edit");
  358. const phaseOptions = [
  359. { label: "A Fazer", value: "a_fazer" },
  360. { label: "Em Progresso", value: "em_progresso" },
  361. { label: "Em Revisão", value: "em_revisao" },
  362. { label: "Concluído", value: "concluido" },
  363. { label: "Demandas Especiais", value: "demandas_especiais" },
  364. ];
  365. const priorityOptions = [
  366. { label: "Alta", value: "alta" },
  367. { label: "Normal", value: "normal" },
  368. { label: "Baixa", value: "baixa" },
  369. ];
  370. const scopeOptions = [
  371. { label: "Interno", value: "internal" },
  372. { label: "Todas as Unidades", value: "all" },
  373. { label: "Unidade Específica", value: "specific" },
  374. ];
  375. const buildPayload = () => ({
  376. description: form.description || null,
  377. due_date: form.due_date || null,
  378. phase: form.phase,
  379. priority: form.priority,
  380. responsible_user_id: form.responsible_user_id,
  381. scope: form.scope,
  382. sector: form.sector || null,
  383. target_unit_id: form.scope === "specific" ? form.target_unit_id : null,
  384. title: form.title,
  385. });
  386. const dateRule = (value) => {
  387. if (!value) return true;
  388. const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  389. if (!match) return "Informe uma data válida";
  390. const [, year, month, day] = match.map(Number);
  391. const date = new Date(year, month - 1, day);
  392. return (
  393. (date.getFullYear() === year &&
  394. date.getMonth() === month - 1 &&
  395. date.getDate() === day) ||
  396. "Informe uma data válida"
  397. );
  398. };
  399. const handleValidationError = async (component) => {
  400. currentTab.value = "atividade";
  401. await nextTick();
  402. if (component) {
  403. component.focus?.();
  404. scrollToComponent(component, scrollAreaRef);
  405. }
  406. };
  407. const loadMedias = async () => {
  408. if (!card?.id) return;
  409. medias.value = await getKanbanMedias(card.id);
  410. };
  411. const loadReplies = async () => {
  412. if (!card?.id) return;
  413. replies.value = await getKanbanReplies(card.id);
  414. };
  415. const maxLengthRule = (maximum) => (value) =>
  416. !value ||
  417. String(value).length <= maximum ||
  418. `O campo deve ter no máximo ${maximum} caracteres`;
  419. const onAddComment = () => {
  420. $q.dialog({
  421. cancel: {
  422. color: "primary",
  423. label: "Cancelar",
  424. outline: true,
  425. },
  426. ok: {
  427. color: "primary",
  428. label: "Salvar",
  429. },
  430. prompt: {
  431. label: "Comentário *",
  432. model: "",
  433. type: "textarea",
  434. },
  435. title: "Adicionar Comentário",
  436. }).onOk(async (text) => {
  437. if (!text?.trim()) return;
  438. await createKanbanReply(card.id, {
  439. reply: text.trim(),
  440. });
  441. loadReplies();
  442. });
  443. };
  444. const onDeleteComment = (reply) => {
  445. $q.dialog({
  446. cancel: {
  447. color: "primary",
  448. label: "Cancelar",
  449. outline: true,
  450. },
  451. message: "Tem certeza que deseja excluir este comentário?",
  452. ok: {
  453. color: "negative",
  454. label: "Excluir",
  455. },
  456. title: "Excluir Comentário",
  457. }).onOk(async () => {
  458. await deleteKanbanReply(card.id, reply.id);
  459. loadReplies();
  460. });
  461. };
  462. const onDeleteMedia = (media) => {
  463. $q.dialog({
  464. cancel: {
  465. color: "primary",
  466. label: "Cancelar",
  467. outline: true,
  468. },
  469. message: `Deseja excluir "${media.file_name}"?`,
  470. ok: {
  471. color: "negative",
  472. label: "Excluir",
  473. },
  474. title: "Excluir Mídia",
  475. }).onOk(async () => {
  476. await deleteKanbanMedia(card.id, media.id);
  477. loadMedias();
  478. });
  479. };
  480. const onEditComment = (reply) => {
  481. $q.dialog({
  482. cancel: {
  483. color: "primary",
  484. label: "Cancelar",
  485. outline: true,
  486. },
  487. ok: {
  488. color: "primary",
  489. label: "Salvar",
  490. },
  491. prompt: {
  492. label: "Comentário",
  493. model: reply.reply,
  494. type: "textarea",
  495. },
  496. title: "Editar Comentário",
  497. }).onOk(async (text) => {
  498. if (!text?.trim()) return;
  499. await updateKanbanReply(card.id, reply.id, {
  500. reply: text.trim(),
  501. });
  502. loadReplies();
  503. });
  504. };
  505. const onFileSelected = async (event) => {
  506. const file = event.target.files?.[0];
  507. if (!file) return;
  508. uploadingMedia.value = true;
  509. try {
  510. await uploadKanbanMedia(card.id, file);
  511. await loadMedias();
  512. } finally {
  513. event.target.value = "";
  514. uploadingMedia.value = false;
  515. }
  516. };
  517. const triggerFileInput = () => {
  518. fileInputRef.value?.click();
  519. };
  520. const onOKClick = async () => {
  521. await execute(() => {
  522. if (card?.id) {
  523. const payload = {
  524. ...getUpdatedFields.value,
  525. };
  526. delete payload.due_date_display;
  527. return updateKanban(card.id, payload);
  528. }
  529. return createKanban(buildPayload());
  530. });
  531. };
  532. watch(selectedUnit, (unit) => {
  533. form.target_unit_id = unit?.value ?? null;
  534. });
  535. watch(
  536. () => form.scope,
  537. (scope) => {
  538. if (scope !== "specific") {
  539. selectedUnit.value = null;
  540. form.target_unit_id = null;
  541. }
  542. },
  543. );
  544. onMounted(() => {
  545. loadMedias();
  546. loadReplies();
  547. });
  548. </script>