ソースを参照

feat(accounts-payable): implement company payables management with create, settle, and reopen functionalities

ebagabee 4 週間 前
コミット
b3a35002c7
2 ファイル変更394 行追加47 行削除
  1. 21 0
      src/api/company_payable.js
  2. 373 47
      src/pages/financial/AccountsPayablePage.vue

+ 21 - 0
src/api/company_payable.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getCompanyPayables = async (params = {}) => {
+  const { data } = await api.get("/company-payable", { params });
+  return data.payload;
+};
+
+export const createCompanyPayable = async (payload) => {
+  const { data } = await api.post("/company-payable", payload);
+  return data.payload;
+};
+
+export const settleCompanyPayable = async (id, payload = {}) => {
+  const { data } = await api.post(`/company-payable/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenCompanyPayable = async (id) => {
+  const { data } = await api.post(`/company-payable/${id}/reopen`);
+  return data.payload;
+};

+ 373 - 47
src/pages/financial/AccountsPayablePage.vue

@@ -4,91 +4,417 @@
 
     <div class="row q-pa-md q-gutter-md">
       <FinancialCard
-        title="Saldo Tesouraria"
-        icon="mdi-bank-outline"
-        :financial-value="0"
-        :integer="0"
-        integer-label="pagamentos pendentes"
-      />
-      <FinancialCard
-        title="Contas Quitadas"
+        title="Pago"
         icon="mdi-check-circle-outline"
-        :financial-value="0"
-        :integer="0"
-        integer-label="pagamentos pendentes"
+        :financial-value="totals.paid"
+        :integer="totals.paidCount"
+        integer-label="pagamentos"
       />
       <FinancialCard
-        title="Contas a Pagar"
+        title="A Pagar"
         icon="mdi-cash-minus"
-        :financial-value="0"
-        :integer="0"
-        integer-label="pagamentos pendentes"
+        :financial-value="totals.pending"
+        :integer="totals.pendingCount"
+        integer-label="pendentes"
+      />
+      <FinancialCard
+        title="Vencidas"
+        icon="mdi-alert-circle-outline"
+        :financial-value="totals.overdue"
+        :integer="totals.overdueCount"
+        integer-label="em atraso"
       />
     </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
-        :color="showMovimentacoes ? 'secondary' : 'primary'"
-        label="Últimas Movimentações"
+        color="primary"
+        label="Nova conta a pagar"
+        icon="mdi-plus"
         unelevated
         no-caps
-        @click="showMovimentacoes = !showMovimentacoes"
+        @click="openCreate"
       />
-      <q-btn color="primary" label="Exportar Relatório" icon="mdi-download" unelevated no-caps />
     </div>
 
     <div class="q-px-md">
       <DefaultTable
-        v-if="!showMovimentacoes"
         v-model:rows="rows"
         no-api-call
-        add-item
+        :add-item="false"
         title="Contas a Pagar"
         description="contas"
         :female="true"
         :columns="columns"
-        @on-add-item="handleAddItem"
-      />
-      <DefaultTable
-        v-else
-        v-model:rows="movimentacoesRows"
-        no-api-call
-        :add-item="false"
-        title="Últimas Movimentações"
-        description="movimentações"
-        :female="true"
-        :columns="movimentacoesColumns"
-        @on-add-item="handleAddItem"
-      />
+      >
+        <template #body-cell-plan_account="{ row }">
+          <q-td class="text-left">
+            {{ row.plan_account ? `${row.plan_account.code} — ${row.plan_account.description}` : "—" }}
+          </q-td>
+        </template>
+
+        <template #body-cell-value="{ row }">
+          <q-td class="text-left">{{ formatCurrency(row.value) }}</q-td>
+        </template>
+
+        <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="row.status !== 'paid' && row.status !== 'cancelled'"
+              color="primary"
+              label="Dar baixa"
+              dense
+              no-caps
+              unelevated
+              class="q-px-sm"
+              @click="openSettle(row)"
+            />
+            <q-btn
+              v-else-if="row.status === 'paid'"
+              color="grey-7"
+              label="Reabrir"
+              dense
+              no-caps
+              outline
+              class="q-px-sm"
+              :loading="reopeningId === row.id"
+              @click="onReopen(row)"
+            />
+          </q-td>
+        </template>
+      </DefaultTable>
     </div>
+
+    <!-- Diálogo de baixa manual -->
+    <q-dialog v-model="settleDialog">
+      <q-card style="min-width: 360px; max-width: 460px">
+        <DefaultDialogHeader title="Dar baixa" @close="settleDialog = false" />
+
+        <q-card-section class="q-gutter-sm">
+          <div class="text-body2 text-grey-8">{{ selected?.history }}</div>
+
+          <DefaultInputDatePicker
+            v-model="settleForm.payment_date"
+            label="Data do pagamento"
+          />
+
+          <div class="row q-col-gutter-sm">
+            <DefaultCurrencyInput
+              v-model="settleForm.discount"
+              label="Desconto"
+              class="col-6"
+              outlined
+            />
+            <DefaultCurrencyInput
+              v-model="settleForm.fine"
+              label="Multa/Juros"
+              class="col-6"
+              outlined
+            />
+          </div>
+
+          <div class="text-caption text-grey-7">
+            Valor da conta: {{ formatCurrency(selected?.value) }} ·
+            <span class="text-weight-medium">
+              Pago: {{ formatCurrency(settleNetValue) }}
+            </span>
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-pa-md">
+          <q-btn flat label="Cancelar" color="grey-7" @click="settleDialog = false" />
+          <q-btn
+            color="primary"
+            label="Confirmar baixa"
+            :loading="settling"
+            @click="onSettle"
+          />
+        </q-card-actions>
+      </q-card>
+    </q-dialog>
+
+    <!-- Diálogo de nova conta manual -->
+    <q-dialog v-model="createDialog">
+      <q-card style="min-width: 380px; max-width: 480px">
+        <DefaultDialogHeader title="Nova conta a pagar" @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.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>
   </div>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { computed, onMounted, ref } from "vue";
+import {
+  getCompanyPayables,
+  createCompanyPayable,
+  settleCompanyPayable,
+  reopenCompanyPayable,
+} from "src/api/company_payable";
+import { getFinancialPlanAccounts } 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";
 import FinancialCard from "src/components/financial/FinancialCard.vue";
 
-const showMovimentacoes = ref(false);
 const rows = ref([]);
-const movimentacoesRows = 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 createDialog = ref(false);
+const createFormRef = ref(null);
+const creating = ref(false);
+const loadingAux = ref(false);
+const planAccountOptions = ref([]);
+const defaultCreateForm = () => ({
+  history: "",
+  value: 0,
+  due_date: new Date().toISOString().slice(0, 10),
+  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: "unit", label: "Unidade", field: "unit", align: "left" },
-  { name: "description", label: "Descrição", field: "description", align: "left" },
-  { name: "category", label: "Categoria", field: "category", align: "left" },
+  { name: "history", label: "Descrição", field: "history", align: "left" },
+  { name: "plan_account", label: "Plano de Contas", field: "plan_account", align: "left" },
   { 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" },
 ];
 
-const movimentacoesColumns = [
-  { name: "description", label: "Descrição", field: "description", align: "left" },
-  { name: "date", label: "Data", field: "date", align: "left" },
-  { name: "value", label: "Valor", field: "value", align: "left" },
-  { name: "status", label: "Status", field: "status", align: "left" },
-];
+function formatCurrency(value) {
+  return new Intl.NumberFormat("pt-BR", {
+    style: "currency",
+    currency: "BRL",
+  }).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);
+});
+
+const totals = computed(() => {
+  const acc = {
+    paid: 0,
+    paidCount: 0,
+    pending: 0,
+    pendingCount: 0,
+    overdue: 0,
+    overdueCount: 0,
+  };
+  for (const r of rows.value) {
+    if (r.status === "paid") {
+      acc.paid += Number(r.paid_value ?? 0);
+      acc.paidCount++;
+    } else if (r.status === "overdue") {
+      acc.overdue += Number(r.value ?? 0);
+      acc.overdueCount++;
+    } else if (r.status === "pending") {
+      acc.pending += Number(r.value ?? 0);
+      acc.pendingCount++;
+    }
+  }
+  return acc;
+});
+
+async function loadData() {
+  try {
+    const params = statusFilter.value ? { status: statusFilter.value } : {};
+    rows.value = await getCompanyPayables(params);
+  } catch (e) {
+    console.error("Erro ao buscar contas a pagar:", e);
+  }
+}
+
+async function loadAux() {
+  if (planAccountOptions.value.length) return;
+  loadingAux.value = true;
+  try {
+    const planAccounts = await getFinancialPlanAccounts();
+    planAccountOptions.value = (planAccounts ?? []).map((a) => ({
+      label: `${a.code} — ${a.description}`,
+      value: a.id,
+    }));
+  } catch (e) {
+    console.error("Erro ao carregar plano de contas:", e);
+  } finally {
+    loadingAux.value = false;
+  }
+}
+
+function openSettle(row) {
+  selected.value = row;
+  settleForm.value = {
+    payment_date: new Date().toISOString().slice(0, 10),
+    discount: Number(row.discount ?? 0),
+    fine: Number(row.fine ?? 0),
+  };
+  settleDialog.value = true;
+}
+
+async function onSettle() {
+  if (!selected.value) return;
+  settling.value = true;
+  try {
+    await settleCompanyPayable(selected.value.id, {
+      payment_date: settleForm.value.payment_date,
+      discount: settleForm.value.discount,
+      fine: settleForm.value.fine,
+    });
+    settleDialog.value = false;
+    await loadData();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    settling.value = false;
+  }
+}
+
+async function onReopen(row) {
+  reopeningId.value = row.id;
+  try {
+    await reopenCompanyPayable(row.id);
+    await loadData();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    reopeningId.value = null;
+  }
+}
+
+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 createCompanyPayable({
+      history: createForm.value.history,
+      value: createForm.value.value,
+      due_date: createForm.value.due_date,
+      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;
+  }
+}
 
-const handleAddItem = () => {};
+onMounted(loadData);
 </script>