AddEditPackageDialog.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. <template>
  2. <q-dialog ref="dialogRef" @hide="onDialogHide">
  3. <div style="width: 100%; max-width: 700px">
  4. <q-card class="overflow-hidden" style="width: 100%">
  5. <DefaultDialogHeader
  6. :title="props.package ? 'Editar Proposta Comercial' : 'Nova Proposta Comercial'"
  7. @close="onDialogCancel"
  8. />
  9. <DefaultForm ref="formRef" @submit="onOKClick">
  10. <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
  11. <q-card-section class="row q-col-gutter-md q-pt-none">
  12. <CustomTabComponent
  13. v-model:active-tab="currentTab"
  14. class="col-12 q-mb-md"
  15. :tabs="tabs"
  16. />
  17. <div
  18. v-show="currentTab === 'dados'"
  19. class="col-12"
  20. >
  21. <div class="row q-col-gutter-md">
  22. <DefaultInput
  23. v-model="form.name"
  24. v-model:error="validationErrors.name"
  25. class="col-12"
  26. label="Nome do Pacote"
  27. :error-message="validationErrors.name"
  28. :rules="[inputRules.required, inputRules.max(255)]"
  29. />
  30. <DefaultInput
  31. v-model="form.quantity_classes"
  32. v-model:error="validationErrors.quantity_classes"
  33. class="col-12"
  34. label="Quantidade de Aulas"
  35. min="1"
  36. type="number"
  37. :error-message="validationErrors.quantity_classes"
  38. :rules="[
  39. inputRules.requiredNumber,
  40. inputRules.integer,
  41. inputRules.minValue(1),
  42. ]"
  43. />
  44. <DefaultInput
  45. v-model="form.class_duration_hours"
  46. v-model:error="validationErrors.class_duration_minutes"
  47. class="col-12"
  48. label="Duração da Aula (horas)"
  49. max="24"
  50. step="0.5"
  51. type="number"
  52. :error-message="validationErrors.class_duration_minutes"
  53. :min="0.5"
  54. :rules="[
  55. inputRules.requiredNumber,
  56. inputRules.durationHours,
  57. ]"
  58. />
  59. <div class="col-12 package-duration-hint">
  60. A duração padrão das aulas é de 2 horas e pode ser alterada
  61. se necessário. É possível configurar no máximo dois dias.
  62. </div>
  63. <DefaultCurrencyInput
  64. v-model="form.contract_register_value"
  65. v-model:error="validationErrors.contract_register_value"
  66. class="col-4"
  67. label="R$ Matrícula"
  68. :error-message="validationErrors.contract_register_value"
  69. />
  70. <DefaultCurrencyInput
  71. v-model="form.contract_value"
  72. v-model:error="validationErrors.contract_value"
  73. class="col-4"
  74. label="R$ Total do Contrato"
  75. :error-message="validationErrors.contract_value"
  76. />
  77. <DefaultInput
  78. v-model="form.contrat_discount_value"
  79. v-model:error="validationErrors.contrat_discount_value"
  80. class="col-4"
  81. label="Desconto em %"
  82. min="0"
  83. type="number"
  84. :error-message="validationErrors.contrat_discount_value"
  85. :rules="[inputRules.minValue(0)]"
  86. />
  87. <div
  88. v-for="(material, index) in form.materials"
  89. :key="index"
  90. class="col-12"
  91. >
  92. <div class="row q-col-gutter-sm items-center">
  93. <DefaultSelect
  94. v-model="material.product_id"
  95. v-model:error="
  96. validationErrors[`materials.${index}.product_id`]
  97. "
  98. class="col"
  99. emit-value
  100. label="Material"
  101. map-options
  102. :error-message="
  103. validationErrors[`materials.${index}.product_id`]
  104. "
  105. :options="productOptions"
  106. :rules="[
  107. inputRules.whenValue(
  108. () => material.price,
  109. inputRules.required,
  110. ),
  111. ]"
  112. @update:model-value="onProductSelected(material)"
  113. />
  114. <DefaultInput
  115. v-model="material.quantity"
  116. v-model:error="
  117. validationErrors[`materials.${index}.quantity`]
  118. "
  119. class="col-2"
  120. label="Qtd"
  121. min="1"
  122. type="number"
  123. :error-message="
  124. validationErrors[`materials.${index}.quantity`]
  125. "
  126. :rules="[
  127. inputRules.whenValue(
  128. () => material.product_id,
  129. inputRules.requiredNumber,
  130. ),
  131. inputRules.whenValue(
  132. () => material.product_id,
  133. inputRules.integer,
  134. ),
  135. inputRules.whenValue(
  136. () => material.product_id,
  137. inputRules.minValue(1),
  138. ),
  139. ]"
  140. />
  141. <DefaultCurrencyInput
  142. v-model="material.price"
  143. v-model:error="
  144. validationErrors[`materials.${index}.price`]
  145. "
  146. class="col-3"
  147. label="R$ Unitário"
  148. :error-message="
  149. validationErrors[`materials.${index}.price`]
  150. "
  151. :rules="[
  152. inputRules.whenValue(
  153. () => material.product_id,
  154. inputRules.requiredNumber,
  155. ),
  156. ]"
  157. />
  158. <div class="col-auto">
  159. <q-btn
  160. v-if="index === form.materials.length - 1"
  161. color="primary"
  162. icon="mdi-plus"
  163. style="border-radius: 8px; height: 40px; width: 40px"
  164. unelevated
  165. @click="addMaterial"
  166. />
  167. <q-btn
  168. v-else
  169. color="negative"
  170. dense
  171. flat
  172. icon="mdi-delete-outline"
  173. round
  174. @click="removeMaterial(index)"
  175. />
  176. </div>
  177. </div>
  178. </div>
  179. </div>
  180. </div>
  181. <div
  182. v-show="currentTab === 'pavao'"
  183. class="col-12"
  184. >
  185. <ProposalPricingTab
  186. v-model="form.pavao"
  187. :product-options="productOptions"
  188. />
  189. </div>
  190. <div
  191. v-show="currentTab === 'irrecusavel'"
  192. class="col-12"
  193. >
  194. <ProposalPricingTab
  195. v-model="form.irrecusavel"
  196. :product-options="productOptions"
  197. show-condition
  198. />
  199. </div>
  200. </q-card-section>
  201. </q-scroll-area>
  202. <q-separator />
  203. <q-card-actions align="right" class="q-pa-md q-gutter-sm">
  204. <q-btn
  205. color="negative"
  206. label="Cancelar"
  207. no-caps
  208. outline
  209. @click="onDialogCancel"
  210. />
  211. <q-btn
  212. color="primary"
  213. label="Salvar"
  214. no-caps
  215. type="submit"
  216. unelevated
  217. :loading="loading"
  218. />
  219. </q-card-actions>
  220. </DefaultForm>
  221. </q-card>
  222. </div>
  223. </q-dialog>
  224. </template>
  225. <script setup>
  226. import { onMounted, computed, ref } from "vue";
  227. import {
  228. createPackage,
  229. getPackage,
  230. updatePackage,
  231. } from "src/api/package";
  232. import { getProductsForSelect } from "src/api/product";
  233. import { useDialogPluginComponent } from "quasar";
  234. import { useForm } from "src/composables/useForm";
  235. import { useInputRules } from "src/composables/useInputRules";
  236. import { useScroll } from "src/composables/useScroll";
  237. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  238. import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
  239. import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
  240. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  241. import DefaultForm from "src/components/defaults/DefaultForm.vue";
  242. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  243. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  244. import ProposalPricingTab from "./ProposalPricingTab.vue";
  245. defineEmits([...useDialogPluginComponent.emits]);
  246. const props = defineProps({
  247. package: {
  248. default: null,
  249. type: Object,
  250. },
  251. });
  252. const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
  253. useDialogPluginComponent();
  254. const { inputRules } = useInputRules();
  255. const { scrollToComponent } = useScroll();
  256. const formRef = ref(null);
  257. const scrollAreaRef = ref(null);
  258. const defaultPricingBlock = (withCondition = false) => ({
  259. classes_discount_percentage: null,
  260. classes_included_in_course: false,
  261. classes_installments_allowed: false,
  262. classes_max_installments: 13,
  263. classes_value: null,
  264. materials: [{ price: null, product_id: null, quantity: 1 }],
  265. materials_included_in_course: false,
  266. materials_installments_allowed: false,
  267. materials_max_installments: 13,
  268. registration_included_in_course: false,
  269. registration_installments_allowed: false,
  270. registration_max_installments: 13,
  271. registration_value: null,
  272. total_max_installments: 13,
  273. total_value: null,
  274. ...(withCondition ? { condition: null } : {}),
  275. });
  276. const { form, getUpdatedFields, setUpdateFormAsOriginal } = useForm(
  277. {
  278. class_duration_hours: (props.package?.class_duration_minutes ?? 120) / 60,
  279. contract_register_value: props.package?.contract_register_value ?? null,
  280. contract_value: props.package?.contract_value ?? null,
  281. contrat_discount_value: props.package?.contrat_discount_value ?? null,
  282. irrecusavel: defaultPricingBlock(true),
  283. materials: [
  284. {
  285. price: null,
  286. product_id: null,
  287. quantity: 1,
  288. },
  289. ],
  290. name: props.package?.name ?? null,
  291. pavao: defaultPricingBlock(),
  292. quantity_classes: props.package?.quantity_classes ?? null,
  293. },
  294. { containerRef: scrollAreaRef },
  295. );
  296. const {
  297. loading,
  298. validationErrors,
  299. execute,
  300. } = useSubmitHandler({
  301. containerRef: scrollAreaRef,
  302. formRef,
  303. onSuccess: (result) => onDialogOK(result),
  304. scrollFn: scrollToComponent,
  305. });
  306. const currentTab = ref("dados");
  307. const products = ref([]);
  308. const productOptions = computed(() =>
  309. products.value.map((product) => ({
  310. label: product.name,
  311. price_sale: product.price_sale,
  312. value: product.id,
  313. })),
  314. );
  315. const tabs = [
  316. { label: "Dados", name: "dados" },
  317. { label: "Pavão", name: "pavao" },
  318. { label: "Irrecusável", name: "irrecusavel" },
  319. ];
  320. const addMaterial = () => {
  321. form.materials.push({
  322. price: null,
  323. product_id: null,
  324. quantity: 1,
  325. });
  326. };
  327. const onProductSelected = (material) => {
  328. const option = productOptions.value.find(
  329. (item) => item.value === material.product_id,
  330. );
  331. if (option) {
  332. material.price = option.price_sale;
  333. }
  334. };
  335. const removeMaterial = (index) => {
  336. form.materials.splice(index, 1);
  337. };
  338. const mapPricingBlockFromApi = (data, existingMaterials) => ({
  339. classes_discount_percentage: data.classes_discount_percentage,
  340. classes_included_in_course: data.classes_included_in_course,
  341. classes_installments_allowed: data.classes_installments_allowed,
  342. classes_max_installments: data.classes_max_installments,
  343. classes_value: data.classes_value,
  344. materials: data.materials?.length
  345. ? data.materials.map((material) => ({
  346. price: material.price,
  347. product_id: material.product_id,
  348. quantity: material.quantity,
  349. }))
  350. : existingMaterials,
  351. materials_included_in_course: data.materials_included_in_course,
  352. materials_installments_allowed: data.materials_installments_allowed,
  353. materials_max_installments: data.materials_max_installments,
  354. registration_included_in_course: data.registration_included_in_course,
  355. registration_installments_allowed: data.registration_installments_allowed,
  356. registration_max_installments: data.registration_max_installments,
  357. registration_value: data.registration_value,
  358. total_max_installments: data.total_max_installments,
  359. total_value: data.total_value,
  360. ...(data.condition !== undefined ? { condition: data.condition } : {}),
  361. });
  362. const numOrNull = (value) =>
  363. value === null || value === "" || value === undefined ? null : Number(value);
  364. const toPricingPayload = (block, { withCondition = false } = {}) => {
  365. const payload = {
  366. classes_discount_percentage: numOrNull(block.classes_discount_percentage),
  367. classes_included_in_course: block.classes_included_in_course,
  368. classes_installments_allowed: block.classes_installments_allowed,
  369. classes_max_installments: Number(block.classes_max_installments) || 13,
  370. classes_value: numOrNull(block.classes_value),
  371. materials: block.materials
  372. .filter((material) => material.product_id)
  373. .map((material) => ({
  374. price: Number(material.price),
  375. product_id: material.product_id,
  376. quantity: Number(material.quantity),
  377. })),
  378. materials_included_in_course: block.materials_included_in_course,
  379. materials_installments_allowed: block.materials_installments_allowed,
  380. materials_max_installments: Number(block.materials_max_installments) || 13,
  381. registration_included_in_course: block.registration_included_in_course,
  382. registration_installments_allowed: block.registration_installments_allowed,
  383. registration_max_installments: Number(block.registration_max_installments) || 13,
  384. registration_value: numOrNull(block.registration_value),
  385. total_max_installments: Number(block.total_max_installments) || 13,
  386. total_value: numOrNull(block.total_value),
  387. };
  388. if (withCondition) {
  389. payload.condition = block.condition ?? null;
  390. }
  391. return payload;
  392. };
  393. const loadData = async () => {
  394. products.value = await getProductsForSelect();
  395. if (props.package?.id) {
  396. const packageData = await getPackage(props.package.id);
  397. if (packageData.materials?.length) {
  398. form.materials = packageData.materials.map((material) => ({
  399. price: material.price,
  400. product_id: material.product_id,
  401. quantity: material.quantity,
  402. }));
  403. }
  404. if (packageData.pavao) {
  405. form.pavao = mapPricingBlockFromApi(packageData.pavao, form.pavao.materials);
  406. }
  407. if (packageData.irrecusavel) {
  408. form.irrecusavel = mapPricingBlockFromApi(packageData.irrecusavel, form.irrecusavel.materials);
  409. }
  410. }
  411. setUpdateFormAsOriginal();
  412. };
  413. const onOKClick = async () => {
  414. const validMaterials = form.materials.filter(
  415. (material) => material.product_id,
  416. );
  417. const payload = {
  418. class_duration_minutes: Math.round(
  419. Number(form.class_duration_hours) * 60,
  420. ),
  421. contract_register_value: form.contract_register_value,
  422. contract_value: form.contract_value,
  423. contrat_discount_value: form.contrat_discount_value,
  424. irrecusavel: toPricingPayload(form.irrecusavel, { withCondition: true }),
  425. materials: validMaterials.map((material) => ({
  426. price: Number(material.price),
  427. product_id: material.product_id,
  428. quantity: Number(material.quantity),
  429. })),
  430. name: form.name,
  431. pavao: toPricingPayload(form.pavao),
  432. quantity_classes: form.quantity_classes,
  433. };
  434. await execute(() => {
  435. if (props.package?.id) {
  436. const updated = {
  437. ...getUpdatedFields.value,
  438. };
  439. if (updated.class_duration_hours !== undefined) {
  440. updated.class_duration_minutes = payload.class_duration_minutes;
  441. delete updated.class_duration_hours;
  442. }
  443. // Pavão/Irrecusável têm validação cruzada entre campos (par exclusivo
  444. // de checkbox) que não é seguro fatiar num diff parcial — vão inteiros.
  445. updated.pavao = payload.pavao;
  446. updated.irrecusavel = payload.irrecusavel;
  447. return updatePackage(props.package.id, updated);
  448. }
  449. return createPackage(payload);
  450. });
  451. };
  452. onMounted(loadData);
  453. </script>
  454. <style scoped>
  455. .package-duration-hint {
  456. color: #757575;
  457. font-size: 12px;
  458. line-height: 1.35;
  459. padding-top: 4px;
  460. padding-bottom: 2px;
  461. }
  462. </style>