ParceirosConveniosPage.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. <template>
  2. <div class="parceiros-page">
  3. <input
  4. ref="importParceiroInput"
  5. type="file"
  6. accept=".xlsx"
  7. class="hidden"
  8. @change="onParceiroFileSelected"
  9. />
  10. <DefaultHeaderPage>
  11. <template #after>
  12. <div v-if="isAdministrador" class="flex gap-sm q-mt-md">
  13. <q-btn
  14. unelevated
  15. class="btn-gradient"
  16. :label="$t('parceiro.import_parceiros')"
  17. icon="mdi-upload"
  18. padding="8px 12px"
  19. :loading="importingParceiros"
  20. @click="onImportParceirosClick"
  21. />
  22. <q-btn
  23. unelevated
  24. class="btn-gradient"
  25. :label="$t('common.actions.add')"
  26. icon="mdi-plus"
  27. padding="8px 12px"
  28. @click="onAddItem"
  29. />
  30. </div>
  31. </template>
  32. </DefaultHeaderPage>
  33. <div class="q-pb-sm">
  34. <q-input
  35. v-model="searchQuery"
  36. outlined
  37. dense
  38. bg-color="white"
  39. :placeholder="$t('parceiro.search_placeholder')"
  40. clearable
  41. class="parceiros-page__search-input"
  42. >
  43. <template #prepend>
  44. <q-icon name="mdi-magnify" />
  45. </template>
  46. </q-input>
  47. </div>
  48. <div class="row q-gutter-xs q-mb-md flex-wrap">
  49. <div
  50. :class="['cat-chip', activeCategory === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
  51. @click="activeCategory = 'all'"
  52. >
  53. {{ $t('common.terms.all') }}
  54. </div>
  55. <div
  56. v-for="cat in categories"
  57. :key="cat.id"
  58. :class="['cat-chip', activeCategory === String(cat.id) ? 'cat-chip--selected' : 'cat-chip--default']"
  59. @click="activeCategory = String(cat.id)"
  60. >
  61. {{ cat.name }}
  62. </div>
  63. </div>
  64. <div v-if="loading" class="flex flex-center q-pa-xl">
  65. <q-spinner color="violet-normal" size="50px" />
  66. </div>
  67. <div v-else-if="filteredPartners.length === 0" class="flex flex-center q-pa-xl text-grey-6">
  68. {{ $t("http.errors.no_records_found") }}
  69. </div>
  70. <div v-else class="row q-col-gutter-md">
  71. <div
  72. v-for="partner in filteredPartners"
  73. :key="partner.id"
  74. class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-12"
  75. >
  76. <PartnerAgreementCard
  77. :partner="partner"
  78. :editable="isAdministrador"
  79. @edit="onEditItem"
  80. />
  81. </div>
  82. </div>
  83. </div>
  84. </template>
  85. <script setup>
  86. import { ref, computed, onMounted, useTemplateRef } from "vue";
  87. import { useRouter } from "vue-router";
  88. import { useQuasar } from "quasar";
  89. import { useI18n } from "vue-i18n";
  90. import { permissionStore } from "src/stores/permission";
  91. import { userStore } from "src/stores/user";
  92. import { getPartnerAgreements, importParceiros } from "src/api/partnerAgreement";
  93. import { getCategories } from "src/api/category";
  94. import { normalizeString } from "src/helpers/utils";
  95. import { useImportPoller } from "src/composables/useImportPoller";
  96. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  97. import PartnerAgreementCard from "./components/PartnerAgreementCard.vue";
  98. const router = useRouter();
  99. const $q = useQuasar();
  100. const { t } = useI18n();
  101. const permission_store = permissionStore();
  102. const { isAdministrador } = userStore();
  103. const loading = ref(true);
  104. const allPartners = ref([]);
  105. const categories = ref([]);
  106. const activeCategory = ref("all");
  107. const searchQuery = ref("");
  108. const importParceiroInput = useTemplateRef("importParceiroInput");
  109. const { polling: importingParceiros, start: startParceiroPolling } = useImportPoller();
  110. const filteredPartners = computed(() => {
  111. let list = allPartners.value;
  112. if (activeCategory.value !== "all") {
  113. list = list.filter((p) => String(p.category_id) === activeCategory.value);
  114. }
  115. if (searchQuery.value) {
  116. const needle = normalizeString(searchQuery.value);
  117. list = list.filter((p) => {
  118. const fields = [p.company_name, p.cnpj, p.responsible, p.email, p.phone, p.category?.name, p.address, p.city?.name];
  119. return fields.some((f) => f && normalizeString(String(f)).includes(needle));
  120. });
  121. }
  122. return list;
  123. });
  124. const onAddItem = () => {
  125. if (!permission_store.getAccess("parceiro.convenio", "add")) {
  126. $q.notify({ type: "negative", message: t("validation.permissions.add") });
  127. return;
  128. }
  129. router.push({ name: "ParceiroCadastroPage" });
  130. };
  131. const onEditItem = (partner) => {
  132. if (!permission_store.getAccess("parceiro.convenio", "edit")) {
  133. $q.notify({ type: "negative", message: t("validation.permissions.edit") });
  134. return;
  135. }
  136. router.push({ name: "ParceiroCadastroPage", params: { id: partner.id } });
  137. };
  138. const loadPartners = async () => {
  139. const [partners, cats] = await Promise.all([
  140. getPartnerAgreements({ type: "partner" }),
  141. getCategories("partner"),
  142. ]);
  143. allPartners.value = partners;
  144. categories.value = cats;
  145. };
  146. const onImportParceirosClick = () => {
  147. if (!permission_store.getAccess("parceiro.convenio", "add")) {
  148. $q.notify({ type: "negative", message: t("validation.permissions.add") });
  149. return;
  150. }
  151. importParceiroInput.value.value = null;
  152. importParceiroInput.value.click();
  153. };
  154. const onParceiroFileSelected = async (event) => {
  155. const file = event.target.files?.[0];
  156. if (!file) return;
  157. try {
  158. const { import_id } = await importParceiros(file);
  159. startParceiroPolling(import_id, {
  160. onComplete: async (stats) => {
  161. $q.notify({
  162. type: "positive",
  163. message: t("parceiro.import_parceiros_result", { created: stats.created, updated: stats.updated, inactivated: stats.inactivated }),
  164. timeout: 7000,
  165. });
  166. await loadPartners();
  167. },
  168. onError: () => $q.notify({ type: "negative", message: t("http.errors.failed") }),
  169. onTimeout: () => $q.notify({ type: "warning", message: t("parceiro.import_processing") }),
  170. });
  171. } catch {
  172. $q.notify({ type: "negative", message: t("http.errors.failed") });
  173. }
  174. };
  175. onMounted(async () => {
  176. try { await loadPartners(); }
  177. finally { loading.value = false; }
  178. });
  179. </script>
  180. <style scoped>
  181. .gap-sm { gap: 8px; }
  182. .parceiros-page { min-height: 100vh; }
  183. .parceiros-page__search-input { max-width: 700px; width: 100%; }
  184. .cat-chip {
  185. display: inline-flex; align-items: center; justify-content: center;
  186. height: 28px; padding: 0 12px; border-radius: 5px;
  187. font-size: 12px; font-weight: 500; cursor: pointer;
  188. user-select: none; transition: background 0.15s, color 0.15s;
  189. }
  190. .cat-chip--default { background: #c9a3dc; color: #fff; }
  191. .cat-chip--selected { background: #4d1658; color: #fff; }
  192. .btn-gradient {
  193. background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
  194. color: white !important; border-radius: 8px !important;
  195. }
  196. .btn-gradient :deep(.q-icon) { color: white !important; }
  197. </style>