AddEditContractDialog.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  1. <template>
  2. <q-dialog ref="dialogRef" @hide="onDialogHide">
  3. <q-card class="q-dialog-plugin dialog-form-card" :style="dialogCardStyle">
  4. <DefaultDialogHeader
  5. :title="props.contract ? 'Editar Contrato' : 'Novo Contrato'"
  6. @close="onDialogCancel"
  7. />
  8. <DefaultForm ref="formRef" @submit="handleSave">
  9. <q-scroll-area ref="scrollAreaRef" class="dialog-form-scroll">
  10. <q-card-section class="q-pt-sm">
  11. <div>
  12. <template v-if="!props.contract">
  13. <DefaultInput
  14. :model-value="store.user?.name"
  15. class="q-mb-md"
  16. disable
  17. label="Franqueado operador"
  18. />
  19. <div class="text-subtitle1 q-mb-md">Dados da Unidade</div>
  20. <div class="row q-col-gutter-sm">
  21. <DefaultInput
  22. v-model:error="validationErrors.unit_id"
  23. :model-value="unitDetails.name"
  24. class="col-12 col-md-6"
  25. disable
  26. label="Nome da Unidade"
  27. />
  28. <DefaultInput
  29. v-model:error="validationErrors.unit_id"
  30. :model-value="unitDetails.cnpj"
  31. :mask="masks.Brasil.cnpj"
  32. class="col-12 col-md-6"
  33. disable
  34. label="CNPJ"
  35. />
  36. </div>
  37. <div class="text-subtitle1 q-mt-lg q-mb-md">Dados do Aluno</div>
  38. <div class="row q-col-gutter-sm">
  39. <div v-if="props.selectStudent" class="col-12">
  40. <div
  41. class="row items-start no-wrap q-col-gutter-sm"
  42. @keydown.enter.prevent
  43. >
  44. <DefaultSelect
  45. v-model="selectedStudent"
  46. v-model:error="validationErrors.student_id"
  47. class="col"
  48. dropdown-icon="mdi-magnify"
  49. fill-input
  50. hide-selected
  51. input-debounce="0"
  52. label="Aluno"
  53. option-label="name"
  54. use-input
  55. :options="filteredStudents"
  56. :rules="[inputRules.required]"
  57. @filter="filterStudents"
  58. >
  59. <template #no-option>
  60. <q-item>
  61. <q-item-section class="text-grey">
  62. {{ $t("http.errors.no_records_found") }}
  63. </q-item-section>
  64. </q-item>
  65. </template>
  66. </DefaultSelect>
  67. <div v-if="canAddStudents" class="col-auto">
  68. <q-btn
  69. aria-label="Cadastrar novo aluno"
  70. color="primary"
  71. icon="mdi-plus"
  72. round
  73. unelevated
  74. @click="openCreateStudent"
  75. >
  76. <q-tooltip> Cadastrar novo aluno </q-tooltip>
  77. </q-btn>
  78. </div>
  79. </div>
  80. </div>
  81. <div v-if="!props.selectStudent" class="col-12">
  82. <DefaultInput
  83. v-model:error="validationErrors.student_id"
  84. :model-value="currentStudent?.name"
  85. :rules="[inputRules.required]"
  86. disable
  87. label="Aluno"
  88. />
  89. </div>
  90. <div class="col-12">
  91. <DefaultInput
  92. v-model:error="validationErrors.birth_date"
  93. :model-value="formattedBirthDate"
  94. disable
  95. label="Data de Nascimento"
  96. />
  97. </div>
  98. </div>
  99. </template>
  100. <div class="row items-center justify-between q-mt-lg q-mb-md">
  101. <div class="text-subtitle1">
  102. Dados do Contrato
  103. </div>
  104. <q-toggle
  105. v-model="isAutomaticProtocol"
  106. color="primary"
  107. label="Protocolo automático"
  108. left-label
  109. dense
  110. @update:model-value="handleToggleAutomaticProtocol"
  111. />
  112. </div>
  113. <div class="row q-col-gutter-sm">
  114. <div class="col-4">
  115. <DefaultInput
  116. v-model="form.protocol"
  117. v-model:error="validationErrors.protocol"
  118. :disable="isAutomaticProtocol"
  119. :loading="loadingProtocol"
  120. label="Protocolo"
  121. :rules="[inputRules.max(255)]"
  122. />
  123. </div>
  124. <div class="col-4">
  125. <DefaultInputDatePicker
  126. v-model="form.signature_date"
  127. v-model:error="validationErrors.signature_date"
  128. :disable="!!props.contract"
  129. :rules="[inputRules.date]"
  130. label="Data de Início"
  131. />
  132. </div>
  133. <div class="col-4">
  134. <DefaultInputDatePicker
  135. v-model="form.end_date"
  136. v-model:error="validationErrors.end_date"
  137. label="Data Encerramento"
  138. reactive-rules
  139. :lazy-rules="false"
  140. :rules="[
  141. inputRules.date,
  142. props.contract
  143. ? inputRules.renewalEndDate(
  144. () => props.contract?.end_date,
  145. )
  146. : inputRules.dateMaxYearsFrom(
  147. () => form.signature_date,
  148. ),
  149. ]"
  150. />
  151. </div>
  152. <div class="col-4">
  153. <DefaultSelect
  154. v-model="form.package_id"
  155. v-model:error="validationErrors.class_package_unit_id"
  156. emit-value
  157. label="Pacote de Aulas"
  158. map-options
  159. option-label="name"
  160. option-value="id"
  161. :options="packages"
  162. :rules="props.contract ? [] : [inputRules.required]"
  163. />
  164. </div>
  165. <div class="col-3">
  166. <DefaultSelect
  167. v-model="form.pricing_mode"
  168. v-model:error="validationErrors.pricing_mode"
  169. emit-value
  170. label="Modalidade"
  171. map-options
  172. option-label="label"
  173. option-value="value"
  174. :options="modalityOptions"
  175. :rules="
  176. modalityOptions.length
  177. ? [inputRules.required]
  178. : []
  179. "
  180. />
  181. </div>
  182. <div class="col-5">
  183. <DefaultInput
  184. v-model="form.class_quantity"
  185. v-model:error="validationErrors.class_quantity"
  186. disable
  187. label="Qtd. Aulas"
  188. type="number"
  189. :rules="[inputRules.integerRange(1)]"
  190. />
  191. </div>
  192. <div class="col-6">
  193. <DefaultCurrencyInput
  194. :model-value="form.total_value"
  195. disable
  196. label="Total do Contrato"
  197. />
  198. </div>
  199. <div class="col-6">
  200. <DefaultInput
  201. :model-value="contractWeeks"
  202. disable
  203. label="Total de Semanas"
  204. />
  205. </div>
  206. <div class="col-4">
  207. <DefaultSelect
  208. v-model="form.weekday"
  209. v-model:error="validationErrors.weekday"
  210. emit-value
  211. label="Dia da Semana"
  212. map-options
  213. option-label="label"
  214. option-value="value"
  215. :options="weekdays"
  216. :rules="[inputRules.integerRange(0, 6)]"
  217. />
  218. </div>
  219. <div class="col-4">
  220. <DefaultInput
  221. v-model="form.start_time"
  222. v-model:error="validationErrors.start_time"
  223. label="Hora de Início"
  224. mask="##:##"
  225. :rules="[inputRules.time]"
  226. >
  227. <template #append>
  228. <q-icon name="mdi-clock-outline" />
  229. </template>
  230. </DefaultInput>
  231. </div>
  232. <div class="col-4">
  233. <DefaultInput
  234. v-model="form.end_time"
  235. v-model:error="validationErrors.end_time"
  236. label="Hora de Término"
  237. mask="##:##"
  238. :rules="[inputRules.time]"
  239. >
  240. <template #append>
  241. <q-icon name="mdi-clock-outline" />
  242. </template>
  243. </DefaultInput>
  244. </div>
  245. <div class="col-4">
  246. <DefaultSelect
  247. v-model="form.second_weekday"
  248. v-model:error="validationErrors.second_weekday"
  249. emit-value
  250. label="2° Dia da Semana"
  251. map-options
  252. option-label="label"
  253. option-value="value"
  254. :options="weekdays"
  255. :rules="[inputRules.integerRange(0, 6)]"
  256. />
  257. </div>
  258. <div class="col-4">
  259. <DefaultInput
  260. v-model="form.second_start_time"
  261. v-model:error="validationErrors.second_start_time"
  262. label="Hora de Início"
  263. mask="##:##"
  264. :rules="[inputRules.time]"
  265. >
  266. <template #append>
  267. <q-icon name="mdi-clock-outline" />
  268. </template>
  269. </DefaultInput>
  270. </div>
  271. <div class="col-4">
  272. <DefaultInput
  273. v-model="form.second_end_time"
  274. v-model:error="validationErrors.second_end_time"
  275. label="Hora de Término"
  276. mask="##:##"
  277. :rules="[inputRules.time]"
  278. >
  279. <template #append>
  280. <q-icon name="mdi-clock-outline" />
  281. </template>
  282. </DefaultInput>
  283. </div>
  284. </div>
  285. <div class="text-subtitle1 q-mt-lg q-mb-md">
  286. Dados Financeiros
  287. </div>
  288. <div class="row q-col-gutter-sm">
  289. <div class="col-4">
  290. <DefaultInput
  291. v-model="form.due_day"
  292. v-model:error="validationErrors.due_day"
  293. label="Dia de Vencimento"
  294. type="number"
  295. :rules="[inputRules.integerRange(1, 31)]"
  296. />
  297. </div>
  298. </div>
  299. <div class="text-subtitle2 q-mt-md q-mb-sm text-grey-8">
  300. Total do Curso
  301. </div>
  302. <div class="row q-col-gutter-sm">
  303. <div class="col-3">
  304. <DefaultCurrencyInput
  305. v-model="form.total_value"
  306. v-model:error="validationErrors.total_value"
  307. label="Valor Total do Curso"
  308. :disable="!props.contract"
  309. />
  310. </div>
  311. <div class="col-3">
  312. <DefaultSelect
  313. v-model="form.total_installments"
  314. v-model:error="validationErrors.total_installments"
  315. emit-value
  316. label="Qtde Parcelas"
  317. map-options
  318. option-label="label"
  319. option-value="value"
  320. :options="packageInstallmentOptions"
  321. :rules="[inputRules.integerRange(1, 13)]"
  322. />
  323. </div>
  324. <div class="col-3">
  325. <DefaultCurrencyInput
  326. v-model:error="validationErrors.total_installments"
  327. :model-value="totalInstallmentValue"
  328. disable
  329. label="Valor da Parcela"
  330. />
  331. </div>
  332. <div class="col-3">
  333. <DefaultInputDatePicker
  334. v-model="form.total_due_date"
  335. v-model:error="validationErrors.total_due_date"
  336. label="Data 1ª Parcela"
  337. :rules="[inputRules.date]"
  338. />
  339. </div>
  340. </div>
  341. <div class="row q-col-gutter-sm q-mt-xs">
  342. <div class="col-6">
  343. <DefaultSelect
  344. v-model="form.payment_method"
  345. v-model:error="validationErrors.payment_method"
  346. emit-value
  347. label="Forma de Pagamento"
  348. map-options
  349. option-label="label"
  350. option-value="value"
  351. :options="paymentMethods"
  352. :rules="[
  353. inputRules.oneOf(['pix', 'credit_card', 'debit_card']),
  354. ]"
  355. />
  356. </div>
  357. <div class="col-6">
  358. <DefaultInput
  359. v-model="form.late_fee"
  360. v-model:error="validationErrors.fine_cancelled"
  361. label="Multa (%)"
  362. type="number"
  363. :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
  364. />
  365. </div>
  366. </div>
  367. </div>
  368. </q-card-section>
  369. </q-scroll-area>
  370. <q-card-actions align="right" class="q-px-md q-pb-md">
  371. <q-btn
  372. color="primary"
  373. label="Cancelar"
  374. no-caps
  375. outline
  376. @click="onDialogCancel"
  377. />
  378. <q-btn
  379. color="primary"
  380. label="Salvar"
  381. no-caps
  382. type="submit"
  383. :loading="saving"
  384. />
  385. </q-card-actions>
  386. </DefaultForm>
  387. </q-card>
  388. </q-dialog>
  389. </template>
  390. <script setup>
  391. import {
  392. addYears,
  393. differenceInCalendarDays,
  394. format,
  395. isValid,
  396. parse,
  397. } from "date-fns";
  398. import {
  399. createStudentContract,
  400. getNextContractProtocol,
  401. updateStudentContract,
  402. } from "src/api/studentContract";
  403. import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from "vue";
  404. import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
  405. import { getStudentsForSelect } from "src/api/student";
  406. import { getUnitMe, updateUnitMe } from "src/api/unit";
  407. import { getUnitPackagesForSelect } from "src/api/package";
  408. import { permissionStore } from "src/stores/permission";
  409. import { useDialogPluginComponent, useQuasar } from "quasar";
  410. import { useForm } from "src/composables/useForm";
  411. import { useInputRules } from "src/composables/useInputRules";
  412. import { useScroll } from "src/composables/useScroll";
  413. import { useSubmitHandler } from "src/composables/useSubmitHandler";
  414. import { userStore } from "src/stores/user";
  415. import masks from "src/helpers/masks";
  416. import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
  417. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  418. import DefaultForm from "src/components/defaults/DefaultForm.vue";
  419. import DefaultInput from "src/components/defaults/DefaultInput.vue";
  420. import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
  421. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  422. import AddEditStudentDialog from "src/pages/students/components/AddEditStudentDialog.vue";
  423. defineEmits([...useDialogPluginComponent.emits]);
  424. const props = defineProps({
  425. contract: {
  426. type: Object,
  427. default: null,
  428. },
  429. selectStudent: {
  430. type: Boolean,
  431. default: false,
  432. },
  433. student: {
  434. type: Object,
  435. default: null,
  436. },
  437. });
  438. const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
  439. useDialogPluginComponent();
  440. const $q = useQuasar();
  441. const { inputRules } = useInputRules();
  442. const { scrollToComponent } = useScroll();
  443. const permissions = permissionStore();
  444. const store = userStore();
  445. const formRef = useTemplateRef("formRef");
  446. const scrollAreaRef = useTemplateRef("scrollAreaRef");
  447. const dateConfirmationOpen = ref(false);
  448. const dateOverrideConfirmed = ref(false);
  449. const filteredStudents = ref([]);
  450. const isAutomaticProtocol = ref(true);
  451. const loadingProtocol = ref(false);
  452. const packages = ref([]);
  453. const restoringDefaultDates = ref(false);
  454. const selectedStudent = ref(props.student);
  455. const students = ref([]);
  456. const studentSearchTerm = ref("");
  457. const syncingFromDate = ref(false);
  458. const syncingFromDay = ref(false);
  459. const unitDetails = ref(store.selectedUnit ?? {});
  460. const canAddStudents = computed(() =>
  461. permissions.getAccess("franchisee_students", "add"),
  462. );
  463. const currentStudent = computed(() => selectedStudent.value ?? props.student);
  464. const contractWeeks = computed(() => {
  465. if (!form.signature_date || !form.end_date) return null;
  466. const startDate = parse(form.signature_date, "dd/MM/yyyy", new Date());
  467. const endDate = parse(form.end_date, "dd/MM/yyyy", new Date());
  468. if (!isValid(startDate) || !isValid(endDate)) {
  469. return null;
  470. }
  471. const days = differenceInCalendarDays(endDate, startDate);
  472. if (days < 0) return null;
  473. return Math.max(1, Math.round(days / 7));
  474. });
  475. const dialogCardStyle = computed(() => ({
  476. height: props.selectStudent ? "95vh" : "auto",
  477. maxHeight: props.selectStudent ? "95vh" : "none",
  478. maxWidth: props.selectStudent ? "1400px" : "1350px",
  479. width: "100%",
  480. }));
  481. const formattedBirthDate = computed(() =>
  482. currentStudent.value?.birth_date
  483. ? formatDateYMDtoDMY(currentStudent.value.birth_date)
  484. : "",
  485. );
  486. const totalInstallmentValue = computed(() => {
  487. if (!form.total_value || !form.total_installments) {
  488. return null;
  489. }
  490. return Number((form.total_value / form.total_installments).toFixed(2));
  491. });
  492. const modalityOptions = computed(() => {
  493. const selectedPackage = packages.value.find(
  494. (item) => item.id === form.package_id,
  495. );
  496. if (!selectedPackage) return [];
  497. const options = [];
  498. if (selectedPackage.pavao) options.push({ label: "Pavão", value: "pavao" });
  499. if (selectedPackage.irrecusavel) {
  500. options.push({ label: "Irrecusável", value: "irrecusavel" });
  501. }
  502. return options;
  503. });
  504. const completeDatePattern = /^\d{2}\/\d{2}\/\d{4}$/;
  505. const defaultContractDates = (() => {
  506. if (props.contract) return null;
  507. const startDate = new Date();
  508. return {
  509. end_date: format(addYears(startDate, 1), "dd/MM/yyyy"),
  510. signature_date: format(startDate, "dd/MM/yyyy"),
  511. };
  512. })();
  513. const packageInstallmentOptions = Array.from({ length: 13 }, (_, index) => ({
  514. label: `${index + 1}x`,
  515. value: index + 1,
  516. }));
  517. const paymentMethods = [
  518. {
  519. label: "Pix",
  520. value: "pix",
  521. },
  522. {
  523. label: "Cartão de Crédito",
  524. value: "credit_card",
  525. },
  526. {
  527. label: "Cartão de Débito",
  528. value: "debit_card",
  529. },
  530. ];
  531. const weekdays = [
  532. {
  533. label: "Segunda",
  534. value: 1,
  535. },
  536. {
  537. label: "Terça",
  538. value: 2,
  539. },
  540. {
  541. label: "Quarta",
  542. value: 3,
  543. },
  544. {
  545. label: "Quinta",
  546. value: 4,
  547. },
  548. {
  549. label: "Sexta",
  550. value: 5,
  551. },
  552. {
  553. label: "Sábado",
  554. value: 6,
  555. },
  556. {
  557. label: "Domingo",
  558. value: 0,
  559. },
  560. ];
  561. const trimTime = (time) => (time ? time.slice(0, 5) : null);
  562. const { form, getUpdatedFields } = useForm({
  563. class_quantity: props.contract?.class_quantity ?? null,
  564. due_day: props.contract?.due_day ?? null,
  565. end_date: props.contract?.end_date ?? defaultContractDates?.end_date ?? null,
  566. end_time: trimTime(props.contract?.end_time),
  567. late_fee: props.contract?.fine_cancelled ?? null,
  568. package_id: props.contract?.class_package_unit_id ?? null,
  569. payment_method: props.contract?.payment_method ?? null,
  570. pricing_mode: props.contract?.pricing_mode ?? null,
  571. protocol: props.contract?.protocol ?? null,
  572. total_due_date: props.contract?.total_due_date ?? null,
  573. total_installments: props.contract?.total_installments ?? null,
  574. total_value: props.contract?.total_value ?? null,
  575. second_end_time: trimTime(props.contract?.second_end_time),
  576. second_start_time: trimTime(props.contract?.second_start_time),
  577. second_weekday: props.contract?.second_weekday ?? null,
  578. signature_date:
  579. props.contract?.signature_date ??
  580. defaultContractDates?.signature_date ??
  581. null,
  582. start_time: trimTime(props.contract?.start_time),
  583. total_classes: props.contract?.class_quantity ?? null,
  584. weekday: props.contract?.weekday ?? null,
  585. });
  586. const {
  587. loading: saving,
  588. validationErrors,
  589. execute,
  590. } = useSubmitHandler({
  591. containerRef: scrollAreaRef,
  592. formRef,
  593. onSuccess: () => {
  594. onDialogOK(true);
  595. },
  596. scrollFn: scrollToComponent,
  597. });
  598. const buildDateFromDay = (day) => {
  599. const parsedDay = Number.parseInt(day);
  600. if (!parsedDay || parsedDay < 1 || parsedDay > 31) {
  601. return null;
  602. }
  603. const now = new Date();
  604. const nextMonthIndex = (now.getMonth() + 1) % 12;
  605. const nextYear =
  606. now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear();
  607. const lastDayOfNextMonth = new Date(
  608. nextYear,
  609. nextMonthIndex + 1,
  610. 0,
  611. ).getDate();
  612. const safeDay = Math.min(parsedDay, lastDayOfNextMonth);
  613. return `${String(safeDay).padStart(2, "0")}/${String(
  614. nextMonthIndex + 1,
  615. ).padStart(2, "0")}/${nextYear}`;
  616. };
  617. const buildPayload = () => ({
  618. class_package_unit_id: form.package_id,
  619. class_quantity: form.class_quantity,
  620. due_day: form.due_day ? Number.parseInt(form.due_day) : null,
  621. end_date: form.end_date ? formatDateDMYtoYMD(form.end_date) : null,
  622. end_time: form.end_time,
  623. fine_cancelled: form.late_fee,
  624. payment_method: form.payment_method,
  625. pricing_mode: form.pricing_mode,
  626. protocol: form.protocol,
  627. second_end_time: form.second_end_time,
  628. second_start_time: form.second_start_time,
  629. second_weekday: form.second_weekday,
  630. signature_date: form.signature_date
  631. ? formatDateDMYtoYMD(form.signature_date)
  632. : null,
  633. start_time: form.start_time,
  634. student_id: currentStudent.value?.id ?? null,
  635. total_due_date: form.total_due_date
  636. ? formatDateDMYtoYMD(form.total_due_date)
  637. : null,
  638. total_installments: form.total_installments,
  639. total_value: form.total_value,
  640. weekday: form.weekday,
  641. });
  642. const calculateEndTime = (startTime, durationMinutes = 120) => {
  643. if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) {
  644. return null;
  645. }
  646. const [hours, minutes] = startTime.split(":").map(Number);
  647. if (hours > 23 || minutes > 59) {
  648. return null;
  649. }
  650. const endMinutes = (hours * 60 + minutes + durationMinutes) % (24 * 60);
  651. return `${String(Math.floor(endMinutes / 60)).padStart(2, "0")}:${String(
  652. endMinutes % 60,
  653. ).padStart(2, "0")}`;
  654. };
  655. const filterStudents = (value, update) => {
  656. studentSearchTerm.value = value.trim();
  657. update(() => {
  658. const search = value.trim().toLocaleLowerCase("pt-BR");
  659. filteredStudents.value = search
  660. ? students.value.filter((student) =>
  661. student.name.toLocaleLowerCase("pt-BR").includes(search),
  662. )
  663. : students.value;
  664. });
  665. };
  666. const handleSave = async () => {
  667. const payload = buildPayload();
  668. await execute(() => {
  669. if (!props.contract) {
  670. return createStudentContract(payload);
  671. }
  672. const changedFields = getUpdatedFields.value;
  673. const updatePayload = {};
  674. const fieldMap = {
  675. late_fee: "fine_cancelled",
  676. package_id: "class_package_unit_id",
  677. total_classes: "class_quantity",
  678. };
  679. for (const key of [
  680. "protocol",
  681. "class_quantity",
  682. "weekday",
  683. "start_time",
  684. "end_time",
  685. "second_weekday",
  686. "second_start_time",
  687. "second_end_time",
  688. "payment_method",
  689. "pricing_mode",
  690. "total_value",
  691. "total_installments",
  692. ]) {
  693. if (key in changedFields) {
  694. updatePayload[key] = payload[key];
  695. }
  696. }
  697. for (const [source, target] of Object.entries(fieldMap)) {
  698. if (source in changedFields) {
  699. updatePayload[target] = payload[target];
  700. }
  701. }
  702. for (const key of [
  703. "signature_date",
  704. "end_date",
  705. "total_due_date",
  706. ]) {
  707. if (key in changedFields) {
  708. updatePayload[key] = payload[key];
  709. }
  710. }
  711. if ("due_day" in changedFields) {
  712. updatePayload.due_day = payload.due_day;
  713. }
  714. return updateStudentContract(props.contract.id, updatePayload);
  715. });
  716. };
  717. const openCreateStudent = () => {
  718. $q.dialog({
  719. component: AddEditStudentDialog,
  720. componentProps: {
  721. initialName: studentSearchTerm.value || null,
  722. },
  723. }).onOk(async (createdStudent) => {
  724. const unitStudents = await getStudentsForSelect();
  725. students.value = unitStudents;
  726. filteredStudents.value = unitStudents;
  727. selectedStudent.value =
  728. unitStudents.find((student) => student.id === createdStudent?.id) ??
  729. createdStudent;
  730. });
  731. };
  732. const restoreDefaultContractDates = () => {
  733. restoringDefaultDates.value = true;
  734. form.signature_date = defaultContractDates.signature_date;
  735. form.end_date = defaultContractDates.end_date;
  736. nextTick(() => {
  737. dateConfirmationOpen.value = false;
  738. restoringDefaultDates.value = false;
  739. });
  740. };
  741. watch(
  742. [() => form.signature_date, () => form.end_date],
  743. ([signatureDate, endDate]) => {
  744. if (
  745. props.contract ||
  746. dateOverrideConfirmed.value ||
  747. dateConfirmationOpen.value ||
  748. restoringDefaultDates.value
  749. ) {
  750. return;
  751. }
  752. const datesAreStillDefault =
  753. signatureDate === defaultContractDates.signature_date &&
  754. endDate === defaultContractDates.end_date;
  755. if (datesAreStillDefault) return;
  756. const datesAreComplete =
  757. (!signatureDate || completeDatePattern.test(signatureDate)) &&
  758. (!endDate || completeDatePattern.test(endDate));
  759. if (!datesAreComplete) return;
  760. dateConfirmationOpen.value = true;
  761. $q.dialog({
  762. cancel: {
  763. color: "primary",
  764. label: "Não",
  765. outline: true,
  766. },
  767. message:
  768. "As datas foram preenchidas automaticamente para a duração de um ano. Tem certeza de que deseja alterá-las?",
  769. ok: {
  770. color: "primary",
  771. label: "Sim",
  772. },
  773. persistent: true,
  774. title: "Alterar duração do contrato?",
  775. })
  776. .onOk(() => {
  777. dateConfirmationOpen.value = false;
  778. dateOverrideConfirmed.value = true;
  779. })
  780. .onCancel(restoreDefaultContractDates);
  781. },
  782. );
  783. watch(
  784. () => form.due_day,
  785. (day) => {
  786. if (syncingFromDate.value) return;
  787. const dateString = buildDateFromDay(day);
  788. if (!dateString) return;
  789. syncingFromDay.value = true;
  790. form.package_due_date = dateString;
  791. nextTick(() => {
  792. syncingFromDay.value = false;
  793. });
  794. },
  795. );
  796. watch(
  797. () => form.package_due_date,
  798. (dateString) => {
  799. if (syncingFromDay.value) return;
  800. if (!dateString || dateString.length < 8) return;
  801. const day = Number.parseInt(dateString.split("/")[0]);
  802. if (!Number.isNaN(day) && day !== Number.parseInt(form.due_day)) {
  803. syncingFromDate.value = true;
  804. form.due_day = day;
  805. nextTick(() => {
  806. syncingFromDate.value = false;
  807. });
  808. }
  809. },
  810. );
  811. watch(
  812. () => form.package_id,
  813. (id) => {
  814. const selectedPackage = packages.value.find((item) => item.id === id);
  815. if (!selectedPackage) return;
  816. form.class_quantity = selectedPackage.quantity_classes;
  817. form.total_classes = selectedPackage.quantity_classes;
  818. form.weekday = selectedPackage.weekday ?? null;
  819. form.start_time = trimTime(selectedPackage.start_time);
  820. form.end_time = calculateEndTime(
  821. form.start_time,
  822. selectedPackage.class_duration_minutes ?? 120,
  823. );
  824. form.second_weekday = selectedPackage.second_weekday ?? null;
  825. form.second_start_time = trimTime(selectedPackage.second_start_time);
  826. form.second_end_time = calculateEndTime(
  827. form.second_start_time,
  828. selectedPackage.class_duration_minutes ?? 120,
  829. );
  830. // Escolhe automaticamente a modalidade quando só há uma disponível;
  831. // se a proposta ainda não tem Pavão nem Irrecusável, limpa a escolha.
  832. if (selectedPackage.pavao) {
  833. form.pricing_mode = "pavao";
  834. } else if (selectedPackage.irrecusavel) {
  835. form.pricing_mode = "irrecusavel";
  836. } else {
  837. form.pricing_mode = null;
  838. }
  839. },
  840. );
  841. // Os valores do contrato vêm da modalidade escolhida na proposta comercial:
  842. // o que estiver marcado "incluso no valor do curso" não é cobrado à parte
  843. // (só entra no Total do Curso); o que "permite parcelar" vira cobrança
  844. // separada com o próprio valor/parcelas daquele item.
  845. watch(
  846. [() => form.package_id, () => form.pricing_mode],
  847. ([id, mode]) => {
  848. const selectedPackage = packages.value.find((item) => item.id === id);
  849. const modality =
  850. mode === "irrecusavel"
  851. ? selectedPackage?.irrecusavel
  852. : selectedPackage?.pavao;
  853. if (!modality) {
  854. form.total_value = null;
  855. form.total_installments = null;
  856. return;
  857. }
  858. form.total_value = modality.total_value;
  859. form.total_installments = modality.total_max_installments;
  860. },
  861. );
  862. watch(
  863. () => form.start_time,
  864. (startTime) => {
  865. const selectedPackage = packages.value.find(
  866. (item) => item.id === form.package_id,
  867. );
  868. const calculatedEndTime = calculateEndTime(
  869. startTime,
  870. selectedPackage?.class_duration_minutes ?? 120,
  871. );
  872. if (calculatedEndTime) {
  873. form.end_time = calculatedEndTime;
  874. }
  875. },
  876. );
  877. watch(
  878. () => form.second_start_time,
  879. (startTime) => {
  880. const selectedPackage = packages.value.find(
  881. (item) => item.id === form.package_id,
  882. );
  883. const calculatedEndTime = calculateEndTime(
  884. startTime,
  885. selectedPackage?.class_duration_minutes ?? 120,
  886. );
  887. if (calculatedEndTime) {
  888. form.second_end_time = calculatedEndTime;
  889. }
  890. },
  891. );
  892. const handleToggleAutomaticProtocol = async (value) => {
  893. try {
  894. await updateUnitMe({ automatic_protocol: value });
  895. if (unitDetails.value) {
  896. unitDetails.value.automatic_protocol = value;
  897. }
  898. if (store.selectedUnit) {
  899. store.selectedUnit.automatic_protocol = value;
  900. }
  901. if (value && !props.contract) {
  902. loadingProtocol.value = true;
  903. try {
  904. const next = await getNextContractProtocol();
  905. form.protocol = next?.protocol ?? "000001";
  906. } finally {
  907. loadingProtocol.value = false;
  908. }
  909. }
  910. } catch (error) {
  911. console.error("Falha ao salvar configuração de protocolo automático:", error);
  912. $q.notify({
  913. message: "Não foi possível atualizar a preferência de protocolo da unidade.",
  914. type: "negative",
  915. });
  916. }
  917. };
  918. onMounted(async () => {
  919. const unitRequest = getUnitMe().catch((error) => {
  920. console.error("Falha ao carregar os dados da unidade:", error);
  921. return store.selectedUnit;
  922. });
  923. const requests = [getUnitPackagesForSelect(), unitRequest];
  924. if (props.selectStudent) {
  925. requests.push(getStudentsForSelect());
  926. }
  927. const [unitPackages, unitData, unitStudents = []] =
  928. await Promise.all(requests);
  929. packages.value = unitPackages;
  930. unitDetails.value = unitData ?? store.selectedUnit ?? {};
  931. isAutomaticProtocol.value = unitDetails.value?.automatic_protocol ?? true;
  932. if (!props.contract && isAutomaticProtocol.value) {
  933. loadingProtocol.value = true;
  934. try {
  935. const next = await getNextContractProtocol();
  936. form.protocol = next?.protocol ?? "000001";
  937. } catch (error) {
  938. console.error("Falha ao carregar próximo protocolo:", error);
  939. form.protocol = "000001";
  940. } finally {
  941. loadingProtocol.value = false;
  942. }
  943. }
  944. students.value = unitStudents;
  945. filteredStudents.value = unitStudents;
  946. });
  947. </script>