Ver código fonte

feat: rename "Pacotes" to "Proposta Comercial" across multiple components and routes

alvesantos 22 horas atrás
pai
commit
c80d68fc6e

+ 2 - 2
src/pages/packages/PackagesPage.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Pacotes" :show-filter-icon="false" />
+    <DefaultHeaderPage title="Proposta Comercial" :show-filter-icon="false" />
 
     <div class="q-px-sm">
       <!-- Add button -->
@@ -48,7 +48,7 @@
           v-if="packages.length === 0"
           class="col-12 text-center text-grey-5 q-py-xl"
         >
-          Nenhum pacote cadastrado
+          Nenhuma proposta comercial cadastrada
         </div>
       </div>
     </div>

+ 130 - 460
src/pages/packages/components/AddEditPackageDialog.vue

@@ -3,7 +3,7 @@
     <div style="width: 100%; max-width: 700px">
       <q-card class="overflow-hidden" style="width: 100%">
         <DefaultDialogHeader
-          :title="props.package ? 'Editar Pacote' : 'Novo Pacote'"
+          :title="props.package ? 'Editar Proposta Comercial' : 'Nova Proposta Comercial'"
           @close="onDialogCancel"
         />
 
@@ -17,7 +17,7 @@
               />
 
               <div
-                v-show="currentTab === 'informacoes'"
+                v-show="currentTab === 'dados'"
                 class="col-12"
               >
                 <div class="row q-col-gutter-md">
@@ -193,300 +193,24 @@
               </div>
 
               <div
-                v-show="currentTab === 'unidades'"
+                v-show="currentTab === 'pavao'"
                 class="col-12"
               >
-                <div class="column q-gutter-y-sm">
-                  <q-input
-                    bg-color="white"
-                    color="secondary"
-                    dense
-                    label="Visível para todas as Unidades / Grupos"
-                    label-color="secondary"
-                    outlined
-                    readonly
-                    style="border-radius: 8px"
-                  >
-                    <template #append>
-                      <q-btn
-                        color="positive"
-                        dense
-                        flat
-                        icon="mdi-check"
-                        round
-                        size="sm"
-                        @click="setAllVisible(true)"
-                      />
-
-                      <q-btn
-                        color="negative"
-                        dense
-                        flat
-                        icon="mdi-close-circle-outline"
-                        round
-                        size="sm"
-                        @click="setAllVisible(false)"
-                      />
-                    </template>
-                  </q-input>
-
-                  <div class="row items-center q-gutter-x-sm">
-                    <q-input
-                      v-model="unitSearch"
-                      bg-color="white"
-                      class="col"
-                      color="secondary"
-                      dense
-                      label="Buscar Unidade / Grupo"
-                      label-color="secondary"
-                      outlined
-                      style="border-radius: 8px"
-                    >
-                      <template #append>
-                        <q-icon color="secondary" name="mdi-magnify" />
-                      </template>
-                    </q-input>
-
-                    <q-btn
-                      color="primary"
-                      icon="mdi-plus"
-                      style="
-                        border-radius: 8px;
-                        height: 40px;
-                        width: 40px;
-                        flex-shrink: 0;
-                      "
-                      unelevated
-                    />
-                  </div>
-
-                  <q-scroll-area
-                    class="rounded-borders"
-                    style="height: 280px; background-color: #f5f5f5"
-                  >
-                    <div class="q-pa-sm">
-                      <div
-                        v-if="loadingUnits"
-                        class="flex flex-center q-py-md"
-                      >
-                        <q-spinner color="secondary" size="sm" />
-                      </div>
-
-                      <template v-else>
-                        <div class="row items-center q-mb-xs q-px-sm">
-                          <q-icon
-                            class="q-mr-xs"
-                            color="secondary"
-                            name="mdi-account-multiple"
-                            size="sm"
-                          />
-
-                          <span class="text-body2">
-                            Unidades Ativas ({{ filteredUnits.length }})
-                          </span>
-                        </div>
-
-                        <q-separator class="q-mb-xs" />
-
-                        <div class="row q-px-sm q-py-xs">
-                          <span class="col text-caption text-grey-6">
-                            Unidade
-                          </span>
-
-                          <span class="col-auto text-caption text-grey-6">
-                            Status
-                          </span>
-                        </div>
-
-                        <template v-for="unit in filteredUnits" :key="unit.id">
-                          <q-separator />
-
-                          <div class="row items-center q-px-sm q-py-sm">
-                            <span
-                              class="col text-body2 row items-center q-gutter-x-xs"
-                            >
-                              {{ formatUnitName(unit) }}
-
-                              <q-icon
-                                v-if="lockedUnitIds.has(unit.id)"
-                                color="amber-7"
-                                name="mdi-lock"
-                                size="xs"
-                              >
-                                <q-tooltip>
-                                  Visibilidade definida por grupo
-                                </q-tooltip>
-                              </q-icon>
-                            </span>
-
-                            <div class="col-auto row q-gutter-x-xs">
-                              <q-btn
-                                icon="mdi-check"
-                                round
-                                size="xs"
-                                unelevated
-                                :color="unit.visible ? 'positive' : 'grey-4'"
-                                @click="unit.visible = true"
-                              />
-
-                              <q-btn
-                                color="negative"
-                                icon="mdi-close"
-                                round
-                                size="xs"
-                                :disable="lockedUnitIds.has(unit.id)"
-                                :outline="unit.visible"
-                                :unelevated="!unit.visible"
-                                @click="
-                                  !lockedUnitIds.has(unit.id) &&
-                                  (unit.visible = false)
-                                "
-                              />
-                            </div>
-                          </div>
-                        </template>
-                      </template>
-                    </div>
-                  </q-scroll-area>
-                </div>
+                <ProposalPricingTab
+                  v-model="form.pavao"
+                  :product-options="productOptions"
+                />
               </div>
 
               <div
