Explorar el Código

feat(billings): implementa fluxo em lote de geracao de Contas a Receber

Ultima aba do TbrPage (Cobrancas) agora lista os calculos persistidos
e abre o GenerateReceivableDialog. O dialog pede apenas o mes de
referencia (ano = atual), carrega preview de todas as unidades com
contrato vigente, exibe por linha Royalties/FNM/Manutencao/Total com
status (Pronto / Ja gerado / Erro) e dispara a geracao em lote.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ebagabee hace 3 semanas
padre
commit
eca25cef17

+ 302 - 0
src/pages/tbr/components/GenerateReceivableDialog.vue

@@ -0,0 +1,302 @@
+<template>
+  <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 100%; max-width: 1100px">
+      <DefaultDialogHeader title="Gerar Contas a Receber" @close="onDialogCancel" />
+
+      <q-card-section class="q-pt-none">
+        <div class="row q-col-gutter-sm items-end">
+          <DefaultSelect
+            v-model="referenceMonth"
+            label="Mês de Referência"
+            class="col-12 col-md-4"
+            :options="monthOptions"
+            option-value="value"
+            option-label="label"
+            emit-value
+            map-options
+          />
+
+          <div class="col-12 col-md-3 text-body2 q-pb-sm">
+            Ano: <b>{{ referenceYear }}</b>
+          </div>
+
+          <div class="col-12 col-md-5 text-right">
+            <q-btn
+              color="primary"
+              outline
+              icon="mdi-calculator"
+              label="Calcular Preview"
+              :loading="loadingPreview"
+              @click="loadPreview"
+            />
+          </div>
+        </div>
+
+        <q-banner v-if="error" class="bg-negative text-white q-mt-md" dense>
+          {{ error }}
+        </q-banner>
+
+        <q-banner v-else-if="loaded && previews.length === 0" class="bg-warning text-white q-mt-md" dense>
+          Nenhum contrato vigente encontrado para o mês informado.
+        </q-banner>
+
+        <q-table
+          v-if="previews.length > 0"
+          flat
+          dense
+          :rows="previews"
+          :columns="columns"
+          row-key="unit_id"
+          class="q-mt-md"
+          :pagination="{ rowsPerPage: 0 }"
+          hide-pagination
+        >
+          <template #body-cell-tbr_value="{ row }">
+            <q-td>{{ formatToBRLCurrency(row.tbr_value) }}</q-td>
+          </template>
+
+          <template #body-cell-royalties_effective_value="{ row }">
+            <q-td>
+              {{ formatToBRLCurrency(row.royalties_effective_value) }}
+              <div class="text-caption text-grey-7">
+                {{ formatPercentage(row.royalties_effective_percentage) }} ({{ row.royalties_applied_criteria }})
+              </div>
+            </q-td>
+          </template>
+
+          <template #body-cell-fnm_effective_value="{ row }">
+            <q-td>
+              {{ formatToBRLCurrency(row.fnm_effective_value) }}
+              <div class="text-caption text-grey-7">
+                {{ formatPercentage(row.fnm_effective_percentage) }}
+              </div>
+            </q-td>
+          </template>
+
+          <template #body-cell-maintenance_effective_value="{ row }">
+            <q-td>
+              {{ formatToBRLCurrency(row.maintenance_effective_value) }}
+              <div class="text-caption text-grey-7">
+                {{ formatPercentage(row.maintenance_effective_percentage) }}
+              </div>
+            </q-td>
+          </template>
+
+          <template #body-cell-final_value="{ row }">
+            <q-td>
+              <b>{{ formatToBRLCurrency(row.final_value) }}</b>
+            </q-td>
+          </template>
+
+          <template #body-cell-status="{ row }">
+            <q-td>
+              <q-chip
+                v-if="row.error"
+                color="negative"
+                text-color="white"
+                dense
+                :label="row.error"
+              />
+              <q-chip
+                v-else-if="row.receivable_already_generated"
+                color="warning"
+                text-color="white"
+                dense
+                label="Já gerado"
+              />
+              <q-chip v-else color="positive" text-color="white" dense label="Pronto" />
+            </q-td>
+          </template>
+        </q-table>
+
+        <div v-if="previews.length > 0" class="row q-mt-md q-gutter-sm items-center">
+          <div class="col">
+            <div class="text-body2">
+              Total geral a cobrar:
+              <b>{{ formatToBRLCurrency(totalToBill) }}</b>
+              em <b>{{ readyCount }}</b> unidade(s) pronta(s) para geração.
+            </div>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-actions>
+        <q-space />
+        <q-btn outline label="Fechar" @click="onDialogCancel" />
+        <q-btn
+          color="primary-2"
+          label="Gerar para Todas Pendentes"
+          icon="mdi-content-save-outline"
+          :loading="loadingGenerate"
+          :disable="readyCount === 0"
+          @click="onGenerateAll"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import {
+  previewBatchTbrCalculation,
+  generateBatchReceivables,
+} from "src/api/tbr_calculation";
+import { formatToBRLCurrency, formatPercentage } from "src/helpers/utils";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const today = new Date();
+const referenceMonth = ref(today.getMonth() + 1);
+const referenceYear = ref(today.getFullYear());
+
+const previews = ref([]);
+const loadingPreview = ref(false);
+const loadingGenerate = ref(false);
+const loaded = ref(false);
+const error = ref(null);
+
+const monthOptions = [
+  { value: 1, label: "Janeiro" },
+  { value: 2, label: "Fevereiro" },
+  { value: 3, label: "Março" },
+  { value: 4, label: "Abril" },
+  { value: 5, label: "Maio" },
+  { value: 6, label: "Junho" },
+  { value: 7, label: "Julho" },
+  { value: 8, label: "Agosto" },
+  { value: 9, label: "Setembro" },
+  { value: 10, label: "Outubro" },
+  { value: 11, label: "Novembro" },
+  { value: 12, label: "Dezembro" },
+];
+
+const columns = [
+  {
+    name: "unit_name",
+    label: "Unidade",
+    field: (row) => row.unit_name ?? `#${row.unit_id}`,
+    align: "left",
+  },
+  {
+    name: "municipality_size_name",
+    label: "Porte",
+    field: "municipality_size_name",
+    align: "left",
+  },
+  {
+    name: "contract_month_reference",
+    label: "Mês Contrato",
+    field: "contract_month_reference",
+    align: "center",
+  },
+  {
+    name: "tbr_value",
+    label: "TBR",
+    field: "tbr_value",
+    align: "left",
+  },
+  {
+    name: "royalties_effective_value",
+    label: "Royalties",
+    field: "royalties_effective_value",
+    align: "left",
+  },
+  {
+    name: "fnm_effective_value",
+    label: "FNM",
+    field: "fnm_effective_value",
+    align: "left",
+  },
+  {
+    name: "maintenance_effective_value",
+    label: "Manutenção",
+    field: "maintenance_effective_value",
+    align: "left",
+  },
+  {
+    name: "final_value",
+    label: "Total",
+    field: "final_value",
+    align: "left",
+  },
+  {
+    name: "status",
+    label: "Status",
+    field: "status",
+    align: "left",
+  },
+];
+
+const readyCount = computed(
+  () =>
+    previews.value.filter((p) => !p.error && !p.receivable_already_generated)
+      .length,
+);
+
+const totalToBill = computed(() =>
+  previews.value
+    .filter((p) => !p.error && !p.receivable_already_generated)
+    .reduce((sum, p) => sum + Number(p.final_value || 0), 0),
+);
+
+async function loadPreview() {
+  loadingPreview.value = true;
+  error.value = null;
+  try {
+    previews.value = await previewBatchTbrCalculation({
+      reference_year: referenceYear.value,
+      reference_month: referenceMonth.value,
+    });
+    loaded.value = true;
+  } catch (err) {
+    error.value =
+      err?.response?.data?.message || "Erro ao calcular o preview em lote.";
+    previews.value = [];
+  } finally {
+    loadingPreview.value = false;
+  }
+}
+
+async function onGenerateAll() {
+  $q.dialog({
+    title: "Confirmar geração",
+    message: `Gerar Contas a Receber para ${readyCount.value} unidade(s)?`,
+    ok: { label: "Gerar", color: "primary" },
+    cancel: { label: "Cancelar", color: "primary", outline: true },
+  }).onOk(async () => {
+    loadingGenerate.value = true;
+    try {
+      const result = await generateBatchReceivables({
+        reference_year: referenceYear.value,
+        reference_month: referenceMonth.value,
+      });
+      $q.notify({
+        type: "positive",
+        message:
+          result.message ||
+          `${result.payload?.generated_count ?? 0} título(s) gerado(s).`,
+      });
+      onDialogOK(result.payload ?? null);
+    } catch (err) {
+      $q.notify({
+        type: "negative",
+        message:
+          err?.response?.data?.message || "Erro ao gerar Contas a Receber.",
+      });
+    } finally {
+      loadingGenerate.value = false;
+    }
+  });
+}
+</script>

