Bläddra i källkod

separacao paginas parceiro e convenio

Gustavo Zanatta 1 månad sedan
förälder
incheckning
132b18e8da

+ 6 - 3
src/api/partnerAgreement.js

@@ -1,15 +1,18 @@
 import api from "src/api";
 
-export const getPartnerAgreements = async () => {
-  const { data } = await api.get("/associado-partner-agreement");
+export const getPartnerAgreements = async ({ type } = {}) => {
+  const params = {};
+  if (type) params.type = type;
+  const { data } = await api.get("/associado-partner-agreement", { params });
   return data.payload;
 };
 
-export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, expiresInDays, createdMonth } = {}) => {
+export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, expiresInDays, createdMonth, type } = {}) => {
   const params = { page, per_page: perPage };
   if (filter)        params.search          = filter;
   if (expiresInDays) params.expires_in_days = expiresInDays;
   if (createdMonth)  params.created_month   = createdMonth;
+  if (type)          params.type            = type;
   const { data } = await api.get("/partner-agreement/paginated", { params });
   return { data: { result: data.payload } };
 };

+ 6 - 2
src/i18n/locales/en.json

@@ -34,7 +34,10 @@
       "description": "Informations about updates, fixes and changes for each system version"
     },
     "parceiros_convenios": {
-      "description": "View, edit and add partners and agreements to the system"
+      "description": "View, edit and add partners to the system"
+    },
+    "convenios_medicos": {
+      "description": "View, edit and add medical agreements to the system"
     }
   },
   "common": {
@@ -380,7 +383,8 @@
       "state": "State",
       "country": "Country",
       "exit": "Exit",
-      "partners": "Partners/Agreements",
+      "partners": "Partners",
+      "convenios_medicos": "Medical Agreements",
       "partner_agreements": "Agreements",
       "partner_services": "Services",
       "store": "Store",

+ 6 - 2
src/i18n/locales/es.json

@@ -34,7 +34,10 @@
       "description": "Información sobre actualizaciones, correcciones y cambios en cada versión del sistema"
     },
     "parceiros_convenios": {
-      "description": "Consulte, edite y agregue los socios y convenios del sistema"
+      "description": "Consulte, edite y agregue los socios del sistema"
+    },
+    "convenios_medicos": {
+      "description": "Consulte, edite y agregue los convenios médicos del sistema"
     }
   },
   "common": {
@@ -380,7 +383,8 @@
       "state": "Estado/Provincia",
       "country": "País",
       "exit": "Salir",
-      "partners": "Socios/Convenios",
+      "partners": "Socios",
+      "convenios_medicos": "Convenios Médicos",
       "partner_agreements": "Convenios",
       "partner_services": "Servicios",
       "store": "Tienda",

+ 6 - 2
src/i18n/locales/pt.json

@@ -34,7 +34,10 @@
       "description": "Informações sobre atualizações, correções e mudanças em cada versão do sistema"
     },
     "parceiros_convenios": {
-      "description": "Consulte, edite e adicione os parceiros e convênios do sistema"
+      "description": "Consulte, edite e adicione os parceiros do sistema"
+    },
+    "convenios_medicos": {
+      "description": "Consulte, edite e adicione os convênios médicos do sistema"
     }
   },
   "common": {
@@ -380,7 +383,8 @@
       "state": "Estados",
       "country": "Países",
       "exit": "Sair",
-      "partners": "Parceiros/Convênios",
+      "partners": "Parceiros",
+      "convenios_medicos": "Convênios Médicos",
       "partner_agreements": "Convênios",
       "partner_services": "Serviços",
       "store": "Loja",

+ 11 - 0
src/pages/parceiros-convenios/ConvenioCadastroPage.vue

@@ -0,0 +1,11 @@
+<template>
+  <CadastroFormPanel
+    entity-type="agreement"
+    cadastro-route-name="ConvenioCadastroPage"
+    :show-scheduling="true"
+  />
+</template>
+
+<script setup>
+import CadastroFormPanel from "./components/CadastroFormPanel.vue";
+</script>

+ 218 - 0
src/pages/parceiros-convenios/ConveniosMedicosPage.vue

@@ -0,0 +1,218 @@
+<template>
+  <div class="convenios-page">
+    <input
+      ref="importConvenioInput"
+      type="file"
+      accept=".xlsx"
+      class="hidden"
+      @change="onConvenioFileSelected"
+    />
+
+    <DefaultHeaderPage>
+      <template #after>
+        <div v-if="isAdministrador" class="flex gap-sm q-mt-md">
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('parceiro.import_convenios')"
+            icon="mdi-upload"
+            padding="8px 12px"
+            :loading="importingConvenios"
+            @click="onImportConveniosClick"
+          />
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('common.actions.add')"
+            icon="mdi-plus"
+            padding="8px 12px"
+            @click="onAddItem"
+          />
+        </div>
+      </template>
+    </DefaultHeaderPage>
+
+    <div class="q-pb-sm">
+      <q-input
+        v-model="searchQuery"
+        outlined
+        dense
+        bg-color="white"
+        :placeholder="$t('parceiro.search_placeholder')"
+        clearable
+        class="convenios-page__search-input"
+      >
+        <template #prepend>
+          <q-icon name="mdi-magnify" />
+        </template>
+      </q-input>
+    </div>
+
+    <div class="row q-gutter-xs q-mb-md flex-wrap">
+      <div
+        :class="['cat-chip', activeCategory === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
+        @click="activeCategory = 'all'"
+      >
+        {{ $t('common.terms.all') }}
+      </div>
+      <div
+        v-for="cat in categories"
+        :key="cat.id"
+        :class="['cat-chip', activeCategory === String(cat.id) ? 'cat-chip--selected' : 'cat-chip--default']"
+        @click="activeCategory = String(cat.id)"
+      >
+        {{ cat.name }}
+      </div>
+    </div>
+
+    <div v-if="loading" class="flex flex-center q-pa-xl">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <div v-else-if="filteredItems.length === 0" class="flex flex-center q-pa-xl text-grey-6">
+      {{ $t("http.errors.no_records_found") }}
+    </div>
+
+    <div v-else class="row q-col-gutter-md">
+      <div
+        v-for="item in filteredItems"
+        :key="item.id"
+        class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-12"
+      >
+        <PartnerAgreementCard
+          :partner="item"
+          :editable="isAdministrador"
+          @edit="onEditItem"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, useTemplateRef } from "vue";
+import { useRouter } from "vue-router";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
+import { getPartnerAgreements, importConveniosMedicos } from "src/api/partnerAgreement";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+import { useImportPoller } from "src/composables/useImportPoller";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import PartnerAgreementCard from "./components/PartnerAgreementCard.vue";
+
+const router = useRouter();
+const $q    = useQuasar();
+const { t } = useI18n();
+const permission_store = permissionStore();
+const { isAdministrador } = userStore();
+
+const loading      = ref(true);
+const allItems     = ref([]);
+const categories   = ref([]);
+const activeCategory = ref("all");
+const searchQuery  = ref("");
+const importConvenioInput = useTemplateRef("importConvenioInput");
+const { polling: importingConvenios, start: startConvenioPolling } = useImportPoller();
+
+const filteredItems = computed(() => {
+  let list = allItems.value;
+  if (activeCategory.value !== "all") {
+    list = list.filter((p) => String(p.category_id) === activeCategory.value);
+  }
+  if (searchQuery.value) {
+    const needle = normalizeString(searchQuery.value);
+    list = list.filter((p) => {
+      const fields = [p.company_name, p.cnpj, p.responsible, p.email, p.phone, p.category?.name, p.address, p.city?.name];
+      return fields.some((f) => f && normalizeString(String(f)).includes(needle));
+    });
+  }
+  return list;
+});
+
+const onAddItem = () => {
+  if (!permission_store.getAccess("parceiro.convenio", "add")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+  router.push({ name: "ConvenioCadastroPage" });
+};
+
+const onEditItem = (item) => {
+  if (!permission_store.getAccess("parceiro.convenio", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  router.push({ name: "ConvenioCadastroPage", params: { id: item.id } });
+};
+
+const loadItems = async () => {
+  const [items, cats] = await Promise.all([
+    getPartnerAgreements({ type: "agreement" }),
+    getCategories("agreement"),
+  ]);
+  allItems.value    = items;
+  categories.value  = cats;
+};
+
+const onImportConveniosClick = () => {
+  if (!permission_store.getAccess("parceiro.convenio", "add")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+  importConvenioInput.value.value = null;
+  importConvenioInput.value.click();
+};
+
+const onConvenioFileSelected = async (event) => {
+  const file = event.target.files?.[0];
+  if (!file) return;
+  try {
+    const { import_id } = await importConveniosMedicos(file);
+    startConvenioPolling(import_id, {
+      onComplete: async (stats) => {
+        $q.notify({
+          type: "positive",
+          message: t("parceiro.import_convenios_result", {
+            partners_created:     stats.partners_created,
+            services_created:     stats.services_created,
+            partners_inactivated: stats.partners_inactivated,
+          }),
+          timeout: 7000,
+        });
+        await loadItems();
+      },
+      onError:   () => $q.notify({ type: "negative", message: t("http.errors.failed") }),
+      onTimeout: () => $q.notify({ type: "warning",  message: t("parceiro.import_processing") }),
+    });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+};
+
+onMounted(async () => {
+  try { await loadItems(); }
+  finally { loading.value = false; }
+});
+</script>
+
+<style scoped>
+.gap-sm { gap: 8px; }
+.convenios-page { min-height: 100vh; }
+.convenios-page__search-input { max-width: 700px; width: 100%; }
+.cat-chip {
+  display: inline-flex; align-items: center; justify-content: center;
+  height: 28px; padding: 0 12px; border-radius: 5px;
+  font-size: 12px; font-weight: 500; cursor: pointer;
+  user-select: none; transition: background 0.15s, color 0.15s;
+}
+.cat-chip--default  { background: #c9a3dc; color: #fff; }
+.cat-chip--selected { background: #4d1658; color: #fff; }
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important; border-radius: 8px !important;
+}
+.btn-gradient :deep(.q-icon) { color: white !important; }
+</style>

+ 6 - 790
src/pages/parceiros-convenios/ParceiroCadastroPage.vue

@@ -1,795 +1,11 @@
 <template>
-  <div class="cadastro-page q-mr-md">
-    <DefaultHeaderPage :title="{ value: pageTitle }" />
-
-    <div class="cadastro-page__tabs q-mb-md">
-      <div
-        v-for="tab in tabsItems"
-        :key="tab.name"
-        class="cadastro-page__tab"
-        :class="{
-          'cadastro-page__tab--active': activeTab === tab.name,
-          'cadastro-page__tab--disabled': tab.disable,
-        }"
-        @click="!tab.disable && (activeTab = tab.name)"
-      >
-        {{ tab.label }}
-      </div>
-    </div>
-
-    <q-tab-panels v-model="activeTab" animated keep-alive>
-
-      <q-tab-panel name="dados" class="q-pa-none">
-        <q-form ref="formDadosRef" @submit="saveDados">
-          <div class="q-pr-md q-pb-md bg-violet-light">
-            <DefaultFilePicker
-              v-model="logoFile"
-              type="image"
-              :label="$t('parceiro.logo')"
-              :initial-image="partner?.logo?.url"
-              style="height: 220px; max-width: 280px"
-            />
-          </div>
-
-          <q-card flat class="cadastro-card q-pa-md">
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="formDados.company_name"
-                v-model:error="errorsDados.company_name"
-                :rules="[inputRules.required]"
-                :label="$t('parceiro.company_name')"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formDados.cnpj"
-                v-model:error="errorsDados.cnpj"
-                :label="$t('common.terms.cnpj')"
-                :placeholder="'00.000.000/0000-00'"
-                :mask="'##.###.###/####-##'"
-                class="col-12"
-              />
-              <PartnerAgreementCategorySelect
-                v-model="selectedCategory"
-                v-model:error="errorsDados.category_id"
-                class="col-12"
-              />
-
-              <!-- Novo usuário — apenas na criação -->
-              <template v-if="!partnerId">
-                <DefaultInput
-                  v-model="newUser.name"
-                  :rules="[inputRules.required]"
-                  :label="$t('common.terms.name')"
-                  class="col-12"
-                />
-                <DefaultInput
-                  v-model="newUser.email"
-                  :rules="[inputRules.required, inputRules.email]"
-                  :label="$t('common.terms.email')"
-                  type="email"
-                  class="col-12"
-                />
-                <DefaultInput
-                  v-model="newUser.password"
-                  :rules="[inputRules.required]"
-                  :label="$t('common.terms.password')"
-                  type="password"
-                  class="col-12"
-                />
-              </template>
-
-              <!-- Usuário vinculado — apenas na edição -->
-              <DefaultInput
-                v-else-if="partner?.user"
-                :model-value="partner.user.name"
-                :label="$t('parceiro.linked_user')"
-                disable
-                class="col-12"
-              />
-
-              <DefaultInput
-                v-model="formDados.responsible"
-                v-model:error="errorsDados.responsible"
-                :label="$t('parceiro.responsible')"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formDados.discount_percentage"
-                v-model:error="errorsDados.discount_percentage"
-                :label="$t('parceiro.discount_percentage')"
-                type="number"
-                suffix="%"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formDados.description"
-                v-model:error="errorsDados.description"
-                :label="$t('common.terms.description')"
-                type="textarea"
-                autogrow
-                class="col-12"
-              />
-            </div>
-          </q-card>
-
-          <div class="row justify-end q-py-md bg-violet-light">
-            <q-btn
-              unelevated
-              class="btn-gradient"
-              :label="$t('common.actions.save')"
-              icon="mdi-check"
-              type="submit"
-              :loading="loadingDados"
-              :disable="!hasUpdatedDados"
-            />
-          </div>
-        </q-form>
-      </q-tab-panel>
-
-      <q-tab-panel name="contato" class="q-pa-none">
-        <q-form ref="formContatoRef" @submit="saveContato">
-          <q-card flat class="cadastro-card q-pa-md">
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="formContato.email"
-                v-model:error="errorsContato.email"
-                :rules="[inputRules.email]"
-                :label="$t('common.terms.email')"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formContato.website"
-                v-model:error="errorsContato.website"
-                :label="$t('parceiro.website')"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formContato.phone"
-                v-model:error="errorsContato.phone"
-                :label="$t('common.terms.phone')"
-                :mask="'(##) #####-####'"
-                class="col-12"
-              />
-              <DefaultInput
-                v-model="formContato.whatsapp"
-                v-model:error="errorsContato.whatsapp"
-                :label="$t('common.terms.whatsapp')"
-                :mask="'(##) #####-####'"
-                class="col-12"
-              />
-            </div>
-          </q-card>
-
-          <div class="row justify-end bg-violet-light q-pt-md">
-            <q-btn
-              unelevated
-              class="btn-gradient"
-              :label="$t('common.actions.save')"
-              icon="mdi-check"
-              type="submit"
-              :loading="loadingContato"
-              :disable="!hasUpdatedContato"
-            />
-          </div>
-        </q-form>
-      </q-tab-panel>
-
-      <q-tab-panel name="endereco" class="q-pa-none">
-        <q-form ref="formEnderecoRef" @submit="saveEndereco">
-          <q-card flat class="cadastro-card q-pa-md">
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="formEndereco.zip_code"
-                v-model:error="errorsEndereco.zip_code"
-                :label="$t('parceiro.zip_code')"
-                :placeholder="'00000-000'"
-                :mask="'#####-###'"
-                class="col-md-4 col-12"
-                @update:model-value="onCepChange"
-              />
-              <DefaultInput
-                v-model="formEndereco.address"
-                v-model:error="errorsEndereco.address"
-                :label="$t('parceiro.address')"
-                :loading="loadingCep"
-                class="col-md-8 col-12"
-              />
-              <DefaultInput
-                v-model="formEndereco.neighborhood"
-                v-model:error="errorsEndereco.neighborhood"
-                :label="$t('parceiro.neighborhood')"
-                :loading="loadingCep"
-                class="col-12"
-              />
-              <StateSelect
-                v-model="selectedState"
-                v-model:error="errorsEndereco.state_id"
-                class="col-md-4 col-12 input-violet"
-              />
-              <CitySelect
-                v-model="selectedCity"
-                v-model:error="errorsEndereco.city_id"
-                :state="selectedState"
-                class="col-md-8 col-12 input-violet"
-              />
-            </div>
-          </q-card>
-
-          <div class="row justify-end q-pt-md bg-violet-light">
-            <q-btn
-              unelevated
-              class="btn-gradient"
-              :label="$t('common.actions.save')"
-              icon="mdi-check"
-              type="submit"
-              :loading="loadingEndereco"
-              :disable="!hasUpdatedEndereco"
-            />
-          </div>
-        </q-form>
-      </q-tab-panel>
-
-      <q-tab-panel name="horario" class="q-pa-none">
-        <q-form ref="formHorarioRef" @submit="saveHorario">
-          <q-card flat class="cadastro-card q-pa-md">
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="formHorario.working_hours"
-                v-model:error="errorsHorario.working_hours"
-                :label="$t('parceiro.working_hours')"
-                :placeholder="$t('parceiro.working_hours_placeholder')"
-                class="col-12"
-              />
-              <DefaultInputDatePicker
-                v-model:untreated-date="formHorario.contract_start"
-                v-model:error="errorsHorario.contract_start"
-                :label="$t('parceiro.contract_start')"
-                class="col-md-6 col-12"
-              />
-              <DefaultInputDatePicker
-                v-model:untreated-date="formHorario.contract_end"
-                v-model:error="errorsHorario.contract_end"
-                :label="$t('parceiro.contract_end')"
-                class="col-md-6 col-12"
-              />
-            </div>
-
-            <div v-if="contractMedia.length > 0" class="q-mt-md">
-              <div class="text-subtitle2 text-violet-normal q-mb-xs">{{ $t("parceiro.contract_files") }}</div>
-              <div class="row q-col-gutter-xs">
-                <div v-for="media in contractMedia" :key="media.id" class="col-auto">
-                  <q-chip
-                    removable
-                    clickable
-                    color="violet-light"
-                    text-color="violet-normal"
-                    icon="mdi-file-outline"
-                    :label="media.name"
-                    @click="openMedia(media)"
-                    @remove="removeMedia(media)"
-                  />
-                </div>
-              </div>
-            </div>
-          </q-card>
-
-          <div class="row justify-end items-center q-pt-md bg-violet-light" style="gap: 8px">
-            <q-file
-              ref="mediaFileInputRef"
-              v-model="newMediaFile"
-              accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
-              style="display: none"
-              @update:model-value="onMediaFileSelected"
-            />
-            <q-btn
-              unelevated
-              class="btn-gradient"
-              :label="$t('parceiro.add_file')"
-              icon="mdi-paperclip"
-              :disable="!partnerId"
-              @click="triggerMediaUpload"
-            />
-            <q-btn
-              unelevated
-              class="btn-gradient"
-              :label="$t('common.actions.save')"
-              icon="mdi-check"
-              type="submit"
-              :loading="loadingHorario"
-              :disable="!hasUpdatedHorario"
-            />
-          </div>
-        </q-form>
-      </q-tab-panel>
-
-      <q-tab-panel name="servicos" class="q-pa-none">
-        <div class="bg-violet-light q-pb-md">
-          <div class="row justify-end q-mb-sm q-gutter-sm">
-            <q-btn
-              unelevated
-              icon="mdi-upload"
-              :label="$t('common.actions.import')"
-              padding="6px 12px"
-              :disable="!partnerId"
-              class="btn-gradient"
-            />
-            <q-btn
-              unelevated
-              icon="mdi-plus"
-              :label="$t('common.actions.new')"
-              padding="6px 12px"
-              :disable="!partnerId"
-              class="btn-gradient"
-              @click="onAddService"
-            />
-          </div>
-
-          <q-input
-            v-model="serviceSearch"
-            :placeholder="$t('parceiro.search_placeholder')"
-            outlined
-            dense
-            clearable
-            color="violet-normal"
-            class="q-mb-sm service-search"
-          >
-            <template #prepend>
-              <q-icon name="mdi-magnify" color="violet-normal" />
-            </template>
-          </q-input>
-        </div>
-
-        <q-card class="">
-          <div v-if="loadingServices" class="flex flex-center q-pa-xl">
-            <q-spinner color="violet-normal" size="40px" />
-          </div>
-
-          <DefaultTable
-            v-else
-            v-model:rows="filteredServices"
-            :columns="serviceColumns"
-            no-api-call
-            :show-search-field="false"
-            open-item
-            @on-row-click="({ row }) => onEditService(row)"
-          />
-        </q-card>
-      </q-tab-panel>
-
-    </q-tab-panels>
-  </div>
+  <CadastroFormPanel
+    entity-type="partner"
+    cadastro-route-name="ParceiroCadastroPage"
+    :show-scheduling="false"
+  />
 </template>
 
 <script setup>
-import { ref, reactive, computed, watch, onMounted, nextTick, useTemplateRef } from "vue";
-import { useRoute, useRouter } from "vue-router";
-import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
-import { useInputRules } from "src/composables/useInputRules";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import {
-  getPartnerAgreement,
-  createPartnerAgreement,
-  updatePartnerAgreement,
-  uploadPartnerLogo,
-  uploadPartnerMedia,
-  deletePartnerMedia,
-} from "src/api/partnerAgreement";
-import { getServicesByPartner } from "src/api/partnerAgreementService";
-import axios from "axios";
-
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import PartnerAgreementCategorySelect from "src/components/selects/PartnerAgreementCategorySelect.vue";
-import CitySelect from "src/components/selects/CitySelect.vue";
-import StateSelect from "src/components/selects/StateSelect.vue";
-
-const route = useRoute();
-const router = useRouter();
-const $q = useQuasar();
-const { t } = useI18n();
-const { inputRules } = useInputRules();
-
-const partnerId = computed(() => (route.params.id ? Number(route.params.id) : null));
-const partner = ref(null);
-const activeTab = ref(route.query.tab ?? "dados");
-const panelContainerRef = ref(null);
-
-watch(activeTab, async () => {
-  const container = panelContainerRef.value;
-  if (!container) return;
-
-  const fromHeight = container.offsetHeight;
-
-  container.style.transition = "none";
-  container.style.overflow   = "hidden";
-  container.style.height     = fromHeight + "px";
-
-  await nextTick();
-
-  void container.offsetHeight;
-
-  requestAnimationFrame(() => {
-    const activePanel = container.querySelector(".panel-item:not(.panel-item--hidden)");
-    const toHeight = activePanel ? activePanel.offsetHeight : fromHeight;
-
-    container.style.transition = "height 0.5s ease";
-    container.style.height     = toHeight + "px";
-
-    setTimeout(() => {
-      container.style.height     = "auto";
-      container.style.overflow   = "";
-      container.style.transition = "";
-    }, 520);
-  });
-}, { flush: "pre" });
-
-const pageTitle = computed(() =>
-  partnerId.value ? t("parceiro.dados_parceiro") : t("parceiro.cadastro_parceiro"),
-);
-
-const tabsItems = computed(() => [
-  { name: "dados",    label: t("parceiro.tab_dados") },
-  { name: "contato",  label: t("parceiro.tab_contato"),  disable: !partnerId.value },
-  { name: "endereco", label: t("parceiro.tab_endereco"), disable: !partnerId.value },
-  { name: "horario",  label: t("parceiro.tab_horario"),  disable: !partnerId.value },
-  { name: "servicos", label: t("parceiro.tab_servicos"), disable: !partnerId.value },
-]);
-
-const formDadosRef = useTemplateRef("formDadosRef");
-const logoFile = ref(null);
-const selectedCategory = ref(null);
-const newUser = reactive({ name: "", email: "", password: "" });
-
-const {
-  form: formDados,
-  getUpdatedFields: updatedDados,
-  hasUpdatedFields: hasUpdatedDados,
-} = useFormUpdateTracker({
-  company_name:        "",
-  cnpj:                "",
-  category_id:         null,
-  responsible:         "",
-  discount_percentage: null,
-  description:         "",
-});
-
-const {
-  loading: loadingDados,
-  validationErrors: errorsDados,
-  execute: execDados,
-} = useSubmitHandler({ formRef: formDadosRef });
-
-watch(selectedCategory, (val) => {
-  formDados.category_id = val?.value ?? null;
-});
-
-const saveDados = async () => {
-  await execDados(async () => {
-    let savedPartner;
-    if (partnerId.value) {
-      savedPartner = await updatePartnerAgreement(partnerId.value, updatedDados.value);
-    } else {
-      savedPartner = await createPartnerAgreement({
-        ...formDados,
-        user_name:     newUser.name,
-        user_email:    newUser.email,
-        user_password: newUser.password,
-      });
-      await router.replace({ name: "ParceiroCadastroPage", params: { id: savedPartner.id } });
-    }
-    if (logoFile.value instanceof File) {
-      const uploadedMedia = await uploadPartnerLogo(savedPartner.id ?? partnerId.value, logoFile.value);
-      savedPartner = { ...savedPartner, logo: uploadedMedia };
-    }
-    partner.value = savedPartner;
-    populateForms(savedPartner);
-    logoFile.value = null;
-    $q.notify({ type: "positive", message: t("http.success") });
-  });
-};
-
-const formContatoRef = useTemplateRef("formContatoRef");
-const {
-  form: formContato,
-  getUpdatedFields: updatedContato,
-  hasUpdatedFields: hasUpdatedContato,
-} = useFormUpdateTracker({ email: "", phone: "", whatsapp: "", website: "" });
-
-const {
-  loading: loadingContato,
-  validationErrors: errorsContato,
-  execute: execContato,
-} = useSubmitHandler({ formRef: formContatoRef });
-
-const saveContato = async () => {
-  await execContato(async () => {
-    const saved = await updatePartnerAgreement(partnerId.value, updatedContato.value);
-    partner.value = saved;
-    $q.notify({ type: "positive", message: t("http.success") });
-  });
-};
-
-const formEnderecoRef = useTemplateRef("formEnderecoRef");
-const selectedState = ref(null);
-const selectedCity = ref(null);
-const loadingCep = ref(false);
-
-const {
-  form: formEndereco,
-  getUpdatedFields: updatedEndereco,
-  hasUpdatedFields: hasUpdatedEndereco,
-} = useFormUpdateTracker({
-  zip_code:     "",
-  address:      "",
-  neighborhood: "",
-  city_id:      null,
-  state_id:     null,
-});
-
-const {
-  loading: loadingEndereco,
-  validationErrors: errorsEndereco,
-  execute: execEndereco,
-} = useSubmitHandler({ formRef: formEnderecoRef });
-
-watch(selectedState, (val) => { formEndereco.state_id = val?.value ?? null; });
-watch(selectedCity,  (val) => { formEndereco.city_id  = val?.value ?? null; });
-
-const onCepChange = async (val) => {
-  const digits = (val ?? "").replace(/\D/g, "");
-  if (digits.length !== 8) return;
-  loadingCep.value = true;
-  try {
-    const { data } = await axios.get(`https://viacep.com.br/ws/${digits}/json/`);
-    if (!data.erro) {
-      formEndereco.address      = data.logradouro ?? formEndereco.address;
-      formEndereco.neighborhood = data.bairro      ?? formEndereco.neighborhood;
-    }
-  } catch {
-    // silent — user fills manually
-  } finally {
-    loadingCep.value = false;
-  }
-};
-
-const saveEndereco = async () => {
-  await execEndereco(async () => {
-    const saved = await updatePartnerAgreement(partnerId.value, updatedEndereco.value);
-    partner.value = saved;
-    $q.notify({ type: "positive", message: t("http.success") });
-  });
-};
-
-const formHorarioRef = useTemplateRef("formHorarioRef");
-const mediaFileInputRef = useTemplateRef("mediaFileInputRef");
-const newMediaFile = ref(null);
-const contractMedia = ref([]);
-
-const {
-  form: formHorario,
-  getUpdatedFields: updatedHorario,
-  hasUpdatedFields: hasUpdatedHorario,
-} = useFormUpdateTracker({ working_hours: "", contract_start: null, contract_end: null });
-
-const {
-  loading: loadingHorario,
-  validationErrors: errorsHorario,
-  execute: execHorario,
-} = useSubmitHandler({ formRef: formHorarioRef });
-
-const onMediaFileSelected = async (file) => {
-  if (!file || !partnerId.value) return;
-  try {
-    const media = await uploadPartnerMedia(partnerId.value, file);
-    contractMedia.value.push(media);
-  } catch {
-    $q.notify({ type: "negative", message: t("http.errors.failed") });
-  } finally {
-    newMediaFile.value = null;
-  }
-};
-
-const triggerMediaUpload = () => {
-  mediaFileInputRef.value?.pickFiles();
-};
-
-const removeMedia = async (media) => {
-  try {
-    await deletePartnerMedia(partnerId.value, media.id);
-    contractMedia.value = contractMedia.value.filter((m) => m.id !== media.id);
-  } catch {
-    $q.notify({ type: "negative", message: t("http.errors.failed") });
-  }
-};
-
-const openMedia = (media) => {
-  window.open(media.url, "_blank");
-};
-
-const saveHorario = async () => {
-  await execHorario(async () => {
-    const saved = await updatePartnerAgreement(partnerId.value, updatedHorario.value);
-    partner.value = saved;
-    $q.notify({ type: "positive", message: t("http.success") });
-  });
-};
-
-const services = ref([]);
-const loadingServices = ref(false);
-const serviceSearch = ref("");
-
-const filteredServices = computed(() => {
-  if (!serviceSearch.value) return services.value;
-  const needle = serviceSearch.value.toLowerCase();
-  return services.value.filter((s) =>
-    [s.name, s.category?.name, s.price, s.associate_price]
-      .some((f) => f != null && String(f).toLowerCase().includes(needle))
-  );
-});
-
-const serviceColumns = computed(() => [
-  { name: "name",            label: t("common.terms.name"),         field: "name",            align: "left", sortable: true },
-  { name: "category",        label: t("parceiro.service_category"), field: (r) => r.category?.name ?? "—", align: "left" },
-  { name: "price",           label: t("parceiro.price"),            field: "price",            align: "left" },
-  { name: "associate_price", label: t("parceiro.associate_price"),  field: "associate_price",  align: "left" },
-  { name: "actions",         label: t("common.terms.actions"),      align: "right", required: true },
-]);
-
-const loadServices = async () => {
-  if (!partnerId.value) return;
-  loadingServices.value = true;
-  try {
-    services.value = await getServicesByPartner(partnerId.value);
-  } finally {
-    loadingServices.value = false;
-  }
-};
-
-const onAddService = () => {
-  router.push({
-    name:   "ParceiroServicoCadastroPage",
-    params: { id: partnerId.value },
-  });
-};
-
-const onEditService = (svc) => {
-  router.push({
-    name:   "ParceiroServicoCadastroPage",
-    params: { id: partnerId.value, serviceId: svc.id },
-  });
-};
-
-const populateForms = (p) => {
-  if (!p) return;
-
-  formDados.company_name        = p.company_name        ?? "";
-  formDados.cnpj                = p.cnpj                ?? "";
-  formDados.category_id         = p.category_id         ?? null;
-  formDados.responsible         = p.responsible         ?? "";
-  formDados.discount_percentage = p.discount_percentage != null ? String(p.discount_percentage) : null;
-  formDados.description         = p.description         ?? "";
-
-  selectedCategory.value = p.category
-    ? { label: p.category.name, value: p.category.id }
-    : null;
-
-  formContato.email    = p.email    ?? "";
-  formContato.phone    = p.phone    ?? "";
-  formContato.whatsapp = p.whatsapp ?? "";
-  formContato.website  = p.website  ?? "";
-
-  formEndereco.zip_code     = p.zip_code     ?? "";
-  formEndereco.address      = p.address      ?? "";
-  formEndereco.neighborhood = p.neighborhood ?? "";
-  formEndereco.city_id      = p.city_id      ?? null;
-  formEndereco.state_id     = p.state_id     ?? null;
-
-  selectedState.value = p.state ? { label: p.state.name, value: p.state.id } : null;
-  selectedCity.value  = p.city  ? { label: p.city.name,  value: p.city.id  } : null;
-
-  formHorario.working_hours  = p.working_hours  ?? "";
-  formHorario.contract_start = p.contract_start ?? null;
-  formHorario.contract_end   = p.contract_end   ?? null;
-
-  contractMedia.value = p.media ?? [];
-};
-
-onMounted(async () => {
-  if (partnerId.value) {
-    try {
-      const p = await getPartnerAgreement(partnerId.value);
-      partner.value = p;
-      populateForms(p);
-    } catch {
-      $q.notify({ type: "negative", message: t("http.errors.failed") });
-    }
-    if (activeTab.value === "servicos") loadServices();
-  }
-});
-
-watch(activeTab, (tab) => {
-  if (tab === "servicos") loadServices();
-});
+import CadastroFormPanel from "./components/CadastroFormPanel.vue";
 </script>
-
-<style lang="scss" scoped>
-@use "src/css/quasar.variables.scss";
-
-.cadastro-page {
-  &__tabs {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 6px;
-  }
-
-  &__tab {
-    padding: 4px 16px;
-    border-radius: 6px;
-    font-size: 14px;
-    font-weight: 600;
-    cursor: pointer;
-    background: white;
-    color: $violet-normal;
-    border: 1.5px solid rgba($violet-normal, 0.3);
-    transition: background 0.15s, color 0.15s;
-    user-select: none;
-
-    &--active {
-      background: $violet-normal;
-      color: white;
-      border-color: $violet-normal;
-    }
-
-    &--disabled {
-      opacity: 0.4;
-      cursor: not-allowed;
-    }
-  }
-}
-
-.tab-panels-wrapper {
-  :deep(.q-panel-parent) {
-    overflow: hidden !important;
-  }
-}
-
-.panels-container {
-  position: relative;
-  overflow: hidden;
-}
-
-.panel-item--hidden {
-  position: absolute !important;
-  top: 0;
-  left: 0;
-  right: 0;
-  visibility: hidden !important;
-  pointer-events: none !important;
-  z-index: -1;
-}
-
-.cadastro-card {
-  border-radius: 12px;
-  background: white;
-}
-
-.btn-gradient {
-  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
-  color: white !important;
-  border-radius: 8px !important;
-  padding: 8px 16px;
-}
-
-.btn-gradient :deep(.q-icon) {
-  color: white !important;
-}
-
-.service-search :deep(.q-field__control) {
-  border-radius: 24px !important;
-}
-</style>

+ 2 - 20
src/pages/parceiros-convenios/ParceiroServicoCadastroPage.vue

@@ -81,24 +81,6 @@
               class="col-12 col-sm-4 input-violet q-pl-sm"
             />
 
-            <div class="col-12 q-pt-md">
-              <div class="q-pl-xs q-mb-sm field-label">
-                {{ $t('parceiro.requires_scheduling') }}
-              </div>
-              <div class="scheduling-select">
-                <span
-                  class="scheduling-option"
-                  :class="{ 'scheduling-option--active': form.requires_scheduling === true }"
-                  @click="form.requires_scheduling = true"
-                >SIM</span>
-                <span class="scheduling-divider"> – </span>
-                <span
-                  class="scheduling-option"
-                  :class="{ 'scheduling-option--active': form.requires_scheduling === false }"
-                  @click="form.requires_scheduling = false"
-                >NÃO</span>
-              </div>
-            </div>
 
             <div class="col-12 row justify-center q-pt-md">
               <q-btn
@@ -200,8 +182,8 @@ const { t }  = useI18n();
 const { inputRules } = useInputRules();
 
 const partnerId = computed(() => Number(route.params.id));
-const serviceId  = computed(() => (route.params.serviceId ? Number(route.params.serviceId) : null));
-const isEdit     = computed(() => !!serviceId.value);
+const serviceId = computed(() => (route.params.serviceId ? Number(route.params.serviceId) : null));
+const isEdit    = computed(() => !!serviceId.value);
 
 const loadingPage      = ref(false);
 const loadingCategories = ref(true);

+ 24 - 122
src/pages/parceiros-convenios/ParceirosConveniosPage.vue

@@ -7,13 +7,6 @@
       class="hidden"
       @change="onParceiroFileSelected"
     />
-    <input
-      ref="importConveniosInput"
-      type="file"
-      accept=".xlsx"
-      class="hidden"
-      @change="onConveniosFileSelected"
-    />
 
     <DefaultHeaderPage>
       <template #after>
@@ -27,15 +20,6 @@
             :loading="importingParceiros"
             @click="onImportParceirosClick"
           />
-          <q-btn
-            unelevated
-            class="btn-gradient"
-            :label="$t('parceiro.import_convenios')"
-            icon="mdi-hospital-box"
-            padding="8px 12px"
-            :loading="importingConvenios"
-            @click="onImportConveniosClick"
-          />
           <q-btn
             unelevated
             class="btn-gradient"
@@ -102,7 +86,6 @@
         />
       </div>
     </div>
-
   </div>
 </template>
 
@@ -113,7 +96,7 @@ import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
-import { getPartnerAgreements, importParceiros, importConveniosMedicos } from "src/api/partnerAgreement";
+import { getPartnerAgreements, importParceiros } from "src/api/partnerAgreement";
 import { getCategories } from "src/api/category";
 import { normalizeString } from "src/helpers/utils";
 import { useImportPoller } from "src/composables/useImportPoller";
@@ -121,28 +104,24 @@ import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import PartnerAgreementCard from "./components/PartnerAgreementCard.vue";
 
 const router = useRouter();
-const $q = useQuasar();
+const $q    = useQuasar();
 const { t } = useI18n();
 const permission_store = permissionStore();
 const { isAdministrador } = userStore();
 
-const loading = ref(true);
-const allPartners = ref([]);
-const categories = ref([]);
+const loading      = ref(true);
+const allPartners  = ref([]);
+const categories   = ref([]);
 const activeCategory = ref("all");
-const searchQuery = ref("");
-const importParceiroInput  = useTemplateRef("importParceiroInput");
-const importConveniosInput = useTemplateRef("importConveniosInput");
-const { polling: importingParceiros, start: startParceiroPolling }   = useImportPoller();
-const { polling: importingConvenios, start: startConveniosPolling }  = useImportPoller();
+const searchQuery  = ref("");
+const importParceiroInput = useTemplateRef("importParceiroInput");
+const { polling: importingParceiros, start: startParceiroPolling } = useImportPoller();
 
 const filteredPartners = computed(() => {
   let list = allPartners.value;
-
   if (activeCategory.value !== "all") {
     list = list.filter((p) => String(p.category_id) === activeCategory.value);
   }
-
   if (searchQuery.value) {
     const needle = normalizeString(searchQuery.value);
     list = list.filter((p) => {
@@ -150,7 +129,6 @@ const filteredPartners = computed(() => {
       return fields.some((f) => f && normalizeString(String(f)).includes(needle));
     });
   }
-
   return list;
 });
 
@@ -172,11 +150,11 @@ const onEditItem = (partner) => {
 
 const loadPartners = async () => {
   const [partners, cats] = await Promise.all([
-    getPartnerAgreements(),
+    getPartnerAgreements({ type: "partner" }),
     getCategories("partner"),
   ]);
   allPartners.value = partners;
-  categories.value = cats;
+  categories.value  = cats;
 };
 
 const onImportParceirosClick = () => {
@@ -188,59 +166,16 @@ const onImportParceirosClick = () => {
   importParceiroInput.value.click();
 };
 
-const onImportConveniosClick = () => {
-  if (!permission_store.getAccess("parceiro.convenio", "add")) {
-    $q.notify({ type: "negative", message: t("validation.permissions.add") });
-    return;
-  }
-  importConveniosInput.value.value = null;
-  importConveniosInput.value.click();
-};
-
 const onParceiroFileSelected = async (event) => {
   const file = event.target.files?.[0];
   if (!file) return;
-
   try {
     const { import_id } = await importParceiros(file);
-
     startParceiroPolling(import_id, {
       onComplete: async (stats) => {
         $q.notify({
           type: "positive",
-          message: t("parceiro.import_parceiros_result", {
-            created:     stats.created,
-            updated:     stats.updated,
-            inactivated: stats.inactivated,
-          }),
-          timeout: 7000,
-        });
-        await loadPartners();
-      },
-      onError:   () => $q.notify({ type: "negative", message: t("http.errors.failed") }),
-      onTimeout: () => $q.notify({ type: "warning",  message: t("parceiro.import_processing") }),
-    });
-  } catch {
-    $q.notify({ type: "negative", message: t("http.errors.failed") });
-  }
-};
-
-const onConveniosFileSelected = async (event) => {
-  const file = event.target.files?.[0];
-  if (!file) return;
-
-  try {
-    const { import_id } = await importConveniosMedicos(file);
-
-    startConveniosPolling(import_id, {
-      onComplete: async (stats) => {
-        $q.notify({
-          type: "positive",
-          message: t("parceiro.import_convenios_result", {
-            partners_created:     stats.partners_created,
-            services_created:     stats.services_created,
-            partners_inactivated: stats.partners_inactivated,
-          }),
+          message: t("parceiro.import_parceiros_result", { created: stats.created, updated: stats.updated, inactivated: stats.inactivated }),
           timeout: 7000,
         });
         await loadPartners();
@@ -254,59 +189,26 @@ const onConveniosFileSelected = async (event) => {
 };
 
 onMounted(async () => {
-  try {
-    await loadPartners();
-  } finally {
-    loading.value = false;
-  }
+  try { await loadPartners(); }
+  finally { loading.value = false; }
 });
 </script>
 
 <style scoped>
-.gap-sm {
-  gap: 8px;
-}
-
-.parceiros-page {
-  min-height: 100vh;
-}
-
-.parceiros-page__search-input {
-  max-width: 700px;
-  width: 100%;
-}
-
+.gap-sm { gap: 8px; }
+.parceiros-page { min-height: 100vh; }
+.parceiros-page__search-input { max-width: 700px; width: 100%; }
 .cat-chip {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  height: 28px;
-  padding: 0 12px;
-  border-radius: 5px;
-  font-size: 12px;
-  font-weight: 500;
-  cursor: pointer;
-  user-select: none;
-  transition: background 0.15s, color 0.15s;
+  display: inline-flex; align-items: center; justify-content: center;
+  height: 28px; padding: 0 12px; border-radius: 5px;
+  font-size: 12px; font-weight: 500; cursor: pointer;
+  user-select: none; transition: background 0.15s, color 0.15s;
 }
-
-.cat-chip--default {
-  background: #c9a3dc;
-  color: #fff;
-}
-
-.cat-chip--selected {
-  background: #4d1658;
-  color: #fff;
-}
-
+.cat-chip--default  { background: #c9a3dc; color: #fff; }
+.cat-chip--selected { background: #4d1658; color: #fff; }
 .btn-gradient {
   background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
-  color: white !important;
-  border-radius: 8px !important;
-}
-
-.btn-gradient :deep(.q-icon) {
-  color: white !important;
+  color: white !important; border-radius: 8px !important;
 }
+.btn-gradient :deep(.q-icon) { color: white !important; }
 </style>

+ 748 - 0
src/pages/parceiros-convenios/components/CadastroFormPanel.vue

@@ -0,0 +1,748 @@
+<template>
+  <div class="cadastro-page q-mr-md">
+    <DefaultHeaderPage :title="{ value: pageTitle }" />
+
+    <div class="cadastro-page__tabs q-mb-md">
+      <div
+        v-for="tab in tabsItems"
+        :key="tab.name"
+        class="cadastro-page__tab"
+        :class="{
+          'cadastro-page__tab--active': activeTab === tab.name,
+          'cadastro-page__tab--disabled': tab.disable,
+        }"
+        @click="!tab.disable && (activeTab = tab.name)"
+      >
+        {{ tab.label }}
+      </div>
+    </div>
+
+    <q-tab-panels v-model="activeTab" animated keep-alive>
+
+      <q-tab-panel name="dados" class="q-pa-none">
+        <q-form ref="formDadosRef" @submit="saveDados">
+          <div class="q-pr-md q-pb-md bg-violet-light">
+            <DefaultFilePicker
+              v-model="logoFile"
+              type="image"
+              :label="$t('parceiro.logo')"
+              :initial-image="entity?.logo?.url"
+              style="height: 220px; max-width: 280px"
+            />
+          </div>
+
+          <q-card flat class="cadastro-card q-pa-md">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="formDados.company_name"
+                v-model:error="errorsDados.company_name"
+                :rules="[inputRules.required]"
+                :label="$t('parceiro.company_name')"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formDados.cnpj"
+                v-model:error="errorsDados.cnpj"
+                :label="$t('common.terms.cnpj')"
+                :placeholder="'00.000.000/0000-00'"
+                :mask="'##.###.###/####-##'"
+                class="col-12"
+              />
+              <PartnerAgreementCategorySelect
+                v-model="selectedCategory"
+                v-model:error="errorsDados.category_id"
+                class="col-12"
+              />
+
+              <template v-if="!entityId">
+                <DefaultInput
+                  v-model="newUser.name"
+                  :rules="[inputRules.required]"
+                  :label="$t('common.terms.name')"
+                  class="col-12"
+                />
+                <DefaultInput
+                  v-model="newUser.email"
+                  :rules="[inputRules.required, inputRules.email]"
+                  :label="$t('common.terms.email')"
+                  type="email"
+                  class="col-12"
+                />
+                <DefaultInput
+                  v-model="newUser.password"
+                  :rules="[inputRules.required]"
+                  :label="$t('common.terms.password')"
+                  type="password"
+                  class="col-12"
+                />
+              </template>
+
+              <DefaultInput
+                v-else-if="entity?.user"
+                :model-value="entity.user.name"
+                :label="$t('parceiro.linked_user')"
+                disable
+                class="col-12"
+              />
+
+              <DefaultInput
+                v-model="formDados.responsible"
+                v-model:error="errorsDados.responsible"
+                :label="$t('parceiro.responsible')"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formDados.discount_percentage"
+                v-model:error="errorsDados.discount_percentage"
+                :label="$t('parceiro.discount_percentage')"
+                type="number"
+                suffix="%"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formDados.description"
+                v-model:error="errorsDados.description"
+                :label="$t('common.terms.description')"
+                type="textarea"
+                autogrow
+                class="col-12"
+              />
+            </div>
+          </q-card>
+
+          <div class="row justify-end q-py-md bg-violet-light">
+            <q-btn
+              unelevated
+              class="btn-gradient"
+              :label="$t('common.actions.save')"
+              icon="mdi-check"
+              type="submit"
+              :loading="loadingDados"
+              :disable="!hasUpdatedDados"
+            />
+          </div>
+        </q-form>
+      </q-tab-panel>
+
+      <q-tab-panel name="contato" class="q-pa-none">
+        <q-form ref="formContatoRef" @submit="saveContato">
+          <q-card flat class="cadastro-card q-pa-md">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="formContato.email"
+                v-model:error="errorsContato.email"
+                :rules="[inputRules.email]"
+                :label="$t('common.terms.email')"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formContato.website"
+                v-model:error="errorsContato.website"
+                :label="$t('parceiro.website')"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formContato.phone"
+                v-model:error="errorsContato.phone"
+                :label="$t('common.terms.phone')"
+                :mask="'(##) #####-####'"
+                class="col-12"
+              />
+              <DefaultInput
+                v-model="formContato.whatsapp"
+                v-model:error="errorsContato.whatsapp"
+                :label="$t('common.terms.whatsapp')"
+                :mask="'(##) #####-####'"
+                class="col-12"
+              />
+            </div>
+          </q-card>
+
+          <div class="row justify-end bg-violet-light q-pt-md">
+            <q-btn
+              unelevated
+              class="btn-gradient"
+              :label="$t('common.actions.save')"
+              icon="mdi-check"
+              type="submit"
+              :loading="loadingContato"
+              :disable="!hasUpdatedContato"
+            />
+          </div>
+        </q-form>
+      </q-tab-panel>
+
+      <q-tab-panel name="endereco" class="q-pa-none">
+        <q-form ref="formEnderecoRef" @submit="saveEndereco">
+          <q-card flat class="cadastro-card q-pa-md">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="formEndereco.zip_code"
+                v-model:error="errorsEndereco.zip_code"
+                :label="$t('parceiro.zip_code')"
+                :placeholder="'00000-000'"
+                :mask="'#####-###'"
+                class="col-md-4 col-12"
+                @update:model-value="onCepChange"
+              />
+              <DefaultInput
+                v-model="formEndereco.address"
+                v-model:error="errorsEndereco.address"
+                :label="$t('parceiro.address')"
+                :loading="loadingCep"
+                class="col-md-8 col-12"
+              />
+              <DefaultInput
+                v-model="formEndereco.neighborhood"
+                v-model:error="errorsEndereco.neighborhood"
+                :label="$t('parceiro.neighborhood')"
+                :loading="loadingCep"
+                class="col-12"
+              />
+              <StateSelect
+                v-model="selectedState"
+                v-model:error="errorsEndereco.state_id"
+                class="col-md-4 col-12 input-violet"
+              />
+              <CitySelect
+                v-model="selectedCity"
+                v-model:error="errorsEndereco.city_id"
+                :state="selectedState"
+                class="col-md-8 col-12 input-violet"
+              />
+            </div>
+          </q-card>
+
+          <div class="row justify-end q-pt-md bg-violet-light">
+            <q-btn
+              unelevated
+              class="btn-gradient"
+              :label="$t('common.actions.save')"
+              icon="mdi-check"
+              type="submit"
+              :loading="loadingEndereco"
+              :disable="!hasUpdatedEndereco"
+            />
+          </div>
+        </q-form>
+      </q-tab-panel>
+
+      <q-tab-panel name="horario" class="q-pa-none">
+        <q-form ref="formHorarioRef" @submit="saveHorario">
+          <q-card flat class="cadastro-card q-pa-md">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="formHorario.working_hours"
+                v-model:error="errorsHorario.working_hours"
+                :label="$t('parceiro.working_hours')"
+                :placeholder="$t('parceiro.working_hours_placeholder')"
+                class="col-12"
+              />
+              <DefaultInputDatePicker
+                v-model:untreated-date="formHorario.contract_start"
+                v-model:error="errorsHorario.contract_start"
+                :label="$t('parceiro.contract_start')"
+                class="col-md-6 col-12"
+              />
+              <DefaultInputDatePicker
+                v-model:untreated-date="formHorario.contract_end"
+                v-model:error="errorsHorario.contract_end"
+                :label="$t('parceiro.contract_end')"
+                class="col-md-6 col-12"
+              />
+            </div>
+
+            <div v-if="contractMedia.length > 0" class="q-mt-md">
+              <div class="text-subtitle2 text-violet-normal q-mb-xs">{{ $t("parceiro.contract_files") }}</div>
+              <div class="row q-col-gutter-xs">
+                <div v-for="media in contractMedia" :key="media.id" class="col-auto">
+                  <q-chip
+                    removable
+                    clickable
+                    color="violet-light"
+                    text-color="violet-normal"
+                    icon="mdi-file-outline"
+                    :label="media.name"
+                    @click="openMedia(media)"
+                    @remove="removeMedia(media)"
+                  />
+                </div>
+              </div>
+            </div>
+          </q-card>
+
+          <div class="row justify-end items-center q-pt-md bg-violet-light" style="gap: 8px">
+            <q-file
+              ref="mediaFileInputRef"
+              v-model="newMediaFile"
+              accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
+              style="display: none"
+              @update:model-value="onMediaFileSelected"
+            />
+            <q-btn
+              unelevated
+              class="btn-gradient"
+              :label="$t('parceiro.add_file')"
+              icon="mdi-paperclip"
+              :disable="!entityId"
+              @click="triggerMediaUpload"
+            />
+            <q-btn
+              unelevated
+              class="btn-gradient"
+              :label="$t('common.actions.save')"
+              icon="mdi-check"
+              type="submit"
+              :loading="loadingHorario"
+              :disable="!hasUpdatedHorario"
+            />
+          </div>
+        </q-form>
+      </q-tab-panel>
+
+      <q-tab-panel name="servicos" class="q-pa-none">
+        <template v-if="!serviceFormVisible">
+          <div class="bg-violet-light q-pb-md">
+            <div class="row justify-end q-mb-sm q-gutter-sm">
+              <q-btn
+                unelevated
+                icon="mdi-plus"
+                :label="$t('common.actions.new')"
+                padding="6px 12px"
+                :disable="!entityId"
+                class="btn-gradient"
+                @click="onAddService"
+              />
+            </div>
+
+            <q-input
+              v-model="serviceSearch"
+              :placeholder="$t('parceiro.search_placeholder')"
+              outlined
+              dense
+              clearable
+              color="violet-normal"
+              class="q-mb-sm service-search"
+            >
+              <template #prepend>
+                <q-icon name="mdi-magnify" color="violet-normal" />
+              </template>
+            </q-input>
+          </div>
+
+          <q-card>
+            <div v-if="loadingServices" class="flex flex-center q-pa-xl">
+              <q-spinner color="violet-normal" size="40px" />
+            </div>
+
+            <DefaultTable
+              v-else
+              v-model:rows="filteredServices"
+              :columns="serviceColumns"
+              no-api-call
+              :show-search-field="false"
+              open-item
+              @on-row-click="({ row }) => onEditService(row)"
+            />
+          </q-card>
+        </template>
+
+        <ServicoFormPanel
+          v-else
+          :partner-id="entityId"
+          :service-id="editingServiceId"
+          :show-scheduling="showScheduling"
+          @back="onServiceFormBack"
+        />
+      </q-tab-panel>
+
+    </q-tab-panels>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, onMounted, useTemplateRef } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  getPartnerAgreement,
+  createPartnerAgreement,
+  updatePartnerAgreement,
+  uploadPartnerLogo,
+  uploadPartnerMedia,
+  deletePartnerMedia,
+} from "src/api/partnerAgreement";
+import { getServicesByPartner } from "src/api/partnerAgreementService";
+import axios from "axios";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import PartnerAgreementCategorySelect from "src/components/selects/PartnerAgreementCategorySelect.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+import ServicoFormPanel from "./ServicoFormPanel.vue";
+
+const props = defineProps({
+  entityType:        { type: String, required: true },
+  cadastroRouteName: { type: String, required: true },
+  showScheduling:    { type: Boolean, default: false },
+});
+
+const route  = useRoute();
+const router = useRouter();
+const $q     = useQuasar();
+const { t }  = useI18n();
+const { inputRules } = useInputRules();
+
+const entityId = computed(() => (route.params.id ? Number(route.params.id) : null));
+
+const entity    = ref(null);
+const activeTab = ref(route.query.tab ?? "dados");
+
+const pageTitle = computed(() =>
+  entityId.value ? t("parceiro.dados_parceiro") : t("parceiro.cadastro_parceiro"),
+);
+
+const tabsItems = computed(() => [
+  { name: "dados",    label: t("parceiro.tab_dados") },
+  { name: "contato",  label: t("parceiro.tab_contato"),  disable: !entityId.value },
+  { name: "endereco", label: t("parceiro.tab_endereco"), disable: !entityId.value },
+  { name: "horario",  label: t("parceiro.tab_horario"),  disable: !entityId.value },
+  { name: "servicos", label: t("parceiro.tab_servicos"), disable: !entityId.value },
+]);
+
+const formDadosRef = useTemplateRef("formDadosRef");
+const logoFile     = ref(null);
+const selectedCategory = ref(null);
+const newUser = reactive({ name: "", email: "", password: "" });
+
+const {
+  form: formDados,
+  getUpdatedFields: updatedDados,
+  hasUpdatedFields: hasUpdatedDados,
+} = useFormUpdateTracker({
+  company_name:        "",
+  cnpj:                "",
+  category_id:         null,
+  responsible:         "",
+  discount_percentage: null,
+  description:         "",
+});
+
+const {
+  loading: loadingDados,
+  validationErrors: errorsDados,
+  execute: execDados,
+} = useSubmitHandler({ formRef: formDadosRef });
+
+watch(selectedCategory, (val) => { formDados.category_id = val?.value ?? null; });
+
+const saveDados = async () => {
+  await execDados(async () => {
+    let saved;
+    if (entityId.value) {
+      saved = await updatePartnerAgreement(entityId.value, updatedDados.value);
+    } else {
+      saved = await createPartnerAgreement({
+        ...formDados,
+        type:          props.entityType,
+        user_name:     newUser.name,
+        user_email:    newUser.email,
+        user_password: newUser.password,
+      });
+      await router.replace({ name: props.cadastroRouteName, params: { id: saved.id } });
+    }
+    if (logoFile.value instanceof File) {
+      const uploadedLogo = await uploadPartnerLogo(saved.id ?? entityId.value, logoFile.value);
+      saved = { ...saved, logo: uploadedLogo };
+    }
+    entity.value   = saved;
+    logoFile.value = null;
+    populateForms(saved);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+const formContatoRef = useTemplateRef("formContatoRef");
+const {
+  form: formContato,
+  getUpdatedFields: updatedContato,
+  hasUpdatedFields: hasUpdatedContato,
+} = useFormUpdateTracker({ email: "", phone: "", whatsapp: "", website: "" });
+
+const {
+  loading: loadingContato,
+  validationErrors: errorsContato,
+  execute: execContato,
+} = useSubmitHandler({ formRef: formContatoRef });
+
+const saveContato = async () => {
+  await execContato(async () => {
+    entity.value = await updatePartnerAgreement(entityId.value, updatedContato.value);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+const formEnderecoRef = useTemplateRef("formEnderecoRef");
+const selectedState   = ref(null);
+const selectedCity    = ref(null);
+const loadingCep      = ref(false);
+
+const {
+  form: formEndereco,
+  getUpdatedFields: updatedEndereco,
+  hasUpdatedFields: hasUpdatedEndereco,
+} = useFormUpdateTracker({
+  zip_code:     "",
+  address:      "",
+  neighborhood: "",
+  city_id:      null,
+  state_id:     null,
+});
+
+const {
+  loading: loadingEndereco,
+  validationErrors: errorsEndereco,
+  execute: execEndereco,
+} = useSubmitHandler({ formRef: formEnderecoRef });
+
+watch(selectedState, (val) => { formEndereco.state_id = val?.value ?? null; });
+watch(selectedCity,  (val) => { formEndereco.city_id  = val?.value ?? null; });
+
+const onCepChange = async (val) => {
+  const digits = (val ?? "").replace(/\D/g, "");
+  if (digits.length !== 8) return;
+  loadingCep.value = true;
+  try {
+    const { data } = await axios.get(`https://viacep.com.br/ws/${digits}/json/`);
+    if (!data.erro) {
+      formEndereco.address      = data.logradouro ?? formEndereco.address;
+      formEndereco.neighborhood = data.bairro      ?? formEndereco.neighborhood;
+    }
+  } catch {
+    // silent — user fills manually
+  } finally {
+    loadingCep.value = false;
+  }
+};
+
+const saveEndereco = async () => {
+  await execEndereco(async () => {
+    entity.value = await updatePartnerAgreement(entityId.value, updatedEndereco.value);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+const formHorarioRef   = useTemplateRef("formHorarioRef");
+const mediaFileInputRef = useTemplateRef("mediaFileInputRef");
+const newMediaFile     = ref(null);
+const contractMedia    = ref([]);
+
+const {
+  form: formHorario,
+  getUpdatedFields: updatedHorario,
+  hasUpdatedFields: hasUpdatedHorario,
+} = useFormUpdateTracker({ working_hours: "", contract_start: null, contract_end: null });
+
+const {
+  loading: loadingHorario,
+  validationErrors: errorsHorario,
+  execute: execHorario,
+} = useSubmitHandler({ formRef: formHorarioRef });
+
+const onMediaFileSelected = async (file) => {
+  if (!file || !entityId.value) return;
+  try {
+    const media = await uploadPartnerMedia(entityId.value, file);
+    contractMedia.value.push(media);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    newMediaFile.value = null;
+  }
+};
+
+const triggerMediaUpload = () => { mediaFileInputRef.value?.pickFiles(); };
+
+const removeMedia = async (media) => {
+  try {
+    await deletePartnerMedia(entityId.value, media.id);
+    contractMedia.value = contractMedia.value.filter((m) => m.id !== media.id);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+};
+
+const openMedia = (media) => { window.open(media.url, "_blank"); };
+
+const saveHorario = async () => {
+  await execHorario(async () => {
+    entity.value = await updatePartnerAgreement(entityId.value, updatedHorario.value);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+const services          = ref([]);
+const loadingServices   = ref(false);
+const serviceSearch     = ref("");
+const serviceFormVisible = ref(false);
+const editingServiceId  = ref(null);
+
+const filteredServices = computed(() => {
+  if (!serviceSearch.value) return services.value;
+  const needle = serviceSearch.value.toLowerCase();
+  return services.value.filter((s) =>
+    [s.name, s.category?.name, s.price, s.associate_price]
+      .some((f) => f != null && String(f).toLowerCase().includes(needle))
+  );
+});
+
+const serviceColumns = computed(() => [
+  { name: "name",            label: t("common.terms.name"),         field: "name",            align: "left", sortable: true },
+  { name: "category",        label: t("parceiro.service_category"), field: (r) => r.category?.name ?? "—", align: "left" },
+  { name: "price",           label: t("parceiro.price"),            field: "price",            align: "left" },
+  { name: "associate_price", label: t("parceiro.associate_price"),  field: "associate_price",  align: "left" },
+  { name: "actions",         label: t("common.terms.actions"),      align: "right", required: true },
+]);
+
+const loadServices = async () => {
+  if (!entityId.value) return;
+  loadingServices.value = true;
+  try {
+    services.value = await getServicesByPartner(entityId.value);
+  } finally {
+    loadingServices.value = false;
+  }
+};
+
+const onAddService = () => {
+  editingServiceId.value   = null;
+  serviceFormVisible.value = true;
+};
+
+const onEditService = (svc) => {
+  editingServiceId.value   = svc.id;
+  serviceFormVisible.value = true;
+};
+
+const onServiceFormBack = async () => {
+  serviceFormVisible.value = false;
+  editingServiceId.value   = null;
+  await loadServices();
+};
+
+const populateForms = (p) => {
+  if (!p) return;
+
+  formDados.company_name        = p.company_name        ?? "";
+  formDados.cnpj                = p.cnpj                ?? "";
+  formDados.category_id         = p.category_id         ?? null;
+  formDados.responsible         = p.responsible         ?? "";
+  formDados.discount_percentage = p.discount_percentage != null ? String(p.discount_percentage) : null;
+  formDados.description         = p.description         ?? "";
+
+  selectedCategory.value = p.category
+    ? { label: p.category.name, value: p.category.id }
+    : null;
+
+  formContato.email    = p.email    ?? "";
+  formContato.phone    = p.phone    ?? "";
+  formContato.whatsapp = p.whatsapp ?? "";
+  formContato.website  = p.website  ?? "";
+
+  formEndereco.zip_code     = p.zip_code     ?? "";
+  formEndereco.address      = p.address      ?? "";
+  formEndereco.neighborhood = p.neighborhood ?? "";
+  formEndereco.city_id      = p.city_id      ?? null;
+  formEndereco.state_id     = p.state_id     ?? null;
+
+  selectedState.value = p.state ? { label: p.state.name, value: p.state.id } : null;
+  selectedCity.value  = p.city  ? { label: p.city.name,  value: p.city.id  } : null;
+
+  formHorario.working_hours  = p.working_hours  ?? "";
+  formHorario.contract_start = p.contract_start ?? null;
+  formHorario.contract_end   = p.contract_end   ?? null;
+
+  contractMedia.value = p.media ?? [];
+};
+
+onMounted(async () => {
+  if (entityId.value) {
+    try {
+      const p = await getPartnerAgreement(entityId.value);
+      entity.value = p;
+      populateForms(p);
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    }
+    if (activeTab.value === "servicos") loadServices();
+  }
+});
+
+watch(activeTab, (tab) => {
+  if (tab === "servicos") loadServices();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss";
+
+.cadastro-page {
+  &__tabs {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+  }
+
+  &__tab {
+    padding: 4px 16px;
+    border-radius: 6px;
+    font-size: 14px;
+    font-weight: 600;
+    cursor: pointer;
+    background: white;
+    color: $violet-normal;
+    border: 1.5px solid rgba($violet-normal, 0.3);
+    transition: background 0.15s, color 0.15s;
+    user-select: none;
+
+    &--active {
+      background: $violet-normal;
+      color: white;
+      border-color: $violet-normal;
+    }
+
+    &--disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+    }
+  }
+}
+
+.cadastro-card {
+  border-radius: 12px;
+  background: white;
+}
+
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 8px !important;
+  padding: 8px 16px;
+}
+
+.btn-gradient :deep(.q-icon) {
+  color: white !important;
+}
+
+.service-search :deep(.q-field__control) {
+  border-radius: 24px !important;
+}
+</style>

+ 0 - 133
src/pages/parceiros-convenios/components/ParceiroCadastroServicosPanel.vue

@@ -1,133 +0,0 @@
-<template>
-  <div>
-    <div class="bg-violet-light q-pb-md">
-      <div class="row justify-end q-mb-sm q-gutter-sm">
-        <q-btn
-          unelevated
-          icon="mdi-upload"
-          :label="$t('common.actions.import')"
-          padding="6px 12px"
-          class="btn-gradient"
-        />
-        <q-btn
-          unelevated
-          icon="mdi-plus"
-          :label="$t('common.actions.new')"
-          padding="6px 12px"
-          class="btn-gradient"
-          @click="onAddService"
-        />
-      </div>
-
-      <q-input
-        v-model="serviceSearch"
-        :placeholder="$t('parceiro.search_placeholder')"
-        outlined
-        dense
-        clearable
-        color="violet-normal"
-        class="q-mb-sm service-search"
-      >
-        <template #prepend>
-          <q-icon name="mdi-magnify" color="violet-normal" />
-        </template>
-      </q-input>
-    </div>
-
-    <q-card>
-      <div v-if="loadingServices" class="flex flex-center q-pa-xl">
-        <q-spinner color="violet-normal" size="40px" />
-      </div>
-
-      <DefaultTable
-        v-else
-        v-model:rows="filteredServices"
-        :columns="serviceColumns"
-        no-api-call
-        :show-search-field="false"
-        open-item
-        @on-row-click="({ row }) => onEditService(row)"
-      />
-    </q-card>
-  </div>
-</template>
-
-<script setup>
-import { ref, computed, watch } from "vue";
-import { useRouter } from "vue-router";
-import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
-import { getServicesByPartner } from "src/api/partnerAgreementService";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-
-const props = defineProps({
-  partnerId: { type: Number, required: true },
-  active:    { type: Boolean, default: false },
-});
-
-const router = useRouter();
-const $q = useQuasar();
-const { t } = useI18n();
-
-const services = ref([]);
-const loadingServices = ref(false);
-const serviceSearch = ref("");
-
-const filteredServices = computed(() => {
-  if (!serviceSearch.value) return services.value;
-  const needle = serviceSearch.value.toLowerCase();
-  return services.value.filter((s) =>
-    [s.name, s.category?.name, s.price, s.associate_price]
-      .some((f) => f != null && String(f).toLowerCase().includes(needle))
-  );
-});
-
-const serviceColumns = computed(() => [
-  { name: "name",            label: t("common.terms.name"),         field: "name",            align: "left", sortable: true },
-  { name: "category",        label: t("parceiro.service_category"), field: (r) => r.category?.name ?? "—", align: "left" },
-  { name: "price",           label: t("parceiro.price"),            field: "price",            align: "left" },
-  { name: "associate_price", label: t("parceiro.associate_price"),  field: "associate_price",  align: "left" },
-  { name: "actions",         label: t("common.terms.actions"),      align: "right", required: true },
-]);
-
-const loadServices = async () => {
-  loadingServices.value = true;
-  try {
-    services.value = await getServicesByPartner(props.partnerId);
-  } catch {
-    $q.notify({ type: "negative", message: t("http.errors.failed") });
-  } finally {
-    loadingServices.value = false;
-  }
-};
-
-const onAddService = () => {
-  router.push({ name: "ParceiroServicoCadastroPage", params: { id: props.partnerId } });
-};
-
-const onEditService = (svc) => {
-  router.push({ name: "ParceiroServicoCadastroPage", params: { id: props.partnerId, serviceId: svc.id } });
-};
-
-watch(() => props.active, (isActive) => {
-  if (isActive) loadServices();
-}, { immediate: true });
-</script>
-
-<style lang="scss" scoped>
-.btn-gradient {
-  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
-  color: white !important;
-  border-radius: 8px !important;
-  padding: 8px 16px;
-}
-
-.btn-gradient :deep(.q-icon) {
-  color: white !important;
-}
-
-.service-search :deep(.q-field__control) {
-  border-radius: 24px !important;
-}
-</style>

+ 445 - 0
src/pages/parceiros-convenios/components/ServicoFormPanel.vue

@@ -0,0 +1,445 @@
+<template>
+  <div class="servico-form-panel">
+    <div v-if="loadingPage" class="flex flex-center q-pa-xl">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <q-form v-else ref="formRef" @submit="onSubmit">
+      <q-card flat class="cadastro-card q-pa-md">
+
+        <div class="row q-col-gutter-md">
+          <div class="col-12">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="idDisplay"
+                label="ID"
+                readonly
+                class="col-3 input-violet"
+              />
+              <DefaultInput
+                v-model="form.name"
+                v-model:error="validationErrors.name"
+                :rules="[inputRules.required]"
+                :label="$t('common.terms.name')"
+                class="col-5 input-violet"
+              />
+            </div>
+          </div>
+          <div class="col-12">
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              :label="$t('common.terms.description')"
+              type="textarea"
+              :rows="6"
+              class="input-violet"
+            />
+          </div>
+        </div>
+
+        <div class="row q-col-gutter-sm q-mt-sm">
+          <div class="col-12 col-md-6 row">
+            <div class="col-12 q-pb-md">
+              <div class="q-pl-xs q-mb-sm field-label">{{ $t('parceiro.service_category') }}</div>
+              <div v-if="loadingCategories" class="q-pa-xs">
+                <q-spinner color="violet-normal" size="20px" />
+              </div>
+              <div v-else class="row q-gutter-xs">
+                <div
+                  v-for="cat in categoryOptions"
+                  :key="cat.value"
+                  class="category-chip"
+                  :class="{ 'category-chip--active': form.category_id === cat.value }"
+                  @click="form.category_id = cat.value"
+                >
+                  {{ cat.label }}
+                </div>
+              </div>
+            </div>
+
+            <DefaultCurrencyInput
+              v-model="form.price"
+              :label="`${$t('parceiro.price')} R$`"
+              input-class="input-violet-native"
+              class="col-12 col-sm-4 input-violet q-pr-sm"
+            />
+            <DefaultCurrencyInput
+              v-model="form.associate_price"
+              :label="`${$t('parceiro.associate_price')} R$`"
+              input-class="input-violet-native"
+              class="col-12 col-sm-4 input-violet q-px-sm"
+            />
+            <DefaultCurrencyInput
+              v-model="form.supplier_price"
+              :label="$t('parceiro.supplier_price')"
+              input-class="input-violet-native"
+              class="col-12 col-sm-4 input-violet q-pl-sm"
+            />
+
+            <div v-if="showScheduling" class="col-12 q-pt-md">
+              <div class="q-pl-xs q-mb-sm field-label">{{ $t('parceiro.requires_scheduling') }}</div>
+              <div class="scheduling-select">
+                <span
+                  class="scheduling-option"
+                  :class="{ 'scheduling-option--active': form.requires_scheduling === true }"
+                  @click="form.requires_scheduling = true"
+                >SIM</span>
+                <span class="scheduling-divider"> – </span>
+                <span
+                  class="scheduling-option"
+                  :class="{ 'scheduling-option--active': form.requires_scheduling === false }"
+                  @click="form.requires_scheduling = false"
+                >NÃO</span>
+              </div>
+            </div>
+
+            <div class="col-12 row justify-center q-pt-md" style="gap: 8px">
+              <q-btn
+                flat
+                color="violet-normal"
+                :label="$t('common.actions.cancel')"
+                @click="$emit('back')"
+              />
+              <q-btn
+                unelevated
+                class="btn-gradient servico-submit-btn"
+                :label="isEdit ? $t('common.actions.save') : 'CADASTRAR'"
+                type="submit"
+                :loading="loading"
+              />
+            </div>
+          </div>
+
+          <div class="col-12 col-md-6">
+            <div class="text-right text-subtitle2 text-violet-normal q-mb-sm">
+              {{ $t('common.terms.media') }}
+            </div>
+            <div class="media-grid">
+              <div class="media-slot media-slot--add" @click="triggerUpload(null)">
+                <div class="media-slot__placeholder" />
+                <div class="media-slot__overlay">
+                  <q-btn
+                    round dense flat
+                    icon="mdi-plus"
+                    color="white"
+                    size="sm"
+                    class="media-slot__btn-add"
+                    :loading="uploadingSlotIdx === -1"
+                  />
+                </div>
+              </div>
+
+              <div v-for="(item, idx) in mediaItems" :key="idx" class="media-slot">
+                <img :src="item.previewUrl || item.url" class="media-slot__img" alt="" />
+                <div class="media-slot__overlay">
+                  <q-btn
+                    round dense flat
+                    icon="mdi-close"
+                    color="white"
+                    size="xs"
+                    class="media-slot__btn-remove"
+                    :loading="removingSlotIdx === idx"
+                    @click.stop="removeMedia(idx)"
+                  />
+                </div>
+              </div>
+            </div>
+
+            <input
+              ref="fileInputRef"
+              type="file"
+              accept="image/*,video/*"
+              class="hidden"
+              @change="onFileSelected"
+            />
+          </div>
+        </div>
+      </q-card>
+    </q-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { getCategories } from "src/api/category";
+import {
+  getPartnerAgreementService,
+  createService,
+  updateService,
+  uploadServiceMedia,
+  deleteServiceMedia,
+} from "src/api/partnerAgreementService";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+
+const props = defineProps({
+  partnerId:       { type: Number, required: true },
+  serviceId:       { type: Number, default: null },
+  showScheduling:  { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["back"]);
+
+const $q    = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const isEdit = computed(() => !!props.serviceId);
+const idDisplay = computed(() => (props.serviceId ? String(props.serviceId).padStart(5, "0") : "—"));
+
+const loadingPage       = ref(false);
+const loadingCategories = ref(true);
+const categoryOptions   = ref([]);
+
+const form = ref({
+  partner_agreement_id: props.partnerId,
+  name:                 "",
+  description:          "",
+  service_number:       "",
+  category_id:          null,
+  price:                null,
+  associate_price:      null,
+  supplier_price:       null,
+  requires_scheduling:  false,
+});
+
+const mediaItems       = ref([]);
+const currentUploadIdx = ref(null);
+const uploadingSlotIdx = ref(null);
+const removingSlotIdx  = ref(null);
+const formRef          = useTemplateRef("formRef");
+const fileInputRef     = useTemplateRef("fileInputRef");
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  formRef,
+  onSuccess: () => emit("back"),
+});
+
+const loadCategories = async () => {
+  loadingCategories.value = true;
+  try {
+    const cats = await getCategories("service");
+    categoryOptions.value = cats.map((c) => ({ label: c.name, value: c.id }));
+  } catch { /* silent */ } finally {
+    loadingCategories.value = false;
+  }
+};
+
+const loadService = async () => {
+  if (!isEdit.value) return;
+  loadingPage.value = true;
+  try {
+    const svc = await getPartnerAgreementService(props.serviceId);
+    form.value.name                = svc.name                ?? "";
+    form.value.description         = svc.description         ?? "";
+    form.value.service_number      = svc.service_number      ?? "";
+    form.value.category_id         = svc.category_id         ?? null;
+    form.value.price               = svc.price               ?? null;
+    form.value.associate_price     = svc.associate_price     ?? null;
+    form.value.supplier_price      = svc.supplier_price      ?? null;
+    form.value.requires_scheduling = svc.requires_scheduling ?? false;
+
+    if (Array.isArray(svc.media)) {
+      mediaItems.value = svc.media.map((m) => ({ type: "existing", id: m.id, url: m.url }));
+    }
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loadingPage.value = false;
+  }
+};
+
+const triggerUpload = (idx) => {
+  currentUploadIdx.value = idx;
+  uploadingSlotIdx.value = idx === null ? -1 : idx;
+  if (fileInputRef.value) {
+    fileInputRef.value.value = "";
+    fileInputRef.value.click();
+  }
+  uploadingSlotIdx.value = null;
+};
+
+const onFileSelected = async (e) => {
+  const file = e.target.files?.[0];
+  if (!file) return;
+
+  const idx        = currentUploadIdx.value;
+  const isNew      = idx === null;
+  const previewUrl = URL.createObjectURL(file);
+
+  if (isEdit.value) {
+    const existingItem = !isNew ? mediaItems.value[idx] : null;
+
+    if (existingItem?.type === "existing") {
+      removingSlotIdx.value = idx;
+      try {
+        await deleteServiceMedia(props.serviceId, existingItem.id);
+      } catch {
+        $q.notify({ type: "negative", message: t("http.errors.failed") });
+        removingSlotIdx.value = null;
+        URL.revokeObjectURL(previewUrl);
+        return;
+      }
+      removingSlotIdx.value = null;
+    }
+
+    uploadingSlotIdx.value = isNew ? -1 : idx;
+    try {
+      const newMedia = await uploadServiceMedia(props.serviceId, file);
+      const newItem  = { type: "existing", id: newMedia.id, url: newMedia.url };
+      if (isNew) {
+        mediaItems.value.push(newItem);
+      } else {
+        mediaItems.value[idx] = newItem;
+      }
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      uploadingSlotIdx.value = null;
+      URL.revokeObjectURL(previewUrl);
+    }
+  } else {
+    const pendingItem = { type: "pending", file, previewUrl };
+    if (isNew) {
+      mediaItems.value.push(pendingItem);
+    } else {
+      const old = mediaItems.value[idx];
+      if (old?.previewUrl) URL.revokeObjectURL(old.previewUrl);
+      mediaItems.value[idx] = pendingItem;
+    }
+  }
+};
+
+const removeMedia = async (idx) => {
+  const item = mediaItems.value[idx];
+  if (!item) return;
+
+  if (item.type === "existing") {
+    removingSlotIdx.value = idx;
+    try {
+      await deleteServiceMedia(props.serviceId, item.id);
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+      removingSlotIdx.value = null;
+      return;
+    }
+    removingSlotIdx.value = null;
+  }
+
+  if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
+  mediaItems.value.splice(idx, 1);
+};
+
+const onSubmit = async () => {
+  await submitForm(async () => {
+    if (isEdit.value) {
+      await updateService(props.serviceId, { ...form.value });
+    } else {
+      const created = await createService({ ...form.value });
+      const pending = mediaItems.value.filter((m) => m?.type === "pending");
+      await Promise.all(pending.map((item) => uploadServiceMedia(created.id, item.file)));
+      pending.forEach((item) => { if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); });
+    }
+  });
+};
+
+onMounted(() => {
+  loadCategories();
+  loadService();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.servico-form-panel {
+  .cadastro-card {
+    background: #fff;
+    border-radius: 8px;
+  }
+
+  .field-label {
+    font-size: 0.8rem;
+    color: vars.$violet-dark;
+  }
+
+  .input-violet {
+    :deep(.q-field__control) {
+      background: vars.$violet-light !important;
+      border-radius: 8px;
+    }
+    :deep(.q-field__native),
+    :deep(.q-field__input),
+    :deep(input.input-violet-native) { color: vars.$violet-dark !important; }
+    :deep(.q-field__label)           { color: vars.$violet-normal !important; }
+    :deep(.q-field--outlined .q-field__control:before) { border-color: transparent !important; }
+    :deep(.q-field--outlined .q-field__control:after)  { border-color: vars.$violet-normal !important; }
+  }
+
+  .category-chip {
+    display: inline-flex; align-items: center;
+    padding: 4px 12px; border-radius: 20px; font-size: 0.75rem;
+    cursor: pointer; background: vars.$violet-light; color: vars.$violet-dark;
+    border: 1px solid transparent; transition: background 0.2s, color 0.2s;
+    user-select: none;
+    &:hover { filter: brightness(0.95); }
+    &--active { background: vars.$violet-normal; color: #fff; border-color: vars.$violet-normal; }
+  }
+
+  .scheduling-select {
+    display: inline-flex; align-items: center;
+    border: 1px solid #d0c4e8; border-radius: 8px;
+    padding: 6px 16px; background: vars.$violet-light;
+    color: vars.$violet-dark; font-size: 0.85rem; gap: 4px;
+
+    .scheduling-option {
+      cursor: pointer; padding: 2px 6px; border-radius: 4px;
+      transition: background 0.15s, color 0.15s; user-select: none;
+      &--active { background: vars.$violet-normal; color: #fff; font-weight: 600; }
+    }
+    .scheduling-divider { color: #aaa; }
+  }
+
+  .media-grid {
+    display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end;
+  }
+
+  .media-slot {
+    position: relative; width: 145px; height: 145px;
+    border-radius: 8px; overflow: hidden; background: vars.$violet-light;
+    flex-shrink: 0; cursor: pointer;
+
+    &__img { width: 100%; height: 100%; object-fit: cover; display: block; }
+    &__placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, #d0b8e8 0%, #b89cd6 100%); }
+    &__overlay {
+      position: absolute; inset: 0; display: flex;
+      align-items: flex-end; justify-content: flex-end;
+      padding: 6px; background: rgba(0,0,0,0.08); gap: 4px;
+    }
+    &__btn-remove {
+      position: absolute; top: 4px; right: 4px;
+      background: rgba(0,0,0,0.45) !important;
+      :deep(.q-icon) { font-size: 14px; }
+    }
+    &__btn-add {
+      background: vars.$violet-normal !important;
+      box-shadow: 0 2px 6px rgba(0,0,0,0.3);
+      :deep(.q-icon) { font-size: 18px; }
+    }
+  }
+
+  .btn-gradient {
+    background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+    color: white !important; border-radius: 8px !important; padding: 8px 16px;
+  }
+
+  .servico-submit-btn {
+    min-width: 200px; text-transform: uppercase; letter-spacing: 1px;
+  }
+}
+</style>

+ 25 - 12
src/router/routes/parceiro-admin.route.js

@@ -1,7 +1,7 @@
 export default [
   {
-    path: "/parceiros-convenios",
-    name: "ParceirosConveniosPage",
+    path: "/parceiros",
+    name: "ParceirosPage",
     component: () => import("pages/parceiros-convenios/ParceirosConveniosPage.vue"),
     meta: {
       title: { value: "ui.navigation.partners", translate: true },
@@ -9,12 +9,26 @@ export default [
       requireAuth: true,
       requiredPermission: "parceiro",
       breadcrumbs: [
-        { name: "ParceirosConveniosPage", title: "ui.navigation.partners", translate: true },
+        { name: "ParceirosPage", title: "ui.navigation.partners", translate: true },
       ],
     },
   },
   {
-    path: "/parceiros-convenios/cadastro/:id?",
+    path: "/convenios-medicos",
+    name: "ConveniosMedicosPage",
+    component: () => import("pages/parceiros-convenios/ConveniosMedicosPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.convenios_medicos", translate: true },
+      description: { value: "page.convenios_medicos.description", translate: true },
+      requireAuth: true,
+      requiredPermission: "parceiro",
+      breadcrumbs: [
+        { name: "ConveniosMedicosPage", title: "ui.navigation.convenios_medicos", translate: true },
+      ],
+    },
+  },
+  {
+    path: "/parceiros/cadastro/:id?",
     name: "ParceiroCadastroPage",
     component: () => import("pages/parceiros-convenios/ParceiroCadastroPage.vue"),
     meta: {
@@ -22,23 +36,22 @@ export default [
       requireAuth: true,
       requiredPermission: "parceiro",
       breadcrumbs: [
-        { name: "ParceirosConveniosPage", title: "ui.navigation.partners", translate: true },
+        { name: "ParceirosPage", title: "ui.navigation.partners", translate: true },
         { name: "ParceiroCadastroPage", title: "parceiro.cadastro_title", translate: true },
       ],
     },
   },
   {
-    path: "/parceiros-convenios/cadastro/:id/servico/:serviceId?",
-    name: "ParceiroServicoCadastroPage",
-    component: () => import("pages/parceiros-convenios/ParceiroServicoCadastroPage.vue"),
+    path: "/convenios-medicos/cadastro/:id?",
+    name: "ConvenioCadastroPage",
+    component: () => import("pages/parceiros-convenios/ConvenioCadastroPage.vue"),
     meta: {
-      title: { value: "parceiro.tab_servicos", translate: true },
+      title: { value: "parceiro.cadastro_title", translate: true },
       requireAuth: true,
       requiredPermission: "parceiro",
       breadcrumbs: [
-        { name: "ParceirosConveniosPage", title: "ui.navigation.partners", translate: true },
-        { name: "ParceiroCadastroPage", title: "parceiro.cadastro_title", translate: true },
-        { name: "ParceiroServicoCadastroPage", title: "parceiro.tab_servicos", translate: true },
+        { name: "ConveniosMedicosPage", title: "ui.navigation.convenios_medicos", translate: true },
+        { name: "ConvenioCadastroPage", title: "parceiro.cadastro_title", translate: true },
       ],
     },
   },

+ 11 - 2
src/stores/navigation.js

@@ -38,8 +38,17 @@ export const navigationStore = defineStore("navigation", () => {
     {
       type: "single",
       title: "ui.navigation.partners",
-      name: "ParceirosConveniosPage",
-      icon: "mdi-heart-outline",
+      name: "ParceirosPage",
+      icon: "mdi-handshake-outline",
+      permission: false,
+      permissionScope: "parceiro",
+      allowedTypes: ["administrador"],
+    },
+    {
+      type: "single",
+      title: "ui.navigation.convenios_medicos",
+      name: "ConveniosMedicosPage",
+      icon: "mdi-hospital-box-outline",
       permission: false,
       permissionScope: "parceiro",
       allowedTypes: ["administrador"],