Ver Fonte

✨ feat(parceiros): implementar módulo de parceiros e convênios com upload de mídia

Fase: dev | Origin: melhoria-interna
Gustavo Zanatta há 1 semana atrás
pai
commit
edae040f06

+ 778 - 0
src/pages/parceiros-convenios/ParceiroCadastroPage.vue

@@ -0,0 +1,778 @@
+<template>
+  <div class="cadastro-page q-mr-md">
+    <DefaultHeaderPage :title="{ value: pageTitle, translate: false }" />
+
+    <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 input-violet"
+              />
+              <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 input-violet"
+              />
+              <PartnerAgreementCategorySelect
+                v-model="selectedCategory"
+                v-model:error="errorsDados.category_id"
+                class="col-12 input-violet"
+              />
+              <DefaultInput
+                v-model="formDados.responsible"
+                v-model:error="errorsDados.responsible"
+                :label="$t('parceiro.responsible')"
+                class="col-12 input-violet"
+              />
+              <DefaultInput
+                v-model="formDados.discount_percentage"
+                v-model:error="errorsDados.discount_percentage"
+                :label="$t('parceiro.discount_percentage')"
+                type="number"
+                suffix="%"
+                class="col-12 input-violet"
+              />
+              <DefaultInput
+                v-model="formDados.description"
+                v-model:error="errorsDados.description"
+                :label="$t('common.terms.description')"
+                type="textarea"
+                autogrow
+                class="col-12 input-violet"
+              />
+            </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 input-violet"
+              />
+              <DefaultInput
+                v-model="formContato.website"
+                v-model:error="errorsContato.website"
+                :label="$t('parceiro.website')"
+                class="col-12 input-violet"
+              />
+              <DefaultInput
+                v-model="formContato.phone"
+                v-model:error="errorsContato.phone"
+                :label="$t('common.terms.phone')"
+                :mask="'(##) #####-####'"
+                class="col-12 input-violet"
+              />
+              <DefaultInput
+                v-model="formContato.whatsapp"
+                v-model:error="errorsContato.whatsapp"
+                :label="$t('common.terms.whatsapp')"
+                :mask="'(##) #####-####'"
+                class="col-12 input-violet"
+              />
+            </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 input-violet"
+                @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 input-violet"
+              />
+              <DefaultInput
+                v-model="formEndereco.neighborhood"
+                v-model:error="errorsEndereco.neighborhood"
+                :label="$t('parceiro.neighborhood')"
+                :loading="loadingCep"
+                class="col-12 input-violet"
+              />
+              <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 input-violet"
+              />
+              <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 input-violet"
+              />
+              <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 input-violet"
+              />
+            </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>
+</template>
+
+<script setup>
+import { ref, 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 {
+  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 });
+      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") });
+    }
+  }
+});
+
+watch(activeTab, (tab) => {
+  if (tab === "servicos") loadServices();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.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: vars.$violet-normal;
+    border: 1.5px solid rgba(vars.$violet-normal, 0.3);
+    transition: background 0.15s, color 0.15s;
+    user-select: none;
+
+    &--active {
+      background: vars.$violet-normal;
+      color: white;
+      border-color: vars.$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;
+}
+
+.input-violet {
+  :deep(.q-field__control) {
+    background: vars.$violet-light !important;
+    border-radius: 8px;
+  }
+
+  :deep(.q-field__native),
+  :deep(.q-field__input) {
+    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;
+  }
+}
+
+.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>

+ 550 - 0
src/pages/parceiros-convenios/ParceiroServicoCadastroPage.vue

@@ -0,0 +1,550 @@
+<template>
+  <div class="servico-cadastro-page q-mr-md">
+    <DefaultHeaderPage :title="{ value: $t('parceiro.tab_servicos'), translate: false }" />
+
+    <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 col-md-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="row col-12 q-mt-sm">
+            <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>
+        <div class="row col-12 q-col-gutter-sm q-mt-sm">
+          <div class="col-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 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
+                unelevated
+                class="btn-gradient servico-submit-btn"
+                :label="isEdit ? $t('common.actions.save') : 'CADASTRAR'"
+                type="submit"
+                :loading="loading"
+              />
+            </div>
+          </div>
+          <div class="col-6">
+            <div class="col-12 col-md-5">
+              <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>
+        </div>
+      </q-card>
+    </q-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, 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 { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { getCategories } from "src/api/category";
+import {
+  getPartnerAgreementService,
+  createService,
+  updateService,
+  uploadServiceMedia,
+  deleteServiceMedia,
+} from "src/api/partnerAgreementService";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+
+const route  = useRoute();
+const router = useRouter();
+const $q     = useQuasar();
+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 loadingPage      = ref(false);
+const loadingCategories = ref(true);
+const categoryOptions  = ref([]);
+
+const idDisplay = computed(() => (serviceId.value ? String(serviceId.value).padStart(5, "0") : "—"));
+
+const form = ref({
+  partner_agreement_id: partnerId.value,
+  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); // null = novo item, number = replace item idx
+const uploadingSlotIdx = ref(null); // -1 = add slot, idx = replace slot
+const removingSlotIdx  = ref(null);
+const formRef         = useTemplateRef("formRef");
+const fileInputRef    = useTemplateRef("fileInputRef");
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  formRef,
+  onSuccess: () => goBack(),
+});
+
+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(serviceId.value);
+    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; // null = novo, number = substituir
+  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(serviceId.value, 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(serviceId.value, 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(serviceId.value, 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(serviceId.value, { ...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);
+      });
+    }
+  });
+};
+
+const goBack = () => {
+  router.push({
+    name:   "ParceiroCadastroPage",
+    params: { id: partnerId.value },
+    query:  { tab: "servicos" },
+  });
+};
+
+onMounted(() => {
+  loadCategories();
+  loadService();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.servico-cadastro-page {
+  .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: 220px;
+    text-transform: uppercase;
+    letter-spacing: 1px;
+  }
+}
+</style>

+ 184 - 0
src/pages/parceiros-convenios/ParceirosConveniosPage.vue

@@ -0,0 +1,184 @@
+<template>
+  <div class="parceiros-page">
+    <DefaultHeaderPage>
+      <template #after>
+        <div v-if="isAdministrador" class="flex gap-sm q-mt-md">
+          <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="parceiros-page__search-input"
+      >
+        <template #prepend>
+          <q-icon name="mdi-magnify" />
+        </template>
+      </q-input>
+    </div>
+
+    <div class="parceiros-page__chips q-pb-md">
+      <q-chip
+        clickable
+        :outline="activeCategory !== 'all'"
+        color="violet-normal"
+        text-color="white"
+        @click="activeCategory = 'all'"
+      >
+        {{ $t('common.terms.all') }}
+      </q-chip>
+      <q-chip
+        v-for="cat in categories"
+        :key="cat.id"
+        clickable
+        :outline="activeCategory !== String(cat.id)"
+        color="violet-normal"
+        text-color="white"
+        @click="activeCategory = String(cat.id)"
+      >
+        {{ cat.name }}
+      </q-chip>
+    </div>
+
+    <div v-if="loading" class="flex flex-center q-pa-xl">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <div v-else-if="filteredPartners.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="partner in filteredPartners"
+        :key="partner.id"
+        class="col-xl-2 col-lg-3 col-md-4 col-sm-6 col-12"
+      >
+        <PartnerAgreementCard
+          :partner="partner"
+          :editable="isAdministrador"
+          @edit="onEditItem"
+        />
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } 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 } from "src/api/partnerAgreement";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+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 allPartners = ref([]);
+const categories = ref([]);
+const activeCategory = ref("all");
+const searchQuery = ref("");
+
+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) => {
+      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: "ParceiroCadastroPage" });
+};
+
+const onEditItem = (partner) => {
+  if (!permission_store.getAccess("parceiro.convenio", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  router.push({ name: "ParceiroCadastroPage", params: { id: partner.id } });
+};
+
+onMounted(async () => {
+  try {
+    const [partners, cats] = await Promise.all([
+      getPartnerAgreements(),
+      getCategories("partner"),
+    ]);
+    allPartners.value = partners;
+    categories.value = cats;
+  } 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%;
+}
+
+.parceiros-page__chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.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>

+ 151 - 0
src/pages/parceiros-convenios/components/AddEditServiceDialog.vue

@@ -0,0 +1,151 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 700px; max-width: 95vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
+            v-model="form.name"
+            v-model:error="validationErrors.name"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.name')"
+            class="col-12"
+          />
+          <CategorySelect
+            v-model="selectedCategory"
+            v-model:error="validationErrors.category_id"
+            type="service"
+            :label="$t('parceiro.service_category')"
+            class="col-md-6 col-12"
+          />
+          <DefaultInput
+            v-model="form.service_number"
+            v-model:error="validationErrors.service_number"
+            :label="$t('parceiro.service_number')"
+            class="col-md-6 col-12"
+          />
+          <DefaultInput
+            v-model="form.price"
+            v-model:error="validationErrors.price"
+            :label="$t('parceiro.price')"
+            type="number"
+            class="col-md-4 col-12"
+          />
+          <DefaultInput
+            v-model="form.associate_price"
+            v-model:error="validationErrors.associate_price"
+            :label="$t('parceiro.associate_price')"
+            type="number"
+            class="col-md-4 col-12"
+          />
+          <DefaultInput
+            v-model="form.supplier_price"
+            v-model:error="validationErrors.supplier_price"
+            :label="$t('parceiro.supplier_price')"
+            type="number"
+            class="col-md-4 col-12"
+          />
+          <DefaultInput
+            v-model="form.description"
+            v-model:error="validationErrors.description"
+            :label="$t('common.terms.description')"
+            type="textarea"
+            autogrow
+            class="col-12"
+          />
+          <div class="col-12">
+            <q-toggle
+              v-model="form.requires_scheduling"
+              :label="$t('parceiro.requires_scheduling')"
+              color="violet-normal"
+            />
+          </div>
+        </q-card-section>
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="violet-normal"
+            :label="service ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, useTemplateRef, watch } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createService, updateService } from "src/api/partnerAgreementService";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import CategorySelect from "src/components/selects/CategorySelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { service, partnerAgreementId, title } = defineProps({
+  service: {
+    type: Object,
+    default: null,
+  },
+  partnerAgreementId: {
+    type: Number,
+    required: true,
+  },
+  title: {
+    type: String,
+    default: () => useI18n().t("common.actions.add"),
+  },
+});
+
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const formRef = useTemplateRef("formRef");
+
+const selectedCategory = ref(
+  service?.category ? { label: service.category.name, value: service.category.id } : null,
+);
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  partner_agreement_id: partnerAgreementId,
+  name:                 service?.name              ?? "",
+  description:          service?.description       ?? "",
+  service_number:       service?.service_number    ?? "",
+  category_id:          service?.category_id       ?? null,
+  price:                service?.price             ?? null,
+  associate_price:      service?.associate_price   ?? null,
+  supplier_price:       service?.supplier_price    ?? null,
+  requires_scheduling:  service?.requires_scheduling ?? false,
+});
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+});
+
+watch(selectedCategory, (val) => {
+  form.category_id = val?.value ?? null;
+});
+
+const onOKClick = async () => {
+  if (service) {
+    await submitForm(() => updateService(service.id, getUpdatedFields.value));
+  } else {
+    await submitForm(() => createService({ ...form }));
+  }
+};
+</script>

