Procházet zdrojové kódy

feat(financial): add FinancialPlanAccountTreeNode component for account tree structure

feat(financial): enhance LeftMenuLayout and LeftMenuLayoutMobile with financial submenu styling

refactor(financial): update AccountsPayablePage and AccountsReceivablePage to improve status handling and form management

feat(financial): create AsaasIntegrationPage for managing Asaas API key configuration

feat(financial): implement ChartOfAccountsPage with a tree structure for accounts

feat(financial): add InvoiceIssuancePage for managing invoice issuance

feat(financial): enhance TreasuryPage with launch editing and deletion capabilities

fix(financial): update AddTreasuryLaunchDialog to support both creation and editing of treasury launches

chore(router): update routes for financial pages including invoice issuance and Asaas integration

chore(navigation): add navigation entries for InvoiceIssuancePage and AsaasIntegrationPage
ebagabee před 1 týdnem
rodič
revize
f1387c1c02

+ 9 - 0
src/api/treasury.js

@@ -31,3 +31,12 @@ export const createTreasuryLaunchMe = async (payload) => {
   const { data } = await api.post("/franchisee/financial/treasury/launches", payload);
   return data.payload;
 };
+
+export const updateTreasuryLaunchMe = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/financial/treasury/launches/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteTreasuryLaunchMe = async (id) => {
+  await api.delete(`/franchisee/financial/treasury/launches/${id}`);
+};

+ 83 - 0
src/components/financial/FinancialPlanAccountTreeNode.vue