+ 79 - 86
src/pages/tbr/tabs/BillingsTab.vue

@@ -5,135 +5,128 @@
     :show-search-field="true"
     :add-item="true"
     no-api-call
-    add-item-label="Gerar Cobranças"
+    add-item-label="Gerar Contas a Receber"
     @on-add-item="onAddItem"
   >
-    <template #top>
-      <DefaultInput
-        v-model="monthYearDisplay"
-        label="Filtrar Mês e Ano"
-        dense
-        readonly
-        clearable
-        style="min-width: 180px"
-        @clear="onClearMonthYear"
-      >
-        <template #append>
-          <q-icon name="mdi-calendar" class="cursor-pointer">
-            <q-popup-proxy
-              cover
-              transition-show="scale"
-              transition-hide="scale"
-            >
-              <q-date
-                v-model="monthYear"
-                mask="MM/YYYY"
-                emit-immediately
-                default-view="Months"
-                @update:model-value="onMonthYearSelect"
-              >
-                <div class="row items-center justify-end">
-                  <q-btn v-close-popup label="OK" color="primary" flat />
-                </div>
-              </q-date>
-            </q-popup-proxy>
-          </q-icon>
-        </template>
-      </DefaultInput>
-    </template>
-
     <template #body-cell-tbr_value="{ row }">
       <q-td>{{ formatToBRLCurrency(row.tbr_value) }}</q-td>
     </template>
 