+ 148 - 0
src/pages/parceiros-convenios/components/PartnerAgreementCard.vue

@@ -0,0 +1,148 @@
+<template>
+  <q-card class="partner-card" :class="{ 'cursor-pointer': editable }" flat>
+    <div class="partner-card__logo-wrap q-pa-md">
+      <q-img
+        v-if="partner.logo?.url"
+        :src="partner.logo.url"
+        fit="cover"
+        class="full-width full-height partner-card__logo-img"
+      />
+      <div v-else class="full-width full-height flex flex-center partner-card__logo-placeholder">
+        <q-icon name="mdi-domain" size="48px" color="grey-4" />
+      </div>
+    </div>
+
+    <q-card-section class="q-pt-sm q-pb-xs">
+      <div class="row items-center justify-between no-wrap q-mb-xs" style="gap: 6px">
+        <div class="text-subtitle2 text-weight-bold ellipsis partner-card__name">
+          {{ partner.company_name }}
+        </div>
+        <q-chip v-if="partner.category?.name" dense class="partner-card__category-chip q-ma-none flex-shrink-0">
+          {{ partner.category.name }}
+        </q-chip>
+        <div v-if="partner.discount_percentage" class="partner-card__discount-badge flex-shrink-0">
+          Desconto -{{ partner.discount_percentage }}%
+        </div>
+      </div>
+
+      <div class="row items-end no-wrap">
+        <div class="col">
+          <div v-if="partner.address || partner.city" class="text-caption partner-card__info row items-center q-mb-xs">
+            <q-icon name="mdi-map-marker-outline" size="14px" class="q-mr-xs" />
+            <span class="ellipsis">{{ addressLine }}</span>
+          </div>
+          <div v-if="partner.phone" class="text-caption partner-card__info row items-center">
+            <q-icon name="mdi-phone-outline" size="14px" class="q-mr-xs" />
+            {{ partner.phone }}
+          </div>
+        </div>
+        <q-btn
+          v-if="editable"
+          round
+          unelevated
+          class="partner-card__edit-btn q-ml-sm"
+          icon="mdi-pencil"
+          size="sm"
+          @click.stop="$emit('edit', partner)"
+        />
+      </div>
+
+      <div class="row items-center justify-between q-mt-sm">
+        <div class="row items-center text-caption partner-card__info" style="gap: 2px">
+          <q-icon name="mdi-star" size="14px" color="amber" />
+          <span>4.5/5</span>
+        </div>
+        <div v-if="partner.contract_end" class="text-caption partner-card__info row items-center" style="gap: 2px">
+          <q-icon name="mdi-calendar-outline" size="13px" />
+          {{ $t('associado.validity_until') }} {{ formatDate(partner.contract_end) }}
+        </div>
+      </div>
+    </q-card-section>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { date } from "quasar";
+
+defineEmits(["edit"]);
+
+const { partner, editable } = defineProps({
+  partner: { type: Object, required: true },
+  editable: { type: Boolean, default: false },
+});
+
+const addressLine = computed(() => {
+  const parts = [partner.address, partner.neighborhood, partner.city?.name].filter(Boolean);
+  return parts.join(", ");
+});
+
+const formatDate = (d) => (d ? date.formatDate(d, "MM/YYYY") : "—");
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.partner-card {
+  border-radius: 12px;
+  background: white;
+  transition: box-shadow 0.2s;
+
+  &:hover {
+    box-shadow: 0 4px 20px rgba(102, 29, 117, 0.15);
+  }
+
+  &__logo-wrap {
+    position: relative;
+    height: 150px;
+    border-radius: 12px 12px 0 0;
+    overflow: hidden;
+  }
+
+  &__logo-img {
+    height: 150px;
+  }
+
+  &__logo-placeholder {
+    height: 150px;
+    background: vars.$neutral-normal;
+  }
+
+  &__edit-btn {
+    background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+    color: white !important;
+  }
+
+  &__name {
+    color: vars.$violet-normal;
+    min-width: 0;
+  }
+
+  &__info {
+    color: vars.$violet-normal;
+    opacity: 0.75;
+  }
+
+  &__category-chip {
+    background: #ead5f0 !important;
+    color: vars.$violet-normal !important;
+    font-size: 11px;
+    font-weight: 500;
+    height: 22px;
+  }
+
+  &__discount-badge {
+    background: vars.$violet-normal;
+    color: white;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 3px 10px;
+    border-radius: 20px;
+    white-space: nowrap;
+  }
+}
+
+.flex-shrink-0 {
+  flex-shrink: 0;
+}
+</style>