GestaoAssociadosPage.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. <template>
  2. <div>
  3. <input
  4. ref="importFileInput"
  5. type="file"
  6. accept=".xlsx"
  7. class="hidden"
  8. @change="onFileSelected"
  9. />
  10. <DefaultHeaderPage>
  11. <template #after>
  12. <div class="flex gap-sm q-mt-md">
  13. <q-btn
  14. unelevated
  15. dense
  16. no-caps
  17. class="btn-gradient"
  18. :label="$t('associado.import_afastamentos')"
  19. icon="mdi-upload"
  20. padding="6px 10px"
  21. />
  22. <q-btn
  23. unelevated
  24. dense
  25. no-caps
  26. class="btn-gradient"
  27. :label="$t('associado.import_associados')"
  28. icon="mdi-upload"
  29. padding="6px 10px"
  30. :loading="importing"
  31. @click="onImportClick"
  32. />
  33. <q-btn
  34. unelevated
  35. dense
  36. class="btn-gradient"
  37. icon="mdi-clock-outline"
  38. padding="6px 10px"
  39. :title="$t('import_log.history_title')"
  40. @click="onOpenImportHistory"
  41. />
  42. <q-btn
  43. unelevated
  44. dense
  45. class="btn-gradient"
  46. icon="mdi-plus"
  47. padding="6px 10px"
  48. @click="onAddItem"
  49. />
  50. </div>
  51. </template>
  52. </DefaultHeaderPage>
  53. <DefaultTableServerSide
  54. ref="tableRef"
  55. :columns="columns"
  56. :api-call="getAssociadosPaginated"
  57. :add-item="false"
  58. :extra-filters="extraFilters"
  59. align-actions-left
  60. open-item
  61. @on-row-click="onRowClick"
  62. >
  63. <template #filters>
  64. <PositionSelect
  65. v-model="selectedPosition"
  66. clearable
  67. dense
  68. outlined
  69. color="violet-normal"
  70. class="col-xs-12 col-sm-6 col-md-auto"
  71. style="min-width: 180px"
  72. :placeholder="$t('associado.filter_by_position')"
  73. />
  74. <SectorSelect
  75. v-model="selectedSector"
  76. clearable
  77. dense
  78. outlined
  79. color="violet-normal"
  80. class="col-xs-12 col-sm-6 col-md-auto"
  81. style="min-width: 180px"
  82. :placeholder="$t('associado.filter_by_sector')"
  83. />
  84. </template>
  85. <template #body-cell-actions="{ row }">
  86. <div class="row no-wrap justify-start items-center" style="gap: 4px">
  87. <q-btn flat round dense color="violet-normal" icon="mdi-pencil" @click.stop="onRowClick({ row })" />
  88. <q-btn
  89. flat round dense
  90. :color="row.status === 'on_leave' || row.status?.value === 'on_leave' ? 'grey-4' : 'violet-normal'"
  91. icon="mdi-account-minus"
  92. :disable="row.status === 'on_leave' || row.status?.value === 'on_leave'"
  93. @click.stop="onSetOnLeave(row)"
  94. />
  95. <q-btn flat round dense color="violet-normal" icon="mdi-calendar" @click.stop="onOpenAppointments(row)" />
  96. <q-btn
  97. v-if="row.status === 'pending' || row.status?.value === 'pending'"
  98. flat
  99. round
  100. dense
  101. color="violet-normal"
  102. icon="mdi-check-circle-outline"
  103. :title="$t('associado.approve_confirm')"
  104. @click.stop="onApprove(row)"
  105. />
  106. </div>
  107. </template>
  108. <template #body-cell-status="{ row }">
  109. <q-td class="text-center">
  110. <q-badge
  111. outline
  112. :color="getStatusColor(row.status)"
  113. :label="$t(getStatusI18nKey(row.status))"
  114. />
  115. </q-td>
  116. </template>
  117. <template #body-cell-first_access_at="{ row }">
  118. <q-td class="text-center">
  119. <q-badge
  120. outline
  121. :color="row.first_access_at ? 'positive' : 'grey-6'"
  122. :label="row.first_access_at ? $t('common.status.yes') : $t('common.status.no')"
  123. >
  124. <q-tooltip v-if="row.first_access_at">{{ row.first_access_at }}</q-tooltip>
  125. </q-badge>
  126. </q-td>
  127. </template>
  128. </DefaultTableServerSide>
  129. <q-separator class="q-my-xl" />
  130. <AccessLogsTable />
  131. </div>
  132. </template>
  133. <script setup>
  134. import { ref, computed, defineAsyncComponent, useTemplateRef } from "vue";
  135. import { useQuasar } from "quasar";
  136. import { useI18n } from "vue-i18n";
  137. import { permissionStore } from "src/stores/permission";
  138. import { getAssociadosPaginated, importAssociados, setUserOnLeave, approveUser } from "src/api/user";
  139. import { getStatusColor, getStatusI18nKey } from "src/helpers/utils";
  140. import { useImportPoller } from "src/composables/useImportPoller";
  141. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  142. import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
  143. import PositionSelect from "src/components/selects/PositionSelect.vue";
  144. import SectorSelect from "src/components/selects/SectorSelect.vue";
  145. import AccessLogsTable from "./components/AccessLogsTable.vue";
  146. const AddEditAssociadoDialog = defineAsyncComponent(
  147. () => import("./components/AddEditAssociadoDialog.vue"),
  148. );
  149. const ImportHistoryDialog = defineAsyncComponent(
  150. () => import("src/components/ImportHistoryDialog.vue"),
  151. );
  152. const AssociadoAppointmentsDialog = defineAsyncComponent(
  153. () => import("./components/AssociadoAppointmentsDialog.vue"),
  154. );
  155. const permission_store = permissionStore();
  156. const $q = useQuasar();
  157. const { t } = useI18n();
  158. const tableRef = ref(null);
  159. const importFileInput = useTemplateRef("importFileInput");
  160. const { polling: importing, start: startPolling } = useImportPoller();
  161. const selectedPosition = ref(null);
  162. const selectedSector = ref(null);
  163. const extraFilters = computed(() => ({
  164. position_id: selectedPosition.value?.value,
  165. sector_id: selectedSector.value?.value,
  166. }));
  167. const columns = computed(() => [
  168. {
  169. name: "registration",
  170. label: t("associado.cracha"),
  171. field: "registration",
  172. align: "left",
  173. sortable: true,
  174. width: "10%",
  175. },
  176. {
  177. name: "name",
  178. label: t("common.terms.name"),
  179. field: "name",
  180. align: "left",
  181. sortable: true,
  182. width: "25%",
  183. },
  184. {
  185. name: "position",
  186. label: t("associado.position"),
  187. field: (row) => row.position?.name ?? "—",
  188. align: "left",
  189. sortable: true,
  190. width: "25%",
  191. },
  192. {
  193. name: "admission_date",
  194. label: t("associado.admission"),
  195. field: (row) => (row.admission_date ? row.admission_date : "—"),
  196. align: "left",
  197. sortable: true,
  198. width: "10%",
  199. },
  200. {
  201. name: "expiry_date",
  202. label: t("associado.validity"),
  203. field: (row) => (row.expiry_date ? row.expiry_date : "—"),
  204. align: "left",
  205. sortable: true,
  206. width: "10%",
  207. },
  208. {
  209. name: "first_access_at",
  210. label: t("associado.primeiro_acesso"),
  211. field: "first_access_at",
  212. align: "center",
  213. width: "10%",
  214. },
  215. {
  216. name: "actions",
  217. label: t("common.terms.actions"),
  218. align: "center",
  219. required: true,
  220. width: "15%",
  221. },
  222. {
  223. name: "status",
  224. label: t("common.terms.status"),
  225. field: "status",
  226. align: "center",
  227. sortable: true,
  228. width: "5%",
  229. },
  230. ]);
  231. const onAddItem = async () => {
  232. if (!permission_store.getAccess("associado", "add")) {
  233. $q.notify({ type: "negative", message: t("validation.permissions.add") });
  234. return;
  235. }
  236. $q.dialog({ component: AddEditAssociadoDialog }).onOk(() => {
  237. tableRef.value?.refresh();
  238. });
  239. };
  240. const onRowClick = async ({ row }) => {
  241. if (!permission_store.getAccess("associado", "edit")) {
  242. $q.notify({ type: "negative", message: t("validation.permissions.edit") });
  243. return;
  244. }
  245. $q.dialog({
  246. component: AddEditAssociadoDialog,
  247. componentProps: {
  248. associado: row,
  249. title: () => t("common.actions.edit") + " " + t("ui.navigation.associados"),
  250. },
  251. }).onOk(() => {
  252. tableRef.value?.refresh();
  253. });
  254. };
  255. const onSetOnLeave = (row) => {
  256. if (!permission_store.getAccess("associado", "edit")) {
  257. $q.notify({ type: "negative", message: t("validation.permissions.edit") });
  258. return;
  259. }
  260. $q.dialog({
  261. title: t("associado.set_on_leave_title"),
  262. message: t("associado.set_on_leave_message", { name: row.name }),
  263. cancel: { flat: true, label: t("common.actions.cancel"), color: "grey-7" },
  264. ok: { unelevated: true, label: t("associado.set_on_leave_confirm"), color: "violet-normal" },
  265. persistent: true,
  266. }).onOk(async () => {
  267. try {
  268. await setUserOnLeave(row.id);
  269. tableRef.value?.refresh();
  270. } catch {
  271. // silent
  272. }
  273. });
  274. };
  275. const onApprove = (row) => {
  276. if (!permission_store.getAccess("associado", "edit")) {
  277. $q.notify({ type: "negative", message: t("validation.permissions.edit") });
  278. return;
  279. }
  280. $q.dialog({
  281. title: t("associado.approve_title"),
  282. message: t("associado.approve_message", { name: row.name }),
  283. cancel: { flat: true, label: t("common.actions.cancel"), color: "grey-7" },
  284. ok: { unelevated: true, label: t("associado.approve_confirm"), color: "violet-normal" },
  285. persistent: true,
  286. }).onOk(async () => {
  287. try {
  288. await approveUser(row.id);
  289. tableRef.value?.refresh();
  290. } catch {
  291. // silent
  292. }
  293. });
  294. };
  295. const onOpenAppointments = (row) => {
  296. if (!permission_store.getAccess("agendamento", "view")) {
  297. $q.notify({ type: "negative", message: t("validation.permissions.view") });
  298. return;
  299. }
  300. $q.dialog({
  301. component: AssociadoAppointmentsDialog,
  302. componentProps: { associado: row },
  303. });
  304. };
  305. const onOpenImportHistory = () => {
  306. $q.dialog({ component: ImportHistoryDialog, componentProps: { type: 'associado' } });
  307. };
  308. const onImportClick = () => {
  309. if (!permission_store.getAccess("associado", "add")) {
  310. $q.notify({ type: "negative", message: t("validation.permissions.add") });
  311. return;
  312. }
  313. importFileInput.value.value = null;
  314. importFileInput.value.click();
  315. };
  316. const onFileSelected = async (event) => {
  317. const file = event.target.files?.[0];
  318. if (!file) return;
  319. try {
  320. const { import_id } = await importAssociados(file);
  321. startPolling(import_id, {
  322. onComplete: (stats) => {
  323. $q.notify({
  324. type: "positive",
  325. message: t("associado.import_sync_result", {
  326. created: stats.created,
  327. updated: stats.updated,
  328. inactivated: stats.inactivated,
  329. }),
  330. timeout: 6000,
  331. });
  332. tableRef.value?.refresh();
  333. },
  334. onError: () => $q.notify({ type: "negative", message: t("http.errors.failed") }),
  335. onTimeout: () => $q.notify({ type: "warning", message: t("associado.import_processing") }),
  336. });
  337. } catch {
  338. $q.notify({ type: "negative", message: t("http.errors.failed") });
  339. }
  340. };
  341. </script>
  342. <style scoped>
  343. .gap-sm {
  344. gap: 8px;
  345. }
  346. .btn-gradient {
  347. background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
  348. color: white !important;
  349. border-radius: 8px !important;
  350. }
  351. .btn-gradient :deep(.q-icon) {
  352. color: white !important;
  353. }
  354. </style>