فهرست منبع

feat: add class_package_unit_variation

Gustavo Mantovani 21 ساعت پیش
والد
کامیت
50f901e727
1فایلهای تغییر یافته به همراه505 افزوده شده و 78 حذف شده
  1. 505 78
      src/pages/packages/components/AddEditPackageDialog.vue

+ 505 - 78
src/pages/packages/components/AddEditPackageDialog.vue

@@ -10,6 +10,12 @@
           @close="onDialogCancel"
         />
 
+        <CustomTabComponent
+          v-model:active-tab="activeTab"
+          class="q-mx-md q-mb-sm"
+          :tabs="tabs"
+        />
+
         <DefaultForm
           ref="formRef"
           @submit="onOKClick"
@@ -18,7 +24,13 @@
             ref="scrollAreaRef"
             class="dialog-form-scroll"
           >
-            <q-card-section class="q-pt-sm">
+            <!-- Tab: Pacote (class_package_units) -->
+
+            <q-card-section
+              v-show="activeTab === 'pacote'"
+              ref="pacotePanelRef"
+              class="q-pt-sm"
+            >
               <div class="row q-col-gutter-sm">
                 <DefaultInput
                   v-model="form.name"
@@ -58,29 +70,6 @@
                   necessário. É possível configurar no máximo dois dias.
                 </div>
 
-                <DefaultCurrencyInput
-                  v-model="form.contract_register_value"
-                  v-model:error="validationErrors.contract_register_value"
-                  class="col-4"
-                  label="R$ Matrícula"
-                />
-
-                <DefaultCurrencyInput
-                  v-model="form.contract_value"
-                  v-model:error="validationErrors.contract_value"
-                  class="col-4"
-                  label="R$ Total do Contrato"
-                />
-
-                <DefaultInput
-                  v-model="form.contrat_discount_value"
-                  class="col-4"
-                  label="Desconto em %"
-                  max="100"
-                  min="0"
-                  type="number"
-                />
-
                 <div
                   v-for="(material, index) in form.materials"
                   :key="index"
@@ -157,6 +146,227 @@
                 </div>
               </div>
             </q-card-section>
