فهرست منبع

feat(unit): adiciona contrato, socios e unidade

ebagabee 2 هفته پیش
والد
کامیت
ba676cccb7

+ 18 - 0
src/api/unit_contract.js

@@ -0,0 +1,18 @@
+import api from "src/api";
+
+export const getContractsByUnit = async (unitId) => {
+  const { data } = await api.get("/unit-contract", { params: { unit_id: unitId } });
+  return data.payload;
+};
+
+export const createContract = async (formData) => {
+  const { data } = await api.post("/unit-contract", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteContract = async (id) => {
+  const { data } = await api.delete(`/unit-contract/${id}`);
+  return data;
+};

+ 34 - 6
src/pages/unit/UnitActionPage.vue

@@ -4,15 +4,23 @@
 
     <CustomTabComponent v-model:active-tab="activeTab" :tabs />
 
-    <UnitDataTab v-show="activeTab === 'unit_data'" :unit-id="unitId" :pending-partners="pendingPartners" />
+    <UnitDataTab
+      v-show="activeTab === 'unit_data'"
+      v-model:form="form"
+      :unit-id="unitId"
+      :get-form-as-form-data="getFormAsFormData"
+      :set-update-form-as-original="setUpdateFormAsOriginal"
+    />
     <PartnersTab
       v-show="activeTab === 'partners'"
+      v-model:partners="form.partners"
+      :unit-id="unitId"
+    />
+    <ContractsTab
+      v-show="activeTab === 'contracts'"
+      v-model:contracts="form.contracts"
       :unit-id="unitId"
-      :pending-partners="pendingPartners"
-      @add-partner="pendingPartners.push($event)"
-      @remove-partner="pendingPartners.splice($event, 1)"
     />
-    <ContractsTab v-show="activeTab === 'contracts'" />
     <FinancialTab v-show="activeTab === 'financial'" />
     <HistoryTab v-show="activeTab === 'history'" />
     <MediasTab v-show="activeTab === 'medias'" />
@@ -30,11 +38,31 @@ import HistoryTab from "src/pages/unit/tabs/HistoryTab.vue";
 import MediasTab from "src/pages/unit/tabs/MediasTab.vue";
 import { ref } from "vue";
 import { useRoute } from "vue-router";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 
 const route = useRoute();
 const unitId = route.params.id ? Number(route.params.id) : null;
 
-const pendingPartners = ref([]);
+const { form, getFormAsFormData, setUpdateFormAsOriginal } = useFormUpdateTracker({
+  fantasy_name: null,
+  social_reason: null,
+  cnpj: null,
+  state_registration: null,
+  name_responsible: null,
+  street: null,
+  address_number: null,
+  postal_code: null,
+  neighborhood: null,
+  complement: null,
+  city_id: null,
+  state_id: null,
+  email: null,
+  secondary_email: null,
+  phone_number: null,
+  cell_number: null,
+  partners: [],
+  contracts: [],
+});
 
 const activeTab = ref("unit_data");
 

+ 210 - 149
src/pages/unit/tabs/ContractsTab.vue

@@ -1,126 +1,98 @@
 <template>
   <div class="q-pa-md">
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept=".pdf"
+      class="hidden"
+      @change="onFileSelected"
+    />
+
     <div class="row q-col-gutter-md">
-      <!-- Coluna esquerda: tabela + aditivos -->
+      <!-- Coluna esquerda: lista -->
       <div class="col-12 col-md-5">
-        <q-table
-          :rows="contracts"
-          :columns="columns"
-          row-key="id"
-          flat
-          hide-bottom
-          class="bg-transparent"
-          :row-class="
-            (_, index) =>
-              index === selectedIndex
-                ? 'contract-item-active cursor-pointer'
-                : 'cursor-pointer'
-          "
-          @row-click="(_, __, index) => (selectedIndex = index)"
-        >
-          <template #body-cell-period="{ row }">
-            <q-td align="left">
-              {{ row.startDate }} - {{ row.endDate }}
-            </q-td>
-          </template>
-
-          <template #body-cell-status="{ row }">
-            <q-td align="center">
-              <q-badge
-                :color="row.status === 'Ativo' ? 'teal' : 'negative'"
-                :label="row.status"
-                style="border-radius: 12px; padding: 4px 10px; font-size: 12px"
-              />
-            </q-td>
-          </template>
-
-          <template #body-cell-actions>
-            <q-td align="center">
-              <div class="flex items-center justify-center q-gutter-x-xs">
-                <q-icon
-                  name="mdi-file-document-outline"
-                  size="sm"
-                  color="grey-8"
-                  class="cursor-pointer"
-                  style="border: 1px solid #c9c9c9; border-radius: 8px; padding: 4px"
-                  @click.stop="console.log('view')"
-                />
-                <q-icon
-                  name="mdi-trash-can-outline"
-                  size="sm"
-                  color="grey-8"
-                  class="cursor-pointer"
-                  style="border: 1px solid #c9c9c9; border-radius: 8px; padding: 4px"
-                  @click.stop="console.log('trash')"
-                />
-              </div>
-            </q-td>
-          </template>
-        </q-table>
-
-        <!-- Aditivos de Contrato -->
-        <div class="q-mt-md">
-          <p class="text-weight-medium q-mb-sm">Aditivos de Contrato</p>
-          <q-list separator>
+        <div class="row justify-between items-center q-mb-md">
+          <span class="text-subtitle1 text-weight-medium">Contratos</span>
+          <q-btn
+            icon="add"
+            color="primary-2"
+            style="height: 40px; width: 40px; border-radius: 8px"
+            :loading="uploading"
+            @click="fileInputRef.click()"
+          />
+        </div>
+
+        <div v-if="loading" class="row justify-center q-pa-xl">
+          <q-spinner color="primary" size="40px" />
+        </div>
+
+        <template v-else>
+          <div v-if="contracts.length === 0" class="text-center text-grey-6 q-pa-xl">
+            <q-icon name="mdi-file-pdf-box" size="48px" color="grey-4" />
+            <div class="q-mt-sm">Nenhum contrato adicionado.</div>
+            <div v-if="!unitId" class="text-caption q-mt-xs">
+              Os contratos serão enviados junto com a unidade.
+            </div>
+          </div>
+
+          <q-list v-else separator>
             <q-item
-              v-for="(additive, i) in additives"
-              :key="i"
+              v-for="(contract, index) in contracts"
+              :key="contract.id ?? index"
               clickable
-              dense
-              class="q-py-sm"
+              :active="selectedIndex === index"
+              active-class="contract-item-active"
+              @click="selectedIndex = index"
             >
               <q-item-section avatar>
-                <q-avatar
-                  size="24px"
-                  color="primary-2"
-                  text-color="white"
-                  style="font-size: 12px"
-                >
-                  {{ i + 1 }}
-                </q-avatar>
+                <q-icon name="mdi-file-pdf-box" color="negative" size="md" />
+              </q-item-section>
+
+              <q-item-section>
+                <q-item-label class="ellipsis" style="max-width: 180px">
+                  {{ contract.name }}
+                </q-item-label>
+                <q-item-label caption>
+                  <span v-if="contract.id">{{ formatDate(contract.created_at) }}</span>
+                  <q-badge v-else color="orange" label="Pendente" />
+                </q-item-label>
               </q-item-section>
-              <q-item-section>{{ additive.title }}</q-item-section>
+
               <q-item-section side>
-                <q-icon
-                  name="mdi-file-document-outline"
+                <q-btn
+                  flat
+                  round
+                  dense
+                  icon="delete"
+                  color="negative"
                   size="sm"
-                  color="grey-6"
-                  class="cursor-pointer"
-                  @click.stop="console.log('additive', i)"
+                  @click.stop="onRemove(contract, index)"
                 />
               </q-item-section>
             </q-item>
           </q-list>
-        </div>
+        </template>
       </div>
 
       <!-- Coluna direita: pré-visualização -->
       <div class="col-12 col-md-7">
-        <div class="row justify-end q-mb-sm">
-          <q-btn
-            icon="add"
-            color="primary-2"
-            style="height: 40px; width: 40px; border-radius: 8px"
-          />
-        </div>
-        <div class="preview-box q-pa-md">
-          <span v-if="selectedIndex === null" class="text-grey-5">
-            Pré - Visualização
-          </span>
-          <div v-else>
-            <p class="text-weight-medium q-mb-xs">
-              Contrato {{ contracts[selectedIndex].id }}
-            </p>
-            <p class="text-grey-7 q-mb-sm">
-              {{ contracts[selectedIndex].startDate }} até
-              {{ contracts[selectedIndex].endDate }}
-            </p>
-            <q-badge
-              :color="contracts[selectedIndex].status === 'Ativo' ? 'teal' : 'negative'"
-              :label="contracts[selectedIndex].status"
-              style="border-radius: 12px; padding: 4px 10px; font-size: 12px"
-            />
+        <div class="preview-box">
+          <div
+            v-if="selectedIndex === null || !contracts[selectedIndex]"
+            class="flex flex-center full-height text-grey-5"
+            style="min-height: 500px"
+          >
+            <div class="column items-center q-gutter-sm">
+              <q-icon name="mdi-file-pdf-box" size="64px" color="grey-3" />
+              <span>Selecione um contrato para visualizar</span>
+            </div>
           </div>
+
+          <iframe
+            v-else
+            :src="getPreviewUrl(contracts[selectedIndex])"
+            style="width: 100%; min-height: 500px; border: none; border-radius: 8px"
+          />
         </div>
       </div>
     </div>
@@ -128,65 +100,154 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted, onUnmounted } from "vue";
+import { useQuasar } from "quasar";
+import { getContractsByUnit, createContract, deleteContract } from "src/api/unit_contract";
 
+const props = defineProps({
+  unitId: {
+    type: Number,
+    default: null,
+  },
+});
+
+const pendingContracts = defineModel("contracts", { type: Array, default: () => [] });
+
+const $q = useQuasar();
+const fileInputRef = ref(null);
+const contracts = ref([]);
 const selectedIndex = ref(null);
+const loading = ref(false);
+const uploading = ref(false);
 
-const columns = [
-  { name: "id", label: "Contrato", field: "id", align: "left" },
-  {
-    name: "period",
-    label: "Data Inicial - Final",
-    field: "startDate",
-    align: "left",
-  },
-  {
-    name: "status",
-    label: "Status Contrato",
-    field: "status",
-    align: "center",
-    style: "width: 130px",
-  },
-  {
-    name: "actions",
-    label: "Ações",
-    field: "actions",
-    align: "center",
-    style: "width: 90px",
-  },
-];
-
-const contracts = ref([
-  {
-    id: "5789128",
-    startDate: "17/02/2025",
-    endDate: "17/02/2026",
-    status: "Ativo",
-  },
-  {
-    id: "5789128",
-    startDate: "17/02/2025",
-    endDate: "17/02/2026",
-    status: "Inativo",
+// ─── Busca contratos salvos (modo edição) ───────────────────────────────────
+async function fetchContracts() {
+  if (!props.unitId) {
+    contracts.value = pendingContracts.value;
+    return;
+  }
+
+  loading.value = true;
+  try {
+    contracts.value = await getContractsByUnit(props.unitId);
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+// ─── Seleção de arquivo ─────────────────────────────────────────────────────
+async function onFileSelected(event) {
+  const file = event.target.files[0];
+  if (!file) return;
+
+  // Limpa o input para permitir selecionar o mesmo arquivo novamente
+  event.target.value = "";
+
+  if (props.unitId) {
+    // Modo edição: envia direto para a API
+    uploading.value = true;
+    try {
+      const formData = new FormData();
+      formData.append("unit_id", props.unitId);
+      formData.append("file", file);
+      formData.append("name", file.name);
+
+      const created = await createContract(formData);
+      contracts.value.unshift(created);
+      selectedIndex.value = 0;
+    } catch (e) {
+      console.error(e);
+    } finally {
+      uploading.value = false;
+    }
+  } else {
+    // Modo criação: adiciona à lista pendente
+    const previewUrl = URL.createObjectURL(file);
+    const item = { name: file.name, file, previewUrl };
+    pendingContracts.value.push(item);
+  }
+}
+
+// ─── Remoção ────────────────────────────────────────────────────────────────
+function onRemove(contract, index) {
+  if (contract.id) {
+    // Modo edição: confirma e deleta via API
+    $q.dialog({
+      title: "Remover contrato",
+      message: `Deseja remover o contrato "${contract.name}"?`,
+      ok: { color: "negative", label: "Remover" },
+      cancel: { color: "primary", outline: true, label: "Cancelar" },
+    }).onOk(async () => {
+      try {
+        await deleteContract(contract.id);
+        contracts.value.splice(index, 1);
+        if (selectedIndex.value === index) selectedIndex.value = null;
+        else if (selectedIndex.value > index) selectedIndex.value--;
+      } catch (e) {
+        console.error(e);
+      }
+    });
+  } else {
+    // Modo criação: revoga object URL e remove da lista pendente
+    if (contract.previewUrl) URL.revokeObjectURL(contract.previewUrl);
+    pendingContracts.value.splice(index, 1);
+  }
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+function getPreviewUrl(contract) {
+  return contract.previewUrl ?? contract.file_url;
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return "";
+  return new Date(dateStr).toLocaleDateString("pt-BR");
+}
+
+// ─── Sincroniza lista pendente quando o pai atualiza ────────────────────────
+// (necessário pois v-show mantém o componente vivo mas não re-executa onMounted)
+import { watch } from "vue";
+
+watch(
+  pendingContracts,
+  (val) => {
+    if (!props.unitId) contracts.value = val;
   },
-]);
-
-const additives = ref([
-  { title: "Aditivo 1" },
-  { title: "Aditivo 2" },
-  { title: "Aditivo  3" },
-  { title: "Aditivo 4" },
-]);
+  { immediate: true },
+);
+
+onMounted(fetchContracts);
+
+onUnmounted(() => {
+  // Libera object URLs de pendentes não enviados
+  if (!props.unitId) {
+    pendingContracts.value.forEach((c) => {
+      if (c.previewUrl) URL.revokeObjectURL(c.previewUrl);
+    });
+  }
+});
 </script>
 
 <style scoped>
+.hidden {
+  display: none;
+}
+
 .preview-box {
   border: 1px solid #e0e0e0;
   border-radius: 8px;
-  min-height: 400px;
+  overflow: hidden;
 }
 
 .contract-item-active {
   background-color: rgba(255, 131, 64, 0.08);
 }
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 </style>

+ 27 - 15
src/pages/unit/tabs/PartnersTab.vue

@@ -16,14 +16,24 @@
     <template v-else>
       <!-- Modo criação: lista de sócios pendentes -->
       <template v-if="!unitId">
-        <div v-if="pendingPartners.length === 0" class="text-center text-grey-6 q-pa-xl">
+        <div
+          v-if="partners.length === 0"
+          class="text-center text-grey-6 q-pa-xl"
+        >
           Nenhum sócio adicionado. Os sócios serão criados junto com a unidade.
         </div>
 
         <div v-else class="row q-col-gutter-md">
-          <div v-for="(partner, index) in pendingPartners" :key="index" class="col-xs-12 col-sm-6 col-md-3">
+          <div
+            v-for="(partner, index) in partners"
+            :key="index"
+            class="col-xs-12 col-sm-6 col-md-3"
+          >
             <div class="relative-position">
-              <PartnerCardComponent :partner @click="onPendingClick(partner, index)" />
+              <PartnerCardComponent
+                :partner
+                @click="onPendingClick(partner, index)"
+              />
 
               <q-btn
                 round
@@ -34,7 +44,7 @@
                 text-color="white"
                 class="absolute"
                 style="top: 8px; right: 8px"
-                @click.stop="emit('remove-partner', index)"
+                @click.stop="partners.splice(index, 1)"
               >
                 <q-tooltip>Remover sócio</q-tooltip>
               </q-btn>
@@ -45,12 +55,19 @@
 
       <!-- Modo edição: sócios salvos -->
       <template v-else>
-        <div v-if="partners.length === 0" class="text-center text-grey-6 q-pa-xl">
+        <div
+          v-if="partners.length === 0"
+          class="text-center text-grey-6 q-pa-xl"
+        >
           Nenhum sócio cadastrado para esta unidade.
         </div>
 
         <div v-else class="row q-col-gutter-md">
-          <div v-for="partner in partners" :key="partner.id" class="col-xs-12 col-sm-6 col-md-3">
+          <div
+            v-for="partner in partners"
+            :key="partner.id"
+            class="col-xs-12 col-sm-6 col-md-3"
+          >
             <div class="relative-position">
               <PartnerCardComponent :partner @click="openDialog(partner)" />
 
@@ -87,16 +104,12 @@ const props = defineProps({
     type: Number,
     default: null,
   },
-  pendingPartners: {
-    type: Array,
-    default: () => [],
-  },
 });
 
-const emit = defineEmits(["add-partner", "remove-partner"]);
+const partners = defineModel("partners", { type: Array, default: () => [] });
 
 const $q = useQuasar();
-const partners = ref([]);
+
 const loading = ref(false);
 
 async function fetchPartners() {
@@ -119,7 +132,7 @@ function onAddClick() {
       component: AddEditPartnerDialog,
       componentProps: { offlineMode: true },
     }).onOk((partnerData) => {
-      emit("add-partner", partnerData);
+      partners.value.push(partnerData);
     });
   }
 }
@@ -129,8 +142,7 @@ function onPendingClick(partner, index) {
     component: AddEditPartnerDialog,
     componentProps: { partner, offlineMode: true },
   }).onOk((updatedData) => {
-    emit("remove-partner", index);
-    emit("add-partner", updatedData);
+    partners.value.splice(index, 1, updatedData);
   });
 }
 

