Explorar o código

feat: adiciona congelamento de contrato

ebagabee hai 3 semanas
pai
achega
0dfa3ab2d8

+ 7 - 2
src/api/studentContract.js

@@ -32,8 +32,13 @@ export const attachContractFile = async (id, formData) => {
   return data.payload;
 };
 
-export const freezeContract = async (id) => {
-  const { data } = await api.post(`/student-contract/${id}/freeze`);
+export const getContractInstallments = async (id) => {
+  const { data } = await api.get(`/student-contract/${id}/installments`);
+  return data.payload;
+};
+
+export const freezeContract = async (id, months) => {
+  const { data } = await api.post(`/student-contract/${id}/freeze`, { months });
   return data.payload;
 };
 

+ 11 - 10
src/pages/contracts/ContractsPage.vue

@@ -10,7 +10,7 @@
           :value="String(metrics.active)"
         />
         <DashboardStatCard
-          title="Contratos Congelados"
+          title="Contratos Trancados"
           icon="mdi-snowflake"
           :value="String(metrics.frozen)"
         />
@@ -85,7 +85,7 @@
                       clickable
                       @click="handleFreeze(row)"
                     >
-                      <q-item-section>Congelar Contrato</q-item-section>
+                      <q-item-section>Trancar Contrato</q-item-section>
                     </q-item>
                     <q-item
                       v-close-popup
@@ -113,10 +113,10 @@ import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
+import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
 import ViewContractDialog from "src/pages/students/components/ViewContractDialog.vue";
 import {
   getAllContracts,
-  freezeContract,
   cancelContract,
   reactivateContract,
 } from "src/api/studentContract";
@@ -172,12 +172,13 @@ function confirmAction(title, message, apiFn, contract) {
 }
 
 function handleFreeze(contract) {
-  confirmAction(
-    "Congelar Contrato",
-    "Você tem certeza que deseja CONGELAR este contrato? Isso irá gerar multas e cancelamento da cobrança recorrente.",
-    freezeContract,
-    contract,
-  );
+  $q.dialog({
+    component: FreezeContractDialog,
+    componentProps: { contract },
+  }).onOk((updated) => {
+    const idx = rows.value.findIndex((r) => r.id === contract.id);
+    if (idx !== -1) rows.value[idx] = { ...rows.value[idx], status: updated.status };
+  });
 }
 
 function handleCancel(contract) {
@@ -207,7 +208,7 @@ function statusColor(status) {
 
 function statusLabel(status) {
   if (status === "active") return "Ativo";
-  if (status === "frozen") return "Congelado";
+  if (status === "frozen") return "Trancado";
   if (status === "cancelled") return "Cancelado";
   return "Inativo";
 }

+ 217 - 0
src/pages/students/components/FreezeContractDialog.vue

@@ -0,0 +1,217 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 100%; max-width: 860px">
+      <DefaultDialogHeader title="Trancar Contrato" @close="onDialogCancel" />
+
+      <q-card-section class="q-pt-sm" style="max-height: 70vh; overflow-y: auto">
+
+        <!-- Seleção de meses -->
+        <div class="row q-col-gutter-sm q-mb-md items-center">
+          <div class="col-12 col-md-4">
+            <DefaultSelect
+              v-model="selectedMonths"
+              label="Tempo de Trancamento"
+              :options="monthOptions"
+              option-value="value"
+              option-label="label"
+              :option-disable="opt => opt.disable"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div v-if="selectedMonths" class="col-12 col-md-8">
+            <q-banner class="bg-blue-1 text-blue-9" dense rounded>
+              <template #avatar>
+                <q-icon name="mdi-information-outline" color="blue-7" />
+              </template>
+              {{ pendingInstallments.length }} parcela(s) pendente(s) serão adiadas
+              <strong>{{ selectedMonths }} {{ selectedMonths === 1 ? 'mês' : 'meses' }}</strong>.
+            </q-banner>
+          </div>
+        </div>
+
+        <!-- Tabela de parcelas / preview -->
+        <div v-if="loading" class="flex flex-center q-py-lg">
+          <q-spinner color="primary" size="32px" />
+        </div>
+
+        <div v-else-if="!pendingInstallments.length" class="text-center text-grey-6 q-py-lg">
+          Nenhuma parcela pendente encontrada para este contrato.
+        </div>
+
+        <q-table
+          v-else
+          :rows="previewRows"
+          :columns="tableColumns"
+          flat
+          bordered
+          dense
+          hide-pagination
+          :rows-per-page-options="[0]"
+          class="q-mt-sm"
+        >
+          <template #body-cell-new_due_date="{ row }">
+            <q-td align="center">
+              <span
+                v-if="row.new_due_date"
+                class="text-positive text-weight-medium"
+              >
+                {{ row.new_due_date }}
+              </span>
+              <span v-else class="text-grey-5">—</span>
+            </q-td>
+          </template>
+
+          <template #body-cell-value="{ row }">
+            <q-td align="right">
+              {{ formatCurrency(row.value) }}
+            </q-td>
+          </template>
+        </q-table>
+
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right">
+        <q-btn outline color="primary" label="CANCELAR" @click="onDialogCancel" />
+        <q-btn
+          color="primary"
+          label="CONFIRMAR TRANCAMENTO"
+          :loading="saving"
+          :disable="!selectedMonths || !pendingInstallments.length"
+          @click="handleConfirm"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import { getContractInstallments, freezeContract } from "src/api/studentContract";
+import { getFinancialMe } from "src/api/unit_financial";
+
+const props = defineProps({
+  contract: { type: Object, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const loading        = ref(true);
+const saving         = ref(false);
+const selectedMonths = ref(null);
+const pendingInstallments = ref([]);
+const maxFreezeCount = ref(null);
+
+// --- Opções de meses ---
+const monthOptions = computed(() => {
+  const max   = maxFreezeCount.value;
+  const limit = max ?? 12;
+
+  const opts = Array.from({ length: limit }, (_, i) => ({
+    value: i + 1,
+    label: i + 1 === 1 ? '1 mês' : `${i + 1} meses`,
+    disable: false,
+  }));
+
+  if (max) {
+    opts.push({
+      value: null,
+      label: `Acima de ${max} meses — não permitido`,
+      disable: true,
+    });
+  }
+
+  return opts;
+});
+
+// --- Data helpers ---
+function addMonthsToDMY(dateStr, months) {
+  if (!dateStr) return null;
+  const [day, month, year] = dateStr.split("/").map(Number);
+  const targetMonthStart = new Date(year, month - 1 + months, 1);
+  const lastDay = new Date(
+    targetMonthStart.getFullYear(),
+    targetMonthStart.getMonth() + 1,
+    0,
+  ).getDate();
+  const safeDay = Math.min(day, lastDay);
+  return (
+    String(safeDay).padStart(2, "0") +
+    "/" +
+    String(targetMonthStart.getMonth() + 1).padStart(2, "0") +
+    "/" +
+    targetMonthStart.getFullYear()
+  );
+}
+
+function formatCurrency(value) {
+  return new Intl.NumberFormat("pt-BR", {
+    style: "currency",
+    currency: "BRL",
+  }).format(value ?? 0);
+}
+
+// --- Preview ---
+const previewRows = computed(() =>
+  pendingInstallments.value.map((inst) => ({
+    ...inst,
+    new_due_date: selectedMonths.value
+      ? addMonthsToDMY(inst.due_date, selectedMonths.value)
+      : null,
+  })),
+);
+
+const tableColumns = [
+  { name: "history",      label: "Histórico",        field: "history",      align: "left"   },
+  { name: "order",        label: "Ordem",             field: "order",        align: "center" },
+  { name: "value",        label: "Valor",             field: "value",        align: "right"  },
+  { name: "due_date",     label: "Vencimento Atual",  field: "due_date",     align: "center" },
+  { name: "new_due_date", label: "Novo Vencimento",   field: "new_due_date", align: "center" },
+];
+
+// --- Init ---
+onMounted(async () => {
+  try {
+    const [installments, financial] = await Promise.all([
+      getContractInstallments(props.contract.id),
+      getFinancialMe(),
+    ]);
+    pendingInstallments.value = installments ?? [];
+    maxFreezeCount.value      = financial?.max_freeze_count ?? null;
+  } catch (e) {
+    console.error(e);
+    $q.notify({ type: "negative", message: "Erro ao carregar parcelas." });
+  } finally {
+    loading.value = false;
+  }
+});
+
+// --- Confirmar ---
+async function handleConfirm() {
+  if (!selectedMonths.value) return;
+  saving.value = true;
+  try {
+    const updated = await freezeContract(props.contract.id, selectedMonths.value);
+    $q.notify({ type: "positive", message: "Contrato trancado com sucesso!" });
+    onDialogOK(updated);
+  } catch (e) {
+    const msg =
+      e?.response?.data?.message ??
+      "Erro ao trancar contrato.";
+    $q.notify({ type: "negative", message: msg });
+  } finally {
+    saving.value = false;
+  }
+}
+</script>

+ 10 - 9
src/pages/students/tabs/ContractTab.vue

@@ -79,7 +79,7 @@
                       clickable
                       @click="handleFreeze(row)"
                     >
-                      <q-item-section>Congelar Contrato</q-item-section>
+                      <q-item-section>Trancar Contrato</q-item-section>
                     </q-item>
                     <q-item
                       v-close-popup
@@ -104,11 +104,11 @@ import { ref, onMounted } from "vue";
 import { useQuasar } from "quasar";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
+import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
 import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
 import {
   getStudentContracts,
   attachContractFile,
-  freezeContract,
   cancelContract,
   reactivateContract,
 } from "src/api/studentContract";
@@ -194,12 +194,13 @@ function confirmAction(title, message, apiFn, contract) {
 }
 
 function handleFreeze(contract) {
-  confirmAction(
-    "Congelar Contrato",
-    "Você tem certeza que deseja CONGELAR este contrato? Isso irá gerar multas e cancelamento da cobrança recorrente.",
-    freezeContract,
-    contract,
-  );
+  $q.dialog({
+    component: FreezeContractDialog,
+    componentProps: { contract },
+  }).onOk((updated) => {
+    const idx = rows.value.findIndex((r) => r.id === contract.id);
+    if (idx !== -1) rows.value[idx] = { ...rows.value[idx], status: updated.status };
+  });
 }
 
 function handleCancel(contract) {
@@ -229,7 +230,7 @@ function statusColor(status) {
 
 function statusLabel(status) {
   if (status === "active") return "Ativo";
-  if (status === "frozen") return "Congelado";
+  if (status === "frozen") return "Trancado";
   if (status === "cancelled") return "Cancelado";
   return "Inativo";
 }