-    <template #body-cell-royalties_value="{ row }">
-      <q-td>{{ formatToBRLCurrency(row.royalties_value) }}</q-td>
+    <template #body-cell-royalties_effective_value="{ row }">
+      <q-td>{{ formatToBRLCurrency(row.royalties_effective_value) }}</q-td>
     </template>
 
-    <template #body-cell-fnm_value="{ row }">
-      <q-td>{{ formatToBRLCurrency(row.fnm_value) }}</q-td>
+    <template #body-cell-fnm_effective_value="{ row }">
+      <q-td>{{ formatToBRLCurrency(row.fnm_effective_value) }}</q-td>
     </template>
 
-    <template #body-cell-maintenance_value="{ row }">
-      <q-td>{{ formatToBRLCurrency(row.maintenance_value) }}</q-td>
+    <template #body-cell-maintenance_effective_value="{ row }">
+      <q-td>{{ formatToBRLCurrency(row.maintenance_effective_value) }}</q-td>
     </template>
 
-    <template #body-cell-total="{ row }">
-      <q-td>{{ formatToBRLCurrency(row.total) }}</q-td>
+    <template #body-cell-final_value="{ row }">
+      <q-td>{{ formatToBRLCurrency(row.final_value) }}</q-td>
     </template>
 
-    <template #body-cell-actions>
-      <q-td align="center">
-        <q-btn flat round dense icon="mdi-eye-outline" size="sm" />
+    <template #body-cell-receivable_generated="{ row }">
+      <q-td>
+        <q-chip
+          v-if="row.receivable_generated"
+          color="positive"
+          text-color="white"
+          dense
+          label="Gerado"
+        />
+        <q-chip v-else color="warning" text-color="white" dense label="Pendente" />
       </q-td>
     </template>
   </DefaultTable>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { onMounted, ref } from "vue";
 import { useQuasar } from "quasar";