+ 37 - 47
src/pages/unit/tabs/UnitDataTab.vue

@@ -163,7 +163,6 @@ import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
 import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
 import StateSelect from "src/components/selects/StateSelect.vue";
 import CitySelect from "src/components/selects/CitySelect.vue";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { useInputRules } from "src/composables/useInputRules";
 import { createUnit, getUnit, updateUnit } from "src/api/unit";
@@ -174,12 +173,18 @@ const props = defineProps({
     type: Number,
     default: null,
   },
-  pendingPartners: {
-    type: Array,
-    default: () => [],
+  getFormAsFormData: {
+    type: Function,
+    required: true,
+  },
+  setUpdateFormAsOriginal: {
+    type: Function,
+    required: true,
   },
 });
 
+const form = defineModel("form", { type: Object, required: true });
+
 const router = useRouter();
 const formRef = ref(null);
 const avatarRef = ref(null);
@@ -191,35 +196,16 @@ const selectedCity = ref(null);
 
 const { inputRules } = useInputRules();
 
-const { form, getFormAsFormData, setUpdateFormAsOriginal } = useFormUpdateTracker({
-  fantasy_name: null,
-  social_reason: null,
-  cnpj: null,
-  state_registration: null,
-  name_responsible: null,
-  street: null,
-  address_number: null,
-  postal_code: null,
-  neighborhood: null,
-  complement: null,
-  city_id: null,
-  state_id: null,
-  email: null,
-  secondary_email: null,
-  phone_number: null,
-  cell_number: null,
-});
-
 watch(selectedState, (state) => {
-  form.state_id = state?.value ?? null;
+  form.value.state_id = state?.value ?? null;
 });
 
 watch(selectedCity, (city) => {
-  form.city_id = city?.value ?? null;
+  form.value.city_id = city?.value ?? null;
 });
 
 function onAvatarChange(file) {
-  form.avatar = file;
+  form.value.avatar = file;
 }
 
 onMounted(async () => {
@@ -228,22 +214,22 @@ onMounted(async () => {
   try {
     const unit = await getUnit(props.unitId);
 
-    form.fantasy_name = unit.fantasy_name;
-    form.social_reason = unit.social_reason;
-    form.cnpj = unit.cnpj;
-    form.state_registration = unit.state_registration;
-    form.name_responsible = unit.name_responsible;
-    form.street = unit.street;
-    form.address_number = unit.address_number;
-    form.postal_code = unit.postal_code;
-    form.neighborhood = unit.neighborhood;
-    form.complement = unit.complement;
-    form.email = unit.email;
-    form.secondary_email = unit.secondary_email;
-    form.phone_number = unit.phone_number;
-    form.cell_number = unit.cell_number;
-    form.state_id = unit.state_id;
-    form.city_id = unit.city_id;
+    form.value.fantasy_name = unit.fantasy_name;
+    form.value.social_reason = unit.social_reason;
+    form.value.cnpj = unit.cnpj;
+    form.value.state_registration = unit.state_registration;
+    form.value.name_responsible = unit.name_responsible;
+    form.value.street = unit.street;
+    form.value.address_number = unit.address_number;
+    form.value.postal_code = unit.postal_code;
+    form.value.neighborhood = unit.neighborhood;
+    form.value.complement = unit.complement;
+    form.value.email = unit.email;
+    form.value.secondary_email = unit.secondary_email;
+    form.value.phone_number = unit.phone_number;
+    form.value.cell_number = unit.cell_number;
+    form.value.state_id = unit.state_id;
+    form.value.city_id = unit.city_id;
 
     if (unit.state_id) {
       stateSelectRef.value?.selectStateById(unit.state_id);
@@ -251,11 +237,11 @@ onMounted(async () => {
     if (unit.city_id) {
       citySelectRef.value?.selectCityById(unit.city_id);
     }
-    if (unit.avatar) {
-      avatarRef.value?.setImageUrl(unit.avatar);
+    if (unit.avatar_url) {
+      avatarRef.value?.setImageUrl(unit.avatar_url);
     }
 
-    setUpdateFormAsOriginal();
+    props.setUpdateFormAsOriginal();
   } catch (e) {
     console.error(e);
   }
@@ -270,13 +256,13 @@ const { loading, execute } = useSubmitHandler({
 
 async function onSave() {
   await execute(() => {
-    const formData = getFormAsFormData();
+    const formData = props.getFormAsFormData();
 
     if (props.unitId) {
       return updateUnit(props.unitId, formData);
     }
 
-    props.pendingPartners.forEach((partner, index) => {
+    form.value.partners.forEach((partner, index) => {
       Object.entries(partner).forEach(([key, value]) => {
         if (value != null) {
           formData.append(`partners[${index}][${key}]`, value);
@@ -284,6 +270,10 @@ async function onSave() {
       });
     });
 
+    form.value.contracts.forEach((contract) => {
+      formData.append("contracts[]", contract.file, contract.name);
+    });
+
     return createUnit(formData);
   });
 }