-                v-show="currentTab === 'grupos'"
+                v-show="currentTab === 'irrecusavel'"
                 class="col-12"
               >
-                <div class="column q-gutter-y-sm">
-                  <q-input
-                    bg-color="white"
-                    color="secondary"
-                    dense
-                    label="Visível Para todos os Grupos"
-                    label-color="secondary"
-                    outlined
-                    readonly
-                    style="border-radius: 8px"
-                  >
-                    <template #append>
-                      <q-btn
-                        color="positive"
-                        dense
-                        flat
-                        icon="mdi-check"
-                        round
-                        size="sm"
-                        @click="setAllGroupsVisible(true)"
-                      />
-
-                      <q-btn
-                        color="negative"
-                        dense
-                        flat
-                        icon="mdi-close-circle-outline"
-                        round
-                        size="sm"
-                        @click="setAllGroupsVisible(false)"
-                      />
-                    </template>
-                  </q-input>
-
-                  <q-input
-                    v-model="groupSearch"
-                    bg-color="white"
-                    color="secondary"
-                    dense
-                    label="Buscar Grupo"
-                    label-color="secondary"
-                    outlined
-                    style="border-radius: 8px"
-                  >
-                    <template #append>
-                      <q-icon color="secondary" name="mdi-magnify" />
-                    </template>
-                  </q-input>
-
-                  <q-scroll-area
-                    class="rounded-borders"
-                    style="height: 280px; background-color: #f5f5f5"
-                  >
-                    <div class="q-pa-sm">
-                      <div
-                        v-if="loadingGroups"
-                        class="flex flex-center q-py-md"
-                      >
-                        <q-spinner color="secondary" size="sm" />
-                      </div>
-
-                      <template v-else>
-                        <div class="row items-center q-mb-xs q-px-sm">
-                          <q-icon
-                            class="q-mr-xs"
-                            color="secondary"
-                            name="mdi-account-group"
-                            size="sm"
-                          />
-
-                          <span class="text-body2">
-                            Grupos ({{ filteredGroups.length }})
-                          </span>
-                        </div>
-
-                        <q-separator class="q-mb-xs" />
-
-                        <div class="row q-px-sm q-py-xs">
-                          <span class="col text-caption text-grey-6">
-                            Grupo
-                          </span>
-
-                          <span class="col-6 text-caption text-grey-6">
-                            Unidades
-                          </span>
-
-                          <span class="col-auto text-caption text-grey-6">
-                            Status
-                          </span>
-                        </div>
-
-                        <template
-                          v-for="group in filteredGroups"
-                          :key="group.id"
-                        >
-                          <q-separator />
-
-                          <div class="row items-center q-px-sm q-py-sm">
-                            <span class="col text-body2">
-                              {{ group.name }}
-                            </span>
-
-                            <span class="col-6 text-caption text-grey-6">
-                              {{ group.unit_ids?.length ?? 0 }} unidade(s)
-                            </span>
-
-                            <div class="col-auto row q-gutter-x-xs">
-                              <q-btn
-                                icon="mdi-check"
-                                round
-                                size="xs"
-                                unelevated
-                                :color="group.visible ? 'positive' : 'grey-4'"
-                                @click="group.visible = true"
-                              />
-
-                              <q-btn
-                                color="negative"
-                                icon="mdi-close"
-                                round
-                                size="xs"
-                                :outline="group.visible"
-                                :unelevated="!group.visible"
-                                @click="group.visible = false"
-                              />
-                            </div>
-                          </div>
-                        </template>
-                      </template>
-                    </div>
-                  </q-scroll-area>
-                </div>
+                <ProposalPricingTab
+                  v-model="form.irrecusavel"
+                  :product-options="productOptions"
+                  show-condition
+                />
               </div>
             </q-card-section>
           </q-scroll-area>