@@ -0,0 +1,83 @@
+<template>
+  <q-expansion-item
+    v-if="hasChildren"
+    v-model="expanded"
+    dense
+    expand-separator
+    header-class="account-tree-row"
+    :style="{ '--account-depth': depth }"
+  >
+    <template #header>
+      <q-item-section class="account-code">{{ node.code }}</q-item-section>
+      <q-item-section class="account-name text-weight-medium">{{ node.description }}</q-item-section>
+      <q-item-section class="account-type">{{ node.chart_type }}</q-item-section>
+      <q-item-section side class="account-actions">
+        <NodeActions :node="node" :can-edit="canEdit" :can-delete="canDelete" @edit="$emit('edit', node)" @delete="$emit('delete', node.id)" />
+      </q-item-section>
+    </template>
+
+    <FinancialPlanAccountTreeNode
+      v-for="child in node.children"
+      :key="child.id"
+      :node="child"
+      :depth="depth + 1"
+      :force-expanded="forceExpanded"
+      :can-edit="canEdit"
+      :can-delete="canDelete"
+      @edit="$emit('edit', $event)"
+      @delete="$emit('delete', $event)"
+    />
+  </q-expansion-item>
+
+  <q-item v-else dense class="account-tree-row" :style="{ '--account-depth': depth }">
+    <q-item-section class="account-code">{{ node.code }}</q-item-section>
+    <q-item-section class="account-name">{{ node.description }}</q-item-section>
+    <q-item-section class="account-type">{{ node.chart_type }}</q-item-section>
+    <q-item-section side class="account-actions">
+      <NodeActions :node="node" :can-edit="canEdit" :can-delete="canDelete" @edit="$emit('edit', node)" @delete="$emit('delete', node.id)" />
+    </q-item-section>
+  </q-item>
+</template>
+
+<script setup>
+import { computed, defineComponent, h, ref, watch } from "vue";
+import { QBtn, QChip } from "quasar";
+
+defineOptions({ name: "FinancialPlanAccountTreeNode" });
+defineEmits(["edit", "delete"]);
+const props = defineProps({
+  node: { type: Object, required: true },
+  depth: { type: Number, default: 0 },
+  forceExpanded: { type: Boolean, default: false },
+  canEdit: { type: Boolean, default: false },
+  canDelete: { type: Boolean, default: false },
+});
+const hasChildren = computed(() => props.node.children?.length > 0);
+const expanded = ref(props.depth === 0);
+watch(() => props.forceExpanded, (value) => value && (expanded.value = true), { immediate: true });
+
+const NodeActions = defineComponent({
+  props: {
+    node: { type: Object, default: () => ({}) },
+    canEdit: Boolean,
+    canDelete: Boolean,
+  },
+  emits: ["edit", "delete"],
+  setup(actionProps, { emit }) {
+    return () => actionProps.node.unit_id
+      ? h("div", { class: "row no-wrap q-gutter-xs" }, [
+          actionProps.canEdit ? h(QBtn, { flat: true, round: true, dense: true, icon: "mdi-pencil-outline", color: "secondary", onClick: (event) => { event.stopPropagation(); emit("edit"); } }) : null,
+          actionProps.canDelete ? h(QBtn, { flat: true, round: true, dense: true, icon: "mdi-trash-can-outline", color: "negative", onClick: (event) => { event.stopPropagation(); emit("delete"); } }) : null,
+        ])
+      : h(QChip, { dense: true, color: "blue-grey-1", textColor: "blue-grey-7", icon: "mdi-lock-outline", label: "Matriz" });
+  },
+});
+</script>
+
+<style scoped>
+:deep(.account-tree-row) { min-height: 48px; padding-left: calc(16px + (var(--account-depth) * 28px)); border-bottom: 1px solid #dedede; }
+:deep(.account-code) { flex: 0 0 15%; min-width: 75px; }
+:deep(.account-name) { flex: 1 1 auto; min-width: 0; }
+:deep(.account-type) { flex: 0 0 16%; min-width: 85px; }
+:deep(.account-actions) { flex: 0 0 105px; }
+</style>

+ 14 - 1
src/components/layout/LeftMenuLayout.vue

@@ -112,7 +112,11 @@
                       </q-item-section>
                       <q-item-section>{{ $t(item.title) }}</q-item-section>
                     </template>
-                    <div v-for="child in item.childrens" :key="child.name">
+                    <div
+                      v-for="child in item.childrens"
+                      :key="child.name"
+                      :class="{ 'financial-submenu': item.title === 'Financeiro' }"
+                    >
                       <q-item
                         v-ripple
                         clickable
@@ -327,6 +331,15 @@ onMounted(() => {
   color: $primary;
 }
 
+.financial-submenu :deep(.q-item) {
+  color: $secondary;
+}
+
+.financial-submenu :deep(.q-item.menu-selected) {
+  background-color: rgba($secondary, 0.14);
+  color: $secondary;
+}
+
 .toggle-button-wrapper {
   transition: transform 0.3s ease;
 }

+ 14 - 1
src/components/layout/LeftMenuLayoutMobile.vue

@@ -54,7 +54,11 @@
                   </q-item-section>
                   <q-item-section>{{ $t(item.title) }}</q-item-section>
                 </template>
-                <div v-for="child in item.childrens" :key="child.name">
+                <div
+                  v-for="child in item.childrens"
+                  :key="child.name"
+                  :class="{ 'financial-submenu': item.title === 'Financeiro' }"
+                >
                   <q-item
                     v-ripple
                     clickable
@@ -149,4 +153,13 @@ onMounted(() => {
   background-color: rgba($primary, 0.1);
   color: $primary;
 }
+
+.financial-submenu :deep(.q-item) {
+  color: $secondary;
+}
+
+.financial-submenu :deep(.q-item.menu-selected) {
+  background-color: rgba($secondary, 0.14);
+  color: $secondary;
+}
 </style>

+ 35 - 84
src/pages/financial/AccountsPayablePage.vue

@@ -26,38 +26,16 @@
       />
     </div>
 
-    <div class="row justify-end items-center q-px-md q-mb-sm q-gutter-sm">
-      <DefaultSelect
-        v-model="statusFilter"
-        label="Status"
-        :options="statusFilterOptions"
-        outlined
-        dense
-        emit-value
-        map-options
-        style="min-width: 180px"
-        @update:model-value="loadData"
-      />
-      <q-btn
-        v-if="canAdd"
-        color="primary"
-        label="Nova conta"
-        icon="mdi-plus"
-        unelevated
-        no-caps
-        @click="openCreate"
-      />
-    </div>
-
     <div class="q-px-md">
       <DefaultTable
         v-model:rows="rows"
         no-api-call
-        :add-item="false"
+        :add-item="canAdd"
         title="Contas a Pagar"
-        descricao="contas"
-        :feminino="true"
+        description="contas"
+        :female="true"
         :columns="columns"
+        @on-add-item="openCreate"
       >
         <template #body-cell-origin="{ row }">
           <q-td class="text-left">
@@ -77,39 +55,18 @@
 
         <template #body-cell-status="{ row }">
           <q-td class="text-left">
-            <q-chip
-              :color="statusMeta(row.status).color"
-              text-color="white"
-              dense
-              square
-              :label="statusMeta(row.status).label"
-            />
-          </q-td>
-        </template>
-
-        <template #body-cell-actions="{ row }">
-          <q-td auto-width>
             <q-btn
-              v-if="canEdit && row.status !== 'paid' && row.status !== 'cancelled'"
-              color="primary-2"
-              label="Dar baixa"
+              :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
               dense
               no-caps
               unelevated
-              class="q-px-sm"
-              @click="openSettle(row)"
-            />
-            <q-btn
-              v-else-if="canEdit && row.status === 'paid'"
-              color="grey-7"
-              label="Reabrir"
-              dense
-              no-caps
-              outline
-              class="q-px-sm"
+              :disable="!canEdit"
               :loading="reopeningId === row.id"
-              @click="onReopen(row)"
-            />
+              @click.stop="changeStatus(row)"
+            >
+              <q-tooltip v-if="canEdit">Clique para alterar o status</q-tooltip>
+            </q-btn>
           </q-td>
         </template>
       </DefaultTable>
@@ -125,6 +82,7 @@
 
           <DefaultInputDatePicker
             v-model="settleForm.payment_date"
+            v-model:untreated-date="settleForm.payment_date_iso"
             label="Data do pagamento"
           />
 
@@ -182,9 +140,11 @@
                 label="Valor"
                 class="col-6"
                 outlined
+                :rules="[() => Number(createForm.value) > 0 || 'Informe o valor']"
               />
               <DefaultInputDatePicker
                 v-model="createForm.due_date"
+                v-model:untreated-date="createForm.due_date_iso"
                 label="Vencimento"
                 class="col-6"
               />
@@ -223,7 +183,6 @@ import {
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
@@ -240,26 +199,17 @@ const canEdit = computed(() =>
 );
 
 const rows = ref([]);
-const statusFilter = ref(null);
 
 const settleDialog = ref(false);
 const selected = ref(null);
 const settling = ref(false);
 const reopeningId = ref(null);
-const settleForm = ref({ payment_date: null, discount: 0, fine: 0 });
+const settleForm = ref({ payment_date: null, payment_date_iso: null, discount: 0, fine: 0 });
 
 const createDialog = ref(false);
 const createFormRef = ref(null);
 const creating = ref(false);
-const createForm = ref({ history: "", value: 0, due_date: null, obs: "" });
-
-const statusFilterOptions = [
-  { label: "Todos", value: null },
-  { label: "Pendentes", value: "pending" },
-  { label: "Pagos", value: "paid" },
-  { label: "Vencidos", value: "overdue" },
-  { label: "Cancelados", value: "cancelled" },
-];
+const createForm = ref({ history: "", value: 0, due_date: null, due_date_iso: null, obs: "" });
 
 const columns = [
   { name: "origin", label: "Origem", field: "origin", align: "left" },
@@ -267,7 +217,6 @@ const columns = [
   { name: "value", label: "Valor", field: "value", align: "left" },
   { name: "due_date", label: "Vencimento", field: "due_date", align: "left" },
   { name: "status", label: "Status", field: "status", align: "left" },
-  { name: "actions", label: "Ações", field: "actions", align: "right" },
 ];
 
 function formatCurrency(value) {
@@ -277,17 +226,6 @@ function formatCurrency(value) {
   }).format(Number(value ?? 0));
 }
 
-const STATUS_META = {
-  pending: { label: "Pendente", color: "orange-7" },
-  paid: { label: "Pago", color: "positive" },
-  overdue: { label: "Vencido", color: "negative" },
-  cancelled: { label: "Cancelado", color: "grey-6" },
-};
-
-function statusMeta(status) {
-  return STATUS_META[status] ?? { label: status, color: "grey-6" };
-}
-
 const settleNetValue = computed(() => {
   const base = Number(selected.value?.value ?? 0);
   return base - Number(settleForm.value.discount ?? 0) + Number(settleForm.value.fine ?? 0);
@@ -319,29 +257,35 @@ const totals = computed(() => {
 
 async function loadData() {
   try {
-    const params = statusFilter.value ? { status: statusFilter.value } : {};
-    rows.value = await getPayablesMe(params);
+    rows.value = await getPayablesMe();
   } catch (e) {
     console.error("Erro ao buscar contas a pagar:", e);
   }
 }
 
 function openSettle(row) {
+  const today = new Date();
   selected.value = row;
   settleForm.value = {
-    payment_date: new Date().toISOString().slice(0, 10),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
     discount: Number(row.discount ?? 0),
     fine: Number(row.fine ?? 0),
   };
   settleDialog.value = true;
 }
 
+function changeStatus(row) {
+  if (row.status === "paid") onReopen(row);
+  else openSettle(row);
+}
+
 async function onSettle() {
   if (!selected.value) return;
   settling.value = true;
   try {
     await settlePayableMe(selected.value.id, {
-      payment_date: settleForm.value.payment_date,
+      payment_date: settleForm.value.payment_date_iso,
       discount: settleForm.value.discount,
       fine: settleForm.value.fine,
     });
@@ -367,7 +311,14 @@ async function onReopen(row) {
 }
 
 function openCreate() {
-  createForm.value = { history: "", value: 0, due_date: null, obs: "" };
+  const today = new Date();
+  createForm.value = {
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: today.toISOString().slice(0, 10),
+    obs: "",
+  };
   createDialog.value = true;
 }
 
@@ -379,7 +330,7 @@ async function onCreate() {
     await createPayableMe({
       history: createForm.value.history,
       value: createForm.value.value,
-      due_date: createForm.value.due_date,
+      due_date: createForm.value.due_date_iso,
       obs: createForm.value.obs || null,
     });
     createDialog.value = false;

+ 42 - 88
src/pages/financial/AccountsReceivablePage.vue

@@ -26,38 +26,16 @@
       />
     </div>
 
-    <div class="row justify-end items-center q-px-md q-mb-sm q-gutter-sm">
-      <DefaultSelect
-        v-model="statusFilter"
-        label="Status"
-        :options="statusFilterOptions"
-        outlined
-        dense
-        emit-value
-        map-options
-        style="min-width: 200px"
-        @update:model-value="loadData"
-      />
-      <q-btn
-        v-if="canAdd"
-        color="primary"
-        label="Nova conta a receber"
-        icon="mdi-plus"
-        unelevated
-        no-caps
-        @click="openCreate"
-      />
-    </div>
-
     <div class="q-px-md">
       <DefaultTable
         v-model:rows="rows"
         no-api-call
-        :add-item="false"
+        :add-item="canAdd"
         title="Contas a Receber"
-        descricao="contas"
-        :feminino="true"
+        description="contas"
+        :female="true"
         :columns="columns"
+        @on-add-item="openCreate"
       >
         <template #body-cell-value="{ row }">
           <q-td class="text-left">{{ formatCurrency(row.value) }}</q-td>
@@ -65,13 +43,18 @@
 
         <template #body-cell-status="{ row }">
           <q-td class="text-left">
-            <q-chip
-              :color="statusMeta(row.status).color"
-              text-color="white"
+            <q-btn
+              :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
               dense
-              square
-              :label="statusMeta(row.status).label"
-            />
+              no-caps
+              unelevated
+              :disable="!canEdit"
+              :loading="reopeningId === row.id"
+              @click.stop="changeStatus(row)"
+            >
+              <q-tooltip v-if="canEdit">Clique para alterar o status</q-tooltip>
+            </q-btn>
           </q-td>
         </template>
 
@@ -106,27 +89,6 @@
                 :loading="chargingId === row.id"
                 @click="onCharge(row)"
               />
-              <q-btn
-                v-if="canEdit && row.status !== 'paid' && row.status !== 'cancelled'"
-                color="primary-2"
-                label="Dar baixa"
-                dense
-                no-caps
-                unelevated
-                class="q-px-sm"
-                @click="openSettle(row)"
-              />
-              <q-btn
-                v-else-if="canEdit && row.status === 'paid'"
-                color="grey-7"
-                label="Reabrir"
-                dense
-                no-caps
-                outline
-                class="q-px-sm"
-                :loading="reopeningId === row.id"
-                @click="onReopen(row)"
-              />
             </div>
           </q-td>
         </template>
@@ -155,10 +117,11 @@
                 label="Valor"
                 class="col-6"
                 outlined
-                :rules="[(v) => Number(v) > 0 || 'Informe o valor']"
+                :rules="[() => Number(createForm.value) > 0 || 'Informe o valor']"
               />
               <DefaultInputDatePicker
                 v-model="createForm.due_date"
+                v-model:untreated-date="createForm.due_date_iso"
                 label="Vencimento"
                 class="col-6"
                 :rules="[(v) => !!v || 'Informe o vencimento']"
@@ -220,6 +183,7 @@
 
           <DefaultInputDatePicker
             v-model="settleForm.payment_date"
+            v-model:untreated-date="settleForm.payment_date_iso"
             label="Data do pagamento"
           />
 
@@ -293,14 +257,13 @@ const canEdit = computed(() =>
 );
 
 const rows = ref([]);
-const statusFilter = ref(null);
 const settleDialog = ref(false);
 const selected = ref(null);
 const settling = ref(false);
 const reopeningId = ref(null);
 const chargingId = ref(null);
 
-const settleForm = ref({ payment_date: null, discount: 0, fine: 0 });
+const settleForm = ref({ payment_date: null, payment_date_iso: null, discount: 0, fine: 0 });
 
 // Novo recebível avulso
 const createDialog = ref(false);
@@ -309,24 +272,20 @@ const createFormRef = ref(null);
 const loadingAux = ref(false);
 const studentOptions = ref([]);
 const planAccountOptions = ref([]);
-const defaultCreateForm = () => ({
-  history: "",
-  value: 0,
-  due_date: new Date().toISOString().slice(0, 10),
-  student_id: null,
-  financial_plan_account_id: null,
-  obs: "",
-});
+const defaultCreateForm = () => {
+  const today = new Date();
+  return {
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: today.toISOString().slice(0, 10),
+    student_id: null,
+    financial_plan_account_id: null,
+    obs: "",
+  };
+};
 const createForm = ref(defaultCreateForm());
 
-const statusFilterOptions = [
-  { label: "Todos", value: null },
-  { label: "Pendentes", value: "pending" },
-  { label: "Pagos", value: "paid" },
-  { label: "Vencidos", value: "overdue" },
-  { label: "Cancelados", value: "cancelled" },
-];
-
 const columns = [
   { name: "student_name", label: "Aluno", field: "student_name", align: "left" },
   { name: "history", label: "Descrição", field: "history", align: "left" },
@@ -344,17 +303,6 @@ function formatCurrency(value) {
   }).format(Number(value ?? 0));
 }
 
-const STATUS_META = {
-  pending: { label: "Pendente", color: "orange-7" },
-  paid: { label: "Pago", color: "positive" },
-  overdue: { label: "Vencido", color: "negative" },
-  cancelled: { label: "Cancelado", color: "grey-6" },
-};
-
-function statusMeta(status) {
-  return STATUS_META[status] ?? { label: status, color: "grey-6" };
-}
-
 const settleNetValue = computed(() => {
   const base = Number(selected.value?.value ?? 0);
   return base - Number(settleForm.value.discount ?? 0) + Number(settleForm.value.fine ?? 0);
@@ -386,8 +334,7 @@ const totals = computed(() => {
 
 async function loadData() {
   try {
-    const params = statusFilter.value ? { status: statusFilter.value } : {};
-    const data = await getReceivablesMe(params);
+    const data = await getReceivablesMe();
     // Chave composta: parcelas e avulsos podem ter o mesmo id numérico.
     // realId é o id verdadeiro usado nas ações (baixa/reabrir/cobrança).
     rows.value = (data ?? []).map((r) => ({
@@ -437,7 +384,7 @@ async function onCreate() {
     await createManualReceivableMe({
       history: createForm.value.history,
       value: createForm.value.value,
-      due_date: createForm.value.due_date,
+      due_date: createForm.value.due_date_iso,
       student_id: createForm.value.student_id || null,
       financial_plan_account_id: createForm.value.financial_plan_account_id || null,
       obs: createForm.value.obs || null,
@@ -452,20 +399,27 @@ async function onCreate() {
 }
 
 function openSettle(row) {
+  const today = new Date();
   selected.value = row;
   settleForm.value = {
-    payment_date: new Date().toISOString().slice(0, 10),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
     discount: Number(row.discount ?? 0),
     fine: Number(row.fine ?? 0),
   };
   settleDialog.value = true;
 }
 
+function changeStatus(row) {
+  if (row.status === "paid") onReopen(row);
+  else openSettle(row);
+}
+
 async function onSettle() {
   if (!selected.value) return;
   settling.value = true;
   const payload = {
-    payment_date: settleForm.value.payment_date,
+    payment_date: settleForm.value.payment_date_iso,
     discount: settleForm.value.discount,
     fine: settleForm.value.fine,
   };

+ 90 - 0
src/pages/financial/AsaasIntegrationPage.vue

@@ -0,0 +1,90 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Integração Asaas" :show-filter-icon="false" />
+
+    <div class="q-pa-md">
+      <q-card flat bordered class="q-pa-md" style="max-width: 720px">
+        <div class="row items-center no-wrap q-mb-xs">
+          <q-icon name="mdi-cash-multiple" size="sm" color="secondary" class="q-mr-sm" />
+          <div class="text-subtitle1 text-weight-medium">Asaas — Conta da Unidade</div>
+          <q-space />
+          <q-badge
+            :color="account.asaas_configured ? 'positive' : 'grey-6'"
+            :label="account.asaas_configured ? 'Configurada' : 'Não configurada'"
+            class="q-px-sm q-py-xs"
+          />
+        </div>
+
+        <div class="text-body2 text-grey-7 q-mb-md">
+          Informe a chave da API da conta Asaas desta unidade para emitir cobranças
+          dos alunos. Sem uma chave configurada, as cobranças continuam disponíveis
+          para controle e baixa manual.
+        </div>
+
+        <q-form ref="formRef" @submit.prevent="onSave">
+          <DefaultPasswordInput
+            v-model="apiKey"
+            v-model:error="validationErrors.asaas_api_key"
+            :label="account.asaas_configured ? 'Nova chave (deixe em branco para manter)' : 'Chave da API do Asaas'"
+            :placeholder="account.asaas_api_key_masked || ''"
+            autocomplete="off"
+          />
+
+          <div class="row justify-end items-center q-mt-md q-gutter-sm">
+            <q-btn
+              v-if="canEdit && account.asaas_configured"
+              outline
+              color="negative"
+              label="Remover chave"
+              no-caps
+              :disable="loading"
+              @click="onRemove"
+            />
+            <q-btn
+              v-if="canEdit"
+              color="secondary"
+              label="Salvar"
+              type="submit"
+              no-caps
+              unelevated
+              :disable="!apiKey"
+              :loading="loading"
+            />
+          </div>
+        </q-form>
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { getPaymentAccountMe, savePaymentAccountMe } from "src/api/unit_payment_account";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import { permissionStore } from "src/stores/permission";
+
+const permissions = permissionStore();
+const canEdit = computed(() => permissions.getAccess("franchisee_financial", "edit"));
+const formRef = ref(null);
+const apiKey = ref("");
+const account = ref({ asaas_configured: false, asaas_api_key_masked: null });
+const { loading, validationErrors, execute } = useSubmitHandler({ formRef });
+
+const fetchData = async () => {
+  account.value = (await getPaymentAccountMe()) ?? account.value;
+};
+
+const persist = async (value) => {
+  await execute(async () => {
+    account.value = await savePaymentAccountMe({ asaas_api_key: value });
+    apiKey.value = "";
+  });
+};
+
+const onSave = () => apiKey.value && persist(apiKey.value);
+const onRemove = () => persist("");
+
+onMounted(fetchData);
+</script>

+ 56 - 72
src/pages/financial/ChartOfAccountsPage.vue

@@ -2,71 +2,32 @@
   <div>
     <DefaultHeaderPage title="Plano de Contas" :show-filter-icon="false" />
 
-    <div class="row justify-end items-center q-px-md q-mb-sm">
-      <q-btn
-        v-if="canAdd"
-        color="primary"
-        label="Nova conta"
-        icon="mdi-plus"
-        unelevated
-        no-caps
-        @click="openCreate"
-      />
-    </div>
-
     <div class="q-px-md">
-      <DefaultTable
-        v-model:rows="rows"
-        no-api-call
-        :add-item="false"
-        title="Plano de Contas"
-        descricao="contas"
-        :feminino="true"
-        :columns="columns"
-        :delete-function="canDelete ? onDelete : null"
-      >
-        <template #body-cell-parent="{ row }">
-          <q-td class="text-left">
-            {{ row.parent ? `${row.parent.code} — ${row.parent.description}` : "—" }}
-          </q-td>
-        </template>
-
-        <template #body-cell-chart_type="{ row }">
-          <q-td class="text-left">
-            <q-chip
-              :color="row.chart_type === 'receita' ? 'positive' : 'blue-grey-5'"
-              text-color="white"
-              dense
-              square
-              :label="chartTypeLabel(row.chart_type)"
-            />
-          </q-td>
-        </template>
-
-        <template #body-cell-actions="{ row }">
-          <q-td auto-width>
-            <q-btn
-              v-if="row.unit_id && canEdit"
-              color="primary-2"
-              label="Editar"
-              dense
-              no-caps
-              outline
-              class="q-px-sm"
-              @click="openEdit(row)"
-            />
-            <q-chip
-              v-else
-              dense
-              square
-              color="blue-grey-1"
-              text-color="blue-grey-7"
-              icon="mdi-lock-outline"
-              label="Matriz"
-            />
-          </q-td>
-        </template>
-      </DefaultTable>
+      <q-card flat bordered class="q-pa-md q-mt-md">
+        <div class="row items-center q-gutter-md q-mb-md">
+          <div><div class="text-h6">Plano de Contas</div><div class="text-body2">{{ rows.length }} contas cadastradas</div></div>
+          <q-space />
+          <q-btn v-if="canAdd" color="secondary" label="Nova Conta" no-caps unelevated @click="openCreate" />
+        </div>
+        <DefaultInput v-model="search" dense clearable debounce="250" label="Buscar por código ou nome" class="q-mb-md">
+          <template #prepend><q-icon name="mdi-magnify" color="grey-6" /></template>
+        </DefaultInput>
+        <div class="account-tree-header"><div>Código</div><div>Nome da Conta</div><div>Tipo</div><div>Ações</div></div>
+        <q-list>
+          <FinancialPlanAccountTreeNode
+            v-for="node in filteredTree"
+            :key="node.id"
+            :node="node"
+            :force-expanded="!!normalizedSearch"
+            :can-edit="canEdit"
+            :can-delete="canDelete"
+            @edit="openEdit"
+            @delete="onDelete"
+          />
+        </q-list>
+        <div v-if="filteredTree.length === 0" class="text-center q-pa-lg">Nenhuma conta encontrada.</div>
+        <q-inner-loading :showing="loadingParents" color="secondary" />
+      </q-card>
     </div>
 
     <q-dialog v-model="dialog">
@@ -159,10 +120,10 @@ import {
 } from "src/api/financial_plan_account";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import FinancialPlanAccountTreeNode from "src/components/financial/FinancialPlanAccountTreeNode.vue";
 import { permissionStore } from "src/stores/permission";
 
 const $q = useQuasar();
@@ -178,6 +139,7 @@ const canDelete = computed(() =>
 );
 
 const rows = ref([]);
+const search = ref("");
 const dialog = ref(false);
 const saving = ref(false);
 const editing = ref(false);
@@ -221,13 +183,31 @@ const selectedParent = computed(() =>
   parentOptions.value.find((o) => o.id === form.value.parent_id),
 );
 
-const columns = [
-  { name: "code", label: "Código", field: "code", align: "left", sortable: true },
-  { name: "description", label: "Descrição", field: "description", align: "left" },
-  { name: "parent", label: "Conta Pai", field: "parent", align: "left" },
-  { name: "chart_type", label: "Tipo", field: "chart_type", align: "left" },
-  { name: "actions", label: "Ações", field: "actions", align: "right" },
-];
+const accountTree = computed(() => {
+  const nodes = new Map(rows.value.map((account) => [account.id, { ...account, children: [] }]));
+  const roots = [];
+  for (const node of nodes.values()) {
+    const parent = node.parent_id ? nodes.get(node.parent_id) : null;
+    if (parent) parent.children.push(node); else roots.push(node);
+  }
+  const sort = (items) => {
+    items.sort((a, b) => String(a.code).localeCompare(String(b.code), "pt-BR", { numeric: true }));
+    items.forEach((item) => sort(item.children));
+    return items;
+  };
+  return sort(roots);
+});
+const normalizedSearch = computed(() => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "");
+const filteredTree = computed(() => {
+  if (!normalizedSearch.value) return accountTree.value;
+  const filter = (nodes) => nodes.reduce((result, node) => {
+    const children = filter(node.children);
+    const matches = `${node.code} ${node.description}`.toLocaleLowerCase("pt-BR").includes(normalizedSearch.value);
+    if (matches || children.length) result.push({ ...node, children });
+    return result;
+  }, []);
+  return filter(accountTree.value);
+});
 
 async function loadData() {
   loadingParents.value = true;
@@ -318,3 +298,7 @@ function onDelete(id) {
 
 onMounted(loadData);
 </script>
+
+<style scoped>
+.account-tree-header { display: grid; grid-template-columns: 15% 1fr 16% 105px; gap: 16px; padding: 12px 56px 12px 16px; border-bottom: 1px solid #c7c7c7; font-weight: 600; }
+</style>

+ 45 - 0
src/pages/financial/InvoiceIssuancePage.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Emissão de Notas" :show-filter-icon="false" />
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        no-api-call
+        :add-item="canAdd"
+        title="Emissão de Notas"
+        description="notas"
+        :female="true"
+        :columns="columns"
+        @on-add-item="notifyUnavailable"
+      >
+        <template #body-cell-actions="{ row }">
+          <q-td align="center">
+            <q-btn outline icon="mdi-file-outline" style="width: 36px" @click.stop="viewInvoice(row)" />
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const rows = ref([]);
+const canAdd = permissionStore().getAccess("franchisee_financial", "add");
+const columns = [
+  { name: "nf", label: "NF", field: "nf", align: "left" },
+  { name: "name", label: "Nome", field: "name", align: "left" },
+  { name: "due_date", label: "Data de Vencimento", field: "due_date", align: "left" },
+  { name: "value", label: "Valor", field: "value", align: "left" },
+  { name: "status", label: "Status", field: "status", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+];
+const notifyUnavailable = () => $q.notify({ type: "info", message: "Emissão fiscal ainda não configurada para esta unidade." });
+const viewInvoice = (row) => row.url && window.open(row.url, "_blank");
+</script>

+ 38 - 12
src/pages/financial/TreasuryPage.vue

@@ -62,25 +62,17 @@
         <div class="text-subtitle1 text-weight-medium">
           Extrato — {{ selectedAccount?.name }}
         </div>
-        <q-btn
-          v-if="canAdd"
-          color="primary-2"
-          label="Nova movimentação"
-          icon="mdi-swap-vertical"
-          unelevated
-          no-caps
-          @click="openLaunchCreate"
-        />
       </div>
 
       <DefaultTable
         v-model:rows="launches"
         no-api-call
-        :add-item="false"
+        :add-item="canAdd"
         title="Movimentações"
-        descricao="movimentações"
-        :feminino="true"
+        description="movimentações"
+        :female="true"
         :columns="launchColumns"
+        @on-add-item="openLaunchCreate"
       >
         <template #body-cell-transaction_type="{ row }">
           <q-td class="text-left">
@@ -102,6 +94,14 @@
             {{ formatCurrency(row.amount) }}
           </q-td>
         </template>
+        <template #body-cell-actions="{ row }">
+          <q-td>
+            <div class="row no-wrap q-gutter-xs">
+              <q-btn v-if="canEdit" outline icon="mdi-pencil-outline" style="width: 36px" @click.stop="openLaunchEdit(row)" />
+              <q-btn v-if="canDelete" outline color="negative" icon="mdi-trash-can-outline" style="width: 36px" @click.stop="deleteLaunch(row)" />
+            </div>
+          </q-td>
+        </template>
       </DefaultTable>
     </div>
   </div>
@@ -113,6 +113,7 @@ import { useQuasar } from "quasar";
 import {
   getTreasuryAccountsMe,
   getTreasuryLaunchesMe,
+  deleteTreasuryLaunchMe,
 } from "src/api/treasury";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -137,12 +138,16 @@ const canAdd = computed(() =>
 const canEdit = computed(() =>
   permissions.getAccess("franchisee_financial", "edit"),
 );
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_financial", "delete"),
+);
 
 const launchColumns = [
   { name: "launch_date", label: "Data", field: "launch_date", align: "left" },
   { name: "description", label: "Descrição", field: "description", align: "left" },
   { name: "transaction_type", label: "Tipo", field: "transaction_type", align: "left" },
   { name: "amount", label: "Valor", field: "amount", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
 ];
 
 const selectedAccount = computed(() =>
@@ -208,6 +213,27 @@ function openLaunchCreate() {
   });
 }
 
+function openLaunchEdit(launch) {
+  $q.dialog({
+    component: AddTreasuryLaunchDialog,
+    componentProps: { accountId: selectedId.value, launch },
+  }).onOk(async () => {
+    await Promise.all([loadAccounts(), loadLaunches()]);
+  });
+}
+
+function deleteLaunch(launch) {
+  $q.dialog({
+    title: "Excluir movimentação",
+    message: `Deseja excluir a movimentação "${launch.description}"?`,
+    ok: { color: "negative", label: "Excluir" },
+    cancel: { color: "secondary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    await deleteTreasuryLaunchMe(launch.id);
+    await Promise.all([loadAccounts(), loadLaunches()]);
+  });
+}
+
 onMounted(loadAccounts);
 </script>
 

+ 23 - 9
src/pages/financial/components/AddTreasuryLaunchDialog.vue

@@ -1,7 +1,7 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="q-dialog-plugin overflow-hidden treasury-dialog-card">
-      <DefaultDialogHeader title="Nova movimentação" @close="onDialogCancel" />
+      <DefaultDialogHeader :title="launch ? 'Editar movimentação' : 'Nova movimentação'" @close="onDialogCancel" />
 
       <q-form ref="formRef" @submit="onOKClick">
         <q-card-section class="row q-col-gutter-md q-px-lg q-pt-md q-pb-sm">
@@ -29,6 +29,7 @@
           />
           <DefaultInputDatePicker
             v-model="form.launch_date"
+            v-model:untreated-date="form.launch_date_iso"
             label="Data"
             class="col-12 col-sm-6"
           />
@@ -46,7 +47,7 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
-import { createTreasuryLaunchMe } from "src/api/treasury";
+import { createTreasuryLaunchMe, updateTreasuryLaunchMe } from "src/api/treasury";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
@@ -61,6 +62,10 @@ const props = defineProps({
     type: Number,
     required: true,
   },
+  launch: {
+    type: Object,
+    default: null,
+  },
 });
 
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
@@ -75,10 +80,13 @@ const transactionTypeOptions = [
 ];
 
 const form = ref({
-  transaction_type: "entrada",
-  description: "",
-  amount: 0,
-  launch_date: new Date().toISOString().slice(0, 10),
+  transaction_type: props.launch?.transaction_type ?? "entrada",
+  description: props.launch?.description ?? "",
+  amount: props.launch?.amount ?? 0,
+  launch_date: props.launch?.launch_date
+    ? props.launch.launch_date.split("-").reverse().join("/")
+    : new Date().toLocaleDateString("pt-BR"),
+  launch_date_iso: props.launch?.launch_date ?? new Date().toISOString().slice(0, 10),
 });
 
 async function onOKClick() {
@@ -88,10 +96,16 @@ async function onOKClick() {
   loading.value = true;
 
   try {
-    await createTreasuryLaunchMe({
+    const payload = {
       account_id: props.accountId,
-      ...form.value,
-    });
+      transaction_type: form.value.transaction_type,
+      description: form.value.description,
+      amount: form.value.amount,
+      launch_date: form.value.launch_date_iso,
+    };
+
+    if (props.launch) await updateTreasuryLaunchMe(props.launch.id, payload);
+    else await createTreasuryLaunchMe(payload);
 
     onDialogOK(true);
   } catch (e) {

+ 2 - 2
src/router/routes/apis.route.js

@@ -2,11 +2,11 @@ export default [
   {
     path: "/apis",
     name: "ApisPage",
-    component: () => import("pages/apis/ApisPage.vue"),
+    redirect: { name: "AsaasIntegrationPage" },
     meta: {
       title: { value: "APIs", translate: false },
       requireAuth: true,
-      requiredPermission: "franchisee_integrations",
+      requiredPermission: "franchisee_financial",
       breadcrumbs: [
         { name: "DashboardPage", title: "Dashboard" },
         { name: "ApisPage", title: "APIs" },

+ 30 - 0
src/router/routes/financial.route.js

@@ -73,4 +73,34 @@ export default [
       ],
     },
   },
+  {
+    path: "/financial/invoice-issuance",
+    name: "InvoiceIssuancePage",
+    component: () => import("pages/financial/InvoiceIssuancePage.vue"),
+    meta: {
+      title: { value: "Emissão de Notas", translate: false },
+      requireAuth: true,
+      requiredPermission: "franchisee_financial",
+      breadcrumbs: [
+        { name: "DashboardPage", title: "Dashboard" },
+        { name: "FinancialPage", title: "Financeiro" },
+        { name: "InvoiceIssuancePage", title: "Emissão de Notas" },
+      ],
+    },
+  },
+  {
+    path: "/financial/asaas-integration",
+    name: "AsaasIntegrationPage",
+    component: () => import("pages/financial/AsaasIntegrationPage.vue"),
+    meta: {
+      title: { value: "Integração Asaas", translate: false },
+      requireAuth: true,
+      requiredPermission: "franchisee_financial",
+      breadcrumbs: [
+        { name: "DashboardPage", title: "Dashboard" },
+        { name: "FinancialPage", title: "Financeiro" },
+        { name: "AsaasIntegrationPage", title: "Integração Asaas" },
+      ],
+    },
+  },
 ];

+ 18 - 9
src/stores/navigation.js

@@ -93,6 +93,24 @@ export const navigationStore = defineStore("navigation", () => {
           permission: false,
           permissionScope: "franchisee_financial",
         },
+        {
+          type: "single",
+          title: "Emissão de Notas",
+          name: "InvoiceIssuancePage",
+          icon: "mdi-file-document-outline",
+          disable: false,
+          permission: false,
+          permissionScope: "franchisee_financial",
+        },
+        {
+          type: "single",
+          title: "Integração Asaas",
+          name: "AsaasIntegrationPage",
+          icon: "mdi-api",
+          disable: false,
+          permission: false,
+          permissionScope: "franchisee_financial",
+        },
       ],
     },
     {
@@ -131,15 +149,6 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "franchisee_unit",
     },
-    {
-      type: "single",
-      title: "APIs",
-      name: "ApisPage",
-      icon: "mdi-api",
-      disable: false,
-      permission: false,
-      permissionScope: "franchisee_integrations",
-    },
     {
       type: "single",
       title: "Suporte",