ChartOfAccountsPage.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <div>
  3. <DefaultHeaderPage
  4. :show-filter-icon="false"
  5. title="Plano de Contas"
  6. />
  7. <div class="q-px-md">
  8. <q-card
  9. bordered
  10. class="q-pa-md q-mt-md"
  11. flat
  12. >
  13. <div class="row items-center q-gutter-md q-mb-md">
  14. <div>
  15. <div class="text-h6">
  16. Plano de Contas
  17. </div>
  18. <div class="text-body2">
  19. {{ rows.length }} contas cadastradas
  20. </div>
  21. </div>
  22. <q-space />
  23. <q-btn
  24. v-if="canAdd"
  25. color="secondary"
  26. label="Nova Conta"
  27. no-caps
  28. unelevated
  29. @click="openCreate"
  30. />
  31. </div>
  32. <DefaultInput
  33. v-model="search"
  34. class="q-mb-md"
  35. clearable
  36. debounce="250"
  37. dense
  38. label="Buscar por código ou nome"
  39. >
  40. <template #prepend>
  41. <q-icon
  42. color="grey-6"
  43. name="mdi-magnify"
  44. />
  45. </template>
  46. </DefaultInput>
  47. <div class="account-tree-header">
  48. <div>Código</div>
  49. <div>Nome da Conta</div>
  50. <div>Tipo</div>
  51. <div>Ações</div>
  52. </div>
  53. <q-list>
  54. <FinancialPlanAccountTreeNode
  55. v-for="node in filteredTree"
  56. :key="node.id"
  57. :can-delete="canDelete"
  58. :can-edit="canEdit"
  59. :force-expanded="!!normalizedSearch"
  60. :node="node"
  61. @delete="onDelete"
  62. @edit="openEdit"
  63. />
  64. </q-list>
  65. <div
  66. v-if="filteredTree.length === 0"
  67. class="text-center q-pa-lg"
  68. >
  69. Nenhuma conta encontrada.
  70. </div>
  71. <q-inner-loading
  72. color="secondary"
  73. :showing="loadingParents"
  74. />
  75. </q-card>
  76. </div>
  77. <q-dialog v-model="dialog">
  78. <q-card
  79. class="dialog-form-card"
  80. style="min-width: 360px; max-width: 460px"
  81. >
  82. <DefaultDialogHeader
  83. :title="editing ? 'Editar conta' : 'Nova conta'"
  84. @close="dialog = false"
  85. />
  86. <DefaultForm
  87. ref="formRef"
  88. @submit="onSave"
  89. >
  90. <q-scroll-area
  91. ref="scrollAreaRef"
  92. class="dialog-form-scroll dialog-form-scroll--md"
  93. >
  94. <q-card-section class="q-gutter-sm">
  95. <q-option-group
  96. v-model="accountKind"
  97. color="primary"
  98. dense
  99. inline
  100. :options="kindOptions"
  101. />
  102. <div
  103. style="display: grid; gap: 8px"
  104. :style="{
  105. gridTemplateColumns:
  106. accountKind === 'parent'
  107. ? 'minmax(0, 7fr) minmax(0, 5fr)'
  108. : 'minmax(0, 1fr)',
  109. }"
  110. >
  111. <DefaultInput
  112. v-model="form.code"
  113. v-model:error="validationErrors.code"
  114. label="Código (ex.: 1.1.01)"
  115. outlined
  116. :rules="[inputRules.required]"
  117. />
  118. <DefaultSelect
  119. v-if="accountKind === 'parent'"
  120. v-model="form.chart_type"
  121. v-model:error="validationErrors.chart_type"
  122. emit-value
  123. label="Tipo"
  124. map-options
  125. outlined
  126. :options="chartTypeOptions"
  127. :rules="[inputRules.required]"
  128. />
  129. </div>
  130. <DefaultSelect
  131. v-if="accountKind === 'child'"
  132. v-model="form.parent_id"
  133. v-model:error="validationErrors.parent_id"
  134. emit-value
  135. label="Conta Pai"
  136. map-options
  137. option-label="label"
  138. option-value="id"
  139. outlined
  140. :loading="loadingParents"
  141. :options="parentOptions"
  142. :rules="[inputRules.required]"
  143. />
  144. <DefaultInput
  145. v-model="form.description"
  146. v-model:error="validationErrors.description"
  147. label="Descrição"
  148. outlined
  149. :rules="[inputRules.required]"
  150. />
  151. <div
  152. v-if="accountKind === 'child' && selectedParent"
  153. class="text-caption text-grey-7"
  154. >
  155. Tipo herdado do pai:
  156. <b>
  157. {{ chartTypeLabel(selectedParent.chart_type) }}
  158. </b>
  159. </div>
  160. </q-card-section>
  161. </q-scroll-area>
  162. <q-card-actions
  163. align="right"
  164. class="q-px-md q-pb-md"
  165. >
  166. <q-btn
  167. color="primary"
  168. label="Cancelar"
  169. no-caps
  170. outline
  171. @click="dialog = false"
  172. />
  173. <q-btn
  174. color="primary"
  175. label="Salvar"
  176. no-caps
  177. type="submit"
  178. :loading="saving"
  179. />
  180. </q-card-actions>
  181. </DefaultForm>
  182. </q-card>
  183. </q-dialog>
  184. </div>
  185. </template>
  186. <script setup>
  187. import { computed, onMounted, ref, useTemplateRef } from "vue";
  188. import {
  189. createPlanAccountMe,
  190. deletePlanAccountMe,
  191. getPlanAccountsMe,
  192. updatePlanAccountMe,
  193. } from "src/api/financial_plan_account";
  194. import { permissionStore } from "src/stores/permission";
  195. import { useForm } from "src/composables/useForm";
  196. import { useInputRules } from "src/composables/useInputRules";
  197. import { useQuasar } from "quasar";
  198. import { useScroll } from "src/composables/useScroll";
  199. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  200. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  201. import DefaultForm from "src/components/defaults/DefaultForm.vue";
  202. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  203. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  204. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  205. import FinancialPlanAccountTreeNode from "src/components/financial/FinancialPlanAccountTreeNode.vue";
  206. const $q = useQuasar();
  207. const { inputRules } = useInputRules();
  208. const { scrollToComponent } = useScroll();
  209. const permissions = permissionStore();
  210. const formRef = useTemplateRef("formRef");
  211. const scrollAreaRef = useTemplateRef("scrollAreaRef");
  212. const accountKind = ref("parent");
  213. const dialog = ref(false);
  214. const editing = ref(false);
  215. const editingId = ref(null);
  216. const loadingParents = ref(false);
  217. const rows = ref([]);
  218. const search = ref("");
  219. const canAdd = computed(() =>
  220. permissions.getAccess("franchisee_financial", "add"),
  221. );
  222. const canDelete = computed(() =>
  223. permissions.getAccess("franchisee_financial", "delete"),
  224. );
  225. const canEdit = computed(() =>
  226. permissions.getAccess("franchisee_financial", "edit"),
  227. );
  228. const accountTree = computed(() => {
  229. const nodes = new Map(
  230. rows.value.map((account) => [
  231. account.id,
  232. {
  233. ...account,
  234. children: [],
  235. },
  236. ]),
  237. );
  238. const roots = [];
  239. for (const node of nodes.values()) {
  240. const parent = node.parent_id
  241. ? nodes.get(node.parent_id)
  242. : null;
  243. if (parent) {
  244. parent.children.push(node);
  245. continue;
  246. }
  247. roots.push(node);
  248. }
  249. const sortAccounts = (accounts) => {
  250. accounts.sort((firstAccount, secondAccount) =>
  251. String(firstAccount.code).localeCompare(
  252. String(secondAccount.code),
  253. "pt-BR",
  254. {
  255. numeric: true,
  256. },
  257. ),
  258. );
  259. accounts.forEach((account) => {
  260. sortAccounts(account.children);
  261. });
  262. return accounts;
  263. };
  264. return sortAccounts(roots);
  265. });
  266. const filteredTree = computed(() => {
  267. if (!normalizedSearch.value) return accountTree.value;
  268. const filterAccounts = (accounts) =>
  269. accounts.reduce((result, account) => {
  270. const children = filterAccounts(account.children);
  271. const matches = `${account.code} ${account.description}`
  272. .toLocaleLowerCase("pt-BR")
  273. .includes(normalizedSearch.value);
  274. if (matches || children.length) {
  275. result.push({
  276. ...account,
  277. children,
  278. });
  279. }
  280. return result;
  281. }, []);
  282. return filterAccounts(accountTree.value);
  283. });
  284. const normalizedSearch = computed(
  285. () => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "",
  286. );
  287. const parentOptions = computed(() =>
  288. rows.value
  289. .filter((account) => account.id !== editingId.value)
  290. .map((account) => ({
  291. chart_type: account.chart_type,
  292. id: account.id,
  293. label: `${account.code} — ${account.description}`,
  294. })),
  295. );
  296. const selectedParent = computed(() =>
  297. parentOptions.value.find(
  298. (option) => option.id === form.parent_id,
  299. ),
  300. );
  301. const chartTypeOptions = [
  302. {
  303. label: "Receita",
  304. value: "receita",
  305. },
  306. {
  307. label: "Despesa",
  308. value: "despesa",
  309. },
  310. ];
  311. const defaultForm = () => ({
  312. chart_type: "despesa",
  313. code: "",
  314. description: "",
  315. parent_id: null,
  316. });
  317. const kindOptions = [
  318. {
  319. label: "Conta Pai",
  320. value: "parent",
  321. },
  322. {
  323. label: "Conta Filho",
  324. value: "child",
  325. },
  326. ];
  327. const { form, getUpdatedFields } = useForm(defaultForm());
  328. const {
  329. loading: saving,
  330. validationErrors,
  331. execute,
  332. } = useSubmitHandler({
  333. containerRef: scrollAreaRef,
  334. formRef,
  335. onSuccess: async () => {
  336. dialog.value = false;
  337. await loadData();
  338. },
  339. scrollFn: scrollToComponent,
  340. });
  341. const chartTypeLabel = (type) =>
  342. chartTypeOptions.find((option) => option.value === type)?.label ??
  343. type;
  344. const loadData = async () => {
  345. loadingParents.value = true;
  346. try {
  347. rows.value = await getPlanAccountsMe();
  348. } catch (error) {
  349. console.error("Erro ao carregar plano de contas:", error);
  350. } finally {
  351. loadingParents.value = false;
  352. }
  353. };
  354. const onDelete = (id) => {
  355. const account = rows.value.find((row) => row.id === id);
  356. if (account && !account.unit_id) {
  357. $q.notify({
  358. message:
  359. "O plano de contas da Matriz não pode ser excluído pela unidade.",
  360. type: "negative",
  361. });
  362. return;
  363. }
  364. $q.dialog({
  365. cancel: true,
  366. message: "Tem certeza que deseja excluir esta conta do plano?",
  367. persistent: true,
  368. title: "Excluir conta",
  369. }).onOk(async () => {
  370. try {
  371. await deletePlanAccountMe(id);
  372. await loadData();
  373. } catch (error) {
  374. console.error(error);
  375. }
  376. });
  377. };
  378. const onSave = async () => {
  379. const payload = {
  380. code: form.code,
  381. description: form.description,
  382. };
  383. if (accountKind.value === "child") {
  384. payload.parent_id = form.parent_id;
  385. } else {
  386. payload.chart_type = form.chart_type;
  387. payload.parent_id = null;
  388. }
  389. await execute(() => {
  390. if (!editing.value) {
  391. return createPlanAccountMe(payload);
  392. }
  393. const changedFields = getUpdatedFields.value;
  394. const updatePayload = {};
  395. for (const key of [
  396. "code",
  397. "description",
  398. "chart_type",
  399. "parent_id",
  400. ]) {
  401. if (key in changedFields) {
  402. updatePayload[key] = payload[key];
  403. }
  404. }
  405. return updatePlanAccountMe(
  406. editingId.value,
  407. updatePayload,
  408. );
  409. });
  410. };
  411. const openCreate = () => {
  412. accountKind.value = "parent";
  413. editing.value = false;
  414. editingId.value = null;
  415. Object.assign(form, defaultForm());
  416. dialog.value = true;
  417. };
  418. const openEdit = (account) => {
  419. accountKind.value = account.parent_id ? "child" : "parent";
  420. editing.value = true;
  421. editingId.value = account.id;
  422. Object.assign(form, {
  423. chart_type: account.chart_type,
  424. code: account.code,
  425. description: account.description,
  426. parent_id: account.parent_id ?? null,
  427. });
  428. dialog.value = true;
  429. };
  430. onMounted(loadData);
  431. </script>
  432. <style scoped>
  433. .account-tree-header {
  434. display: grid;
  435. grid-template-columns: 15% 1fr 16% 105px;
  436. gap: 16px;
  437. padding: 12px 56px 12px 16px;
  438. border-bottom: 1px solid #c7c7c7;
  439. font-weight: 600;
  440. }
  441. </style>