Эх сурвалжийг харах

feat: add track de historico para contrato + restricoes de data do contrato

Gustavo Mantovani 4 өдөр өмнө
parent
commit
113a852ece

+ 4 - 4
src/components/defaults/CustomFileInput.vue

@@ -1,6 +1,10 @@
 <template>
   <q-file
     v-model="model"
+    color="secondary"
+    hide-bottom-space
+    label-color="secondary"
+    lazy-rules="ondemand"
     v-bind="attrs"
     :accept
     :class="{ 'required-field': required }"
@@ -11,10 +15,6 @@
     :label
     :outlined
     :rules
-    color="secondary"
-    hide-bottom-space
-    label-color="secondary"
-    lazy-rules="ondemand"
     @update:model-value="error = null"
   >
     <template #prepend>

+ 9 - 1
src/components/defaults/DefaultCepInput.vue

@@ -1,8 +1,8 @@
 <template>
   <DefaultInput
     v-model="model"
-    v-bind="$attrs"
     debounce="500"
+    v-bind="$attrs"
     :class="disable ? 'no-pointer-events' : ''"
     :disable="disable"
     :label
@@ -13,6 +13,7 @@
 <script setup>
 import { nextTick, ref, watch } from "vue";
 import { useQuasar } from "quasar";
+
 import masks from "src/helpers/masks.js";
 
 import DefaultInput from "./DefaultInput.vue";