+
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import GenerateBillingsDialog from "src/pages/tbr/components/GenerateBillingsDialog.vue";
+import GenerateReceivableDialog from "src/pages/tbr/components/GenerateReceivableDialog.vue";
+
+import api from "src/api";
 import { formatToBRLCurrency } from "src/helpers/utils";
 
 const $q = useQuasar();
-const rows = ref([
-  {
-    id: 1,
-    unit_name: "Foz do Iguaçu",
-    royalties_value: 648.4,
-    royalties_rule: "FIXO 40%",
-    fnm_value: 324.2,
-    fnm_rule: "FIXO 20%",
-    maintenance_value: 486.3,
-    total: 1458.9,
-  },
-]);
-const monthYear = ref(null);
-const monthYearDisplay = ref(null);
+const rows = ref([]);
 
 const columns = [
   { name: "id", label: "ID", field: "id", align: "left" },
-  { name: "unit_name", label: "Unidade", field: "unit_name", align: "left" },
   {
-    name: "royalties_value",
+    name: "unit_id",
+    label: "Unidade",
+    field: (row) => row.unit?.fantasy_name ?? `#${row.unit_id}`,
+    align: "left",
+  },
+  {
+    name: "contract_month_reference",
+    label: "Mês Contrato",
+    field: "contract_month_reference",
+    align: "left",
+  },
+  {
+    name: "tbr_value",
+    label: "TBR",
+    field: "tbr_value",
+    align: "left",
+  },
+  {
+    name: "royalties_effective_value",
     label: "Royalties",
-    field: "royalties_value",
+    field: "royalties_effective_value",
     align: "left",
   },
   {
-    name: "royalties_rule",
-    label: "Regra",
-    field: "royalties_rule",
+    name: "fnm_effective_value",
+    label: "FNM",
+    field: "fnm_effective_value",
     align: "left",
   },
-  { name: "fnm_value", label: "FNM", field: "fnm_value", align: "left" },
-  { name: "fnm_rule", label: "Regra", field: "fnm_rule", align: "left" },
   {
-    name: "maintenance_value",
+    name: "maintenance_effective_value",
     label: "Manutenção",
-    field: "maintenance_value",
+    field: "maintenance_effective_value",
+    align: "left",
+  },
+  {
+    name: "final_value",
+    label: "Total",
+    field: "final_value",
+    align: "left",
+  },
+  {
+    name: "royalties_applied_criteria",
+    label: "Critério",
+    field: "royalties_applied_criteria",
+    align: "left",
+  },
+  {
+    name: "receivable_generated",
+    label: "Status",
+    field: "receivable_generated",
     align: "left",
   },
-  { name: "total", label: "Total", field: "total", align: "left" },
-  { name: "actions", label: "Ações", field: "actions", align: "center" },
 ];
 
-function onMonthYearSelect(value) {
-  if (!value) return;
-  monthYearDisplay.value = value;
-}
-
-function onClearMonthYear() {
-  monthYear.value = null;
-  monthYearDisplay.value = null;
+async function loadData() {
+  try {
+    const { data } = await api.get("/tbr-calculation");
+    rows.value = data.payload ?? [];
+  } catch (err) {
+    console.error(err);
+    $q.notify({ type: "negative", message: "Falha ao carregar cálculos." });
+  }
 }
 
 function onAddItem() {
-  $q.dialog({ component: GenerateBillingsDialog }).onOk(() => {
-    // TODO: reload billings after generation
-  });
+  $q.dialog({ component: GenerateReceivableDialog }).onOk(() => loadData());
 }
+
+onMounted(loadData);
 </script>