Преглед изворни кода

feat(receivable): criar conta a receber avulsa manual

- Botão "Nova conta a receber" + dialog (descrição, valor, vencimento,
  aluno e plano de contas opcionais).
- Baixa/reabrir roteados por origem (parcela x avulso); "Gerar cobrança"
  só para parcelas de aluno. Chave de tabela composta para não colidir ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee пре 4 недеља
родитељ
комит
fe68197b07
2 измењених фајлова са 200 додато и 9 уклоњено
  1. 16 0
      src/api/unit_receivable.js
  2. 184 9
      src/pages/financial/AccountsReceivablePage.vue

+ 16 - 0
src/api/unit_receivable.js

@@ -19,3 +19,19 @@ export const chargeReceivableMe = async (id) => {
   const { data } = await api.post(`/unit-receivable/me/${id}/charge`);
   return data.payload;
 };
+
+// Recebíveis avulsos (manuais)
+export const createManualReceivableMe = async (payload) => {
+  const { data } = await api.post("/unit-receivable/me/manual", payload);
+  return data.payload;
+};
+
+export const settleManualReceivableMe = async (id, payload = {}) => {
+  const { data } = await api.post(`/unit-receivable/me/manual/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenManualReceivableMe = async (id) => {
+  const { data } = await api.post(`/unit-receivable/me/manual/${id}/reopen`);
+  return data.payload;
+};

+ 184 - 9
src/pages/financial/AccountsReceivablePage.vue

@@ -38,6 +38,14 @@
         style="min-width: 200px"
         @update:model-value="loadData"
       />
+      <q-btn
+        color="primary"
+        label="Nova conta a receber"
+        icon="mdi-plus"
+        unelevated
+        no-caps
+        @click="openCreate"
+      />
     </div>
 
     <div class="q-px-md">
@@ -81,7 +89,11 @@
                 @click="openInvoice(row)"
               />
               <q-btn
-                v-else-if="row.status !== 'paid' && row.status !== 'cancelled'"
+                v-else-if="
+                  row.source !== 'manual' &&
+                  row.status !== 'paid' &&
+                  row.status !== 'cancelled'
+                "
                 color="deep-purple-5"
                 label="Gerar cobrança"
                 icon="mdi-cash-fast"
@@ -119,6 +131,79 @@
       </DefaultTable>
     </div>
 
+    <!-- Diálogo de novo recebível avulso -->
+    <q-dialog v-model="createDialog">
+      <q-card style="min-width: 380px; max-width: 480px">
+        <DefaultDialogHeader
+          title="Nova conta a receber"
+          @close="createDialog = false"
+        />
+
+        <q-form ref="createFormRef">
+          <q-card-section class="q-gutter-sm">
+            <DefaultInput
+              v-model="createForm.history"
+              label="Descrição"
+              outlined
+              :rules="[(v) => !!v || 'Informe a descrição']"
+            />
+            <div class="row q-col-gutter-sm">
+              <DefaultCurrencyInput
+                v-model="createForm.value"
+                label="Valor"
+                class="col-6"
+                outlined
+                :rules="[(v) => Number(v) > 0 || 'Informe o valor']"
+              />
+              <DefaultInputDatePicker
+                v-model="createForm.due_date"
+                label="Vencimento"
+                class="col-6"
+                :rules="[(v) => !!v || 'Informe o vencimento']"
+              />
+            </div>
+            <DefaultSelect
+              v-model="createForm.student_id"
+              label="Aluno (opcional)"
+              :options="studentOptions"
+              outlined
+              clearable
+              emit-value
+              map-options
+              :loading="loadingAux"
+            />
+            <DefaultSelect
+              v-model="createForm.financial_plan_account_id"
+              label="Plano de contas (opcional)"
+              :options="planAccountOptions"
+              outlined
+              clearable
+              emit-value
+              map-options
+              :loading="loadingAux"
+            />
+            <DefaultInput
+              v-model="createForm.obs"
+              label="Observação (opcional)"
+              type="textarea"
+              autogrow
+              outlined
+            />
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-pa-md">
+            <q-btn flat label="Cancelar" color="grey-7" @click="createDialog = false" />
+            <q-btn
+              color="primary"
+              label="Salvar"
+              :loading="creating"
+              @click="onCreate"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </q-dialog>
+
     <!-- Diálogo de baixa manual -->
     <q-dialog v-model="settleDialog">
       <q-card style="min-width: 360px; max-width: 460px">
@@ -179,11 +264,17 @@ import {
   settleReceivableMe,
   reopenReceivableMe,
   chargeReceivableMe,
+  createManualReceivableMe,
+  settleManualReceivableMe,
+  reopenManualReceivableMe,
 } from "src/api/unit_receivable";
+import { getStudents } from "src/api/student";
+import { getPlanAccountsMe } from "src/api/financial_plan_account";
 
 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";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
@@ -199,6 +290,23 @@ const chargingId = ref(null);
 
 const settleForm = ref({ payment_date: null, discount: 0, fine: 0 });
 
+// Novo recebível avulso
+const createDialog = ref(false);
+const creating = ref(false);
+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 createForm = ref(defaultCreateForm());
+
 const statusFilterOptions = [
   { label: "Todos", value: null },
   { label: "Pendentes", value: "pending" },
@@ -267,12 +375,70 @@ const totals = computed(() => {
 async function loadData() {
   try {
     const params = statusFilter.value ? { status: statusFilter.value } : {};
-    rows.value = await getReceivablesMe(params);
+    const data = await getReceivablesMe(params);
+    // 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) => ({
+      ...r,
+      realId: r.id,
+      id: `${r.source}-${r.id}`,
+    }));
   } catch (e) {
     console.error("Erro ao buscar contas a receber:", e);
   }
 }
 
+async function loadAux() {
+  if (studentOptions.value.length || planAccountOptions.value.length) return;
+  loadingAux.value = true;
+  try {
+    const [students, planAccounts] = await Promise.all([
+      getStudents(),
+      getPlanAccountsMe(),
+    ]);
+    studentOptions.value = (students ?? []).map((s) => ({
+      label: s.name,
+      value: s.id,
+    }));
+    planAccountOptions.value = (planAccounts ?? []).map((a) => ({
+      label: `${a.code} — ${a.description}`,
+      value: a.id,
+    }));
+  } catch (e) {
+    console.error("Erro ao carregar dados auxiliares:", e);
+  } finally {
+    loadingAux.value = false;
+  }
+}
+
+function openCreate() {
+  createForm.value = defaultCreateForm();
+  createDialog.value = true;
+  loadAux();
+}
+
+async function onCreate() {
+  const valid = await createFormRef.value?.validate();
+  if (!valid) return;
+  creating.value = true;
+  try {
+    await createManualReceivableMe({
+      history: createForm.value.history,
+      value: createForm.value.value,
+      due_date: createForm.value.due_date,
+      student_id: createForm.value.student_id || null,
+      financial_plan_account_id: createForm.value.financial_plan_account_id || null,
+      obs: createForm.value.obs || null,
+    });
+    createDialog.value = false;
+    await loadData();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    creating.value = false;
+  }
+}
+
 function openSettle(row) {
   selected.value = row;
   settleForm.value = {
@@ -286,12 +452,17 @@ function openSettle(row) {
 async function onSettle() {
   if (!selected.value) return;
   settling.value = true;
+  const payload = {
+    payment_date: settleForm.value.payment_date,
+    discount: settleForm.value.discount,
+    fine: settleForm.value.fine,
+  };
   try {
-    await settleReceivableMe(selected.value.id, {
-      payment_date: settleForm.value.payment_date,
-      discount: settleForm.value.discount,
-      fine: settleForm.value.fine,
-    });
+    if (selected.value.source === "manual") {
+      await settleManualReceivableMe(selected.value.realId, payload);
+    } else {
+      await settleReceivableMe(selected.value.realId, payload);
+    }
     settleDialog.value = false;
     await loadData();
   } catch (e) {
@@ -304,7 +475,11 @@ async function onSettle() {
 async function onReopen(row) {
   reopeningId.value = row.id;
   try {
-    await reopenReceivableMe(row.id);
+    if (row.source === "manual") {
+      await reopenManualReceivableMe(row.realId);
+    } else {
+      await reopenReceivableMe(row.realId);
+    }
     await loadData();
   } catch (e) {
     console.error(e);
@@ -316,7 +491,7 @@ async function onReopen(row) {
 async function onCharge(row) {
   chargingId.value = row.id;
   try {
-    await chargeReceivableMe(row.id);
+    await chargeReceivableMe(row.realId);
     await loadData();
   } catch (e) {
     console.error(e);