@@ -53,16 +54,22 @@ watch(
     if (value < 0) {
       model.value = 0;
     }
+
     if (value && value.length > 8) {
       try {
         loading.value = true;
+
         const response = await fetch(`https://viacep.com.br/ws/${value}/json`);
         const data = await response.json();
+
         emit("cepData", data);
         emit("estado", data.estado);
         emit("uf", data.uf);
+
         // this is a hack to work well with the city and state select
+
         await nextTick();
+
         emit("cidade", data.localidade);
         emit("rua", data.logradouro);
         emit("bairro", data.bairro);
@@ -70,6 +77,7 @@ watch(
         emit("complemento", null);
       } catch (error) {
         console.error(error);
+
         $q.notify({
           message: "CEP inválido",
           color: "negative",

+ 15 - 9
src/components/defaults/DefaultDialogHeader.vue

@@ -1,6 +1,7 @@
 <template>
-  <q-bar class="bg-transparent q-px-md" v-bind="$attrs" style="height: 55px">
+  <q-bar class="bg-transparent q-px-md" style="height: 55px" v-bind="$attrs">
     <q-icon v-if="icon" :name="icon" />
+
     <div>{{ title }}</div>
 
     <q-space />
@@ -13,31 +14,36 @@
       @click="onMaximazedClick"
     />
 
-    <q-btn dense flat icon="mdi-close" @click="emit('close')" />
+    <q-btn
+      dense
+      flat
+      icon="mdi-close"
+      @click="emit('close')"
+    />
   </q-bar>
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
+import { onMounted, ref } from "vue";
 
 const emit = defineEmits(["maximized", "close"]);
 
 const { title, fullscreen, maximizable, icon } = defineProps({
-  title: {
-    type: String,
-    default: "Titulo",
-  },
   fullscreen: {
     type: Boolean,
     default: false,
   },
+  icon: {
+    type: String,
+    default: "",
+  },
   maximizable: {
     type: Boolean,
     default: false,
   },
-  icon: {
+  title: {
     type: String,
-    default: "",
+    default: "Titulo",
   },
 });
 

+ 1 - 1
src/components/defaults/DefaultFilePicker.vue

@@ -17,11 +17,11 @@
 
     <q-field
       v-model="model"
-      v-bind="inputAttrs"
       borderless
       class="image-preview-container"
       hide-bottom-space
       lazy-rules="ondemand"
+      v-bind="inputAttrs"
       :error="error"
       :error-message="errorMessage"
       :rules="rules"

+ 2 - 1
src/components/defaults/DefaultImagePicker.vue

@@ -22,13 +22,14 @@
     </div>
 
     <div class="preview-area flex flex-center">
-      <img v-if="previewUrl" :src="previewUrl" class="preview-image" />
+      <img v-if="previewUrl" class="preview-image" :src="previewUrl" />
     </div>
   </div>
 </template>
 
 <script setup>
 import { onBeforeUnmount, ref } from "vue";
+
 import CustomFileInput from "./CustomFileInput.vue";
 
 const { accept, clearable, dense, error, errorMessage, label, outlined } =

+ 1 - 1
src/components/defaults/DefaultInput.vue

@@ -4,11 +4,11 @@
       <q-input
         ref="inputRef"
         v-model="model"
-        v-bind="inputAttrs"
         color="secondary"
         hide-bottom-space
         label-color="secondary"
         lazy-rules="ondemand"
+        v-bind="inputAttrs"
         :bg-color
         :class="[inputClass, { 'required-field': required }]"
         :error="!!error"

+ 23 - 9
src/components/defaults/DefaultInputDatePicker.vue

@@ -2,21 +2,22 @@
   <DefaultInput
     v-model="treatedDate"
     v-model:error="error"
+    clearable
     v-bind="$attrs"
     :label="label"
     :mask="inputMask"
-    clearable
   >
     <template #append>
       <q-icon
-        :name="time ? 'mdi-calendar-clock' : 'mdi-calendar'"
         class="cursor-pointer"
+        color="secondary"
+        :name="time ? 'mdi-calendar-clock' : 'mdi-calendar'"
       >
-        <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+        <q-popup-proxy cover transition-hide="scale" transition-show="scale">
           <template v-if="!time">
             <q-date v-model="date" mask="YYYY-MM-DD">
               <div class="row items-center justify-end">
-                <q-btn v-close-popup label="OK" color="primary" flat />
+                <q-btn v-close-popup color="primary" flat label="OK" />
               </div>
             </q-date>
           </template>
@@ -25,11 +26,11 @@
             <q-tab-panels
               v-model="activePanel"
               animated
+              class="bg-white"
               transition-prev="slide-right"
               transition-next="slide-left"
-              class="bg-white"
             >
-              <q-tab-panel name="date" class="q-pa-none">
+              <q-tab-panel class="q-pa-none" name="date">
                 <q-date
                   v-model="date"
                   mask="YYYY-MM-DD HH:mm"
@@ -37,8 +38,8 @@
                 />
               </q-tab-panel>
 
-              <q-tab-panel name="time" class="q-pa-none">
-                <q-time v-model="date" mask="YYYY-MM-DD HH:mm" format24h />
+              <q-tab-panel class="q-pa-none" name="time">
+                <q-time v-model="date" format24h mask="YYYY-MM-DD HH:mm" />
               </q-tab-panel>
             </q-tab-panels>
           </template>
@@ -49,8 +50,9 @@
 </template>
 
 <script setup>
-import { watch, computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
 import { useI18n } from "vue-i18n";
+
 import masks from "src/helpers/masks";
 
 import DefaultInput from "./DefaultInput.vue";
@@ -68,6 +70,7 @@ const { label, time } = defineProps({
 
 const treatedDate = defineModel({ type: [String, null] });
 const untreatedDate = defineModel("untreatedDate", { type: [String, null] });
+
 const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
@@ -103,6 +106,7 @@ const inputMask = computed(() => {
   if (time) {
     return masks.Brasil.datetime;
   }
+
   return masks.Brasil.date;
 });
 
@@ -119,8 +123,10 @@ watch(treatedDate, (value) => {
     untreatedDate.value = null;
     treatedDate.value = null;
     activePanel.value = "date";
+
     return;
   }
+
   date.value = unformatDate(value);
 });
 
@@ -130,8 +136,10 @@ watch(untreatedDate, (value) => {
     untreatedDate.value = null;
     treatedDate.value = null;
     activePanel.value = "date";
+
     return;
   }
+
   date.value = value;
   treatedDate.value = formatDate(value);
 });
@@ -143,3 +151,9 @@ if (untreatedDate.value) {
   date.value = unformatDate(treatedDate.value);
 }
 </script>
+
+<style scoped>
+:deep(.q-field__focusable-action) {
+  color: var(--q-secondary) !important;
+}
+</style>

+ 2 - 1
src/components/defaults/DefaultPasswordInput.vue

@@ -6,9 +6,9 @@
   >
     <template #append>
       <q-icon
-        :name="seePassword ? 'mdi-lock-open-outline' : 'mdi-lock-outline'"
         class="cursor-pointer q-ml-md"
         color="secondary"
+        :name="seePassword ? 'mdi-lock-open-outline' : 'mdi-lock-outline'"
         @click="seePassword = !seePassword"
       />
     </template>
@@ -18,6 +18,7 @@
 import DefaultInput from "./DefaultInput.vue";
 
 const password = defineModel({ type: String });
+
 const seePassword = defineModel("seePassword", {
   default: false,
   type: Boolean,

+ 1 - 1
src/components/defaults/DefaultSelect.vue

@@ -9,7 +9,6 @@
     <q-select
       ref="selectRef"
       v-model="model"
-      v-bind="selectAttrs"
       class="default-select-field"
       color="secondary"
       dense
@@ -18,6 +17,7 @@
       input-class="default-select-native"
       label-color="secondary"
       lazy-rules="ondemand"
+      v-bind="selectAttrs"
       :bg-color
       :class="[
         inputClass,

+ 182 - 133
src/components/defaults/DefaultTable.vue

@@ -1,19 +1,19 @@
 <template>
   <q-table
     v-model:fullscreen="fullscreen"
+    class="softpar-table q-pa-sm q-mt-md bg-transparent"
     flat
     row-key="id"
+    :columns
+    :filter
+    :grid="$q.screen.lt.sm"
+    :loading
     :pagination="{ rowsPerPage }"
     :pagination-label="getPaginationLabel"
+    :rows
     :rows-per-page-label="$t('common.ui.table.rows_per_page')"
     :rows-per-page-options="[0]"
-    :grid="$q.screen.lt.sm"
     :visible-columns
-    :filter
-    :columns
-    :loading
-    :rows
-    class="softpar-table q-pa-sm q-mt-md bg-transparent"
     @row-click="onRowClick"
   >
     <template #top>
@@ -23,9 +23,14 @@
       >
         <div v-if="title" class="column text-h6">
           <span>{{ title }}</span>
-          <span class="text-body2">{{
-            `${rows.length} ${descricao} ${feminino ? "cadastradas" : "cadastrados"}`
-          }}</span>
+
+          <span class="text-body2">
+            {{
+              `${rows.length} ${descricao} ${
+                feminino ? "cadastradas" : "cadastrados"
+              }`
+            }}
+          </span>
         </div>
 
         <slot name="top" :rows="rows" />
@@ -35,43 +40,46 @@
         <q-btn
           v-if="addItem"
           color="primary"
-          style="width: 40px; height: 40px"
           icon="mdi-plus"
+          style="width: 40px; height: 40px"
           :outline="outlineAdd"
           @click="onAddItem"
-        >
-        </q-btn>
+        />
       </div>
 
-      <div class="flex full-width align-center q-mb-md" style="gap: 1rem">
+      <div
+        class="flex full-width align-center q-mb-md"
+        style="gap: 1rem"
+      >
         <DefaultInput
           v-if="showSearchField"
           v-model="filter"
-          debounce="250"
-          label="Busque por Unidade, status ou Responsavel"
-          :placeholder="$t('common.actions.search')"
-          clearable
+          bg-color="transparent"
           class="q-mt-sm full-width search-input"
+          clearable
           color="primary"
-          bg-color="transparent"
+          debounce="250"
           dense
           input-class="q-pl-xs"
+          label="Busque por Unidade, status ou Responsavel"
+          :placeholder="$t('common.actions.search')"
         >
           <template #prepend>
-            <q-icon name="mdi-magnify" color="grey-6" />
+            <q-icon color="grey-6" name="mdi-magnify" />
           </template>
         </DefaultInput>
+
         <DefaultSelect
           v-if="showColumnsSelect"
           v-model="visibleColumns"
+          emit-value
+          map-options
           multiple
           options-outlined
+          options-selected-class="text-bold"
+          style="width: 150px"
           :display-value="$q.lang.table.columns"
-          emit-value
-          map-options
           :options="mapColumns"
-          style="width: 150px"
-          options-selected-class="text-bold"
         />
       </div>
     </template>
@@ -80,12 +88,13 @@
       <q-td auto-width>
         <q-item-section class="no-wrap" style="flex-direction: row">
           <slot name="body-cell-actions" :row="row" />
+
           <q-btn
             v-if="deleteFunction"
-            outline
+            class="q-ml-auto q-mr-sm"
             icon="mdi-trash-can-outline"
+            outline
             style="width: 36px"
-            class="q-ml-auto q-mr-sm"
             @click.prevent.stop="onDelete(row.id)"
           />
         </q-item-section>
@@ -96,8 +105,8 @@
       <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3">
         <q-card
           bordered
-          flat
           class="q-pa-sm"
+          flat
           @click="onRowClick($event, row, index)"
         >
           <q-list dense>
@@ -107,19 +116,25 @@
             >
               <template v-if="col.name !== 'actions'">
                 <q-item-section>
-                  <q-item-label caption>{{ col.label }}</q-item-label>
-                  <q-item-label>{{ col.value }}</q-item-label>
+                  <q-item-label caption>
+                    {{ col.label }}
+                  </q-item-label>
+
+                  <q-item-label>
+                    {{ col.value }}
+                  </q-item-label>
                 </q-item-section>
               </template>
+
               <template v-else>
                 <slot name="body-cell-actions" :row="row" />
+
                 <q-item-section v-if="deleteFunction">
                   <q-btn
-                    v-if="deleteFunction"
-                    outline
+                    class="q-mr-sm"
                     icon="mdi-trash-can-outline"
+                    outline
                     style="width: 36px"
-                    class="q-mr-sm"
                     @click.prevent.stop="onDelete(row.id)"
                   />
                 </q-item-section>
@@ -131,212 +146,250 @@
     </template>
 
     <template #loading>
-      <q-inner-loading showing color="primary" />
+      <q-inner-loading color="primary" showing />
     </template>
 
     <template v-if="!hideNoDataLabel" #no-data>
-      <div v-if="!loading" class="q-my-md row justify-center full-width">
+      <div
+        v-if="!loading"
+        class="q-my-md row justify-center full-width"
+      >
         <div class="q-pa-md body2">
           {{ $t("http.errors.no_records_found") }}
         </div>
       </div>
     </template>
-    <template v-for="slotName in usableSlots($slots)" #[slotName]="data">
+
+    <template
+      v-for="slotName in usableSlots($slots)"
+      #[slotName]="data"
+    >
       <slot :name="slotName" v-bind="data" />
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, onMounted, toRaw, watch } from "vue";
+import { onMounted, ref, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
-import { useRouter } from "vue-router";
 import { useQuasar } from "quasar";
+import { useRouter } from "vue-router";
 
 import DefaultInput from "./DefaultInput.vue";
 import DefaultSelect from "./DefaultSelect.vue";
 
-const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
+const emit = defineEmits([
+  "noRows",
+  "onAddItem",
+  "onRowClick",
+]);
 
 const {
-  columns,
+  addItem,
+  addItemRoute,
   apiCall,
+  columns,
+  deleteFunction,
   descricao,
   feminino,
-  outlineAdd,
+  hideNoDataLabel,
+  noApiCall,
   openItem,
   openItemRoute,
-  addItem,
-  addItemRoute,
+  outlineAdd,
   rowsPerPage,
+  showColumnsSelect,
   showSearchField,
-  noApiCall,
-  hideNoDataLabel,
-  deleteFunction,
+  title,
 } = defineProps({
-  title: {
+  addItem: {
+    default: false,
+    type: Boolean,
+  },
+  addItemRoute: {
+    default: "",
     type: String,
+  },
+  apiCall: {
     default: null,
+    type: Function,
   },
   columns: {
-    type: Array,
     required: true,
+    type: Array,
   },
-  apiCall: {
-    type: Function,
+  deleteFunction: {
     default: null,
+    type: Function,
   },
   descricao: {
-    type: String,
     default: "linhas",
+    type: String,
   },
   feminino: {
-    type: Boolean,
     default: true,
+    type: Boolean,
   },
-  outlineAdd: {
+  hideNoDataLabel: {
+    default: false,
     type: Boolean,
+  },
+  noApiCall: {
     default: false,
+    type: Boolean,
   },
   openItem: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   openItemRoute: {
-    type: String,
     default: "",
+    type: String,
   },
-  addItem: {
-    type: Boolean,
+  outlineAdd: {
     default: false,
-  },
-  addItemRoute: {
-    type: String,
-    default: "",
+    type: Boolean,
   },
   rowsPerPage: {
-    type: Number,
     default: 10,
-  },
-  showSearchField: {
-    type: Boolean,
-    default: true,
+    type: Number,
   },
   showColumnsSelect: {
-    type: Boolean,
     default: false,
-  },
-  noApiCall: {
     type: Boolean,
-    default: false,
   },
-  hideNoDataLabel: {
+  showSearchField: {
+    default: true,
     type: Boolean,
-    default: false,
   },
-  deleteFunction: {
-    type: Function,
+  title: {
     default: null,
+    type: String,
   },
 });
 
-const router = useRouter();
 const { t } = useI18n();
 const $q = useQuasar();
+const router = useRouter();
+
+const rows = defineModel("rows", {
+  default: [],
+  type: Array,
+});
 
-const rows = defineModel("rows", { type: Array, default: [] });
 const filter = ref("");
-const loading = ref(true);
 const fullscreen = ref(false);
+const loading = ref(true);
 
-const mapColumns = columns.reduce((accm, column) => {
+const mapColumns = columns.reduce((columnsList, column) => {
   if (!column.required) {
-    accm.push({
+    columnsList.push({
       label: column.label,
       value: column.name,
     });
   }
-  return accm;
+
+  return columnsList;
 }, []);
 
 const visibleColumns = ref(mapColumns.map((column) => column.value));
 
-const getPaginationLabel = (from, to, last) => {
-  return `${from}-${to} ${t("common.ui.table.of")} ${last}`;
-};
-
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (openItem) {
-    if (openItemRoute) {
-      router.push({ name: openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
-};
+const getPaginationLabel = (from, to, last) =>
+  `${from}-${to} ${t("common.ui.table.of")} ${last}`;
 
 const onAddItem = () => {
-  if (addItem) {
-    if (addItemRoute) {
-      router.push({ name: addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
+  if (!addItem) return;
+
+  if (addItemRoute) {
+    router.push({
+      name: addItemRoute,
+    });
+
+    return;
   }
+
+  emit("onAddItem");
 };
 
 const onDelete = async (id) => {
-  if (deleteFunction) {
-    $q.dialog({
-      title: t("common.ui.messages.confirm_action"),
-      message: t("common.ui.messages.are_you_sure_delete"),
-      ok: {
-        color: "negative",
-        label: t("common.actions.delete"),
-      },
-      cancel: {
-        color: "primary",
-        outline: true,
-        label: t("common.actions.cancel"),
-      },
-    }).onOk(async () => {
-      loading.value = true;
-      try {
-        await deleteFunction(id);
-        await onRequest();
-      } catch (error) {
-        console.error(error);
-      } finally {
-        loading.value = false;
-      }
-    });
-  }
+  if (!deleteFunction) return;
+
+  $q.dialog({
+    cancel: {
+      color: "primary",
+      label: t("common.actions.cancel"),
+      outline: true,
+    },
+    message: t("common.ui.messages.are_you_sure_delete"),
+    ok: {
+      color: "negative",
+      label: t("common.actions.delete"),
+    },
+    title: t("common.ui.messages.confirm_action"),
+  }).onOk(async () => {
+    loading.value = true;
+
+    try {
+      await deleteFunction(id);
+      await onRequest();
+    } catch (error) {
+      console.error(error);
+    } finally {
+      loading.value = false;
+    }
+  });
 };
 
 const onRequest = async () => {
   if (noApiCall) {
     loading.value = false;
+
     return;
   }
+
   loading.value = true;
 
   const response = await apiCall();
+
   rows.value = response;
 
-  if (rows.value.length == 0) {
+  if (rows.value.length === 0) {
     emit("noRows");
   }
+
   loading.value = false;
 };
 
+const onRowClick = (event, row, index) => {
+  if (!openItem) return;
+
+  const item = toRaw(row);
+
+  if (openItemRoute) {
+    router.push({
+      name: openItemRoute,
+      params: {
+        id: item.id,
+      },
+    });
+
+    return;
+  }
+
+  emit("onRowClick", {
+    event,
+    index,
+    row,
+  });
+};
+
 const usableSlots = (slots) => {
   const availableSlots = Object.keys(slots);
-  const usableSlots = availableSlots.filter(
+
+  return availableSlots.filter(
     (slot) =>
-      !["body-cell-actions", "top", "loading", "no-data"].includes(slot),
+      !["body-cell-actions", "loading", "no-data", "top"].includes(slot),
   );
-  return usableSlots;
 };
 
 watch(
@@ -346,11 +399,7 @@ watch(
   },
 );
 
-onMounted(async () => {
-  await onRequest({
-    filter: undefined,
-  });
-});
+onMounted(onRequest);
 
 defineExpose({
   refresh: onRequest,
@@ -359,4 +408,4 @@ defineExpose({
 
 <style lang="scss">
 @import "src/css/table.scss";
-</style>
+</style>

+ 167 - 133
src/components/defaults/DefaultTableServerSide.vue

@@ -1,20 +1,19 @@
-# DefaultTableServerSide.vue
 <template>
   <q-table
     v-model:fullscreen="fullscreen"
     v-model:pagination="pagination"
-    row-key="id"
-    flat
     class="softpar-table q-pa-sm"
-    :pagination-label="getPaginationLabel"
-    :rows="rows"
-    :rows-per-page-label="$t('common.ui.table.rows_per_page')"
+    flat
+    row-key="id"
+    v-bind="$attrs"
     :columns="columns"
-    :visible-columns="visibleColumns"
     :filter="pagination.filter"
     :grid="$q.screen.lt.sm"
     :loading="loading"
-    v-bind="$attrs"
+    :pagination-label="getPaginationLabel"
+    :rows="rows"
+    :rows-per-page-label="$t('common.ui.table.rows_per_page')"
+    :visible-columns="visibleColumns"
     @row-click="onRowClick"
   >
     <template #top>
@@ -25,12 +24,12 @@
         <DefaultInput
           v-if="showSearchField"
           v-model="pagination.filter"
-          outlined
-          dense
+          autofocus
+          clearable
           debounce="500"
+          dense
+          outlined
           :placeholder="$t('common.actions.search')"
-          clearable
-          autofocus
         >
           <template #append>
             <q-icon name="mdi-magnify" />
@@ -41,16 +40,16 @@
           v-if="showColumnsSelect"
           v-model="visibleColumns"
           class="q-ml-md"
-          multiple
           dense
-          outlined
-          options-outlined
-          :display-value="$q.lang.table.columns"
           emit-value
           map-options
-          :options="mapColumns"
-          style="min-width: 150px"
+          multiple
+          options-outlined
           options-selected-class="text-bold"
+          outlined
+          style="min-width: 150px"
+          :display-value="$q.lang.table.columns"
+          :options="mapColumns"
         />
 
         <q-space />
@@ -59,8 +58,8 @@
           v-if="addItem"
           color="primary"
           padding="12px 16px"
-          :outline="outlineAdd"
           :label="labelAdd"
+          :outline="outlineAdd"
           @click="onAddItem"
         />
       </div>
@@ -70,12 +69,12 @@
       <q-td v-if="deleteFunction">
         <q-item-section>
           <q-btn
+            class="q-ml-auto q-mr-sm"
             color="negative"
-            flat
             dense
+            flat
             icon="mdi-delete"
             style="width: 45px"
-            class="q-ml-auto q-mr-sm"
             @click.prevent.stop="onDelete(row.id)"
           />
         </q-item-section>
@@ -85,6 +84,7 @@
     <template v-if="!hideNoDataLabel" #no-data>
       <div class="q-my-md row justify-center full-width">
         <q-spinner v-if="loading" color="primary" size="30px" />
+
         <div v-else class="q-pa-md body2">
           {{ $t("http.errors.no_records_found") }}
         </div>
@@ -95,47 +95,53 @@
       <div class="flex full-width justify-end">
         <div class="flex items-center">
           {{ $t("common.ui.table.rows_per_page") }}
+
           <DefaultSelect
             v-model="pagination.rowsPerPage"
+            borderless
             class="q-mx-sm"
             dense
-            borderless
             :options="rowsPerPageOptions"
           >
             <template #option="selectData">
               <q-item v-bind="selectData.itemProps">
                 <q-item-section>
-                  <q-item-label>{{
-                    selectData.opt == 0
-                      ? $t("common.ui.misc.all")
-                      : selectData.opt
-                  }}</q-item-label>
+                  <q-item-label>
+                    {{
+                      selectData.opt == 0
+                        ? $t("common.ui.misc.all")
+                        : selectData.opt
+                    }}
+                  </q-item-label>
                 </q-item-section>
               </q-item>
             </template>
           </DefaultSelect>
         </div>
+
         <div class="flex items-center">
           {{ pagination.from + "-" + pagination.to }}
           {{ $t("common.ui.table.of") }}
           {{ pagination.rowsNumber }}
         </div>
+
         <div class="flex items-center">
           <q-btn
-            icon="mdi-chevron-left"
             color="grey-8"
-            round
             dense
             flat
+            icon="mdi-chevron-left"
+            round
             :disable="scope.isFirstPage"
             @click="prevPage"
           />
+
           <q-btn
-            icon="mdi-chevron-right"
             color="grey-8"
-            round
             dense
             flat
+            icon="mdi-chevron-right"
+            round
             :disable="scope.isLastPage"
             @click="nextPage"
           />
@@ -144,13 +150,13 @@
     </template>
 
     <template v-for="name in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
+      <slot :name="name" v-bind="data" />
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, toRaw, watch } from "vue";
+import { computed, onMounted, ref, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 
@@ -158,179 +164,178 @@ import DefaultInput from "./DefaultInput.vue";
 import DefaultSelect from "./DefaultSelect.vue";
 
 const emit = defineEmits([
-  "onRowClick",
-  "onAddItem",
   "noRows",
+  "onAddItem",
+  "onRowClick",
   "togglePrincipal",
 ]);
 
 const {
-  columns,
+  addItem,
+  addItemRoute,
   apiCall,
-  outlineAdd,
+  columns,
+  deleteFunction,
+  hideNoDataLabel,
+  labelAdd,
   openItem,
   openItemRoute,
-  addItem,
-  addItemRoute,
+  outlineAdd,
   rowsPerPage,
+  showColumnsSelect,
   showSearchField,
-  hideNoDataLabel,
-  deleteFunction,
 } = defineProps({
-  columns: {
-    type: Array,
-    required: true,
+  addItem: {
+    default: true,
+    type: Boolean,
+  },
+  addItemRoute: {
+    default: "",
+    type: String,
   },
   apiCall: {
+    required: true,
     type: Function,
+  },
+  columns: {
     required: true,
+    type: Array,
+  },
+  deleteFunction: {
+    default: null,
+    type: Function,
+  },
+  hideNoDataLabel: {
+    default: false,
+    type: Boolean,
   },
   labelAdd: {
-    type: String,
     default: "Adicionar",
+    type: String,
   },
-  outlineAdd: {
-    type: Boolean,
+  noApiRoute: {
     default: false,
+    type: Boolean,
   },
   openItem: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   openItemRoute: {
-    type: String,
     default: "",
+    type: String,
   },
-  addItem: {
+  outlineAdd: {
+    default: false,
     type: Boolean,
-    default: true,
-  },
-  addItemRoute: {
-    type: String,
-    default: "",
   },
   rowsPerPage: {
-    type: Number,
     default: 10,
+    type: Number,
   },
   showColumnsSelect: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   showSearchField: {
-    type: Boolean,
     default: true,
-  },
-  noApiRoute: {
     type: Boolean,
-    default: false,
-  },
-  hideNoDataLabel: {
-    type: Boolean,
-    default: false,
-  },
-  deleteFunction: {
-    type: Function,
-    default: null,
   },
 });
 
 const { t } = useI18n();
 const router = useRouter();
-const rows = ref([]);
-const loading = ref(true);
+
 const fullscreen = ref(false);
-const rowsPerPageOptions = [10, 15, 25, 50];
+const loading = ref(true);
+const rows = ref([]);
 
 const pagination = ref({
   filter: undefined,
+  from: 0,
   page: 1,
-  rowsPerPage: rowsPerPage,
   rowsNumber: 0,
-  from: 0,
+  rowsPerPage,
   to: 0,
 });
 
-const mapColumns = computed(() => {
-  return columns.reduce((accm, column) => {
+const mapColumns = computed(() =>
+  columns.reduce((columnOptions, column) => {
     if (!column.required) {
-      accm.push({
+      columnOptions.push({
         label: column.label.toUpperCase(),
         value: column.name,
       });
     }
-    return accm;
-  }, []);
-});
 
-const visibleColumns = ref(mapColumns.value.map((column) => column.value));
+    return columnOptions;
+  }, []),
+);
 
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (openItem) {
-    if (openItemRoute) {
-      router.push({ name: openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
+const visibleColumns = ref(
+  mapColumns.value.map((column) => column.value),
+);
+
+const rowsPerPageOptions = [10, 15, 25, 50];
+
+let isFetching = false;
+
+const getPaginationLabel = (from, to, total) =>
+  `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
+
+const nextPage = () => {
+  pagination.value.page += 1;
+
+  onRequest();
 };
 
 const onAddItem = () => {
-  if (addItem) {
-    if (addItemRoute) {
-      router.push({ name: addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
-  }
-};
+  if (!addItem) return;
 
-const onDelete = async (id) => {
-  if (deleteFunction) {
-    loading.value = true;
-    try {
-      await deleteFunction(id);
-      await onRequest();
-    } catch (error) {
-      console.error(error);
-    } finally {
-      loading.value = false;
-    }
+  if (addItemRoute) {
+    router.push({
+      name: addItemRoute,
+    });
+
+    return;
   }
-};
 
-const prevPage = () => {
-  pagination.value.page--;
-  onRequest();
+  emit("onAddItem");
 };
 
-const nextPage = () => {
-  pagination.value.page++;
-  onRequest();
-};
+const onDelete = async (id) => {
+  if (!deleteFunction) return;
+
+  loading.value = true;
 
-const getPaginationLabel = (from, to, total) => {
-  return `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
+  try {
+    await deleteFunction(id);
+    await onRequest();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    loading.value = false;
+  }
 };
 
-let isFetching = false;
 const onRequest = async () => {
   if (isFetching) return;
 
   isFetching = true;
+
   loading.value = true;
 
   try {
     const response = await apiCall({
+      filter: pagination.value.filter,
       page: pagination.value.page,
       perPage: pagination.value.rowsPerPage,
-      filter: pagination.value.filter,
     });
 
     rows.value = response.data.result.data;
-    pagination.value.rowsNumber = response.data.result.total;
+
     pagination.value.from = response.data.result.from;
+    pagination.value.rowsNumber = response.data.result.total;
     pagination.value.to = response.data.result.to;
 
     if (rows.value.length === 0) {
@@ -339,25 +344,56 @@ const onRequest = async () => {
   } catch (error) {
     console.error("Error fetching data:", error);
   } finally {
-    loading.value = false;
     isFetching = false;
+    loading.value = false;
+  }
+};
+
+const onRowClick = (event, row, index) => {
+  if (!openItem) return;
+
+  const item = toRaw(row);
+
+  if (openItemRoute) {
+    router.push({
+      name: openItemRoute,
+      params: {
+        id: item.id,
+      },
+    });
+
+    return;
   }
+
+  emit("onRowClick", {
+    event,
+    index,
+    row,
+  });
+};
+
+const prevPage = () => {
+  pagination.value.page -= 1;
+
+  onRequest();
 };
 
 watch(
   () => apiCall,
-  () => onRequest(),
+  () => {
+    onRequest();
+  },
 );
 
 watch(
   pagination,
-  async (newVal, oldVal) => {
-    if (!oldVal || loading.value) return;
+  async (newValue, oldValue) => {
+    if (!oldValue || loading.value) return;
 
     if (
-      newVal.rowsPerPage !== oldVal.rowsPerPage ||
-      newVal.filter !== oldVal.filter ||
-      newVal.page !== oldVal.page
+      newValue.rowsPerPage !== oldValue.rowsPerPage ||
+      newValue.filter !== oldValue.filter ||
+      newValue.page !== oldValue.page
     ) {
       await onRequest();
     }
@@ -365,9 +401,7 @@ watch(
   { deep: true },
 );
 
-onMounted(async () => {
-  await onRequest();
-});
+onMounted(onRequest);
 
 defineExpose({
   refresh: onRequest,
@@ -376,4 +410,4 @@ defineExpose({
 
 <style lang="scss">
 @import "src/css/table.scss";
-</style>
+</style>

+ 15 - 13
src/components/defaults/DefaultTabs.vue

@@ -1,20 +1,20 @@
 <template>
   <q-tabs
     v-model="tab"
+    active-bg-color="white"
+    active-color="primary"
+    align="justify"
     class="button bg-background-2 text-font"
     indicator-color="transparent"
-    active-color="primary"
     v-bind="$attrs"
-    align="justify"
-    active-bg-color="white"
   >
     <q-tab
-      v-for="(q_tab, i) in tabsItems"
-      :key="i"
-      :name="q_tab.name"
-      :label="q_tab.label"
-      :disable="q_tab.disable"
-      :class="{ hidden: q_tab.hide }"
+      v-for="(tabItem, index) in tabsItems"
+      :key="index"
+      :class="{ hidden: tabItem.hide }"
+      :disable="tabItem.disable"
+      :label="tabItem.label"
+      :name="tabItem.name"
     />
   </q-tabs>
 </template>
@@ -22,11 +22,13 @@
 <script setup>
 const { tabsItems } = defineProps({
   tabsItems: {
-    type: Array,
-    required: false,
     default: () => [],
+    required: false,
+    type: Array,
   },
 });
 
-const tab = defineModel({ type: String });
-</script>
+const tab = defineModel({
+  type: String,
+});
+</script>

+ 104 - 12
src/composables/useInputRules.js

@@ -1,4 +1,11 @@
-import { format, isValid, parse } from "date-fns";
+import {
+  addYears,
+  differenceInCalendarDays,
+  format,
+  isValid,
+  parse,
+  startOfDay,
+} from "date-fns";
 import { useI18n } from "vue-i18n";
 
 export const useInputRules = () => {
@@ -45,19 +52,91 @@ export const useInputRules = () => {
     date: (value) => {
       if (!hasValue(value)) return true;
 
-      const formats = ["dd/MM/yyyy", "yyyy-MM-dd"];
+      return parseInputDate(value) || t("validation.rules.date");
+    },
+
+    dateMaxYearsFrom:
+      (getStartDate, maxYears = 1) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const startValue =
+          typeof getStartDate === "function" ? getStartDate() : getStartDate;
+
+        if (!hasValue(startValue)) return true;
 
-      const valid = formats.some((dateFormat) => {
-        const parsedDate = parse(value, dateFormat, new Date());
+        const startDate = parseInputDate(startValue);
+        const endDate = parseInputDate(value);
+
+        if (!startDate || !endDate) return true;
 
         return (
-          isValid(parsedDate) &&
-          format(parsedDate, dateFormat) === value
+          endDate <= addYears(startDate, maxYears) ||
+          t("validation.rules.date_max_years_from", { maxYears })
         );
-      });
+      },
 
-      return valid || t("validation.rules.date");
-    },
+    dateRangeDays:
+      (getStartDate, maxDays = 365) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const startValue =
+          typeof getStartDate === "function" ? getStartDate() : getStartDate;
+
+        if (!hasValue(startValue)) return true;
+
+        const startDate = parseInputDate(startValue);
+        const endDate = parseInputDate(value);
+
+        if (!startDate || !endDate) return true;
+
+        if (endDate < startDate) {
+          return t("validation.rules.date_before_start");
+        }
+
+        return (
+          differenceInCalendarDays(endDate, startDate) <= maxDays ||
+          t("validation.rules.date_range_days", { maxDays })
+        );
+      },
+
+    renewalEndDate:
+      (getCurrentEndDate, maxYears = 1) =>
+      (value) => {
+        const currentValue =
+          typeof getCurrentEndDate === "function"
+            ? getCurrentEndDate()
+            : getCurrentEndDate;
+
+        if (hasValue(currentValue) && !hasValue(value)) {
+          return t("validation.rules.renewal_end_date_cannot_decrease");
+        }
+
+        if (!hasValue(value)) return true;
+
+        const currentEndDate = parseInputDate(currentValue);
+        const newEndDate = parseInputDate(value);
+
+        if (!newEndDate) return true;
+
+        if (currentEndDate && newEndDate < currentEndDate) {
+          return t("validation.rules.renewal_end_date_cannot_decrease");
+        }
+
+        if (currentEndDate && newEndDate.getTime() === currentEndDate.getTime()) {
+          return true;
+        }
+
+        const maximumEndDate = addYears(startOfDay(new Date()), maxYears);
+
+        return (
+          newEndDate <= maximumEndDate ||
+          t("validation.rules.renewal_max_end_date", {
+            date: format(maximumEndDate, "dd/MM/yyyy"),
+          })
+        );
+      },
 
     durationHours: (value) => {
       const minutes = Number(value) * 60;
@@ -153,8 +232,7 @@ export const useInputRules = () => {
     password: (value) =>
       !value || passwordPattern.test(value) || t("validation.rules.password"),
 
-    positive: (value) =>
-      Number(value) > 0 || t("validation.rules.positive"),
+    positive: (value) => Number(value) > 0 || t("validation.rules.positive"),
 
     samePassword: (otherValue) => (value) =>
       value === otherValue || t("validation.rules.same_password"),
@@ -201,6 +279,20 @@ export const useInputRules = () => {
   };
 };
 
+const parseInputDate = (value) => {
+  const formats = ["dd/MM/yyyy", "yyyy-MM-dd"];
+
+  for (const dateFormat of formats) {
+    const parsedDate = parse(value, dateFormat, new Date());
+
+    if (isValid(parsedDate) && format(parsedDate, dateFormat) === value) {
+      return parsedDate;
+    }
+  }
+
+  return null;
+};
+
 const isValidCNPJ = (cnpj) => {
   if (!cnpj) return false;
 
@@ -274,4 +366,4 @@ const isValidCPF = (cpf) => {
   if (rev !== parseInt(cpf.charAt(10))) return false;
 
   return true;
-};
+};

+ 5 - 0
src/i18n/locales/en.json

@@ -176,6 +176,11 @@
       "required": "This field is required",
       "email": "This field must be a valid email | These fields must be valid emails",
       "date": "This field must be a valid date",
+      "date_before_start": "The end date cannot be before the start date",
+      "date_max_years_from": "The contract duration cannot exceed {maxYears} year from the signature date",
+      "date_range_days": "The contract period cannot exceed {maxDays} days",
+      "renewal_end_date_cannot_decrease": "The end date cannot be decreased or removed",
+      "renewal_max_end_date": "The renewal end date cannot be later than {date}",
       "duration_hours": "Enter a valid duration between 1 minute and 24 hours",
       "min": "This field must have at least",
       "max": "This field must have at most",

+ 5 - 0
src/i18n/locales/es.json

@@ -176,6 +176,11 @@
       "required": "Este campo es obligatorio",
       "email": "Este campo debe ser un correo electrónico válido | Estos campos deben ser correos electrónicos válidos",
       "date": "Este campo debe ser una fecha válida",
+      "date_before_start": "La fecha de finalización no puede ser anterior a la fecha de inicio",
+      "date_max_years_from": "La duración del contrato no puede superar {maxYears} año desde la fecha de firma",
+      "date_range_days": "El período del contrato no puede exceder {maxDays} días",
+      "renewal_end_date_cannot_decrease": "La fecha de finalización no puede reducirse ni eliminarse",
+      "renewal_max_end_date": "La fecha de finalización de la renovación no puede ser posterior al {date}",
       "duration_hours": "Ingrese una duración válida entre 1 minuto y 24 horas",
       "min": "Este campo debe tener al menos",
       "max": "Este campo debe tener como máximo",

+ 5 - 0
src/i18n/locales/pt.json

@@ -176,6 +176,11 @@
       "required": "Este campo é obrigatório",
       "email": "Este campo deve ser um e-mail válido | Estes campos devem ser e-mails válidos",
       "date": "Este campo deve ser uma data válida",
+      "date_before_start": "A data de encerramento não pode ser anterior à data de início",
+      "date_max_years_from": "A duração do contrato não pode ultrapassar {maxYears} ano a partir da assinatura",
+      "date_range_days": "O período do contrato não pode ultrapassar {maxDays} dias",
+      "renewal_end_date_cannot_decrease": "A data de encerramento não pode diminuir nem ser removida",
+      "renewal_max_end_date": "A data de encerramento da renovação não pode ser posterior a {date}",
       "duration_hours": "Informe uma duração válida entre 1 minuto e 24 horas",
       "min": "Este campo deve ter no mínimo",
       "max": "Este campo deve ter no máximo",

+ 259 - 117
src/pages/contracts/ContractsPage.vue

@@ -5,41 +5,53 @@
     <div class="q-px-sm">
       <div class="stat-cards-row q-mb-md">
         <DashboardStatCard
-          title="Contratos Ativos"
           icon="mdi-file-check-outline"
+          title="Contratos Ativos"
           :value="String(metrics.active)"
         />
+
         <DashboardStatCard
-          title="Contratos Trancados"
           icon="mdi-snowflake"
+          title="Contratos Trancados"
           :value="String(metrics.frozen)"
         />
+
         <DashboardStatCard
-          title="Contratos Cancelados"
           icon="mdi-file-remove-outline"
+          title="Contratos Cancelados"
           :value="String(metrics.cancelled)"
         />
+
         <DashboardStatCard
-          title="Contratos Inadimplentes"
           icon="mdi-alert-circle-outline"
+          title="Contratos Inadimplentes"
           value="0"
         />
       </div>
 
       <DefaultTable
         v-model:rows="rows"
+        descricao="contratos"
+        no-api-call
         title="Contratos"
         :columns
-        descricao="contratos"
         :feminino="false"
-        no-api-call
         :loading="isLoading"
       >
         <template #body-cell-contato="{ row }">
           <q-td>
-            <div>{{ row.student_phone }}</div>
+            <div>
+              {{ row.student_phone }}
+            </div>
+
             <div class="text-grey-6 text-caption">
-              {{ row.student_city }}<template v-if="row.student_city && row.student_state_code">, </template>{{ row.student_state_code }}
+              {{ row.student_city }}
+
+              <template v-if="row.student_city && row.student_state_code">
+                ,
+              </template>
+
+              {{ row.student_state_code }}
             </div>
           </q-td>
         </template>
@@ -55,50 +67,94 @@
 
         <template #body-cell-actions="{ row }">
           <q-td align="center">
-            <q-item-section class="no-wrap" style="flex-direction: row; gap: 4px">
-            <q-btn
-              outline
-              icon="mdi-eye-outline"
-              style="width: 36px"
-              @click.prevent.stop="handleView(row)"
-            />
-            <q-btn
-              v-if="canEdit"
-              outline
-              icon="mdi-dots-vertical"
-              style="width: 36px"
-              @click.prevent.stop
+            <q-item-section
+              class="no-wrap"
+              style="flex-direction: row; gap: 4px"
             >
-              <q-menu>
-                <q-list style="min-width: 170px">
-                  <template v-if="row.status === 'frozen' || row.status === 'cancelled'">
-                    <q-item
-                      v-close-popup
-                      clickable
-                      @click="handleReactivate(row)"
-                    >
-                      <q-item-section>Reativar Contrato</q-item-section>
-                    </q-item>
-                  </template>
-                  <template v-else>
-                    <q-item
-                      v-close-popup
-                      clickable
-                      @click="handleFreeze(row)"
-                    >
-                      <q-item-section>Trancar Contrato</q-item-section>
-                    </q-item>
-                    <q-item
-                      v-close-popup
-                      clickable
-                      @click="handleCancel(row)"
+              <q-btn
+                icon="mdi-eye-outline"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleView(row)"
+              >
+                <q-tooltip>
+                  Visualizar contrato
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="canEdit"
+                icon="mdi-pencil-outline"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleEdit(row)"
+              >
+                <q-tooltip>
+                  Editar contrato
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                icon="mdi-history"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleHistory(row)"
+              >
+                <q-tooltip>
+                  Ver histórico
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="canEdit"
+                icon="mdi-dots-vertical"
+                outline
+                style="width: 36px"
+                @click.prevent.stop
+              >
+                <q-menu>
+                  <q-list style="min-width: 170px">
+                    <template
+                      v-if="
+                        row.status === 'frozen' ||
+                        row.status === 'cancelled'
+                      "
                     >
-                      <q-item-section>Cancelar Contrato</q-item-section>
-                    </q-item>
-                  </template>
-                </q-list>
-              </q-menu>
-            </q-btn>
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleReactivate(row)"
+                      >
+                        <q-item-section>
+                          Reativar Contrato
+                        </q-item-section>
+                      </q-item>
+                    </template>
+
+                    <template v-else>
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleFreeze(row)"
+                      >
+                        <q-item-section>
+                          Trancar Contrato
+                        </q-item-section>
+                      </q-item>
+
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleCancel(row)"
+                      >
+                        <q-item-section>
+                          Cancelar Contrato
+                        </q-item-section>
+                      </q-item>
+                    </template>
+                  </q-list>
+                </q-menu>
+              </q-btn>
             </q-item-section>
           </q-td>
         </template>
@@ -108,123 +164,209 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from "vue";
-import { useQuasar } from "quasar";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
-import { userStore } from "src/stores/user";
-import { permissionStore } from "src/stores/permission";
-import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
-import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
-import ViewContractDialog from "src/pages/students/components/ViewContractDialog.vue";
 import {
-  getAllContracts,
   cancelContract,
+  getAllContracts,
   reactivateContract,
 } from "src/api/studentContract";
 
+import { computed, onMounted, ref, watch } from "vue";
+import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
+import { useQuasar } from "quasar";
+
+import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
+import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
+import ContractHistoryDialog from "src/pages/students/components/ContractHistoryDialog.vue";
+import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
+import ViewContractDialog from "src/pages/students/components/ViewContractDialog.vue";
+
+import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
 const $q = useQuasar();
 const store = userStore();
+
 const permissions = permissionStore();
+
+const isLoading = ref(false);
+const rows = ref([]);
+
 const canEdit = computed(() =>
   permissions.getAccess("franchisee_contracts", "edit"),
 );
-const rows = ref([]);
-const isLoading = ref(false);
 
 const metrics = computed(() => ({
-  active: rows.value.filter((r) => r.status === "active").length,
-  frozen: rows.value.filter((r) => r.status === "frozen").length,
-  cancelled: rows.value.filter((r) => r.status === "cancelled").length,
+  active: rows.value.filter((row) => row.status === "active").length,
+  cancelled: rows.value.filter((row) => row.status === "cancelled").length,
+  frozen: rows.value.filter((row) => row.status === "frozen").length,
 }));
 
-const columns = ref([
-  { name: "name", label: "Nome", field: "student_name", align: "left" },
-  { name: "contato", label: "Contato/Endereço", field: null, align: "left" },
-  { name: "package_name", label: "Pacote", field: "package_name", align: "left" },
-  { name: "status", label: "Status Contrato", field: "status", align: "center" },
-  { name: "actions", label: "Ações", field: null, align: "center" },
-]);
-
-async function loadContracts() {
-  isLoading.value = true;
-  try {
-    rows.value = await getAllContracts();
-  } finally {
-    isLoading.value = false;
-  }
-}
-
-function handleView(contract) {
-  $q.dialog({
-    component: ViewContractDialog,
-    componentProps: { id: contract.id },
-  });
-}
+const columns = [
+  {
+    align: "left",
+    field: "student_name",
+    label: "Nome",
+    name: "name",
+  },
+  {
+    align: "left",
+    field: null,
+    label: "Contato/Endereço",
+    name: "contato",
+  },
+  {
+    align: "left",
+    field: "package_name",
+    label: "Pacote",
+    name: "package_name",
+  },
+  {
+    align: "center",
+    field: "status",
+    label: "Status Contrato",
+    name: "status",
+  },
+  {
+    align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
+];
 
-function confirmAction(title, message, apiFn, contract) {
+const confirmAction = (title, message, apiFunction, contract) => {
   $q.dialog({
     component: ContractActionConfirmDialog,
-    componentProps: { title, message },
+    componentProps: {
+      message,
+      title,
+    },
   }).onOk(async () => {
     try {
-      const updated = await apiFn(contract.id);
-      const idx = rows.value.findIndex((r) => r.id === contract.id);
-      if (idx !== -1) rows.value[idx] = { ...rows.value[idx], status: updated.status };
-    } catch (e) {
-      console.error(e);
-      $q.notify({ type: "negative", message: "Erro ao atualizar status do contrato." });
-    }
-  });
-}
+      const updated = await apiFunction(contract.id);
 
-function handleFreeze(contract) {
-  $q.dialog({
-    component: FreezeContractDialog,
-    componentProps: { contract },
-  }).onOk((updated) => {
-    const idx = rows.value.findIndex((r) => r.id === contract.id);
-    if (idx !== -1) rows.value[idx] = { ...rows.value[idx], status: updated.status };
+      const index = rows.value.findIndex(
+        (row) => row.id === contract.id,
+      );
+
+      if (index !== -1) {
+        rows.value[index] = {
+          ...rows.value[index],
+          status: updated.status,
+        };
+      }
+    } catch (error) {
+      console.error(error);
+
+      $q.notify({
+        message: "Erro ao atualizar status do contrato.",
+        type: "negative",
+      });
+    }
   });
-}
+};
 
-function handleCancel(contract) {
+const handleCancel = (contract) => {
   confirmAction(
     "Cancelar Contrato",
     "Você tem certeza que deseja CANCELAR este contrato? Isso irá gerar multas e cancelamento da cobrança recorrente.",
     cancelContract,
     contract,
   );
-}
+};
+
+const handleEdit = (contract) => {
+  $q.dialog({
+    component: AddEditContractDialog,
+    componentProps: {
+      contract,
+    },
+  }).onOk(loadContracts);
+};
+
+const handleFreeze = (contract) => {
+  $q.dialog({
+    component: FreezeContractDialog,
+    componentProps: {
+      contract,
+    },
+  }).onOk((updated) => {
+    const index = rows.value.findIndex(
+      (row) => row.id === contract.id,
+    );
+
+    if (index !== -1) {
+      rows.value[index] = {
+        ...rows.value[index],
+        status: updated.status,
+      };
+    }
+  });
+};
 
-function handleReactivate(contract) {
+const handleHistory = (contract) => {
+  $q.dialog({
+    component: ContractHistoryDialog,
+    componentProps: {
+      contractData: contract,
+      id: contract.id,
+    },
+  });
+};
+
+const handleReactivate = (contract) => {
   confirmAction(
     "Reativar Contrato",
     "Você tem certeza que deseja REATIVAR este contrato?",
     reactivateContract,
     contract,
   );
-}
+};
 
-function statusColor(status) {
+const handleView = (contract) => {
+  $q.dialog({
+    component: ViewContractDialog,
+    componentProps: {
+      id: contract.id,
+    },
+  });
+};
+
+const loadContracts = async () => {
+  isLoading.value = true;
+
+  try {
+    rows.value = await getAllContracts();
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+const statusColor = (status) => {
   if (status === "active") return "positive";
   if (status === "frozen") return "info";
   if (status === "cancelled") return "negative";
+
   return "warning";
-}
+};
 
-function statusLabel(status) {
+const statusLabel = (status) => {
   if (status === "active") return "Ativo";
   if (status === "frozen") return "Trancado";
   if (status === "cancelled") return "Cancelado";
+
   return "Inativo";
-}
+};
 
 watch(
   () => store.selectedUnit?.id,
   (newId, oldId) => {
-    if (newId && newId !== oldId) loadContracts();
+    if (newId && newId !== oldId) {
+      loadContracts();
+    }
   },
 );
 

+ 119 - 92
src/pages/students/StudentPage.vue

@@ -1,8 +1,8 @@
 <template>
   <div>
     <DefaultHeaderPage
-      title="Alunos"
       show-filter-icon
+      title="Alunos"
       :filter-open="showFilter"
       @show-filter="showFilter = !showFilter"
     />
@@ -13,28 +13,28 @@
           <DefaultInputDatePicker
             v-model="startDate"
             v-model:untreated-date="startDateRaw"
+            class="filter-item"
+            color="secondary"
             dense
             label="Data Inicial"
-            color="secondary"
-            class="filter-item"
           />
 
           <DefaultInputDatePicker
             v-model="endDate"
             v-model:untreated-date="endDateRaw"
+            class="filter-item"
+            color="secondary"
             dense
             label="Data Final"
-            color="secondary"
-            class="filter-item"
           />
 
           <q-btn
             v-if="hasDateFilter"
-            flat
-            no-caps
             color="secondary"
+            flat
             icon="mdi-filter-remove-outline"
             label="Limpar"
+            no-caps
             @click="clearFilters"
           />
         </template>
@@ -44,19 +44,24 @@
     <div class="q-px-sm">
       <DefaultTable
         v-model:rows="rows"
-        title="Lista de alunos"
-        :columns
         descricao="Alunos"
-        :feminino="false"
         no-api-call
+        title="Lista de alunos"
         :add-item="canAdd"
+        :columns
+        :feminino="false"
         :loading="isLoading"
         @on-add-item="onAddStudent"
       >
         <template #body-cell-contato="{ row }">
           <q-td>
-            <div>{{ row.phone }}</div>
-            <div class="text-grey-6 text-caption">{{ row.city }}</div>
+            <div>
+              {{ row.phone }}
+            </div>
+
+            <div class="text-grey-6 text-caption">
+              {{ row.city }}
+            </div>
           </q-td>
         </template>
 
@@ -74,18 +79,19 @@
             <q-item-section class="no-wrap" style="flex-direction: row">
               <q-btn
                 v-if="canEdit"
-                outline
+                class="q-mr-sm"
                 icon="mdi-account-edit-outline"
+                outline
                 style="width: 36px"
-                class="q-mr-sm"
                 @click.prevent.stop="onEditStudent(row)"
               />
+
               <q-btn
                 v-if="canDelete"
-                outline
+                class="q-mr-sm"
                 icon="mdi-trash-can-outline"
+                outline
                 style="width: 36px"
-                class="q-mr-sm"
                 @click.prevent.stop="onDeleteStudent(row)"
               />
             </q-item-section>
@@ -97,139 +103,160 @@
 </template>
 
 <script setup>
-import { computed, defineAsyncComponent, ref, onMounted, watch } from "vue";
+import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue";
+
+import {
+  deleteStudent,
+  getStudents,
+} from "src/api/student";
+
+import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
 import { useQuasar } from "quasar";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import { getStudents, deleteStudent } from "src/api/student";
-import { userStore } from "src/stores/user";
-import { permissionStore } from "src/stores/permission";
-
-const AddEditStudentDialog = defineAsyncComponent(
-  () => import("src/pages/students/components/AddEditStudentDialog.vue"),
-);
-
-const EditStudentDialog = defineAsyncComponent(
-  () => import("src/pages/students/components/EditStudentDialog.vue"),
-);
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
 
 const $q = useQuasar();
-const store = userStore();
 const permissions = permissionStore();
+const store = userStore();
+
+const endDate = ref(null);
+const endDateRaw = ref(null);
+const isLoading = ref(false);
+const rows = ref([]);
+const showFilter = ref(false);
+const startDate = ref(null);
+const startDateRaw = ref(null);
+
 const canAdd = computed(() =>
   permissions.getAccess("franchisee_students", "add"),
 );
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_students", "delete"),
+);
+
 const canEdit = computed(() =>
   permissions.getAccess("franchisee_students", "edit"),
 );
-const canDelete = computed(() =>
-  permissions.getAccess("franchisee_students", "delete"),
+
+const hasDateFilter = computed(
+  () => !!startDateRaw.value || !!endDateRaw.value,
 );
 
-const rows = ref([]);
-const isLoading = ref(false);
-const showFilter = ref(false);
-const startDate = ref(null);
-const endDate = ref(null);
-const startDateRaw = ref(null);
-const endDateRaw = ref(null);
+const AddEditStudentDialog = defineAsyncComponent(
+  () => import("src/pages/students/components/AddEditStudentDialog.vue"),
+);
 
-const hasDateFilter = computed(() => !!startDateRaw.value || !!endDateRaw.value);
+const EditStudentDialog = defineAsyncComponent(
+  () => import("src/pages/students/components/EditStudentDialog.vue"),
+);
 
-const columns = ref([
+const columns = [
   {
-    name: "name",
-    label: "Nome",
-    field: "name",
     align: "left",
+    field: "name",
+    label: "Nome",
+    name: "name",
   },
   {
-    name: "contato",
-    label: "Contato",
-    field: "contato",
     align: "left",
+    field: "contato",
+    label: "Contato",
+    name: "contato",
   },
   {
-    name: "status",
-    label: "Status",
-    field: "status",
     align: "center",
+    field: "status",
+    label: "Status",
+    name: "status",
   },
   {
-    name: "actions",
-    label: "Ações",
-    field: null,
     align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
   },
-]);
+];
 
-async function loadStudents() {
+const clearFilters = () => {
+  endDate.value = null;
+  endDateRaw.value = null;
+  startDate.value = null;
+  startDateRaw.value = null;
+};
+
+const loadStudents = async () => {
   isLoading.value = true;
+
   try {
     rows.value = await getStudents({
-      contract_start_date: startDateRaw.value,
       contract_end_date: endDateRaw.value,
+      contract_start_date: startDateRaw.value,
     });
   } catch (error) {
     console.error("Failed to load students:", error);
   } finally {
     isLoading.value = false;
   }
-}
+};
 
-function clearFilters() {
-  startDate.value = null;
-  endDate.value = null;
-  startDateRaw.value = null;
-  endDateRaw.value = null;
-}
-
-function onAddStudent() {
+const onAddStudent = () => {
   $q.dialog({
     component: AddEditStudentDialog,
-  }).onOk(() => {
-    loadStudents();
-  });
-}
+  }).onOk(loadStudents);
+};
 
-function onEditStudent(student) {
+const onDeleteStudent = (student) => {
   $q.dialog({
-    component: EditStudentDialog,
-    componentProps: { student },
-  }).onOk(() => {
-    loadStudents();
-  });
-}
-
-function onDeleteStudent(student) {
-  $q.dialog({
-    title: "Excluir Aluno",
-    message: `Deseja excluir o aluno "${student.name}"?`,
     cancel: true,
+    message: `Deseja excluir o aluno "${student.name}"?`,
     persistent: true,
+    title: "Excluir Aluno",
   }).onOk(async () => {
     try {
       await deleteStudent(student.id);
-      rows.value = rows.value.filter((r) => r.id !== student.id);
-    } catch (e) {
-      console.error(e);
-      $q.notify({ type: "negative", message: "Erro ao excluir aluno." });
+
+      rows.value = rows.value.filter((row) => row.id !== student.id);
+    } catch (error) {
+      const responseData = error?.response?.data;
+
+      const validationMessage = Object.values(responseData?.errors ?? {})
+        .flat()
+        .find(Boolean);
+
+      $q.notify({
+        message:
+          responseData?.message ??
+          validationMessage ??
+          "Erro ao excluir aluno.",
+        type: "negative",
+      });
     }
   });
-}
+};
+
+const onEditStudent = (student) => {
+  $q.dialog({
+    component: EditStudentDialog,
+    componentProps: {
+      student,
+    },
+  }).onOk(loadStudents);
+};
 
-// Recarrega alunos sempre que a unidade ativa mudar
 watch(
   () => store.selectedUnit?.id,
   (newId, oldId) => {
-    if (newId && newId !== oldId) loadStudents();
+    if (newId && newId !== oldId) {
+      loadStudents();
+    }
   },
 );
 
-watch([startDateRaw, endDateRaw], () => {
-  loadStudents();
-});
+watch([startDateRaw, endDateRaw], loadStudents);
 
 onMounted(loadStudents);
 </script>
@@ -254,4 +281,4 @@ onMounted(loadStudents);
     max-width: 100%;
   }
 }
-</style>
+</style>

+ 198 - 422
src/pages/students/components/AddEditContractDialog.vue

@@ -1,145 +1,103 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card
-      class="q-dialog-plugin dialog-form-card"
-      :style="dialogCardStyle"
-    >
+    <q-card class="q-dialog-plugin dialog-form-card" :style="dialogCardStyle">
       <DefaultDialogHeader
         :title="props.contract ? 'Editar Contrato' : 'Novo Contrato'"
         @close="onDialogCancel"
       />
 
-      <template v-if="props.contract">
-        <q-tabs
-          v-model="activeTab"
-          active-color="primary"
-          align="left"
-          class="q-px-md text-grey-7"
-          dense
-          indicator-color="primary"
-        >
-          <q-tab
-            label="Dados do Contrato"
-            name="dados"
-          />
-
-          <q-tab
-            label="Mídias do Contrato"
-            name="midias"
-          />
-        </q-tabs>
-
-        <q-separator />
-      </template>
-
-      <DefaultForm
-        ref="formRef"
-        @submit="handleSave"
-      >
-        <q-scroll-area
-          ref="scrollAreaRef"
-          class="dialog-form-scroll"
-        >
+      <DefaultForm ref="formRef" @submit="handleSave">
+        <q-scroll-area ref="scrollAreaRef" class="dialog-form-scroll">
           <q-card-section class="q-pt-sm">
-            <div v-show="activeTab === 'dados'">
-              <DefaultInput
-                :model-value="store.user?.name"
-                class="q-mb-md"
-                label="Franqueado operador"
-                readonly
-              />
-
-              <div class="text-subtitle1 q-mb-md">
-                Dados da Unidade
-              </div>
-
-              <div class="row q-col-gutter-sm">
+            <div>
+              <template v-if="!props.contract">
                 <DefaultInput
-                  v-model:error="validationErrors.unit_id"
-                  :model-value="unitDetails.name"
-                  class="col-12 col-md-6"
-                  label="Nome da Unidade"
+                  :model-value="store.user?.name"
+                  class="q-mb-md"
+                  label="Franqueado operador"
                   readonly
                 />
 
-                <DefaultInput
-                  v-model:error="validationErrors.unit_id"
-                  :model-value="unitDetails.cnpj"
-                  :mask="masks.Brasil.cnpj"
-                  class="col-12 col-md-6"
-                  label="CNPJ"
-                  readonly
-                />
-              </div>
+                <div class="text-subtitle1 q-mb-md">Dados da Unidade</div>
 
-              <div class="text-subtitle1 q-mt-lg q-mb-md">
-                Dados do Aluno
-              </div>
+                <div class="row q-col-gutter-sm">
+                  <DefaultInput
+                    v-model:error="validationErrors.unit_id"
+                    :model-value="unitDetails.name"
+                    class="col-12 col-md-6"
+                    label="Nome da Unidade"
+                    readonly
+                  />
 
-              <div class="row q-col-gutter-sm">
-                <div
-                  v-if="props.selectStudent"
-                  class="col-12"
-                >
-                  <div class="row items-start no-wrap q-col-gutter-sm" @keydown.enter.prevent>
-                    <DefaultSelect
-                      v-model="selectedStudent"
-                      v-model:error="validationErrors.student_id"
-                      class="col"
-                      dropdown-icon="mdi-magnify"
-                      fill-input
-                      hide-selected
-                      input-debounce="0"
-                      label="Aluno"
-                      option-label="name"
-                      use-input
-                      :options="filteredStudents"
-                      :rules="[inputRules.required]"
-                      @filter="filterStudents"
-                    />
+                  <DefaultInput
+                    v-model:error="validationErrors.unit_id"
+                    :model-value="unitDetails.cnpj"
+                    :mask="masks.Brasil.cnpj"
+                    class="col-12 col-md-6"
+                    label="CNPJ"
+                    readonly
+                  />
+                </div>
+
+                <div class="text-subtitle1 q-mt-lg q-mb-md">Dados do Aluno</div>
 
+                <div class="row q-col-gutter-sm">
+                  <div v-if="props.selectStudent" class="col-12">
                     <div
-                      v-if="canAddStudents"
-                      class="col-auto"
+                      class="row items-start no-wrap q-col-gutter-sm"
+                      @keydown.enter.prevent
                     >
-                      <q-btn
-                        aria-label="Cadastrar novo aluno"
-                        color="primary"
-                        icon="mdi-plus"
-                        round
-                        unelevated
-                        @click="openCreateStudent"
-                      >
-                        <q-tooltip>
-                          Cadastrar novo aluno
-                        </q-tooltip>
-                      </q-btn>
+                      <DefaultSelect
+                        v-model="selectedStudent"
+                        v-model:error="validationErrors.student_id"
+                        class="col"
+                        dropdown-icon="mdi-magnify"
+                        fill-input
+                        hide-selected
+                        input-debounce="0"
+                        label="Aluno"
+                        option-label="name"
+                        use-input
+                        :options="filteredStudents"
+                        :rules="[inputRules.required]"
+                        @filter="filterStudents"
+                      />
+
+                      <div v-if="canAddStudents" class="col-auto">
+                        <q-btn
+                          aria-label="Cadastrar novo aluno"
+                          color="primary"
+                          icon="mdi-plus"
+                          round
+                          unelevated
+                          @click="openCreateStudent"
+                        >
+                          <q-tooltip> Cadastrar novo aluno </q-tooltip>
+                        </q-btn>
+                      </div>
                     </div>
                   </div>
-                </div>
 
-                <div
-                  v-if="!props.selectStudent"
-                  class="col-12"
-                >
-                  <DefaultInput
-                    v-model:error="validationErrors.student_id"
-                    :model-value="currentStudent?.name"
-                    :rules="[inputRules.required]"
-                    disable
-                    label="Aluno"
-                  />
-                </div>
+                  <div v-if="!props.selectStudent" class="col-12">
+                    <DefaultInput
+                      v-model:error="validationErrors.student_id"
+                      :model-value="currentStudent?.name"
+                      :rules="[inputRules.required]"
+                      disable
+                      label="Aluno"
+                    />
+                  </div>
 
-                <div class="col-12">
-                  <DefaultInput
-                    v-model:error="validationErrors.birth_date"
-                    :model-value="formattedBirthDate"
-                    disable
-                    label="Data de Nascimento"
-                  />
+                  <div class="col-12">
+                    <DefaultInput
+                      v-model:error="validationErrors.birth_date"
+                      :model-value="formattedBirthDate"
+                      disable
+                      label="Data de Nascimento"
+                    />
+                  </div>
                 </div>
-              </div>
+              </template>
 
               <div class="text-subtitle1 q-mt-lg q-mb-md">
                 Dados do Contrato
@@ -159,8 +117,9 @@
                   <DefaultInputDatePicker
                     v-model="form.signature_date"
                     v-model:error="validationErrors.signature_date"
-                    label="Data Assinatura"
+                    :disable="!!props.contract"
                     :rules="[inputRules.date]"
+                    label="Data de Início"
                   />
                 </div>
 
@@ -169,7 +128,18 @@
                     v-model="form.end_date"
                     v-model:error="validationErrors.end_date"
                     label="Data Encerramento"
-                    :rules="[inputRules.date]"
+                    reactive-rules
+                    :lazy-rules="false"
+                    :rules="[
+                      inputRules.date,
+                      props.contract
+                        ? inputRules.renewalEndDate(
+                            () => props.contract?.end_date,
+                          )
+                        : inputRules.dateMaxYearsFrom(
+                            () => form.signature_date,
+                          ),
+                    ]"
                   />
                 </div>
 
@@ -183,7 +153,7 @@
                     option-label="name"
                     option-value="id"
                     :options="packages"
-                    :rules="[inputRules.required]"
+                    :rules="props.contract ? [] : [inputRules.required]"
                   />
                 </div>
 
@@ -198,6 +168,22 @@
                   />
                 </div>
 
+                <div class="col-6">
+                  <DefaultCurrencyInput
+                    :model-value="form.package_value"
+                    disable
+                    label="Total do Contrato"
+                  />
+                </div>
+
+                <div class="col-6">
+                  <DefaultInput
+                    :model-value="contractWeeks"
+                    disable
+                    label="Total de Semanas"
+                  />
+                </div>
+
                 <div class="col-4">
                   <DefaultSelect
                     v-model="form.weekday"
@@ -308,8 +294,8 @@
                   <DefaultCurrencyInput
                     v-model="form.enrollment_fee"
                     v-model:error="validationErrors.tax_register"
-                    disable
                     label="Valor da Matrícula"
+                    :disable="!props.contract"
                   />
                 </div>
 
@@ -355,8 +341,8 @@
                   <DefaultCurrencyInput
                     v-model="form.package_value"
                     v-model:error="validationErrors.package_value"
-                    disable
                     label="Valor do Pacote"
+                    :disable="!props.contract"
                   />
                 </div>
 
@@ -400,10 +386,7 @@
                     v-model:error="validationErrors.early_payment_discount"
                     label="Desconto até o vencimento (%)"
                     type="number"
-                    :rules="[
-                      inputRules.minValue(0),
-                      inputRules.maxValue(100),
-                    ]"
+                    :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
                   />
                 </div>
 
@@ -418,11 +401,7 @@
                     option-value="value"
                     :options="paymentMethods"
                     :rules="[
-                      inputRules.oneOf([
-                        'pix',
-                        'credit_card',
-                        'debit_card',
-                      ]),
+                      inputRules.oneOf(['pix', 'credit_card', 'debit_card']),
                     ]"
                   />
                 </div>
@@ -443,61 +422,15 @@
                     v-model:error="validationErrors.fine_cancelled"
                     label="Multa (%)"
                     type="number"
-                    :rules="[
-                      inputRules.minValue(0),
-                      inputRules.maxValue(100),
-                    ]"
+                    :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
                   />
                 </div>
               </div>
             </div>
-
-            <div
-              v-if="props.contract"
-              v-show="activeTab === 'midias'"
-            >
-              <DefaultTable
-                v-model:rows="medias"
-                :columns="mediaColumns"
-                :feminino="true"
-                :loading="loadingMedias"
-                :show-search-field="false"
-                descricao="mídias"
-                no-api-call
-                title="Mídias"
-              >
-                <template #body-cell-actions="{ row }">
-                  <q-td align="center">
-                    <q-item-section
-                      class="no-wrap"
-                      style="flex-direction: row; gap: 4px"
-                    >
-                      <q-btn
-                        icon="mdi-eye-outline"
-                        outline
-                        style="width: 36px"
-                        @click.prevent.stop="openFile(row.file_url)"
-                      />
-
-                      <q-btn
-                        color="negative"
-                        icon="mdi-trash-can-outline"
-                        outline
-                        style="width: 36px"
-                        @click.prevent.stop="handleDeleteMedia(row)"
-                      />
-                    </q-item-section>
-                  </q-td>
-                </template>
-              </DefaultTable>
-            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions
-          align="right"
-          class="q-px-md q-pb-md"
-        >
+        <q-card-actions align="right" class="q-px-md q-pb-md">
           <q-btn
             color="primary"
             label="Cancelar"
@@ -507,7 +440,6 @@
           />
 
           <q-btn
-            v-if="activeTab === 'dados'"
             color="primary"
             label="Salvar"
             no-caps
@@ -521,32 +453,21 @@
 </template>
 
 <script setup>
-import { addYears, format } from "date-fns";
+import {
+  addYears,
+  differenceInCalendarDays,
+  format,
+  isValid,
+  parse,
+} from "date-fns";
 
 import {
   createStudentContract,
   updateStudentContract,
 } from "src/api/studentContract";
 
-import {
-  computed,
-  nextTick,
-  onMounted,
-  ref,
-  useTemplateRef,
-  watch,
-} from "vue";
-
-import {
-  deleteContractMedia,
-  getContractMedias,
-} from "src/api/student_media";
-
-import {
-  formatDateDMYtoYMD,
-  formatDateYMDtoDMY,
-} from "src/helpers/utils";
-
+import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from "vue";
+import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
 import { getFinancialMe } from "src/api/unit_financial";
 import { getStudentsForSelect } from "src/api/student";
 import { getUnitMe } from "src/api/unit";
@@ -567,7 +488,6 @@ import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import AddEditStudentDialog from "src/pages/students/components/AddEditStudentDialog.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
@@ -600,12 +520,9 @@ const store = userStore();
 const formRef = useTemplateRef("formRef");
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-const activeTab = ref("dados");
 const dateConfirmationOpen = ref(false);
 const dateOverrideConfirmed = ref(false);
 const filteredStudents = ref([]);
-const loadingMedias = ref(false);
-const medias = ref([]);
 const packages = ref([]);
 const restoringDefaultDates = ref(false);
 const selectedStudent = ref(props.student);
@@ -622,9 +539,25 @@ const canViewFinancial = computed(() =>
   permissions.getAccess("franchisee_financial", "view"),
 );
 
-const currentStudent = computed(
-  () => selectedStudent.value ?? props.student,
-);
+const currentStudent = computed(() => selectedStudent.value ?? props.student);
+
+const contractWeeks = computed(() => {
+  if (!form.signature_date || !form.end_date) return null;
+
+  const startDate = parse(form.signature_date, "dd/MM/yyyy", new Date());
+
+  const endDate = parse(form.end_date, "dd/MM/yyyy", new Date());
+
+  if (!isValid(startDate) || !isValid(endDate)) {
+    return null;
+  }
+
+  const days = differenceInCalendarDays(endDate, startDate);
+
+  if (days < 0) return null;
+
+  return Math.max(1, Math.round(days / 7));
+});
 
 const dialogCardStyle = computed(() => ({
   height: props.selectStudent ? "95vh" : "auto",
@@ -638,9 +571,7 @@ const enrollmentInstallmentValue = computed(() => {
     return null;
   }
 
-  return Number(
-    (form.enrollment_fee / form.installments).toFixed(2),
-  );
+  return Number((form.enrollment_fee / form.installments).toFixed(2));
 });
 
 const formattedBirthDate = computed(() =>
@@ -654,12 +585,7 @@ const packageInstallmentValue = computed(() => {
     return null;
   }
 
-  return Number(
-    (
-      form.package_value /
-      form.package_installments
-    ).toFixed(2),
-  );
+  return Number((form.package_value / form.package_installments).toFixed(2));
 });
 
 const completeDatePattern = /^\d{2}\/\d{2}\/\d{4}$/;
@@ -675,36 +601,15 @@ const defaultContractDates = (() => {
   };
 })();
 
-const enrollmentInstallmentOptions = Array.from(
-  { length: 12 },
-  (_, index) => ({
-    label: `${index + 1}x`,
-    value: index + 1,
-  }),
-);
-
-const mediaColumns = [
-  {
-    align: "left",
-    field: "created_at",
-    label: "Data de Anexo",
-    name: "created_at",
-  },
-  {
-    align: "center",
-    field: null,
-    label: "Ações",
-    name: "actions",
-  },
-];
+const enrollmentInstallmentOptions = Array.from({ length: 12 }, (_, index) => ({
+  label: `${index + 1}x`,
+  value: index + 1,
+}));
 
-const packageInstallmentOptions = Array.from(
-  { length: 13 },
-  (_, index) => ({
-    label: `${index + 1}x`,
-    value: index + 1,
-  }),
-);
+const packageInstallmentOptions = Array.from({ length: 13 }, (_, index) => ({
+  label: `${index + 1}x`,
+  value: index + 1,
+}));
 
 const paymentMethods = [
   {
@@ -752,25 +657,19 @@ const weekdays = [
   },
 ];
 
-const trimTime = (time) =>
-  time ? time.slice(0, 5) : null;
+const trimTime = (time) => (time ? time.slice(0, 5) : null);
 
 const { form, getUpdatedFields } = useForm({
   class_quantity: props.contract?.class_quantity ?? null,
-  due_day: props.contract?.recurring_day ?? null,
+  due_day: props.contract?.due_day ?? null,
 
-  early_payment_discount:
-    props.contract?.early_payment_discount ?? null,
+  early_payment_discount: props.contract?.early_payment_discount ?? null,
 
-  end_date:
-    props.contract?.end_date ??
-    defaultContractDates?.end_date ??
-    null,
+  end_date: props.contract?.end_date ?? defaultContractDates?.end_date ?? null,
 
   end_time: trimTime(props.contract?.end_time),
 
-  enrollment_due_date:
-    props.contract?.enrollment_due_date ?? null,
+  enrollment_due_date: props.contract?.enrollment_due_date ?? null,
 
   enrollment_fee: props.contract?.tax_register ?? null,
   installments: props.contract?.installments ?? null,
@@ -778,26 +677,19 @@ const { form, getUpdatedFields } = useForm({
   late_fee: props.contract?.fine_cancelled ?? null,
   package_due_date: props.contract?.package_due_date ?? null,
 
-  package_id:
-    props.contract?.class_package_unit_id ?? null,
+  package_id: props.contract?.class_package_unit_id ?? null,
 
-  package_installments:
-    props.contract?.package_installments ?? null,
+  package_installments: props.contract?.package_installments ?? null,
 
   package_value: props.contract?.package_value ?? null,
   payment_method: props.contract?.payment_method ?? null,
   protocol: props.contract?.protocol ?? null,
 
-  second_end_time: trimTime(
-    props.contract?.second_end_time,
-  ),
+  second_end_time: trimTime(props.contract?.second_end_time),
 
-  second_start_time: trimTime(
-    props.contract?.second_start_time,
-  ),
+  second_start_time: trimTime(props.contract?.second_start_time),
 
-  second_weekday:
-    props.contract?.second_weekday ?? null,
+  second_weekday: props.contract?.second_weekday ?? null,
 
   signature_date:
     props.contract?.signature_date ??
@@ -833,9 +725,7 @@ const buildDateFromDay = (day) => {
   const nextMonthIndex = (now.getMonth() + 1) % 12;
 
   const nextYear =
-    now.getMonth() === 11
-      ? now.getFullYear() + 1
-      : now.getFullYear();
+    now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear();
 
   const lastDayOfNextMonth = new Date(
     nextYear,
@@ -843,10 +733,7 @@ const buildDateFromDay = (day) => {
     0,
   ).getDate();
 
-  const safeDay = Math.min(
-    parsedDay,
-    lastDayOfNextMonth,
-  );
+  const safeDay = Math.min(parsedDay, lastDayOfNextMonth);
 
   return `${String(safeDay).padStart(2, "0")}/${String(
     nextMonthIndex + 1,
@@ -857,15 +744,11 @@ const buildPayload = () => ({
   class_package_unit_id: form.package_id,
   class_quantity: form.class_quantity,
 
-  due_day: form.due_day
-    ? Number.parseInt(form.due_day)
-    : null,
+  due_day: form.due_day ? Number.parseInt(form.due_day) : null,
 
   early_payment_discount: form.early_payment_discount,
 
-  end_date: form.end_date
-    ? formatDateDMYtoYMD(form.end_date)
-    : null,
+  end_date: form.end_date ? formatDateDMYtoYMD(form.end_date) : null,
 
   end_time: form.end_time,
 
@@ -899,94 +782,36 @@ const buildPayload = () => ({
   weekday: form.weekday,
 });
 
-const calculateEndTime = (
-  startTime,
-  durationMinutes = 120,
-) => {
+const calculateEndTime = (startTime, durationMinutes = 120) => {
   if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) {
     return null;
   }
 
-  const [hours, minutes] = startTime
-    .split(":")
-    .map(Number);
+  const [hours, minutes] = startTime.split(":").map(Number);
 
   if (hours > 23 || minutes > 59) {
     return null;
   }
 
-  const endMinutes =
-    (hours * 60 + minutes + durationMinutes) %
-    (24 * 60);
+  const endMinutes = (hours * 60 + minutes + durationMinutes) % (24 * 60);
 
-  return `${String(
-    Math.floor(endMinutes / 60),
-  ).padStart(2, "0")}:${String(
+  return `${String(Math.floor(endMinutes / 60)).padStart(2, "0")}:${String(
     endMinutes % 60,
   ).padStart(2, "0")}`;
 };
 
-const fetchMedias = async () => {
-  if (!props.contract) return;
-
-  loadingMedias.value = true;
-
-  try {
-    medias.value = await getContractMedias(
-      props.contract.id,
-    );
-  } finally {
-    loadingMedias.value = false;
-  }
-};
-
 const filterStudents = (value, update) => {
   update(() => {
-    const search = value
-      .trim()
-      .toLocaleLowerCase("pt-BR");
+    const search = value.trim().toLocaleLowerCase("pt-BR");
 
     filteredStudents.value = search
       ? students.value.filter((student) =>
-          student.name
-            .toLocaleLowerCase("pt-BR")
-            .includes(search),
+          student.name.toLocaleLowerCase("pt-BR").includes(search),
         )
       : students.value;
   });
 };
 
-const handleDeleteMedia = (media) => {
-  $q.dialog({
-    cancel: {
-      color: "primary",
-      label: "Cancelar",
-      outline: true,
-    },
-    message: "Deseja excluir esta mídia permanentemente?",
-    ok: {
-      color: "negative",
-      label: "Excluir",
-    },
-    title: "Excluir mídia",
-  }).onOk(async () => {
-    try {
-      await deleteContractMedia(media.id);
-
-      medias.value = medias.value.filter(
-        (item) => item.id !== media.id,
-      );
-    } catch (error) {
-      console.error(error);
-
-      $q.notify({
-        message: "Erro ao excluir mídia.",
-        type: "negative",
-      });
-    }
-  });
-};
-
 const handleSave = async () => {
   const payload = buildPayload();
 
@@ -997,9 +822,7 @@ const handleSave = async () => {
 
     const changedFields = getUpdatedFields.value;
 
-    const updatePayload = {
-      student_id: currentStudent.value?.id ?? null,
-    };
+    const updatePayload = {};
 
     const fieldMap = {
       enrollment_fee: "tax_register",
@@ -1029,9 +852,7 @@ const handleSave = async () => {
       }
     }
 
-    for (const [source, target] of Object.entries(
-      fieldMap,
-    )) {
+    for (const [source, target] of Object.entries(fieldMap)) {
       if (source in changedFields) {
         updatePayload[target] = payload[target];
       }
@@ -1052,10 +873,7 @@ const handleSave = async () => {
       updatePayload.due_day = payload.due_day;
     }
 
-    return updateStudentContract(
-      props.contract.id,
-      updatePayload,
-    );
+    return updateStudentContract(props.contract.id, updatePayload);
   });
 };
 
@@ -1069,16 +887,11 @@ const openCreateStudent = () => {
     filteredStudents.value = unitStudents;
 
     selectedStudent.value =
-      unitStudents.find(
-        (student) => student.id === createdStudent?.id,
-      ) ?? createdStudent;
+      unitStudents.find((student) => student.id === createdStudent?.id) ??
+      createdStudent;
   });
 };
 
-const openFile = (url) => {
-  window.open(url, "_blank");
-};
-
 const restoreDefaultContractDates = () => {
   restoringDefaultDates.value = true;
 
@@ -1105,17 +918,14 @@ watch(
     }
 
     const datesAreStillDefault =
-      signatureDate ===
-        defaultContractDates.signature_date &&
+      signatureDate === defaultContractDates.signature_date &&
       endDate === defaultContractDates.end_date;
 
     if (datesAreStillDefault) return;
 
     const datesAreComplete =
-      (!signatureDate ||
-        completeDatePattern.test(signatureDate)) &&
-      (!endDate ||
-        completeDatePattern.test(endDate));
+      (!signatureDate || completeDatePattern.test(signatureDate)) &&
+      (!endDate || completeDatePattern.test(endDate));
 
     if (!datesAreComplete) return;
 
@@ -1169,14 +979,9 @@ watch(
 
     if (!dateString || dateString.length < 8) return;
 
-    const day = Number.parseInt(
-      dateString.split("/")[0],
-    );
+    const day = Number.parseInt(dateString.split("/")[0]);
 
-    if (
-      !Number.isNaN(day) &&
-      day !== Number.parseInt(form.due_day)
-    ) {
+    if (!Number.isNaN(day) && day !== Number.parseInt(form.due_day)) {
       syncingFromDate.value = true;
       form.due_day = day;
 
@@ -1187,50 +992,33 @@ watch(
   },
 );
 
-watch(activeTab, (tab) => {
-  if (tab === "midias") {
-    fetchMedias();
-  }
-});
-
 watch(
   () => form.package_id,
   (id) => {
-    const selectedPackage = packages.value.find(
-      (item) => item.id === id,
-    );
+    const selectedPackage = packages.value.find((item) => item.id === id);
 
     if (!selectedPackage) return;
 
-    form.class_quantity =
-      selectedPackage.quantity_classes;
+    form.class_quantity = selectedPackage.quantity_classes;
 
-    form.total_classes =
-      selectedPackage.quantity_classes;
+    form.total_classes = selectedPackage.quantity_classes;
 
-    form.enrollment_fee =
-      selectedPackage.contract_register_value;
+    form.enrollment_fee = selectedPackage.contract_register_value;
 
-    form.package_value =
-      selectedPackage.contract_value;
+    form.package_value = selectedPackage.contract_value;
 
     form.weekday = selectedPackage.weekday ?? null;
 
-    form.start_time = trimTime(
-      selectedPackage.start_time,
-    );
+    form.start_time = trimTime(selectedPackage.start_time);
 
     form.end_time = calculateEndTime(
       form.start_time,
       selectedPackage.class_duration_minutes ?? 120,
     );
 
-    form.second_weekday =
-      selectedPackage.second_weekday ?? null;
+    form.second_weekday = selectedPackage.second_weekday ?? null;
 
-    form.second_start_time = trimTime(
-      selectedPackage.second_start_time,
-    );
+    form.second_start_time = trimTime(selectedPackage.second_start_time);
 
     form.second_end_time = calculateEndTime(
       form.second_start_time,
@@ -1277,33 +1065,23 @@ watch(
 
 onMounted(async () => {
   const unitRequest = getUnitMe().catch((error) => {
-    console.error(
-      "Falha ao carregar os dados da unidade:",
-      error,
-    );
+    console.error("Falha ao carregar os dados da unidade:", error);
 
     return store.selectedUnit;
   });
 
-  const requests = [
-    getUnitPackagesForSelect(),
-    unitRequest,
-  ];
+  const requests = [getUnitPackagesForSelect(), unitRequest];
 
   if (props.selectStudent) {
     requests.push(getStudentsForSelect());
   }
 
-  const [
-    unitPackages,
-    unitData,
-    unitStudents = [],
-  ] = await Promise.all(requests);
+  const [unitPackages, unitData, unitStudents = []] =
+    await Promise.all(requests);
 
   packages.value = unitPackages;
 
-  unitDetails.value =
-    unitData ?? store.selectedUnit ?? {};
+  unitDetails.value = unitData ?? store.selectedUnit ?? {};
 
   students.value = unitStudents;
   filteredStudents.value = unitStudents;
@@ -1315,13 +1093,11 @@ onMounted(async () => {
       if (!financial) return;
 
       if (financial.default_discount != null) {
-        form.early_payment_discount =
-          financial.default_discount;
+        form.early_payment_discount = financial.default_discount;
       }
 
       if (financial.default_interest != null) {
-        form.interest_rate =
-          financial.default_interest;
+        form.interest_rate = financial.default_interest;
       }
 
       if (financial.default_fine != null) {

+ 256 - 0
src/pages/students/components/ContractHistoryDialog.vue

@@ -0,0 +1,256 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        title="Histórico do Contrato"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section
+        v-if="loading"
+        class="flex flex-center"
+        style="min-height: 280px"
+      >
+        <q-spinner color="primary" size="40px" />
+      </q-card-section>
+
+      <q-card-section v-else class="q-pt-sm">
+        <div v-if="contract" class="row q-col-gutter-sm q-mb-md">
+          <DefaultInput
+            :model-value="contract.student_name"
+            class="col-12 col-md-6"
+            disable
+            label="Aluno"
+          />
+
+          <DefaultInput
+            :model-value="contract.version"
+            class="col-12 col-md-6"
+            disable
+            label="Versão Atual"
+          />
+        </div>
+
+        <q-table
+          bordered
+          flat
+          no-data-label="Nenhuma versão anterior encontrada."
+          row-key="id"
+          :columns="columns"
+          :rows="historyRows"
+          :rows-per-page-options="[10, 20, 0]"
+        >
+          <template #body-cell-period="{ row }">
+            <q-td>
+              {{ row.signature_date || "—" }} — {{ row.end_date || "—" }}
+            </q-td>
+          </template>
+
+          <template #body-cell-package_value="{ row }">
+            <q-td align="right">
+              {{ formatCurrency(row.package_value) }}
+            </q-td>
+          </template>
+
+          <template #body-cell-status="{ row }">
+            <q-td align="center">
+              <q-badge
+                :color="statusColor(row.status)"
+                :label="statusLabel(row.status)"
+              />
+            </q-td>
+          </template>
+
+          <template #body-cell-updated_at="{ row }">
+            <q-td>
+              {{ formatDateTime(row.updated_at) }}
+            </q-td>
+          </template>
+        </q-table>
+      </q-card-section>
+
+      <q-card-actions align="right" class="q-px-md q-pb-md">
+        <q-btn
+          color="primary"
+          label="Fechar"
+          no-caps
+          outline
+          @click="onDialogCancel"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { getUnitPackagesForSelect } from "src/api/package";
+import { getStudentContractById } from "src/api/studentContract";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  id: {
+    type: Number,
+    required: true,
+  },
+  contractData: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const contract = ref(null);
+const loading = ref(false);
+const packages = ref([]);
+
+const columns = [
+  {
+    align: "center",
+    field: "version",
+    label: "Versão",
+    name: "version",
+  },
+  {
+    align: "left",
+    field: "created_by_user_name",
+    label: "Alterado por",
+    name: "created_by_user_name",
+  },
+  {
+    align: "left",
+    field: "protocol",
+    label: "Protocolo",
+    name: "protocol",
+  },
+  {
+    align: "left",
+    field: null,
+    label: "Período",
+    name: "period",
+  },
+  {
+    align: "left",
+    field: "package_name",
+    label: "Pacote",
+    name: "package_name",
+  },
+  {
+    align: "right",
+    field: "package_value",
+    label: "Total do Contrato",
+    name: "package_value",
+  },
+  {
+    align: "center",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "left",
+    field: "updated_at",
+    label: "Alterado em",
+    name: "updated_at",
+  },
+];
+
+const normalizeId = (value) => (value == null ? null : String(value));
+
+const packageNames = computed(
+  () =>
+    new Map(
+      packages.value.map((item) => [normalizeId(item.id), item.name]),
+    ),
+);
+
+const historyRows = computed(() =>
+  [...(contract.value?.version_history ?? [])]
+    .map((item) => ({
+      ...item,
+      package_name:
+        item.package_name ??
+        packageNames.value.get(normalizeId(item.class_package_unit_id)) ??
+        (normalizeId(item.class_package_unit_id) ===
+        normalizeId(props.contractData?.class_package_unit_id)
+          ? props.contractData?.package_name
+          : null),
+    }))
+    .sort(
+      (first, second) => Number(second.version) - Number(first.version),
+    ),
+);
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const formatDateTime = (value) => {
+  if (!value) return "—";
+
+  const [date, time = ""] = value.split(" ");
+  const [year, month, day] = date.split("-");
+
+  if (!year || !month || !day) return value;
+
+  return `${day}/${month}/${year}${time ? ` ${time.slice(0, 5)}` : ""}`;
+};
+
+const statusColor = (status) => {
+  if (status === "active") return "positive";
+  if (status === "frozen") return "info";
+  if (status === "cancelled") return "negative";
+
+  return "warning";
+};
+
+const statusLabel = (status) => {
+  if (status === "active") return "Ativo";
+  if (status === "frozen") return "Trancado";
+  if (status === "cancelled") return "Cancelado";
+
+  return "Inativo";
+};
+
+onMounted(async () => {
+  loading.value = true;
+
+  try {
+    const [loadedContract, unitPackages] = await Promise.all([
+      getStudentContractById(props.id),
+      getUnitPackagesForSelect().catch(() => []),
+    ]);
+
+    packages.value = unitPackages;
+
+    contract.value = {
+      ...props.contractData,
+      ...loadedContract,
+      student_name:
+        loadedContract.student_name ?? props.contractData?.student_name,
+    };
+  } catch (error) {
+    console.error(error);
+
+    $q.notify({
+      message: "Não foi possível carregar o histórico do contrato.",
+      type: "negative",
+    });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 55 - 1
src/pages/students/components/ViewContractDialog.vue

@@ -125,6 +125,22 @@
                 />
               </div>
 
+              <div class="col-6">
+                <DefaultCurrencyInput
+                  :model-value="contract.package_value"
+                  disable
+                  label="Total do Contrato"
+                />
+              </div>
+
+              <div class="col-6">
+                <DefaultInput
+                  :model-value="contractWeeks"
+                  disable
+                  label="Total de Semanas"
+                />
+              </div>
+
               <div class="col-4">
                 <DefaultInput
                   :model-value="weekdayLabel(contract.weekday)"
@@ -338,9 +354,16 @@
 </template>
 
 <script setup>
+import { computed, onMounted, ref, watch } from "vue";
+
+import {
+  differenceInCalendarDays,
+  isValid,
+  parse,
+} from "date-fns";
+
 import { getContractMedias } from "src/api/student_media";
 import { getStudentContractById } from "src/api/studentContract";
-import { onMounted, ref, watch } from "vue";
 import { useDialogPluginComponent } from "quasar";
 import { useInputRules } from "src/composables/useInputRules";
 
@@ -371,6 +394,37 @@ const loading = ref(false);
 const loadingMedias = ref(false);
 const medias = ref([]);
 
+const contractWeeks = computed(() => {
+  if (!contract.value?.signature_date || !contract.value?.end_date) {
+    return null;
+  }
+
+  const startDate = parse(
+    contract.value.signature_date,
+    "dd/MM/yyyy",
+    new Date(),
+  );
+
+  const endDate = parse(
+    contract.value.end_date,
+    "dd/MM/yyyy",
+    new Date(),
+  );
+
+  if (!isValid(startDate) || !isValid(endDate)) {
+    return null;
+  }
+
+  const days = differenceInCalendarDays(
+    endDate,
+    startDate,
+  );
+
+  return days < 0
+    ? null
+    : Math.max(1, Math.round(days / 7));
+});
+
 const mediaColumns = [
   {
     align: "left",

+ 225 - 109
src/pages/students/tabs/ContractTab.vue

@@ -2,25 +2,27 @@
   <div>
     <input
       ref="fileInputRef"
-      type="file"
       accept="image/*,video/*,.pdf"
       style="display: none"
+      type="file"
       @change="onFileSelected"
     />
 
     <DefaultTable
       v-model:rows="rows"
-      title="Contratos"
-      :columns
       descricao="contratos"
-      :feminino="false"
       no-api-call
+      title="Contratos"
       :add-item="canAdd"
+      :columns
+      :feminino="false"
       :show-search-field="false"
       @on-add-item="handleAdd"
     >
       <template #body-cell-period="{ row }">
-        <q-td>{{ row.signature_date }} — {{ row.end_date }}</q-td>
+        <q-td>
+          {{ row.signature_date }} — {{ row.end_date }}
+        </q-td>
       </template>
 
       <template #body-cell-status="{ row }">
@@ -34,54 +36,87 @@
 
       <template #body-cell-actions="{ row }">
         <q-td align="center">
-          <q-item-section class="no-wrap" style="flex-direction: row; gap: 4px">
+          <q-item-section
+            class="no-wrap"
+            style="flex-direction: row; gap: 4px"
+          >
             <q-btn
               v-if="canEditContract"
-              outline
               icon="mdi-pencil-outline"
+              outline
               style="width: 36px"
               @click.prevent.stop="handleEdit(row)"
-            />
+            >
+              <q-tooltip>
+                Editar contrato
+              </q-tooltip>
+            </q-btn>
+
             <q-btn
-              v-if="canEdit"
+              icon="mdi-history"
               outline
+              style="width: 36px"
+              @click.prevent.stop="handleHistory(row)"
+            >
+              <q-tooltip>
+                Ver histórico
+              </q-tooltip>
+            </q-btn>
+
+            <q-btn
+              v-if="canEdit"
               icon="mdi-paperclip"
+              outline
               style="width: 36px"
               :loading="uploadingId === row.id"
               @click.prevent.stop="triggerFileInput(row)"
             />
+
             <q-btn
               v-if="canEdit"
-              outline
               icon="mdi-dots-vertical"
+              outline
               style="width: 36px"
               @click.prevent.stop
             >
               <q-menu>
                 <q-list style="min-width: 170px">
-                  <template v-if="row.status === 'frozen' || row.status === 'cancelled'">
+                  <template
+                    v-if="
+                      row.status === 'frozen' ||
+                      row.status === 'cancelled'
+                    "
+                  >
                     <q-item
                       v-close-popup
                       clickable
                       @click="handleReactivate(row)"
                     >
-                      <q-item-section>Reativar Contrato</q-item-section>
+                      <q-item-section>
+                        Reativar Contrato
+                      </q-item-section>
                     </q-item>
                   </template>
+
                   <template v-else>
                     <q-item
                       v-close-popup
                       clickable
                       @click="handleFreeze(row)"
                     >
-                      <q-item-section>Trancar Contrato</q-item-section>
+                      <q-item-section>
+                        Trancar Contrato
+                      </q-item-section>
                     </q-item>
+
                     <q-item
                       v-close-popup
                       clickable
                       @click="handleCancel(row)"
                     >
-                      <q-item-section>Cancelar Contrato</q-item-section>
+                      <q-item-section>
+                        Cancelar Contrato
+                      </q-item-section>
                     </q-item>
                   </template>
                 </q-list>
@@ -95,153 +130,234 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from "vue";
-import { useQuasar } from "quasar";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
-import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
-import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
 import {
-  getStudentContracts,
   attachContractFile,
   cancelContract,
+  getStudentContracts,
   reactivateContract,
 } from "src/api/studentContract";
+
+import { computed, onMounted, ref } from "vue";
 import { permissionStore } from "src/stores/permission";
+import { useQuasar } from "quasar";
+
+import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
+import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
+import ContractHistoryDialog from "src/pages/students/components/ContractHistoryDialog.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
 
 const props = defineProps({
   student: {
-    type: Object,
     required: true,
+    type: Object,
   },
 });
 
 const $q = useQuasar();
 const permissions = permissionStore();
-const canAdd = computed(() =>
-  permissions.getAccess("franchisee_contracts", "add"),
-);
-const canEdit = computed(() => permissions.getAccess("franchisee_contracts", "edit"));
-const canEditContract = computed(() => canEdit.value);
-const rows = ref([]);
+
 const fileInputRef = ref(null);
+const rows = ref([]);
 const uploadingId = ref(null);
-let pendingContractId = null;
-
-async function loadContracts() {
-  rows.value = await getStudentContracts(props.student.id);
-}
 
-onMounted(loadContracts);
-
-function openDialog(contract = null) {
-  $q.dialog({
-    component: AddEditContractDialog,
-    componentProps: { student: props.student, contract },
-  }).onOk(loadContracts);
-}
-
-function handleAdd() {
-  openDialog();
-}
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_contracts", "add"),
+);
 
-function handleEdit(contract) {
-  openDialog(contract);
-}
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_contracts", "edit"),
+);
 
-function triggerFileInput(row) {
-  pendingContractId = row.id;
-  fileInputRef.value.value = "";
-  fileInputRef.value.click();
-}
+const canEditContract = computed(() => canEdit.value);
 
-async function onFileSelected(event) {
-  const file = event.target.files?.[0];
-  if (!file || !pendingContractId) return;
+const columns = [
+  {
+    align: "left",
+    field: "protocol",
+    label: "Contrato",
+    name: "contract",
+  },
+  {
+    align: "left",
+    field: null,
+    label: "Data Assinatura - Encerramento",
+    name: "period",
+  },
+  {
+    align: "center",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
+];
 
-  uploadingId.value = pendingContractId;
-  try {
-    const formData = new FormData();
-    formData.append("file", file);
-    const updated = await attachContractFile(pendingContractId, formData);
-    const idx = rows.value.findIndex((r) => r.id === pendingContractId);
-    if (idx !== -1) rows.value[idx] = updated;
-  } catch (e) {
-    console.error(e);
-    $q.notify({ type: "negative", message: "Erro ao anexar arquivo." });
-  } finally {
-    uploadingId.value = null;
-    pendingContractId = null;
-  }
-}
+let pendingContractId = null;
 
-function confirmAction(title, message, apiFn, contract) {
+const confirmAction = (title, message, apiFunction, contract) => {
   $q.dialog({
     component: ContractActionConfirmDialog,
-    componentProps: { title, message },
+    componentProps: {
+      message,
+      title,
+    },
   }).onOk(async () => {
     try {
-      const updated = await apiFn(contract.id);
-      const idx = rows.value.findIndex((r) => r.id === contract.id);
-      if (idx !== -1) rows.value[idx] = updated;
-    } catch (e) {
-      console.error(e);
-      $q.notify({ type: "negative", message: "Erro ao atualizar status do contrato." });
+      const updated = await apiFunction(contract.id);
+
+      const index = rows.value.findIndex(
+        (row) => row.id === contract.id,
+      );
+
+      if (index !== -1) {
+        rows.value[index] = updated;
+      }
+    } catch (error) {
+      console.error(error);
+
+      $q.notify({
+        message: "Erro ao atualizar status do contrato.",
+        type: "negative",
+      });
     }
   });
-}
+};
 
-function handleFreeze(contract) {
-  $q.dialog({
-    component: FreezeContractDialog,
-    componentProps: { contract },
-  }).onOk((updated) => {
-    const idx = rows.value.findIndex((r) => r.id === contract.id);
-    if (idx !== -1) rows.value[idx] = { ...rows.value[idx], status: updated.status };
-  });
-}
+const handleAdd = () => {
+  openDialog();
+};
 
-function handleCancel(contract) {
+const handleCancel = (contract) => {
   confirmAction(
     "Cancelar Contrato",
     "Você tem certeza que deseja CANCELAR este contrato? Isso irá gerar multas e cancelamento da cobrança recorrente.",
     cancelContract,
     contract,
   );
-}
+};
 
-function handleReactivate(contract) {
+const handleEdit = (contract) => {
+  openDialog(contract);
+};
+
+const handleFreeze = (contract) => {
+  $q.dialog({
+    component: FreezeContractDialog,
+    componentProps: {
+      contract,
+    },
+  }).onOk((updated) => {
+    const index = rows.value.findIndex(
+      (row) => row.id === contract.id,
+    );
+
+    if (index !== -1) {
+      rows.value[index] = {
+        ...rows.value[index],
+        status: updated.status,
+      };
+    }
+  });
+};
+
+const handleHistory = (contract) => {
+  $q.dialog({
+    component: ContractHistoryDialog,
+    componentProps: {
+      contractData: contract,
+      id: contract.id,
+    },
+  });
+};
+
+const handleReactivate = (contract) => {
   confirmAction(
     "Reativar Contrato",
     "Você tem certeza que deseja REATIVAR este contrato?",
     reactivateContract,
     contract,
   );
-}
+};
+
+const loadContracts = async () => {
+  rows.value = await getStudentContracts(props.student.id);
+};
 
-function statusColor(status) {
+const onFileSelected = async (event) => {
+  const file = event.target.files?.[0];
+
+  if (!file || !pendingContractId) return;
+
+  uploadingId.value = pendingContractId;
+
+  try {
+    const formData = new FormData();
+
+    formData.append("file", file);
+
+    const updated = await attachContractFile(
+      pendingContractId,
+      formData,
+    );
+
+    const index = rows.value.findIndex(
+      (row) => row.id === pendingContractId,
+    );
+
+    if (index !== -1) {
+      rows.value[index] = updated;
+    }
+  } catch (error) {
+    console.error(error);
+
+    $q.notify({
+      message: "Erro ao anexar arquivo.",
+      type: "negative",
+    });
+  } finally {
+    pendingContractId = null;
+    uploadingId.value = null;
+  }
+};
+
+const openDialog = (contract = null) => {
+  $q.dialog({
+    component: AddEditContractDialog,
+    componentProps: {
+      contract,
+      student: props.student,
+    },
+  }).onOk(loadContracts);
+};
+
+const statusColor = (status) => {
   if (status === "active") return "positive";
   if (status === "frozen") return "info";
   if (status === "cancelled") return "negative";
+
   return "warning";
-}
+};
 
-function statusLabel(status) {
+const statusLabel = (status) => {
   if (status === "active") return "Ativo";
   if (status === "frozen") return "Trancado";
   if (status === "cancelled") return "Cancelado";
+
   return "Inativo";
-}
+};
 
-const columns = ref([
-  { name: "contract", label: "Contrato", field: "protocol", align: "left" },
-  {
-    name: "period",
-    label: "Data Assinatura - Encerramento",
-    field: null,
-    align: "left",
-  },
-  { name: "status", label: "Status", field: "status", align: "center" },
-  { name: "actions", label: "Ações", field: null, align: "center" },
-]);
-</script>
+const triggerFileInput = (row) => {
+  pendingContractId = row.id;
+
+  fileInputRef.value.value = "";
+  fileInputRef.value.click();
+};
+
+onMounted(loadContracts);
+</script>