@@ -518,7 +242,7 @@
 </template>
 
 <script setup>
-import { computed, onMounted, ref, watch } from "vue";
+import { onMounted, computed, ref } from "vue";
 
 import {
   createPackage,
@@ -526,10 +250,7 @@ import {
   updatePackage,
 } from "src/api/package";
 
-import { formatUnitName } from "src/helpers/utils";
-import { getGroupsForSelect } from "src/api/group";
 import { getProductsForSelect } from "src/api/product";
-import { getUnitsForSelect } from "src/api/unit";
 import { useDialogPluginComponent } from "quasar";
 import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
@@ -542,6 +263,7 @@ import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue
 import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import ProposalPricingTab from "./ProposalPricingTab.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -562,12 +284,36 @@ const { scrollToComponent } = useScroll();
 const formRef = ref(null);
 const scrollAreaRef = ref(null);
 
+const defaultPricingBlock = (withCondition = false) => ({
+  classes_discount_percentage: null,
+  classes_included_in_course: false,
+  classes_installments_allowed: false,
+  classes_max_installments: 13,
+  classes_value: null,
+
+  materials: [{ price: null, product_id: null, quantity: 1 }],
+  materials_included_in_course: false,
+  materials_installments_allowed: false,
+  materials_max_installments: 13,
+
+  registration_included_in_course: false,
+  registration_installments_allowed: false,
+  registration_max_installments: 13,
+  registration_value: null,
+
+  total_max_installments: 13,
+  total_value: null,
+
+  ...(withCondition ? { condition: null } : {}),
+});
+
 const { form, getUpdatedFields, setUpdateFormAsOriginal } = useForm(
   {
     class_duration_hours: (props.package?.class_duration_minutes ?? 120) / 60,
     contract_register_value: props.package?.contract_register_value ?? null,
     contract_value: props.package?.contract_value ?? null,
     contrat_discount_value: props.package?.contrat_discount_value ?? null,
+    irrecusavel: defaultPricingBlock(true),
     materials: [
       {
         price: null,
@@ -576,6 +322,7 @@ const { form, getUpdatedFields, setUpdateFormAsOriginal } = useForm(
       },
     ],
     name: props.package?.name ?? null,
+    pavao: defaultPricingBlock(),
     quantity_classes: props.package?.quantity_classes ?? null,
   },
   { containerRef: scrollAreaRef },
@@ -592,48 +339,8 @@ const {
   scrollFn: scrollToComponent,
 });
 
-const currentTab = ref("informacoes");
-const groupSearch = ref("");
-const groups = ref([]);
-const loadingGroups = ref(false);
-const loadingUnits = ref(false);
-const originalGroupIds = ref([]);
-const originalUnitVisibilities = ref([]);
+const currentTab = ref("dados");
 const products = ref([]);
-const unitSearch = ref("");
-const units = ref([]);
-
-const filteredGroups = computed(() => {
-  const search = groupSearch.value.toLowerCase();
-
-  if (!search) return groups.value;
-
-  return groups.value.filter((group) =>
-    group.name.toLowerCase().includes(search),
-  );
-});
-
-const filteredUnits = computed(() => {
-  const search = unitSearch.value.toLowerCase();
-
-  if (!search) return units.value;
-
-  return units.value.filter((unit) =>
-    formatUnitName(unit).toLowerCase().includes(search),
-  );
-});
-
-const lockedUnitIds = computed(() => {
-  const ids = new Set();
-
-  groups.value.forEach((group) => {
-    if (group.visible && group.unit_ids) {
-      group.unit_ids.forEach((id) => ids.add(id));
-    }
-  });
-
-  return ids;
-});
 
 const productOptions = computed(() =>
   products.value.map((product) => ({
@@ -644,9 +351,9 @@ const productOptions = computed(() =>
 );
 
 const tabs = [
-  { label: "Informações", name: "informacoes" },
-  { label: "Grupos", name: "grupos" },
-  { label: "Unidades", name: "unidades" },
+  { label: "Dados", name: "dados" },
+  { label: "Pavão", name: "pavao" },
+  { label: "Irrecusável", name: "irrecusavel" },
 ];
 
 const addMaterial = () => {
@@ -671,99 +378,97 @@ const removeMaterial = (index) => {
   form.materials.splice(index, 1);
 };
 
-const setAllGroupsVisible = (visible) => {
-  groups.value.forEach((group) => {
-    group.visible = visible;
-  });
-};
-
-const setAllVisible = (visible) => {
-  units.value.forEach((unit) => {
-    if (!visible && lockedUnitIds.value.has(unit.id)) return;
-
-    unit.visible = visible;
-  });
-};
+const mapPricingBlockFromApi = (data, existingMaterials) => ({
+  classes_discount_percentage: data.classes_discount_percentage,
+  classes_included_in_course: data.classes_included_in_course,
+  classes_installments_allowed: data.classes_installments_allowed,
+  classes_max_installments: data.classes_max_installments,
+  classes_value: data.classes_value,
+
+  materials: data.materials?.length
+    ? data.materials.map((material) => ({
+        price: material.price,
+        product_id: material.product_id,
+        quantity: material.quantity,
+      }))
+    : existingMaterials,
+  materials_included_in_course: data.materials_included_in_course,
+  materials_installments_allowed: data.materials_installments_allowed,
+  materials_max_installments: data.materials_max_installments,
+
+  registration_included_in_course: data.registration_included_in_course,
+  registration_installments_allowed: data.registration_installments_allowed,
+  registration_max_installments: data.registration_max_installments,
+  registration_value: data.registration_value,
+
+  total_max_installments: data.total_max_installments,
+  total_value: data.total_value,
+
+  ...(data.condition !== undefined ? { condition: data.condition } : {}),
+});
 
-const loadData = async () => {
-  loadingGroups.value = true;
-  loadingUnits.value = true;
+const numOrNull = (value) =>
+  value === null || value === "" || value === undefined ? null : Number(value);
 
-  try {
-    const [allUnits, allProducts, allGroups] = await Promise.all([
-      getUnitsForSelect(),
-      getProductsForSelect(),
-      getGroupsForSelect(),
-    ]);
+const toPricingPayload = (block, { withCondition = false } = {}) => {
+  const payload = {
+    classes_discount_percentage: numOrNull(block.classes_discount_percentage),
+    classes_included_in_course: block.classes_included_in_course,
+    classes_installments_allowed: block.classes_installments_allowed,
+    classes_max_installments: Number(block.classes_max_installments) || 13,
+    classes_value: numOrNull(block.classes_value),
+
+    materials: block.materials
+      .filter((material) => material.product_id)
+      .map((material) => ({
+        price: Number(material.price),
+        product_id: material.product_id,
+        quantity: Number(material.quantity),
+      })),
+    materials_included_in_course: block.materials_included_in_course,
+    materials_installments_allowed: block.materials_installments_allowed,
+    materials_max_installments: Number(block.materials_max_installments) || 13,
+
+    registration_included_in_course: block.registration_included_in_course,
+    registration_installments_allowed: block.registration_installments_allowed,
+    registration_max_installments: Number(block.registration_max_installments) || 13,
+    registration_value: numOrNull(block.registration_value),
+
+    total_max_installments: Number(block.total_max_installments) || 13,
+    total_value: numOrNull(block.total_value),
+  };
 
-    products.value = allProducts;
+  if (withCondition) {
+    payload.condition = block.condition ?? null;
+  }
 
-    const visibilityMap = {};
+  return payload;
+};
 
-    let savedMaterials = null;
-    let savedGroupIds = [];
+const loadData = async () => {
+  products.value = await getProductsForSelect();
 
-    if (props.package?.id) {
-      const packageData = await getPackage(props.package.id);
-
-      (packageData.unit_visibilities ?? []).forEach((visibility) => {
-        visibilityMap[visibility.unit_id] = visibility.visible;
-      });
-
-      if (packageData.materials?.length) {
-        savedMaterials = packageData.materials.map((material) => ({
-          price: material.price,
-          product_id: material.product_id,
-          quantity: material.quantity,
-        }));
-      }
+  if (props.package?.id) {
+    const packageData = await getPackage(props.package.id);
 
-      savedGroupIds = packageData.group_ids ?? [];
+    if (packageData.materials?.length) {
+      form.materials = packageData.materials.map((material) => ({
+        price: material.price,
+        product_id: material.product_id,
+        quantity: material.quantity,
+      }));
     }
 
-    groups.value = allGroups.map((group) => ({
-      id: group.id,
-      name: group.name,
-      unit_ids: group.unit_ids ?? [],
-      visible: savedGroupIds.includes(group.id),
-    }));
-
-    const groupLockedIds = new Set();
-
-    groups.value.forEach((group) => {
-      if (group.visible) {
-        group.unit_ids.forEach((id) => groupLockedIds.add(id));
-      }
-    });
-
-    units.value = allUnits.map((unit) => ({
-      fantasy_name: unit.fantasy_name,
-      id: unit.id,
-      name: unit.name,
-      social_reason: unit.social_reason,
-      visible: groupLockedIds.has(unit.id)
-        ? true
-        : (visibilityMap[unit.id] ?? true),
-    }));
-
-    if (savedMaterials) {
-      form.materials = savedMaterials;
+    if (packageData.pavao) {
+      form.pavao = mapPricingBlockFromApi(packageData.pavao, form.pavao.materials);
     }
 
-    originalGroupIds.value = groups.value
-      .filter((group) => group.visible)
-      .map((group) => group.id);
-
-    originalUnitVisibilities.value = units.value.map((unit) => ({
-      unit_id: unit.id,
-      visible: unit.visible,
-    }));
-
-    setUpdateFormAsOriginal();
-  } finally {
-    loadingGroups.value = false;
-    loadingUnits.value = false;
+    if (packageData.irrecusavel) {
+      form.irrecusavel = mapPricingBlockFromApi(packageData.irrecusavel, form.irrecusavel.materials);
+    }
   }
+
+  setUpdateFormAsOriginal();
 };
 
 const onOKClick = async () => {
@@ -780,9 +485,7 @@ const onOKClick = async () => {
     contract_value: form.contract_value,
     contrat_discount_value: form.contrat_discount_value,
 
-    group_ids: groups.value
-      .filter((group) => group.visible)
-      .map((group) => group.id),
+    irrecusavel: toPricingPayload(form.irrecusavel, { withCondition: true }),
 
     materials: validMaterials.map((material) => ({
       price: Number(material.price),
@@ -791,12 +494,8 @@ const onOKClick = async () => {
     })),
 
     name: form.name,
+    pavao: toPricingPayload(form.pavao),
     quantity_classes: form.quantity_classes,
-
-    unit_visibilities: units.value.map((unit) => ({
-      unit_id: unit.id,
-      visible: unit.visible,
-    })),
   };
 
   await execute(() => {
@@ -806,32 +505,15 @@ const onOKClick = async () => {
       };
 
       if (updated.class_duration_hours !== undefined) {
-        updated.class_duration_minutes = Math.round(
-          Number(updated.class_duration_hours) * 60,
-        );
+        updated.class_duration_minutes = payload.class_duration_minutes;
 
         delete updated.class_duration_hours;
       }
 
-      const groupIds = groups.value
-        .filter((group) => group.visible)
-        .map((group) => group.id);
-
-      const unitVisibilities = units.value.map((unit) => ({
-        unit_id: unit.id,
-        visible: unit.visible,
-      }));
-
-      if (JSON.stringify(groupIds) !== JSON.stringify(originalGroupIds.value)) {
-        updated.group_ids = groupIds;
-      }
-
-      if (
-        JSON.stringify(unitVisibilities) !==
-        JSON.stringify(originalUnitVisibilities.value)
-      ) {
-        updated.unit_visibilities = unitVisibilities;
-      }
+      // Pavão/Irrecusável têm validação cruzada entre campos (par exclusivo
+      // de checkbox) que não é seguro fatiar num diff parcial — vão inteiros.
+      updated.pavao = payload.pavao;
+      updated.irrecusavel = payload.irrecusavel;
 
       return updatePackage(props.package.id, updated);
     }
@@ -840,18 +522,6 @@ const onOKClick = async () => {
   });
 };
 
-watch(
-  lockedUnitIds,
-  (newLockedIds) => {
-    units.value.forEach((unit) => {
-      if (newLockedIds.has(unit.id)) {
-        unit.visible = true;
-      }
-    });
-  },
-  { deep: true },
-);
-
 onMounted(loadData);
 </script>
 

+ 44 - 0
src/pages/packages/components/ExclusiveCheckboxPair.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="row q-col-gutter-md">
+    <div class="col-6">
+      <q-checkbox
+        color="primary"
+        label="Incluso no valor do curso"
+        :model-value="included"
+        @update:model-value="onToggle('included', $event)"
+      />
+    </div>
+
+    <div class="col-6">
+      <q-checkbox
+        color="primary"
+        label="Permite Parcelar"
+        :model-value="installments"
+        @update:model-value="onToggle('installments', $event)"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  included: {
+    default: false,
+    type: Boolean,
+  },
+  installments: {
+    default: false,
+    type: Boolean,
+  },
+});
+
+const emit = defineEmits(["update:included", "update:installments"]);
+
+// Comportam-se como um par exclusivo (um dos dois é sempre obrigatório):
+// marcar um desmarca o outro; desmarcar direto é ignorado.
+const onToggle = (which, value) => {
+  if (!value) return;
+
+  emit(which === "included" ? "update:included" : "update:installments", true);
+};
+</script>

+ 338 - 0
src/pages/packages/components/ProposalPricingTab.vue

@@ -0,0 +1,338 @@
+<template>
+  <div class="row q-col-gutter-md">
+    <!-- Matrícula -->
+    <DefaultCurrencyInput
+      v-model="modelValue.registration_value"
+      class="col-12"
+      label="Valor da Matrícula"
+    />
+
+    <ExclusiveCheckboxPair
+      class="col-12"
+      :included="modelValue.registration_included_in_course"
+      :installments="modelValue.registration_installments_allowed"
+      @update:included="setExclusive('registration', 'included')"
+      @update:installments="setExclusive('registration', 'installments')"
+    />
+
+    <template v-if="modelValue.registration_installments_allowed">
+      <DefaultInput
+        v-model="modelValue.registration_max_installments"
+        class="col-6"
+        label="Quantidade de Parcelas Máximas"
+        min="1"
+        type="number"
+      />
+
+      <div class="col-6 installment-hint">
+        Valor da Parcela: {{ formatInstallment(registrationInstallmentValue) }}
+      </div>
+    </template>
+
+    <q-separator class="col-12" />
+
+    <!-- Aulas -->
+    <DefaultCurrencyInput
+      v-model="modelValue.classes_value"
+      class="col-12"
+      label="Valor de Aulas"
+    />
+
+    <ExclusiveCheckboxPair
+      class="col-12"
+      :included="modelValue.classes_included_in_course"
+      :installments="modelValue.classes_installments_allowed"
+      @update:included="setExclusive('classes', 'included')"
+      @update:installments="setExclusive('classes', 'installments')"
+    />
+
+    <template v-if="modelValue.classes_installments_allowed">
+      <DefaultInput
+        v-model="modelValue.classes_max_installments"
+        class="col-4"
+        label="Quantidade de Parcelas Máximas"
+        min="1"
+        type="number"
+      />
+
+      <div class="col-4 installment-hint">
+        Valor da Parcela: {{ formatInstallment(classesInstallmentValue) }}
+      </div>
+
+      <DefaultInput
+        v-model="modelValue.classes_discount_percentage"
+        class="col-4"
+        label="Desconto (%)"
+        max="100"
+        min="0"
+        type="number"
+      />
+    </template>
+
+    <q-separator class="col-12" />
+
+    <!-- Materiais -->
+    <div class="col-12 text-weight-medium">Materiais</div>
+
+    <div
+      v-for="(material, index) in modelValue.materials"
+      :key="index"
+      class="col-12"
+    >
+      <div class="row q-col-gutter-sm items-center">
+        <DefaultSelect
+          v-model="material.product_id"
+          class="col"
+          emit-value
+          label="Material"
+          map-options
+          :options="productOptions"
+          @update:model-value="onProductSelected(material)"
+        />
+
+        <DefaultInput
+          v-model="material.quantity"
+          class="col-2"
+          label="Qtd"
+          min="1"
+          type="number"
+        />
+
+        <DefaultCurrencyInput
+          v-model="material.price"
+          class="col-3"
+          label="R$ Unitário"
+        />
+
+        <div class="col-auto">
+          <q-btn
+            v-if="index === modelValue.materials.length - 1"
+            color="primary"
+            icon="mdi-plus"
+            style="border-radius: 8px; height: 40px; width: 40px"
+            unelevated
+            @click="addMaterial"
+          />
+
+          <q-btn
+            v-else
+            color="negative"
+            dense
+            flat
+            icon="mdi-delete-outline"
+            round
+            @click="removeMaterial(index)"
+          />
+        </div>
+      </div>
+    </div>
+
+    <ExclusiveCheckboxPair
+      class="col-12"
+      :included="modelValue.materials_included_in_course"
+      :installments="modelValue.materials_installments_allowed"
+      @update:included="setExclusive('materials', 'included')"
+      @update:installments="setExclusive('materials', 'installments')"
+    />
+
+    <template v-if="modelValue.materials_installments_allowed">
+      <DefaultInput
+        v-model="modelValue.materials_max_installments"
+        class="col-6"
+        label="Quantidade de Parcelas Máximas"
+        min="1"
+        type="number"
+      />
+
+      <div class="col-6 installment-hint">
+        Valor da Parcela: {{ formatInstallment(materialsInstallmentValue) }}
+      </div>
+    </template>
+
+    <q-separator class="col-12" />
+
+    <!-- Valor Total do Curso -->
+    <div class="col-12 text-weight-medium">Valor Total do Curso</div>
+
+    <DefaultCurrencyInput
+      v-model="modelValue.total_value"
+      class="col-12"
+      label="Valor Total do Curso"
+    />
+
+    <DefaultInput
+      v-model="modelValue.total_max_installments"
+      class="col-6"
+      label="Quantidade de Parcelas Máximas"
+      min="1"
+      type="number"
+      :rules="[inputRules.required, inputRules.minValue(1)]"
+    />
+
+    <div class="col-6 installment-hint">
+      Valor da Parcela: {{ formatInstallment(totalInstallmentValue) }}
+    </div>
+
+    <!-- Condição (só Irrecusável) -->
+    <template v-if="showCondition">
+      <q-separator class="col-12" />
+
+      <DefaultInput
+        v-model="modelValue.condition"
+        autogrow
+        class="col-12"
+        label="Condição"
+        type="textarea"
+      />
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { computed, watch } from "vue";
+
+import { formatToBRLCurrency } from "src/helpers/utils";
+import { useInputRules } from "src/composables/useInputRules";
+
+import ExclusiveCheckboxPair from "./ExclusiveCheckboxPair.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { inputRules } = useInputRules();
+
+const modelValue = defineModel({ type: Object, required: true });
+
+const props = defineProps({
+  productOptions: {
+    default: () => [],
+    type: Array,
+  },
+  showCondition: {
+    default: false,
+    type: Boolean,
+  },
+});
+
+const materialsTotalValue = computed(() =>
+  modelValue.value.materials.reduce(
+    (sum, material) =>
+      sum + (Number(material.quantity) || 0) * (Number(material.price) || 0),
+    0,
+  ),
+);
+
+const registrationInstallmentValue = computed(() =>
+  installmentValue(
+    modelValue.value.registration_value,
+    modelValue.value.registration_max_installments,
+  ),
+);
+
+const classesInstallmentValue = computed(() =>
+  installmentValue(
+    modelValue.value.classes_value,
+    modelValue.value.classes_max_installments,
+  ),
+);
+
+const materialsInstallmentValue = computed(() =>
+  installmentValue(
+    materialsTotalValue.value,
+    modelValue.value.materials_max_installments,
+  ),
+);
+
+const totalInstallmentValue = computed(() =>
+  installmentValue(
+    modelValue.value.total_value,
+    modelValue.value.total_max_installments,
+  ),
+);
+
+// Soma sugerida do Valor Total, considerando só as seções marcadas como
+// "incluso no valor do curso". O campo continua editável: só reaplicamos a
+// sugestão enquanto o usuário não tiver digitado um valor diferente dela.
+let lastSuggestedTotal = null;
+
+const suggestedTotal = computed(() => {
+  let total = 0;
+
+  if (modelValue.value.registration_included_in_course) {
+    total += Number(modelValue.value.registration_value) || 0;
+  }
+
+  if (modelValue.value.classes_included_in_course) {
+    total += Number(modelValue.value.classes_value) || 0;
+  }
+
+  if (modelValue.value.materials_included_in_course) {
+    total += materialsTotalValue.value;
+  }
+
+  return total;
+});
+
+watch(
+  suggestedTotal,
+  (newTotal) => {
+    if (
+      modelValue.value.total_value === null ||
+      modelValue.value.total_value === lastSuggestedTotal
+    ) {
+      modelValue.value.total_value = newTotal;
+    }
+
+    lastSuggestedTotal = newTotal;
+  },
+  { immediate: true },
+);
+
+const setExclusive = (section, option) => {
+  const includedKey = `${section}_included_in_course`;
+  const installmentsKey = `${section}_installments_allowed`;
+
+  modelValue.value[includedKey] = option === "included";
+  modelValue.value[installmentsKey] = option === "installments";
+};
+
+const addMaterial = () => {
+  modelValue.value.materials.push({
+    price: null,
+    product_id: null,
+    quantity: 1,
+  });
+};
+
+const removeMaterial = (index) => {
+  modelValue.value.materials.splice(index, 1);
+};
+
+const onProductSelected = (material) => {
+  const option = props.productOptions.find(
+    (item) => item.value === material.product_id,
+  );
+
+  if (option) {
+    material.price = option.price_sale;
+  }
+};
+
+const formatInstallment = (value) =>
+  value === null ? "—" : formatToBRLCurrency(value);
+
+function installmentValue(value, maxInstallments) {
+  if (!value || !maxInstallments || maxInstallments <= 0) return null;
+
+  return Number(value) / Number(maxInstallments);
+}
+</script>
+
+<style scoped>
+.installment-hint {
+  display: flex;
+  align-items: center;
+  color: #757575;
+  font-size: 13px;
+}
+</style>

+ 1 - 1
src/pages/permissions/permission_menus.js

@@ -49,7 +49,7 @@ export const FRANCHISOR_PERMISSION_MENUS = Object.freeze([
   },
   {
     scope: "franchisor_packages",
-    label: "Pacotes",
+    label: "Proposta Comercial",
     icon: "mdi-package-variant-closed",
   },
   { scope: "franchisor_products", label: "Produtos", icon: "mdi-cart-outline" },

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

@@ -5,7 +5,7 @@ export default [
     component: () => import("pages/packages/PackagesPage.vue"),
     meta: {
       title: {
-        value: "Pacotes",
+        value: "Proposta Comercial",
         translate: false,
       },
       requireAuth: true,
@@ -17,7 +17,7 @@ export default [
         },
         {
           name: "PackagesPage",
-          title: "Pacotes",
+          title: "Proposta Comercial",
         },
       ],
     },

+ 1 - 1
src/stores/navigation.js

@@ -25,7 +25,7 @@ export const navigationStore = defineStore("navigation", () => {
     },
     {
       type: "single",
-      title: "Pacotes",
+      title: "Proposta Comercial",
       name: "PackagesPage",
       icon: "mdi-package-variant-closed",
       disable: false,