+
+            <!-- Tabs: Propostas (class_package_unit_variations) -->
+
+            <q-card-section
+              v-for="(variation, index) in form.variations"
+              v-show="activeTab === variation.type"
+              :key="variation.type"
+              :ref="(el) => setVariationPanelRef(variation.type, el)"
+              class="q-pt-sm"
+            >
+              <div class="row q-col-gutter-sm">
+                <div class="col-12 variation-section-title">
+                  Matrícula
+                </div>
+
+                <DefaultCurrencyInput
+                  v-model="variation.register_value"
+                  v-model:error="
+                    validationErrors[`variations.${index}.register_value`]
+                  "
+                  class="col-4"
+                  label="R$ Matrícula"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultInput
+                  v-model="variation.register_installments_min"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.register_installments_min`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Mín."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                  ]"
+                />
+
+                <DefaultInput
+                  v-model="variation.register_installments_max"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.register_installments_max`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Máx."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                    gteInstallmentsMin(
+                      () => variation.register_installments_min,
+                    ),
+                    ...(variation.type === 'irrecusavel'
+                      ? [
+                          ltePavaoInstallmentsMax(
+                            () =>
+                              form.variations[0]
+                                .register_installments_max,
+                          ),
+                        ]
+                      : []),
+                  ]"
+                />
+
+                <div class="col-12 variation-section-title">
+                  Pacote
+                </div>
+
+                <DefaultCurrencyInput
+                  v-model="variation.package_value"
+                  v-model:error="
+                    validationErrors[`variations.${index}.package_value`]
+                  "
+                  class="col-4"
+                  label="R$ Pacote"
+                  :rules="[
+                    inputRules.required,
+                    ...(variation.type === 'irrecusavel'
+                      ? [irrecusavelTotalMenorQuePavao]
+                      : []),
+                  ]"
+                />
+
+                <DefaultInput
+                  v-model="variation.package_installments_min"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.package_installments_min`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Mín."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                  ]"
+                />
+
+                <DefaultInput
+                  v-model="variation.package_installments_max"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.package_installments_max`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Máx."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                    gteInstallmentsMin(
+                      () => variation.package_installments_min,
+                    ),
+                    ...(variation.type === 'irrecusavel'
+                      ? [
+                          ltePavaoInstallmentsMax(
+                            () =>
+                              form.variations[0]
+                                .package_installments_max,
+                          ),
+                        ]
+                      : []),
+                  ]"
+                />
+
+                <DefaultInput
+                  v-model="variation.discount_value"
+                  v-model:error="
+                    validationErrors[`variations.${index}.discount_value`]
+                  "
+                  class="col-4"
+                  label="Desconto em %"
+                  max="100"
+                  min="0"
+                  type="number"
+                  :rules="[
+                    inputRules.minValue(0),
+                    inputRules.maxValue(100),
+                  ]"
+                />
+
+                <div class="col-12 variation-section-title">
+                  Material
+                </div>
+
+                <DefaultInput
+                  class="col-4"
+                  label="R$ Material"
+                  :model-value="formatToBRLCurrency(materialsTotal)"
+                  readonly
+                />
+
+                <DefaultInput
+                  v-model="variation.material_installments_min"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.material_installments_min`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Mín."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                  ]"
+                />
+
+                <DefaultInput
+                  v-model="variation.material_installments_max"
+                  v-model:error="
+                    validationErrors[
+                      `variations.${index}.material_installments_max`
+                    ]
+                  "
+                  class="col-4"
+                  label="Parcelas Máx."
+                  max="60"
+                  min="1"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.integerRange(1, 60),
+                    gteInstallmentsMin(
+                      () => variation.material_installments_min,
+                    ),
+                    ...(variation.type === 'irrecusavel'
+                      ? [
+                          ltePavaoInstallmentsMax(
+                            () =>
+                              form.variations[0]
+                                .material_installments_max,
+                          ),
+                        ]
+                      : []),
+                  ]"
+                />
+
+                <div class="col-12 package-duration-hint">
+                  O valor do material é a soma dos materiais do pacote e não
+                  pode ser alterado na proposta.
+                </div>
+              </div>
+            </q-card-section>
           </q-scroll-area>
 
           <q-card-actions
@@ -172,11 +382,38 @@
             />
 
             <q-btn
+              v-if="!props.package && activeTab !== tabOrder[0]"
+              color="primary"
+              label="Voltar"
+              no-caps
+              outline
+              @click="goToTab(-1)"
+            />
+
+            <q-btn
+              v-if="
+                !props.package &&
+                activeTab !== tabOrder[tabOrder.length - 1]
+              "
+              color="primary"
+              label="Avançar"
+              no-caps
+              :disable="
+                tabs[tabOrder.indexOf(activeTab) + 1]?.disable
+              "
+              @click="goToTab(1)"
+            />
+
+            <q-btn
+              v-show="
+                !!props.package ||
+                activeTab === tabOrder[tabOrder.length - 1]
+              "
               color="primary"
               label="Salvar"
               no-caps
-              type="submit"
               :loading="loading"
+              @click="onOKClick"
             />
           </q-card-actions>
         </DefaultForm>
@@ -188,6 +425,7 @@
 <script setup>
 import {
   computed,
+  nextTick,
   onMounted,
   ref,
   useTemplateRef,
@@ -205,7 +443,9 @@ import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
 import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { formatToBRLCurrency } from "src/helpers/utils";
 
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
 import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultForm from "src/components/defaults/DefaultForm.vue";
@@ -229,28 +469,25 @@ const { scrollToComponent } = useScroll();
 
 const formRef = useTemplateRef("formRef");
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
-
-const products = ref([]);
-
-const productOptions = computed(() =>
-  products.value.map((product) => ({
-    label: product.name,
-    price_sale: product.price_sale,
-    value: product.id,
-  })),
-);
+const pacotePanelRef = useTemplateRef("pacotePanelRef");
+
+const defaultVariation = (type) => ({
+  type,
+  register_value: null,
+  register_installments_min: 1,
+  register_installments_max: 1,
+  package_value: null,
+  package_installments_min: 1,
+  package_installments_max: 1,
+  discount_value: null,
+  material_installments_min: 1,
+  material_installments_max: 1,
+});
 
 const { form, getUpdatedFields } = useForm({
-  class_duration_hours:
+  class_duration_hours: String(
     (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,
+  ),
 
   materials: [
     {
@@ -261,9 +498,34 @@ const { form, getUpdatedFields } = useForm({
   ],
 
   name: props.package?.name ?? null,
-  quantity_classes: props.package?.quantity_classes ?? null,
+  quantity_classes:
+    props.package?.quantity_classes != null
+      ? String(props.package.quantity_classes)
+      : null,
+
+  variations: [
+    defaultVariation("pavao"),
+    defaultVariation("irrecusavel"),
+  ],
 });
 
+// scrollFn customizado: antes de rolar ate o campo com erro,
+// descobre em qual tab ele esta e troca para ela
+
+const tabScrollFn = async (errorElement, containerRef, options) => {
+  const tab = tabOrder.find((name) =>
+    getPanelElement(name)?.contains(errorElement),
+  );
+
+  if (tab && tab !== activeTab.value) {
+    activeTab.value = tab;
+
+    await nextTick();
+  }
+
+  return scrollToComponent(errorElement, containerRef, options);
+};
+
 const {
   loading,
   validationErrors,
@@ -274,9 +536,66 @@ const {
   onSuccess: (result) => {
     onDialogOK(result);
   },
-  scrollFn: scrollToComponent,
+  scrollFn: tabScrollFn,
 });
 
+const activeTab = ref("pacote");
+const products = ref([]);
+const variationPanelRefs = ref({});
+
+const tabOrder = ["pacote", "pavao", "irrecusavel"];
+
+const materialsTotal = computed(() =>
+  form.materials.reduce(
+    (total, material) =>
+      material.product_id
+        ? total +
+          Number(material.quantity || 0) * Number(material.price || 0)
+        : total,
+    0,
+  ),
+);
+
+// uma tab so fica disponivel quando tudo da tab anterior esta preenchido
+
+const hasValue = (value) =>
+  value !== null && value !== undefined && value !== "";
+
+const pacoteFilled = computed(
+  () =>
+    hasValue(form.name) &&
+    hasValue(form.quantity_classes) &&
+    hasValue(form.class_duration_hours),
+);
+
+const productOptions = computed(() =>
+  products.value.map((product) => ({
+    label: product.name,
+    price_sale: product.price_sale,
+    value: product.id,
+  })),
+);
+
+const variationFilled = (variation) =>
+  hasValue(variation.register_value) &&
+  hasValue(variation.package_value);
+
+const tabs = computed(() => [
+  { name: "pacote", label: "Pacote" },
+  {
+    name: "pavao",
+    label: "Proposta Pavão",
+    disable: !pacoteFilled.value,
+  },
+  {
+    name: "irrecusavel",
+    label: "Proposta Irrecusável",
+    disable:
+      !pacoteFilled.value ||
+      !variationFilled(form.variations[0]),
+  },
+]);
+
 const addMaterial = () => {
   form.materials.push({
     price: null,
@@ -285,6 +604,92 @@ const addMaterial = () => {
   });
 };
 
+const buildVariationPayload = (variation) => ({
+  type: variation.type,
+  register_value: variation.register_value ?? 0,
+  register_installments_min: Number(
+    variation.register_installments_min,
+  ),
+  register_installments_max: Number(
+    variation.register_installments_max,
+  ),
+  package_value: variation.package_value ?? 0,
+  package_installments_min: Number(
+    variation.package_installments_min,
+  ),
+  package_installments_max: Number(
+    variation.package_installments_max,
+  ),
+  discount_value:
+    variation.discount_value === null ||
+    variation.discount_value === ""
+      ? null
+      : Number(variation.discount_value),
+  material_installments_min: Number(
+    variation.material_installments_min,
+  ),
+  material_installments_max: Number(
+    variation.material_installments_max,
+  ),
+});
+
+const irrecusavelTotalMenorQuePavao = () =>
+  variationTotal(form.variations[1]) <
+    variationTotal(form.variations[0]) ||
+  "O total da proposta irrecusável deve ser menor que o total da proposta pavão";
+
+const ltePavaoInstallmentsMax = (getPavaoMax) => (value) =>
+  Number(value) <= Number(getPavaoMax()) ||
+  "Não pode ser maior que o máximo de parcelas da proposta pavão";
+
+const getPanelElement = (tab) => {
+  const panel =
+    tab === "pacote"
+      ? pacotePanelRef.value
+      : variationPanelRefs.value[tab];
+
+  return panel?.$el ?? panel ?? null;
+};
+
+const goToTab = (direction) => {
+  const index = tabOrder.indexOf(activeTab.value);
+
+  activeTab.value = tabOrder[index + direction];
+};
+
+const gteInstallmentsMin = (getMin) => (value) =>
+  Number(value) >= Number(getMin()) ||
+  "Deve ser maior ou igual ao mínimo de parcelas";
+
+const setVariationPanelRef = (type, el) => {
+  if (el) {
+    variationPanelRefs.value[type] = el;
+  }
+};
+
+const variationTotal = (variation) =>
+  Number(variation.register_value || 0) +
+  Number(variation.package_value || 0) +
+  materialsTotal.value;
+
+//
+
+const onProductSelected = (material) => {
+  const option = productOptions.value.find(
+    (product) => product.value === material.product_id,
+  );
+
+  if (option) {
+    material.price = option.price_sale;
+  }
+};
+
+const removeMaterial = (index) => {
+  form.materials.splice(index, 1);
+};
+
+//
+
 const onOKClick = async () => {
   const validMaterials = form.materials.filter(
     (material) => material.product_id,
@@ -295,14 +700,6 @@ const onOKClick = async () => {
       Number(form.class_duration_hours) * 60,
     ),
 
-    contract_register_value:
-      form.contract_register_value,
-
-    contract_value: form.contract_value,
-
-    contrat_discount_value:
-      form.contrat_discount_value,
-
     materials: validMaterials.map((material) => ({
       price: Number(material.price),
       product_id: material.product_id,
@@ -311,6 +708,8 @@ const onOKClick = async () => {
 
     name: form.name,
     quantity_classes: form.quantity_classes,
+
+    variations: form.variations.map(buildVariationPayload),
   };
 
   await execute(() => {
@@ -321,13 +720,7 @@ const onOKClick = async () => {
     const changedFields = getUpdatedFields.value;
     const updatePayload = {};
 
-    const directKeys = [
-      "name",
-      "quantity_classes",
-      "contract_value",
-      "contract_register_value",
-      "contrat_discount_value",
-    ];
+    const directKeys = ["name", "quantity_classes"];
 
     for (const key of directKeys) {
       if (key in changedFields) {
@@ -344,6 +737,10 @@ const onOKClick = async () => {
       updatePayload.materials = payload.materials;
     }
 
+    if ("variations" in changedFields) {
+      updatePayload.variations = payload.variations;
+    }
+
     return updateUnitPackage(
       props.package.id,
       updatePayload,
@@ -351,20 +748,6 @@ const onOKClick = async () => {
   });
 };
 
-const onProductSelected = (material) => {
-  const option = productOptions.value.find(
-    (product) => product.value === material.product_id,
-  );
-
-  if (option) {
-    material.price = option.price_sale;
-  }
-};
-
-const removeMaterial = (index) => {
-  form.materials.splice(index, 1);
-};
-
 onMounted(async () => {
   const productsResponse = await getProductsForSelect();
 
@@ -376,18 +759,54 @@ onMounted(async () => {
     props.package.id,
   );
 
-  form.class_duration_hours =
-    (packageData.class_duration_minutes ?? 120) / 60;
+  form.class_duration_hours = String(
+    (packageData.class_duration_minutes ?? 120) / 60,
+  );
 
   if (packageData.materials?.length) {
     form.materials = packageData.materials.map(
       (material) => ({
         price: material.price,
         product_id: material.product_id,
-        quantity: material.quantity,
+        quantity: String(material.quantity),
       }),
     );
   }
+
+  for (const variation of form.variations) {
+    const existing = packageData.variations?.find(
+      (item) => item.type === variation.type,
+    );
+
+    if (existing) {
+      Object.assign(variation, {
+        register_value: existing.register_value,
+        register_installments_min: String(
+          existing.register_installments_min,
+        ),
+        register_installments_max: String(
+          existing.register_installments_max,
+        ),
+        package_value: existing.package_value,
+        package_installments_min: String(
+          existing.package_installments_min,
+        ),
+        package_installments_max: String(
+          existing.package_installments_max,
+        ),
+        discount_value:
+          existing.discount_value != null
+            ? String(existing.discount_value)
+            : null,
+        material_installments_min: String(
+          existing.material_installments_min,
+        ),
+        material_installments_max: String(
+          existing.material_installments_max,
+        ),
+      });
+    }
+  }
 });
 </script>
 
@@ -399,4 +818,12 @@ onMounted(async () => {
   font-size: 12px;
   line-height: 1.35;
 }
+
+.variation-section-title {
+  padding-top: 8px;
+  padding-bottom: 2px;
+  color: #424242;
+  font-size: 13px;
+  font-weight: 600;
+}
 </style>