Procházet zdrojové kódy

feat: :sparkles: feat (loja-admin) criada a loja no layout admin

feat (loja-admin) criada a loja no layout admin

fase:dev | origin:escopo
Gustavo Zanatta před 1 měsícem
rodič
revize
766b15581f

+ 20 - 0
src/api/partnerAgreement.js

@@ -19,6 +19,26 @@ export const getPartnerAgreement = async (id) => {
   return data.payload;
 };
 
+export const getPartnerAgreementDados = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/dados`);
+  return data.payload;
+};
+
+export const getPartnerAgreementContato = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/contato`);
+  return data.payload;
+};
+
+export const getPartnerAgreementEndereco = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/endereco`);
+  return data.payload;
+};
+
+export const getPartnerAgreementContrato = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/contrato`);
+  return data.payload;
+};
+
 export const createPartnerAgreement = async (payload) => {
   const { data } = await api.post("/partner-agreement", payload);
   return data.payload;

+ 65 - 0
src/api/storeItem.js

@@ -5,6 +5,39 @@ export const getStoreItems = async () => {
   return data.payload;
 };
 
+export const getStoreItemsPaginated = async ({ page = 1, perPage = 10, filter, status, category_id } = {}) => {
+  const params = { page, per_page: perPage };
+  if (filter)      params.search      = filter;
+  if (status)      params.status      = status;
+  if (category_id) params.category_id = category_id;
+  const { data } = await api.get("/store-item/paginated", { params });
+  return { data: { result: data.payload } };
+};
+
+export const getStoreItem = async (id) => {
+  const { data } = await api.get(`/store-item/${id}`);
+  return data.payload;
+};
+
+export const createStoreItem = async (payload) => {
+  const { data } = await api.post("/store-item", payload);
+  return data.payload;
+};
+
+export const updateStoreItem = async (id, payload) => {
+  const { data } = await api.put(`/store-item/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteStoreItem = async (id) => {
+  await api.delete(`/store-item/${id}`);
+};
+
+export const toggleStoreItemStatus = async (id) => {
+  const { data } = await api.patch(`/store-item/${id}/toggle-status`);
+  return data.payload;
+};
+
 export const getMyInterests = async () => {
   const { data } = await api.get("/store-item/my/interests");
   return data.payload;
@@ -14,3 +47,35 @@ export const toggleInterest = async (id) => {
   const { data } = await api.post(`/store-item/${id}/interest`);
   return data.payload;
 };
+
+export const uploadStoreItemMedia = async (id, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/store-item/${id}/media`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteStoreItemMedia = async (id, mediaId) => {
+  await api.delete(`/store-item/${id}/media/${mediaId}`);
+};
+
+export const getStoreItemInterests = async (id) => {
+  const { data } = await api.get(`/store-item/${id}/interests`);
+  return data.payload;
+};
+
+export const addStoreItemVariation = async (itemId, payload) => {
+  const { data } = await api.post(`/store-item/${itemId}/variation`, payload);
+  return data.payload;
+};
+
+export const updateStoreItemVariation = async (itemId, variationId, payload) => {
+  const { data } = await api.patch(`/store-item/${itemId}/variation/${variationId}`, payload);
+  return data.payload;
+};
+
+export const deleteStoreItemVariation = async (itemId, variationId) => {
+  await api.delete(`/store-item/${itemId}/variation/${variationId}`);
+};

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

@@ -1,5 +1,5 @@
 <template>
-  <div class="column no-wrap" :class="attrs.class" :style="attrs.style">
+  <div class="column no-wrap col" :class="attrs.class" :style="attrs.style">
     <div v-if="label || $slots.label" class="q-pl-xs">
       <slot name="label">
         <span>{{ label }}</span>
@@ -14,8 +14,10 @@
       :error-message="errorMessage"
       :rules
       hide-bottom-space
+      class="input-violet"
       :class="inputClass"
       :input-class="nativeInputClass"
+      :mask="mask"
       @update:model-value="error = null"
     >
       <template v-for="(_, slotName) in $slots" #[slotName]>
@@ -56,6 +58,10 @@ const { label, nativeInputClass, inputClass, rules } = defineProps({
     type: String,
     default: null,
   },
+  mask: {
+    type: [String],
+    default: undefined,
+  },
 });
 
 const attrs = useAttrs();

+ 5 - 5
src/components/defaults/DefaultTableServerSide.vue

@@ -20,8 +20,7 @@
   >
     <template v-if="!semTableTop" #top>
       <div
-        class="flex full-width justify-between items-center q-mb-md q-pl-sm"
-        style="gap: 1rem"
+        class="flex full-width justify-between items-center"
       >
         <DefaultInput
           v-if="showSearchField"
@@ -29,11 +28,12 @@
           outlined
           dense
           debounce="500"
-          :placeholder="$t('common.actions.search')"
+          :placeholder="$t('associado.search_placeholder')"
           clearable
           autofocus
+          color="violet-normal"
         >
-          <template #append>
+          <template #prepend>
             <q-icon name="mdi-magnify" />
           </template>
         </DefaultInput>
@@ -54,7 +54,7 @@
           options-selected-class="text-bold"
         />
 
-        <q-space />
+        <q-space v-if="showSearchField || showColumnsSelect" />
 
         <q-btn
           v-if="addItem"

+ 1 - 0
src/components/selects/CategorySelect.vue

@@ -10,6 +10,7 @@
     :label
     :loading
     :placeholder
+    class="input-violet"
     @filter="filterFn"
   >
     <template #no-option>

+ 37 - 73
src/components/selects/CitySelect.vue

@@ -24,25 +24,14 @@
 
 <script setup>
 import { getCities } from "src/api/city";
-import { ref, onMounted, watch } from "vue";
+import { ref, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const emit = defineEmits(["selectedStateId"]);
 
-const { state, label, initialId, country, placeholder } = defineProps({
-  // This country prop is here for future use, maybe
-  country: {
-    type: Object,
-    required: false,
-    default: () => {
-      return {
-        label: "Brasil",
-        value: 1,
-      };
-    },
-  },
+const { state, label, placeholder } = defineProps({
   state: {
     type: Object,
     required: false,
@@ -59,73 +48,67 @@ const { state, label, initialId, country, placeholder } = defineProps({
       " " +
       useI18n().t("ui.navigation.city"),
   },
-  initialId: {
-    type: Number,
-    required: false,
-    default: null,
-  },
 });
 
 const selectedCity = defineModel({ type: Object });
 
-const loading = ref(true);
+const loading = ref(false);
 const baseOptions = ref([]);
 const cityOptions = ref([]);
 
+const ensureOnlyPossibleOptions = (state_id) => {
+  cityOptions.value = state_id
+    ? baseOptions.value.filter((c) => c.state_id === state_id)
+    : baseOptions.value;
+};
+
 const filterFn = async (val, update) => {
-  ensureOnlyPossibleOptions(country?.value, state?.value);
+  if (baseOptions.value.length === 0) {
+    loading.value = true;
+    try {
+      const data = await getCities();
+      baseOptions.value = data.map((c) => ({
+        label: c.name,
+        value: c.id,
+        state_id: c.state_id,
+      }));
+    } catch (e) {
+      console.error(e);
+    } finally {
+      loading.value = false;
+    }
+  }
+  ensureOnlyPossibleOptions(state?.value);
   const needle = normalizeString(val);
-  cityOptions.value = cityOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
+  if (needle) {
+    cityOptions.value = cityOptions.value.filter((v) =>
+      normalizeString(v.label).includes(needle),
     );
-  });
+  }
   update();
 };
 
 const selectCityByName = (name) => {
-  if (selectedCity.value?.label === name) {
-    return;
-  }
-  selectedCity.value = baseOptions.value.find((city) => city.label === name);
+  if (selectedCity.value?.label === name) return;
+  selectedCity.value = baseOptions.value.find((c) => c.label === name);
 };
 
 const selectCityById = (id) => {
-  if (selectedCity.value?.value === id) {
-    return;
-  }
-  selectedCity.value = baseOptions.value.find((city) => city.value === id);
-};
-
-const ensureOnlyPossibleOptions = (country_id, state_id) => {
-  if (state_id) {
-    cityOptions.value = baseOptions.value.filter((city) => {
-      if (country_id) {
-        return city.country_id === country_id && city.state_id === state_id;
-      }
-      return city.state_id === state_id;
-    });
-  }
-  if (!!state_id && !country_id) {
-    cityOptions.value = baseOptions.value;
-  }
+  if (selectedCity.value?.value === id) return;
+  selectedCity.value = baseOptions.value.find((c) => c.value === id);
 };
 
 watch(
   () => state,
   (value, oldValue) => {
-    if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedCity.value?.state_id
-    ) {
+    if (!oldValue) return;
+    if (value?.value != oldValue?.value && value?.value != selectedCity.value?.state_id) {
       selectedCity.value = null;
     }
-    if (value) {
-      ensureOnlyPossibleOptions(country?.value, value.value);
+    if (value && baseOptions.value.length > 0) {
+      ensureOnlyPossibleOptions(value.value);
     }
   },
-  { immediate: true },
 );
 
 watch(selectedCity, () => {
@@ -134,25 +117,6 @@ watch(selectedCity, () => {
   }
 });
 
-onMounted(async () => {
-  try {
-    const baseCities = await getCities();
-    baseOptions.value = baseCities.map((city) => ({
-      label: city.name,
-      value: city.id,
-      state_id: city.state_id,
-    }));
-    cityOptions.value = baseOptions.value;
-    if (initialId) {
-      selectCityById(initialId);
-    }
-  } catch (e) {
-    console.error(e);
-  } finally {
-    loading.value = false;
-  }
-});
-
 defineExpose({
   selectCityByName,
   selectCityById,

+ 39 - 54
src/components/selects/StateSelect.vue

@@ -24,14 +24,14 @@
 
 <script setup>
 import { getStates } from "src/api/state";
-import { ref, onMounted, watch } from "vue";
+import { ref, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const emit = defineEmits(["selectedCountryId"]);
 
-const { country, initialId, placeholder } = defineProps({
+const { country, placeholder } = defineProps({
   country: {
     type: Object,
     required: false,
@@ -51,70 +51,75 @@ const { country, initialId, placeholder } = defineProps({
     type: String,
     default: () => useI18n().t("ui.navigation.state"),
   },
-  initialId: {
-    type: Number,
-    required: false,
-    default: null,
-  },
 });
 
 const selectedState = defineModel({ type: Object });
 
-const loading = ref(true);
+const loading = ref(false);
 const baseOptions = ref([]);
 const stateOptions = ref([]);
 
-const filterFn = (val, update) => {
+const ensureOnlyPossibleOptions = (country_id) => {
+  stateOptions.value = country_id
+    ? baseOptions.value.filter((s) => s.country_id === country_id)
+    : baseOptions.value;
+};
+
+const filterFn = async (val, update) => {
+  if (baseOptions.value.length === 0) {
+    loading.value = true;
+    try {
+      const data = await getStates();
+      baseOptions.value = data.map((s) => ({
+        label: s.name,
+        value: s.id,
+        code: s.code,
+        country_id: s.country_id,
+      }));
+    } catch (e) {
+      console.error(e);
+    } finally {
+      loading.value = false;
+    }
+  }
   ensureOnlyPossibleOptions(country?.value);
   const needle = normalizeString(val);
-  stateOptions.value = stateOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
+  if (needle) {
+    stateOptions.value = stateOptions.value.filter(
+      (v) =>
+        normalizeString(v.label).includes(needle) ||
+        normalizeString(v.code).includes(needle),
     );
-  });
+  }
   update();
 };
 
-const selectStateById = async (id) => {
+const selectStateById = (id) => {
   if (selectedState.value?.value === id) return;
-  selectedState.value = baseOptions.value.find((state) => state.value === id);
+  selectedState.value = baseOptions.value.find((s) => s.value === id);
 };
 
 const selectStateByName = (name) => {
   if (selectedState.value?.label === name) return;
-  selectedState.value = baseOptions.value.find((state) => state.label === name);
+  selectedState.value = baseOptions.value.find((s) => s.label === name);
 };
 
 const selectStateByCode = (code) => {
   if (selectedState.value?.code === code) return;
-  selectedState.value = baseOptions.value.find((state) => state.code === code);
-};
-
-const ensureOnlyPossibleOptions = (country_id) => {
-  if (country_id) {
-    stateOptions.value = baseOptions.value.filter(
-      (state) => state.country_id === country_id,
-    );
-  } else {
-    stateOptions.value = baseOptions.value;
-  }
+  selectedState.value = baseOptions.value.find((s) => s.code === code);
 };
 
 watch(
   () => country,
   (value, oldValue) => {
-    if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedState.value?.country_id
-    ) {
+    if (!oldValue) return;
+    if (value?.value != oldValue?.value && value?.value != selectedState.value?.country_id) {
       selectedState.value = null;
     }
-    if (value) {
+    if (value && baseOptions.value.length > 0) {
       ensureOnlyPossibleOptions(value.value);
     }
   },
-  { immediate: true },
 );
 
 watch(selectedState, () => {
@@ -123,26 +128,6 @@ watch(selectedState, () => {
   }
 });
 
-onMounted(async () => {
-  try {
-    const baseStates = await getStates();
-    baseOptions.value = baseStates.map((state) => ({
-      label: state.name,
-      value: state.id,
-      code: state.code,
-      country_id: state.country_id,
-    }));
-    stateOptions.value = baseOptions.value;
-    if (initialId) {
-      selectStateById(initialId);
-    }
-  } catch (e) {
-    console.error(e);
-  } finally {
-    loading.value = false;
-  }
-});
-
 defineExpose({
   selectStateById,
   selectStateByName,

+ 25 - 1
src/css/app.scss

@@ -1,5 +1,5 @@
 @use "sass:map";
-@use "src/css/quasar.variables.scss";
+@use "/src/css/quasar.variables.scss";
 
 .flex-grow {
   flex-grow: 1;
@@ -185,3 +185,27 @@ input[type="number"]::-webkit-outer-spin-button {
     }
   }
 }
+
+.input-violet {
+  .q-field__control {
+    background: $violet-light !important;
+    border-radius: 8px;
+  }
+
+  .q-field__native,
+  .q-field__input {
+    color: $violet-dark !important;
+  }
+
+  .q-field__label {
+    color: $violet-normal !important;
+  }
+
+  .q-field--outlined .q-field__control:before {
+    border-color: transparent !important;
+  }
+
+  .q-field--outlined .q-field__control:after {
+    border-color: $violet-normal !important;
+  }
+}

+ 41 - 1
src/i18n/locales/en.json

@@ -1,5 +1,8 @@
 {
   "page": {
+    "loja": {
+      "description": "Manage the products available in the store"
+    },
     "associado": {
       "profile": { "description": "View and edit your personal data and dependents" },
       "carteirinha": { "description": "Your digital membership card with QR Code" },
@@ -415,6 +418,11 @@
     "search_placeholder": "Search Members",
     "no_dependents": "No dependents registered",
     "kinship": "Kinship",
+    "dependent_statuses": {
+      "approved": "Approved",
+      "refused": "Refused",
+      "pending": "Pending"
+    },
     "kinship_options": {
       "conjuge": "Spouse",
       "filho": "Son",
@@ -458,7 +466,8 @@
       "pt": "Português",
       "en": "English",
       "es": "Español"
-    }
+    },
+    "search_placeholder": "Search Members"
   },
   "charts": {
     "nps": {
@@ -521,6 +530,35 @@
       }
     }
   },
+  "loja": {
+    "product":                    "Product",
+    "products":                   "Products",
+    "supplier_price":             "Supplier Price",
+    "supplier":                   "Supplier",
+    "stock":                       "Stock",
+    "interests":                  "Interested",
+    "interested_btn":             "INTERESTED",
+    "interested_in":              "Interested in",
+    "media":                      "Media",
+    "add_media":                  "Add Media",
+    "uploading_media":            "Uploading media...",
+    "activate":                   "Activate",
+    "deactivate":                 "Deactivate",
+    "variation":                  "Variation",
+    "variation_tamanho":          "Size",
+    "variation_cor":              "Color",
+    "variation_modelo":           "Model",
+    "variation_label":            "Name",
+    "variation_value":            "Value (R$)",
+    "variation_stock":            "Stock",
+    "variation_cor_placeholder":  "E.g.: Purple, White...",
+    "variation_modelo_placeholder": "E.g.: Associate, Premium...",
+    "variation_empty":            "No variation added",
+    "add_variation":              "Add",
+    "filter_all":                 "All",
+    "filter_recent":              "Recent",
+    "filter_others":              "Others"
+  },
   "parceiro": {
     "category":                "Category",
     "company_name":            "Company Name",
@@ -547,6 +585,8 @@
     "requires_scheduling":     "Requires Scheduling",
     "search_placeholder":      "Search for service",
     "cadastro_title":          "Partner Registration",
+    "cadastro_parceiro":       "Partner Registration",
+    "dados_parceiro":          "Partner Data",
     "tab_dados":               "Company Data",
     "tab_contato":             "Contact",
     "tab_endereco":            "Address",

+ 53 - 11
src/i18n/locales/es.json

@@ -1,5 +1,8 @@
 {
   "page": {
+    "loja": {
+      "description": "Gestione los productos disponibles en la tienda"
+    },
     "associado": {
       "profile": { "description": "Visualice y edite sus datos personales y dependientes" },
       "carteirinha": { "description": "Su tarjeta digital con código QR de identificación" },
@@ -25,7 +28,7 @@
       "description": "Consulte, edite y agregue usuarios con acceso al sistema"
     },
     "system-dashboard": {
-      "description": "Visão general del sistema"
+      "description": "Visión general del sistema"
     },
     "versions": {
       "description": "Información sobre actualizaciones, correcciones y cambios en cada versión del sistema"
@@ -36,7 +39,7 @@
   },
   "common": {
     "actions": {
-      "import": "Import",
+      "import": "Importar",
       "save": "Guardar",
       "cancel": "Cancelar",
       "edit": "Editar",
@@ -54,11 +57,11 @@
       "go_home": "Ir al Inicio"
     },
     "terms": {
-      "actions": "Ações",
+      "actions": "Acciones",
       "name": "Nombre",
       "email": "Correo electrónico",
-      "contact_info": "Contato",
-      "branch": "Branch",
+      "contact_info": "Información de Contacto",
+      "branch": "Rama de actuación",
       "password": "Contraseña",
       "description": "Descripción",
       "date": "Fecha",
@@ -85,8 +88,8 @@
       "cep": "Código Postal",
       "order_number": "Número de pedido",
       "order_amount": "Monto del pedido",
-      "owner_name": "Nome do dono",
-      "client_name": "Nome do cliente",
+      "owner_name": "Nombre del dueño",
+      "client_name": "Nombre del cliente",
       "total_amount": "Monto total",
       "payment": "Pago",
       "payment_method": "Método de pago",
@@ -99,7 +102,7 @@
       "banner": "Banner",
       "logo": "Logo",
       "light": "Claro",
-      "dark": "Escuro",
+      "dark": "Oscuro",
       "media": "Media",
       "month": "Mes",
       "week": "Semana",
@@ -349,7 +352,7 @@
     "navigation": {
       "collapse_menu": "Contraer menú",
       "dashboards": "Dashboards",
-      "home": "Início",
+      "home": "Inicio",
       "versions": "Versiónes",
       "expand_menu": "Expandir menú",
       "dashboard": "Panel",
@@ -404,7 +407,7 @@
   "associado": {
     "personal_data": "Datos Personales",
     "registration": "Matrícula",
-    "cracha": "Crachá",
+    "cracha": "Identificador",
     "position": "Cargo",
     "sector": "Sector",
     "admission_date": "Fecha de Admisión",
@@ -415,6 +418,11 @@
     "search_placeholder": "Buscar Asociados",
     "no_dependents": "Ningún dependiente registrado",
     "kinship": "Parentesco",
+    "dependent_statuses": {
+      "approved": "Aprobado",
+      "refused": "Rechazado",
+      "pending": "Pendiente"
+    },
     "kinship_options": {
       "conjuge": "Cónyuge",
       "filho": "Hijo",
@@ -432,6 +440,9 @@
     "services": "Servicios",
     "filter_by_category": "Filtrar por categoría",
     "view_services": "Ver Servicios",
+    "search_by_segment": "Buscar por segmento",
+    "discount": "Descuento",
+    "validity_until": "Vigente hasta",
     "price": "Precio",
     "available_sizes": "Tallas disponibles",
     "i_want": "Lo quiero",
@@ -455,7 +466,8 @@
       "pt": "Português",
       "en": "English",
       "es": "Español"
-    }
+    },
+    "search_placeholder": "Buscar por asociados"
   },
   "charts": {
     "nps": {
@@ -518,6 +530,34 @@
       }
     }
   },
+  "loja": {
+    "product":                    "Producto",
+    "products":                   "Productos",
+    "supplier_price":             "Precio Proveedor",
+    "supplier":                   "Proveedor",
+    "interests":                  "Interesados",
+    "interested_btn":             "INTERESADOS",
+    "interested_in":              "Interesados en",
+    "media":                      "Medios",
+    "add_media":                  "Agregar Medio",
+    "uploading_media":            "Subiendo medio...",
+    "activate":                   "Activar",
+    "deactivate":                 "Desactivar",
+    "variation":                  "Variación",
+    "variation_tamanho":          "Talla",
+    "variation_cor":              "Color",
+    "variation_modelo":           "Modelo",
+    "variation_label":            "Nombre",
+    "variation_value":            "Valor (R$)",
+    "variation_stock":            "Stock",
+    "variation_cor_placeholder":  "Ej: Morado, Blanco...",
+    "variation_modelo_placeholder": "Ej: Asociado, Premium...",
+    "variation_empty":            "Sin variaciones agregadas",
+    "add_variation":              "Agregar",
+    "filter_all":                 "Todos",
+    "filter_recent":              "Recientes",
+    "filter_others":              "Otros"
+  },
   "parceiro": {
     "category":                "Categoría",
     "company_name":            "Nombre de la Empresa",
@@ -544,6 +584,8 @@
     "requires_scheduling":     "Requiere Programación",
     "search_placeholder":      "Buscar por servicio",
     "cadastro_title":          "Registro de Socio",
+    "cadastro_parceiro":       "Registro de Socio",
+    "dados_parceiro":          "Datos del Socio",
     "tab_dados":               "Datos de la Empresa",
     "tab_contato":             "Contacto",
     "tab_endereco":            "Dirección",

+ 34 - 1
src/i18n/locales/pt.json

@@ -1,5 +1,8 @@
 {
   "page": {
+    "loja": {
+      "description": "Gerencie os produtos disponíveis na loja"
+    },
     "associado": {
       "profile": { "description": "Visualize e edite seus dados pessoais e dependentes" },
       "carteirinha": { "description": "Sua carteira digital com QR Code de identificação" },
@@ -463,7 +466,8 @@
       "pt": "Português",
       "en": "English",
       "es": "Español"
-    }
+    },
+    "search_placeholder": "Buscar por associados"
   },
   "charts": {
     "nps": {
@@ -526,6 +530,35 @@
       }
     }
   },
+  "loja": {
+    "product":                    "Produto",
+    "products":                   "Produtos",
+    "supplier_price":             "Preço Fornecedor",
+    "supplier":                   "Fornecedor",
+    "stock":                       "Estoque",
+    "interests":                  "Interessados",
+    "interested_btn":             "INTERESSADOS",
+    "interested_in":              "Interessados em",
+    "media":                      "Mídias",
+    "add_media":                  "Adicionar Mídia",
+    "uploading_media":            "Enviando mídia...",
+    "activate":                   "Ativar",
+    "deactivate":                 "Desativar",
+    "variation":                  "Variação",
+    "variation_tamanho":          "Tamanho",
+    "variation_cor":              "Cor",
+    "variation_modelo":           "Modelo",
+    "variation_label":            "Nome",
+    "variation_value":            "Valor (R$)",
+    "variation_stock":            "Estoque",
+    "variation_cor_placeholder":  "Ex: Roxo, Branco...",
+    "variation_modelo_placeholder": "Ex: Associado, Premium...",
+    "variation_empty":            "Nenhuma variação adicionada",
+    "add_variation":              "Adicionar",
+    "filter_all":                 "Todos",
+    "filter_recent":              "Recentes",
+    "filter_others":              "Outros"
+  },
   "parceiro": {
     "category":                "Categoria",
     "company_name":            "Nome da Empresa",

+ 1 - 1
src/pages/gestao-associados/components/AddEditAssociadoDialog.vue

@@ -10,7 +10,7 @@
             :rules="[inputRules.required]"
             :label="$t('common.terms.name')"
             :placeholder="$t('user.profile.name_and_surname')"
-            class="col-md-8 col-12"
+            class="col-md-8 col-12 input-violet"
           />
           <DefaultSelect
             v-model="form.status"

+ 498 - 0
src/pages/loja/LojaPage.vue

@@ -0,0 +1,498 @@
+<template>
+  <div>
+    <DefaultHeaderPage>
+      <template #after>
+        <div class="flex gap-sm q-mt-md">
+          <q-btn
+            unelevated
+            class="btn-gradient"
+            :label="$t('common.actions.new')"
+            icon="mdi-plus"
+            padding="8px 12px"
+            @click="onAddItem"
+          />
+        </div>
+      </template>
+    </DefaultHeaderPage>
+
+    <div class="q-pa-md">
+      <div class="row q-col-gutter-sm q-mb-md">
+        <div class="col-12 col-md-5">
+          <q-input
+            v-model="search"
+            :placeholder="$t('common.actions.search')"
+            outlined
+            dense
+            clearable
+          >
+            <template #prepend>
+              <q-icon name="mdi-magnify" />
+            </template>
+          </q-input>
+        </div>
+      </div>
+
+      <div class="row q-gutter-xs q-mb-md flex-wrap">
+        <div
+          :class="['cat-chip', activeTab === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'all'"
+        >
+          {{ $t('loja.filter_all') }}
+        </div>
+        <div
+          v-for="cat in storeCategories"
+          :key="cat.value"
+          :class="['cat-chip', activeTab === cat.value ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = cat.value"
+        >
+          {{ cat.label }}
+        </div>
+        <div
+          :class="['cat-chip', activeTab === 'recent' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'recent'"
+        >
+          {{ $t('loja.filter_recent') }}
+        </div>
+        <div class="cat-chip cat-chip--disabled">
+          {{ $t('loja.filter_others') }}
+        </div>
+      </div>
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <div v-else-if="filteredItems.length === 0" class="text-center text-grey q-py-xl">
+        {{ $t('http.errors.no_records_found') }}
+      </div>
+
+      <div v-else class="row q-col-gutter-md items-stretch">
+        <div
+          v-for="item in filteredItems"
+          :key="item.id"
+          class="col-12 col-sm-6 col-md-4"
+        >
+          <q-card flat bordered class="store-card" style="height: 100%">
+            <q-card-section class="q-pa-sm col column" style="height: 100%">
+              <div class="row no-wrap store-card-inner" style="flex: 1">
+                <div class="col column justify-between q-pr-sm">
+                  <div class="row items-center justify-between no-wrap q-mb-xs">
+                    <div class="row items-center q-gutter-xs flex-wrap">
+                      <span v-if="item.category?.name" class="badge-category">
+                        {{ item.category.name }}
+                      </span>
+                      <span :class="isActive(item) ? 'badge-active' : 'badge-inactive'">
+                        {{ isActive(item) ? $t('common.status.active') : $t('common.status.inactive') }}
+                      </span>
+                    </div>
+                    <div class="row no-wrap items-center" style="gap: 2px">
+                      <q-btn
+                        flat round dense size="sm"
+                        color="negative"
+                        icon="mdi-pause-circle-outline"
+                        @click="onSetStatus(item, 'inactive')"
+                      />
+                      <q-btn
+                        flat round dense size="sm"
+                        color="positive"
+                        icon="mdi-play-circle-outline"
+                        @click="onSetStatus(item, 'active')"
+                      />
+                      <q-btn
+                        flat round dense size="sm"
+                        class="btn-edit-icon"
+                        icon="mdi-pencil"
+                        @click="onEdit(item)"
+                      />
+                    </div>
+                  </div>
+                  <div class="text-subtitle1 text-weight-medium text-violet-medium q-mb-xs leading-tight">
+                    {{ item.name }}
+                  </div>
+
+                  <div v-if="item.description" class="text-caption text-grey-7 q-mb-sm ellipsis-2-lines">
+                    {{ item.description }}
+                  </div>
+
+                  <template v-if="item.variations?.length">
+                    <div class="text-caption text-grey-6 q-mb-xs">
+                      {{ variationTypeLabel(item) }}
+                    </div>
+                    <div class="row justify-end q-gutter-xs q-mb-sm">
+                      <div
+                        v-for="v in item.variations"
+                        :key="v.id"
+                        :class="['variation-tag', activeVariation(item)?.id === v.id ? 'variation-tag--selected' : 'variation-tag--default']"
+                        @click="selectVariation(item, v)"
+                      >
+                        {{ v.variation_label }}
+                      </div>
+                    </div>
+                  </template>
+
+                  <div class="row items-end justify-between q-mt-sm">
+                    <div class="column" style="gap: 1px">
+                      <span class="text-caption text-grey-6">
+                        {{ $t('loja.stock') }}: {{ displayStock(item) }}
+                      </span>
+                      <span class="text-caption text-grey-6">
+                        {{ $t('loja.interests') }}: {{ item.interests_count ?? 0 }}
+                      </span>
+                    </div>
+                    <div class="column items-end" style="gap: 1px">
+                      <span v-if="item.price" class="text-caption text-grey-5 text-strike">
+                        R$ {{ formatPrice(item.price) }}
+                      </span>
+                      <span v-if="displayPrice(item) != null" class="text-subtitle2 text-weight-bold text-violet-dark">
+                        R$ {{ formatPrice(displayPrice(item)) }}
+                      </span>
+                    </div>
+                  </div>
+                </div>
+
+                <div class="store-card-right column items-stretch no-wrap">
+                  <div class="store-card-image col">
+                    <img
+                      v-if="item.media?.length"
+                      :src="item.media[0].url"
+                      :alt="item.name"
+                      class="store-card-img"
+                    />
+                    <q-icon
+                      v-else
+                      name="mdi-image-off-outline"
+                      size="32px"
+                      color="grey-4"
+                      class="absolute-center"
+                    />
+                  </div>
+                  <q-btn
+                    unelevated
+                    size="sm"
+                    class="btn-gradient q-mt-xs"
+                    :label="$t('loja.interested_btn')"
+                    icon="mdi-thumb-up"
+                    padding="5px 8px"
+                    style="width: 100%"
+                    @click="onInterests(item)"
+                  />
+                </div>
+
+              </div>
+            </q-card-section>
+          </q-card>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, defineAsyncComponent } from "vue";
+import { useRouter } from "vue-router";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getStoreItems, toggleStoreItemStatus } from "src/api/storeItem";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const StoreItemInterestsDialog = defineAsyncComponent(
+  () => import("./components/StoreItemInterestsDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const { t } = useI18n();
+const router = useRouter();
+
+const items = ref([]);
+const loading = ref(true);
+const search = ref("");
+const activeTab = ref("all");
+const storeCategories = ref([]);
+
+const selectedVariations = ref({});
+
+const selectVariation = (item, variation) => {
+  selectedVariations.value[item.id] = variation;
+};
+
+const activeVariation = (item) => selectedVariations.value[item.id] ?? null;
+
+const displayPrice = (item) => {
+  const v = activeVariation(item);
+  if (v && v.variation_value != null) return v.variation_value;
+  return item.associate_price;
+};
+
+const displayStock = (item) => {
+  const v = activeVariation(item);
+  if (v) return v.stock ?? 0;
+  return item.stock ?? 0;
+};
+
+const filteredItems = computed(() => {
+  let list = items.value;
+
+  if (activeTab.value === "recent") {
+    const dateToCompare = new Date().setDate(new Date().getDate() - 30);
+    list = list.filter((i) => new Date(i.created_at).getTime() >= dateToCompare);
+  } else if (activeTab.value !== "all") {
+    list = list.filter((i) => i.category_id === activeTab.value);
+  }
+
+  if (search.value) {
+    const needle = normalizeString(search.value);
+    list = list.filter((i) => normalizeString(i.name || "").includes(needle));
+  }
+
+  return list;
+});
+
+const isActive = (item) =>
+  item.status?.value === "active" || item.status === "active";
+
+const variationTypeLabel = (item) => {
+  const type = item.variations?.[0]?.variation_type;
+  if (type === "tamanho") return t("loja.variation_tamanho");
+  if (type === "cor")     return t("loja.variation_cor");
+  if (type === "modelo")  return t("loja.variation_modelo");
+  return "";
+};
+
+const formatPrice = (price) =>
+  Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+
+onMounted(async () => {
+  try {
+    const [itemsData, catsData] = await Promise.all([
+      getStoreItems(),
+      getCategories("store"),
+    ]);
+    items.value = itemsData;
+    storeCategories.value = catsData.map((c) => ({ label: c.name, value: c.id }));
+
+    itemsData.forEach((item) => {
+      if (item.variations?.length) {
+        selectedVariations.value[item.id] = item.variations ?? item.variations[0];
+      }
+    });
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+const onAddItem = () => {
+  if (!permission_store.getAccess("loja.item", "add")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+  router.push({ name: "StoreItemNewPage" });
+};
+
+const onEdit = (item) => {
+  if (!permission_store.getAccess("loja.item", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  router.push({ name: "StoreItemEditPage", params: { id: item.id } });
+};
+
+const onSetStatus = async (item) => {
+  if (!permission_store.getAccess("loja.item", "edit")) {
+    $q.notify({ type: "negative", message: t("validation.permissions.edit") });
+    return;
+  }
+  try {
+    const updated = await toggleStoreItemStatus(item.id);
+    const idx = items.value.findIndex((i) => i.id === item.id);
+    if (idx !== -1) items.value[idx] = { ...items.value[idx], ...updated };
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+};
+
+
+const onInterests = (item) => {
+  $q.dialog({
+    component: StoreItemInterestsDialog,
+    componentProps: {
+      storeItemId: item.id,
+      storeItemName: item.name,
+    },
+  });
+};
+</script>
+
+<style lang="scss" scoped>
+.store-card {
+  transition: box-shadow 0.2s;
+  display: flex;
+  flex-direction: column;
+  &:hover { box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12); }
+}
+
+.store-card-inner {
+  min-height: 0;
+  flex: 1;
+}
+
+.store-card-right {
+  width: 110px;
+  min-width: 110px;
+  flex-shrink: 0;
+}
+
+.store-card-image {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid rgba(102, 29, 117, 0.15);
+  background: #f5f0f7;
+  min-height: 110px;
+}
+
+.store-card-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.ellipsis-2-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.category-tabs {
+  :deep(.q-tab__label) { font-size: 13px; }
+}
+
+// --- category chips ---
+.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;
+
+  &--default {
+    background: #c9a3dc;
+    color: #fff;
+  }
+
+  &--selected {
+    background: #4d1658;
+    color: #fff;
+  }
+
+  &--disabled {
+    background: #c9a3dc;
+    color: rgba(255,255,255,0.5);
+    cursor: default;
+    pointer-events: none;
+  }
+}
+
+// --- badges ---
+.badge-category {
+  display: inline-flex;
+  align-items: center;
+  background: #ede0f5;
+  color: #4d1658;
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 20px;
+  line-height: 1.4;
+}
+
+.badge-active {
+  display: inline-flex;
+  align-items: center;
+  background: #22c55e;
+  color: #fff;
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 20px;
+  line-height: 1.4;
+}
+
+.badge-inactive {
+  display: inline-flex;
+  align-items: center;
+  background: #eab308;
+  color: #fff;
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 20px;
+  line-height: 1.4;
+}
+
+// --- variation tags ---
+.variation-tag {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 28px;
+  height: 26px;
+  padding: 0 7px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default {
+    background: #ede0f5;
+    color: #4d1658;
+  }
+
+  &--selected {
+    background: #4d1658;
+    color: #ede0f5;
+  }
+}
+
+.gap-sm { gap: 8px; }
+
+.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; }
+
+.btn-edit-icon {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 6px !important;
+  width: 28px;
+  height: 28px;
+}
+
+.btn-edit-icon :deep(.q-icon) { color: white !important; }
+
+.text-violet-dark   { color: #4d1658; }
+.text-violet-medium { color: #7b2d97; }
+
+.leading-tight { line-height: 1.25; }
+</style>

+ 575 - 0
src/pages/loja/StoreItemFormPage.vue

@@ -0,0 +1,575 @@
+<template>
+  <div>
+    <DefaultHeaderPage>
+      <template #after>
+        <div class="flex items-center gap-sm q-mt-md">
+          <q-btn
+            flat
+            icon="mdi-arrow-left"
+            :label="$t('common.actions.back')"
+            color="violet-normal"
+            padding="8px 12px"
+            @click="router.push({ name: 'LojaPage' })"
+          />
+        </div>
+      </template>
+    </DefaultHeaderPage>
+
+    <div class="q-pa-md">
+      <div v-if="loadingItem" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <q-form v-else ref="formRef" @submit="onSubmit">
+        <q-card flat bordered class="q-pa-md q-mb-md">
+          <div class="text-subtitle1 text-weight-bold text-violet-dark q-mb-md">
+            {{ isEdit ? $t('common.actions.edit') + ' ' + $t('loja.product') : $t('common.actions.new') + ' ' + $t('loja.product') }}
+          </div>
+          <div class="row q-col-gutter-sm">
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              :rules="[inputRules.required]"
+              :label="$t('common.terms.name')"
+              class="col-12 col-md-8"
+              @update:model-value="validationErrors.name = null"
+            />
+            <DefaultSelect
+              v-model="form.status"
+              v-model:error="validationErrors.status"
+              :options="statusOptions"
+              :label="$t('common.terms.status')"
+              emit-value
+              map-options
+              class="col-12 col-md-4 input-violet"
+            />
+            <CategorySelect
+              v-model="selectedCategory"
+              v-model:error="validationErrors.category_id"
+              type="store"
+              class="col-12"
+            />
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              type="textarea"
+              :label="$t('common.terms.description')"
+              autogrow
+              class="col-12"
+              @update:model-value="validationErrors.description = null"
+            />
+          </div>
+        </q-card>
+
+        <q-card flat bordered class="row no-wrap">
+          <div class="col-12 col-md-7 q-pa-md">
+              <div class="row q-col-gutter-sm">
+                <DefaultInput
+                  v-model="form.price"
+                  v-model:error="validationErrors.price"
+                  :label="$t('parceiro.price')"
+                  type="number"
+                  prefix="R$"
+                  class="col-12 col-md-4"
+                  @update:model-value="validationErrors.price = null"
+                />
+                <DefaultInput
+                  v-model="form.associate_price"
+                  v-model:error="validationErrors.associate_price"
+                  :label="$t('parceiro.associate_price')"
+                  type="number"
+                  prefix="R$"
+                  class="col-12 col-md-4"
+                  @update:model-value="validationErrors.associate_price = null"
+                />
+                <DefaultInput
+                  v-model="form.supplier_price"
+                  v-model:error="validationErrors.supplier_price"
+                  :label="$t('loja.supplier_price')"
+                  type="number"
+                  prefix="R$"
+                  class="col-12 col-md-4"
+                  @update:model-value="validationErrors.supplier_price = null"
+                />
+              </div>
+
+              <div class="q-mt-md">
+                <div class="text-subtitle2 text-violet-normal q-mb-sm">{{ $t('loja.variation') }}</div>
+
+                <q-btn-toggle
+                  v-model="variationType"
+                  unelevated
+                  toggle-color="purple-8"
+                  :options="variationTypeOptions"
+                  class="q-mb-md"
+                  @update:model-value="onVariationTypeChange"
+                />
+
+                <div v-if="variationType === 'tamanho'" class="row q-col-gutter-sm">
+                  <div v-for="size in sizeSlots" :key="size.label" class="col-12 col-sm-6 col-md-3">
+                    <q-card flat bordered class="q-pa-sm">
+                      <div class="flex items-center justify-between q-mb-xs">
+                        <span class="text-weight-medium">{{ size.label }}</span>
+                        <q-toggle v-model="size.enabled" color="purple-8" dense @update:model-value="syncSizeVariations" />
+                      </div>
+                      <template v-if="size.enabled">
+                        <div class="row q-col-gutter-xs">
+                          <div class="col-6">
+                            <DefaultInput
+                              v-model="size.value"
+                              :label="$t('loja.variation_value')"
+                              type="number"
+                              prefix="R$"
+                              dense
+                              @update:model-value="syncSizeVariations"
+                            />
+                          </div>
+                          <div class="col-6">
+                            <DefaultInput
+                              v-model="size.stock"
+                              :label="$t('loja.variation_stock')"
+                              type="number"
+                              dense
+                              @update:model-value="syncSizeVariations"
+                            />
+                          </div>
+                        </div>
+                      </template>
+                    </q-card>
+                  </div>
+                </div>
+
+                <div v-else>
+                  <div class="row q-gutter-sm q-mb-sm items-end">
+                    <div class="col">
+                      <DefaultInput
+                        v-model="newVariationLabel"
+                        :label="$t('loja.variation_label')"
+                        :placeholder="variationType === 'cor' ? $t('loja.variation_cor_placeholder') : $t('loja.variation_modelo_placeholder')"
+                        dense
+                        @keyup.enter="addVariation"
+                      />
+                    </div>
+                    <div class="col">
+                      <DefaultInput
+                        v-model="newVariationStock"
+                        :label="$t('loja.variation_stock')"
+                        type="number"
+                        dense
+                      />
+                    </div>
+                    <div class="col-auto">
+                      <DefaultInput
+                        v-model="newVariationValue"
+                        :label="$t('loja.variation_value')"
+                        type="number"
+                        prefix="R$"
+                        dense
+                        style="width: 130px"
+                      />
+                    </div>
+                    <div class="col-auto q-pb-xs">
+                      <q-btn
+                        unelevated
+                        color="purple-8"
+                        :label="$t('loja.add_variation')"
+                        icon="mdi-plus"
+                        padding="6px 12px"
+                        :disable="!newVariationLabel.trim()"
+                        :loading="addingVariation"
+                        @click="addVariation"
+                      />
+                    </div>
+                  </div>
+
+                  <div v-if="variations.length === 0" class="text-caption text-grey-5 q-mt-xs">
+                    {{ $t('loja.variation_empty') }}
+                  </div>
+
+                  <div v-for="(v, idx) in variations" :key="v.id ?? idx" class="row q-gutter-xs q-mb-xs items-end">
+                    <div class="col">
+                      <DefaultInput
+                        v-model="v.variation_label"
+                        :label="$t('loja.variation_label')"
+                        dense
+                      />
+                    </div>
+                    <div class="col">
+                      <DefaultInput
+                        v-model="v.stock"
+                        :label="$t('loja.variation_stock')"
+                        type="number"
+                        dense
+                      />
+                    </div>
+                    <div class="col-auto">
+                      <DefaultInput
+                        v-model="v.variation_value"
+                        :label="$t('loja.variation_value')"
+                        type="number"
+                        prefix="R$"
+                        dense
+                        style="width: 130px"
+                      />
+                    </div>
+                    <div class="col-auto q-pb-xs">
+                      <q-btn
+                        flat
+                        round
+                        dense
+                        size="sm"
+                        color="negative"
+                        icon="mdi-delete-outline"
+                        :loading="v.deleting"
+                        @click="removeVariation(idx, v)"
+                      />
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              <div class="flex justify-end q-mt-lg">
+                <q-btn
+                  unelevated
+                  type="submit"
+                  class="btn-gradient"
+                  :label="$t('common.actions.save')"
+                  :loading="loading"
+                  padding="10px 24px"
+                />
+              </div>
+          </div>
+
+          <q-separator vertical />
+
+          <div class="col-12 col-md-5 q-pa-md">
+              <div class="text-subtitle2 text-weight-bold text-violet-dark q-mb-md">{{ $t('loja.media') }}</div>
+
+              <div class="row q-col-gutter-sm">
+                <div class="col-6 col-sm-4">
+                  <DefaultFilePicker
+                    v-model="newMediaFile"
+                    type="image"
+                    :label="null"
+                    @update:model-value="onNewMediaSelected"
+                  />
+                </div>
+
+                <div
+                  v-for="media in existingMedia"
+                  :key="media.id"
+                  class="col-6 col-sm-4"
+                >
+                  <div class="media-thumb">
+                    <img :src="media.url" :alt="media.id" class="media-thumb-img" />
+                    <q-btn
+                      round unelevated dense size="xs"
+                      color="negative"
+                      icon="mdi-close"
+                      class="media-thumb-delete"
+                      :loading="deletingMediaId === media.id"
+                      @click="onDeleteMedia(media)"
+                    />
+                  </div>
+                </div>
+              </div>
+
+              <div v-if="uploadingMedia" class="text-caption text-grey-6 q-mt-sm">
+                <q-spinner size="16px" class="q-mr-xs" />{{ $t('loja.uploading_media') }}
+              </div>
+          </div>
+        </q-card>
+      </q-form>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, watch, computed, onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useInputRules } from "src/composables/useInputRules";
+import {
+  getStoreItem,
+  createStoreItem,
+  updateStoreItem,
+  uploadStoreItemMedia,
+  deleteStoreItemMedia,
+  addStoreItemVariation,
+  deleteStoreItemVariation,
+} from "src/api/storeItem";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
+import CategorySelect from "src/components/selects/CategorySelect.vue";
+
+const route = useRoute();
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const formRef = ref(null);
+const { inputRules } = useInputRules();
+
+const isEdit = computed(() => !!route.params.id);
+const loadingItem = ref(false);
+
+const { form, getUpdatedFields } = useFormUpdateTracker({
+  name:            "",
+  description:     "",
+  category_id:     null,
+  price:           null,
+  associate_price: null,
+  supplier_price:  null,
+  status:          "active",
+});
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => router.push({ name: "LojaPage" }),
+  formRef,
+});
+
+const statusOptions = [
+  { label: t("common.status.active"),   value: "active" },
+  { label: t("common.status.inactive"), value: "inactive" },
+];
+
+const selectedCategory = ref(null);
+
+const variationType = ref("tamanho");
+const variations = ref([]);
+const newVariationLabel = ref("");
+const newVariationValue = ref(null);
+const newVariationStock = ref(null);
+const addingVariation = ref(false);
+
+const variationTypeOptions = computed(() => [
+  { label: t("loja.variation_tamanho"), value: "tamanho" },
+  { label: t("loja.variation_cor"),     value: "cor"     },
+  { label: t("loja.variation_modelo"),  value: "modelo"  },
+]);
+
+const sizeSlots = reactive([
+  { label: "P",  enabled: false, value: null, stock: null },
+  { label: "M",  enabled: false, value: null, stock: null },
+  { label: "G",  enabled: false, value: null, stock: null },
+  { label: "GG", enabled: false, value: null, stock: null },
+]);
+
+const syncSizeVariations = () => {
+  variations.value = sizeSlots
+    .filter((s) => s.enabled)
+    .map((s) => ({
+      variation_type:  "tamanho",
+      variation_label: s.label,
+      variation_value: s.value ?? null,
+      stock:           s.stock ? Number(s.stock) : 0,
+    }));
+};
+
+const onVariationTypeChange = () => {
+  variations.value = [];
+  newVariationLabel.value = "";
+  newVariationValue.value = null;
+  newVariationStock.value = null;
+  sizeSlots.forEach((s) => { s.enabled = false; s.value = null; s.stock = null; });
+};
+
+
+
+const addVariation = async () => {
+  const label = newVariationLabel.value.trim();
+  if (!label) return;
+
+  const payload = {
+    variation_type:  variationType.value,
+    variation_label: label,
+    variation_value: newVariationValue.value ?? null,
+    stock:           newVariationStock.value ? Number(newVariationStock.value) : 0,
+  };
+
+  if (isEdit.value) {
+    addingVariation.value = true;
+    try {
+      const created = await addStoreItemVariation(route.params.id, payload);
+      variations.value.push(created);
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      addingVariation.value = false;
+    }
+  } else {
+    variations.value.push(payload);
+  }
+
+  newVariationLabel.value = "";
+  newVariationValue.value = null;
+  newVariationStock.value = null;
+};
+
+const removeVariation = async (idx, v) => {
+  if (isEdit.value && v?.id) {
+    v.deleting = true;
+    try {
+      await deleteStoreItemVariation(route.params.id, v.id);
+      variations.value.splice(idx, 1);
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+      v.deleting = false;
+    }
+  } else {
+    variations.value.splice(idx, 1);
+  }
+};
+const existingMedia = ref([]);
+const newMediaFile = ref(null);
+const uploadingMedia = ref(false);
+const deletingMediaId = ref(null);
+const pendingFiles = ref([]);
+
+const onNewMediaSelected = async (file) => {
+  if (!(file instanceof File)) return;
+  if (isEdit.value) {
+    uploadingMedia.value = true;
+    try {
+      const media = await uploadStoreItemMedia(route.params.id, file);
+      existingMedia.value.push(media);
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      uploadingMedia.value = false;
+      newMediaFile.value = null;
+    }
+  } else {
+    pendingFiles.value.push(file);
+    newMediaFile.value = null;
+  }
+};
+
+const onDeleteMedia = async (media) => {
+  deletingMediaId.value = media.id;
+  try {
+    await deleteStoreItemMedia(route.params.id, media.id);
+    existingMedia.value = existingMedia.value.filter((m) => m.id !== media.id);
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    deletingMediaId.value = null;
+  }
+};
+
+const onSubmit = async () => {
+  if (isEdit.value) {
+    await submitForm(() =>
+      updateStoreItem(route.params.id, {
+        ...getUpdatedFields.value,
+        variations: variations.value,
+      }),
+    );
+  } else {
+    await submitForm(async () => {
+      const created = await createStoreItem({ ...form, variations: variations.value });
+      for (const file of pendingFiles.value) {
+        await uploadStoreItemMedia(created.id, file);
+      }
+      return created;
+    });
+  }
+};
+
+watch(selectedCategory, (val) => { form.category_id = val?.value ?? null; });
+
+onMounted(async () => {
+  if (!isEdit.value) return;
+  loadingItem.value = true;
+  try {
+    const item = await getStoreItem(route.params.id);
+
+    form.name            = item.name            ?? "";
+    form.description     = item.description     ?? "";
+    form.category_id     = item.category_id     ?? null;
+    form.price           = item.price           ?? null;
+    form.associate_price = item.associate_price ?? null;
+    form.supplier_price  = item.supplier_price  ?? null;
+    form.status          = item.status?.value ?? item.status ?? "active";
+
+    existingMedia.value = item.media ?? [];
+
+    if (item.category) {
+      selectedCategory.value = {
+        label: item.category.name,
+        value: item.category_id,
+        type:  item.category.type,
+      };
+    }
+
+    if (item.variations?.length) {
+      const type = item.variations[0].variation_type;
+      variationType.value = type;
+
+      if (type === "tamanho") {
+        const labelMap = { P: 0, M: 1, G: 2, GG: 3 };
+        item.variations.forEach((v) => {
+          const idx = labelMap[v.variation_label];
+          if (idx !== undefined) {
+            sizeSlots[idx].enabled = true;
+            sizeSlots[idx].value = v.variation_value;
+            sizeSlots[idx].stock = v.stock ?? null;
+          }
+        });
+        syncSizeVariations();
+      } else {
+        variations.value = item.variations.map((v) => ({
+          variation_type:  v.variation_type,
+          variation_label: v.variation_label,
+          variation_value: v.variation_value,
+          stock:           v.stock ?? 0,
+        }));
+      }
+    }
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loadingItem.value = false;
+  }
+});
+
+</script>
+
+<style lang="scss" scoped>
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 8px !important;
+}
+
+.text-violet-dark { color: #4d1658; }
+
+.gap-sm { gap: 8px; }
+
+.media-thumb {
+  position: relative;
+  height: 200px;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid rgba(102, 29, 117, 0.2);
+  background: #f5f0f7;
+}
+
+.media-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.media-thumb-delete {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+}
+</style>

+ 71 - 0
src/pages/loja/components/StoreItemInterestsDialog.vue

@@ -0,0 +1,71 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card style="width: 480px; max-width: 95vw">
+      <DefaultDialogHeader
+        :title="() => $t('loja.interested_in') + ': ' + storeItemName"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div v-if="loading" class="flex flex-center q-py-md">
+          <q-spinner color="primary" size="32px" />
+        </div>
+
+        <div v-else-if="interests.length === 0" class="text-center text-grey q-py-md">
+          {{ $t('http.errors.no_records_found') }}
+        </div>
+
+        <q-list v-else separator>
+          <q-item v-for="person in interests" :key="person.id">
+            <q-item-section avatar>
+              <q-avatar color="purple-8" text-color="white" size="36px">
+                {{ initials(person.name) }}
+              </q-avatar>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label>{{ person.name }}</q-item-label>
+              <q-item-label caption>{{ person.email }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar, useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getStoreItemInterests } from "src/api/storeItem";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  storeItemId:   { type: Number, required: true },
+  storeItemName: { type: String, default: "" },
+});
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const loading = ref(true);
+const interests = ref([]);
+
+const initials = (name) => {
+  if (!name) return "?";
+  return name.split(" ").slice(0, 2).map((w) => w[0]).join("").toUpperCase();
+};
+
+onMounted(async () => {
+  try {
+    interests.value = await getStoreItemInterests(props.storeItemId);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 22 - 46
src/pages/parceiros-convenios/ParceiroCadastroPage.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="cadastro-page q-mr-md">
-    <DefaultHeaderPage :title="{ value: pageTitle, translate: false }" />
+    <DefaultHeaderPage :title="{ value: pageTitle }" />
 
     <div class="cadastro-page__tabs q-mb-md">
       <div
@@ -38,7 +38,7 @@
                 v-model:error="errorsDados.company_name"
                 :rules="[inputRules.required]"
                 :label="$t('parceiro.company_name')"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formDados.cnpj"
@@ -46,18 +46,18 @@
                 :label="$t('common.terms.cnpj')"
                 :placeholder="'00.000.000/0000-00'"
                 :mask="'##.###.###/####-##'"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <PartnerAgreementCategorySelect
                 v-model="selectedCategory"
                 v-model:error="errorsDados.category_id"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formDados.responsible"
                 v-model:error="errorsDados.responsible"
                 :label="$t('parceiro.responsible')"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formDados.discount_percentage"
@@ -65,7 +65,7 @@
                 :label="$t('parceiro.discount_percentage')"
                 type="number"
                 suffix="%"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formDados.description"
@@ -73,7 +73,7 @@
                 :label="$t('common.terms.description')"
                 type="textarea"
                 autogrow
-                class="col-12 input-violet"
+                class="col-12"
               />
             </div>
           </q-card>
@@ -101,27 +101,27 @@
                 v-model:error="errorsContato.email"
                 :rules="[inputRules.email]"
                 :label="$t('common.terms.email')"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formContato.website"
                 v-model:error="errorsContato.website"
                 :label="$t('parceiro.website')"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formContato.phone"
                 v-model:error="errorsContato.phone"
                 :label="$t('common.terms.phone')"
                 :mask="'(##) #####-####'"
-                class="col-12 input-violet"
+                class="col-12"
               />
               <DefaultInput
                 v-model="formContato.whatsapp"
                 v-model:error="errorsContato.whatsapp"
                 :label="$t('common.terms.whatsapp')"
                 :mask="'(##) #####-####'"
-                class="col-12 input-violet"
+                class="col-12"
               />
             </div>
           </q-card>
@@ -150,7 +150,7 @@
                 :label="$t('parceiro.zip_code')"
                 :placeholder="'00000-000'"
                 :mask="'#####-###'"
-                class="col-md-4 col-12 input-violet"
+                class="col-md-4 col-12"
                 @update:model-value="onCepChange"
               />
               <DefaultInput
@@ -158,14 +158,14 @@
                 v-model:error="errorsEndereco.address"
                 :label="$t('parceiro.address')"
                 :loading="loadingCep"
-                class="col-md-8 col-12 input-violet"
+                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 input-violet"
+                class="col-12"
               />
               <StateSelect
                 v-model="selectedState"
@@ -204,19 +204,19 @@
                 v-model:error="errorsHorario.working_hours"
                 :label="$t('parceiro.working_hours')"
                 :placeholder="$t('parceiro.working_hours_placeholder')"
-                class="col-12 input-violet"
+                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 input-violet"
+                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 input-violet"
+                class="col-md-6 col-12"
               />
             </div>
 
@@ -677,7 +677,7 @@ watch(activeTab, (tab) => {
 </script>
 
 <style lang="scss" scoped>
-@use "src/css/quasar.variables.scss" as vars;
+@use "src/css/quasar.variables.scss";
 
 .cadastro-page {
   &__tabs {
@@ -693,15 +693,15 @@ watch(activeTab, (tab) => {
     font-weight: 600;
     cursor: pointer;
     background: white;
-    color: vars.$violet-normal;
-    border: 1.5px solid rgba(vars.$violet-normal, 0.3);
+    color: $violet-normal;
+    border: 1.5px solid rgba($violet-normal, 0.3);
     transition: background 0.15s, color 0.15s;
     user-select: none;
 
     &--active {
-      background: vars.$violet-normal;
+      background: $violet-normal;
       color: white;
-      border-color: vars.$violet-normal;
+      border-color: $violet-normal;
     }
 
     &--disabled {
@@ -737,30 +737,6 @@ watch(activeTab, (tab) => {
   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;

+ 143 - 0
src/pages/parceiros-convenios/components/ParceiroCadastroContatoPanel.vue

@@ -0,0 +1,143 @@
+<template>
+  <q-form ref="formContatoRef" @submit="saveContato">
+    <q-card flat class="cadastro-card q-pa-md">
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="violet-normal" size="40px" />
+      </div>
+
+      <div v-else 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>
+</template>
+
+<script setup>
+import { ref, watch, useTemplateRef } from "vue";
+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 { getPartnerAgreementContato, updatePartnerAgreement } from "src/api/partnerAgreement";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+const props = defineProps({
+  partnerId: { type: Number, required: true },
+  active:    { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["partner-saved"]);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const formContatoRef = useTemplateRef("formContatoRef");
+const loading = ref(false);
+const loaded = ref(false);
+
+const {
+  form: formContato,
+  getUpdatedFields: updatedContato,
+  hasUpdatedFields: hasUpdatedContato,
+  setUpdateFormAsOriginal,
+} = useFormUpdateTracker({ email: "", phone: "", whatsapp: "", website: "" });
+
+const {
+  loading: loadingContato,
+  validationErrors: errorsContato,
+  execute: execContato,
+} = useSubmitHandler({ formRef: formContatoRef });
+
+const populate = (p) => {
+  formContato.email    = p.email    ?? "";
+  formContato.phone    = p.phone    ?? "";
+  formContato.whatsapp = p.whatsapp ?? "";
+  formContato.website  = p.website  ?? "";
+  setUpdateFormAsOriginal();
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const p = await getPartnerAgreementContato(props.partnerId);
+    populate(p);
+    loaded.value = true;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const saveContato = async () => {
+  await execContato(async () => {
+    const saved = await updatePartnerAgreement(props.partnerId, updatedContato.value);
+    populate(saved);
+    emit("partner-saved", saved);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+watch(() => props.active, (isActive) => {
+  if (isActive && !loaded.value) loadData();
+}, { immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.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;
+}
+</style>

+ 205 - 0
src/pages/parceiros-convenios/components/ParceiroCadastroContratoPanel.vue

@@ -0,0 +1,205 @@
+<template>
+  <q-form ref="formHorarioRef" @submit="saveHorario">
+    <q-card flat class="cadastro-card q-pa-md">
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="violet-normal" size="40px" />
+      </div>
+
+      <template v-else>
+        <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>
+      </template>
+    </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"
+        @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>
+</template>
+
+<script setup>
+import { ref, watch, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  getPartnerAgreementContrato,
+  updatePartnerAgreement,
+  uploadPartnerMedia,
+  deletePartnerMedia,
+} from "src/api/partnerAgreement";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+
+const props = defineProps({
+  partnerId: { type: Number, required: true },
+  active:    { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["partner-saved"]);
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const formHorarioRef = useTemplateRef("formHorarioRef");
+const mediaFileInputRef = useTemplateRef("mediaFileInputRef");
+const newMediaFile = ref(null);
+const contractMedia = ref([]);
+const loading = ref(false);
+const loaded = ref(false);
+
+const {
+  form: formHorario,
+  getUpdatedFields: updatedHorario,
+  hasUpdatedFields: hasUpdatedHorario,
+  setUpdateFormAsOriginal,
+} = useFormUpdateTracker({ working_hours: "", contract_start: null, contract_end: null });
+
+const {
+  loading: loadingHorario,
+  validationErrors: errorsHorario,
+  execute: execHorario,
+} = useSubmitHandler({ formRef: formHorarioRef });
+
+const populate = (p) => {
+  formHorario.working_hours  = p.working_hours  ?? "";
+  formHorario.contract_start = p.contract_start ?? null;
+  formHorario.contract_end   = p.contract_end   ?? null;
+  contractMedia.value = p.media ?? [];
+  setUpdateFormAsOriginal();
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const p = await getPartnerAgreementContrato(props.partnerId);
+    populate(p);
+    loaded.value = true;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const triggerMediaUpload = () => {
+  mediaFileInputRef.value?.pickFiles();
+};
+
+const onMediaFileSelected = async (file) => {
+  if (!file) return;
+  try {
+    const media = await uploadPartnerMedia(props.partnerId, file);
+    contractMedia.value.push(media);
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    newMediaFile.value = null;
+  }
+};
+
+const removeMedia = async (media) => {
+  try {
+    await deletePartnerMedia(props.partnerId, 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(props.partnerId, updatedHorario.value);
+    populate(saved);
+    emit("partner-saved", saved);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+watch(() => props.active, (isActive) => {
+  if (isActive && !loaded.value) loadData();
+}, { immediate: true });
+</script>
+
+<style lang="scss" scoped>
+.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;
+}
+</style>

+ 215 - 0
src/pages/parceiros-convenios/components/ParceiroCadastroDadosPanel.vue

@@ -0,0 +1,215 @@
+<template>
+  <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="initialLogoUrl"
+        style="height: 220px; max-width: 280px"
+      />
+    </div>
+
+    <q-card flat class="cadastro-card q-pa-md">
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="violet-normal" size="40px" />
+      </div>
+
+      <div v-else 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"
+        />
+        <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 && !logoFile"
+      />
+    </div>
+  </q-form>
+</template>
+
+<script setup>
+import { ref, watch, useTemplateRef } from "vue";
+import { 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 {
+  getPartnerAgreementDados,
+  createPartnerAgreement,
+  updatePartnerAgreement,
+  uploadPartnerLogo,
+} from "src/api/partnerAgreement";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
+import PartnerAgreementCategorySelect from "src/components/selects/PartnerAgreementCategorySelect.vue";
+
+const props = defineProps({
+  partnerId: { type: Number, default: null },
+  active:    { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["partner-created", "partner-saved"]);
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const formDadosRef = useTemplateRef("formDadosRef");
+const logoFile = ref(null);
+const initialLogoUrl = ref(null);
+const selectedCategory = ref(null);
+const loading = ref(false);
+const loaded = ref(false);
+
+const {
+  form: formDados,
+  getUpdatedFields: updatedDados,
+  hasUpdatedFields: hasUpdatedDados,
+  setUpdateFormAsOriginal,
+} = 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 populate = (p) => {
+  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;
+
+  initialLogoUrl.value = p.logo?.url ?? null;
+  setUpdateFormAsOriginal();
+};
+
+const loadData = async () => {
+  if (!props.partnerId) { loaded.value = true; return; }
+  loading.value = true;
+  try {
+    const p = await getPartnerAgreementDados(props.partnerId);
+    populate(p);
+    loaded.value = true;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const saveDados = async () => {
+  await execDados(async () => {
+    let savedPartner;
+    if (props.partnerId) {
+      savedPartner = await updatePartnerAgreement(props.partnerId, updatedDados.value);
+    } else {
+      savedPartner = await createPartnerAgreement({ ...formDados });
+      emit("partner-created", savedPartner);
+      await router.replace({ name: "ParceiroCadastroPage", params: { id: savedPartner.id } });
+    }
+    if (logoFile.value instanceof File) {
+      const uploadedMedia = await uploadPartnerLogo(savedPartner.id ?? props.partnerId, logoFile.value);
+      savedPartner = { ...savedPartner, logo: uploadedMedia };
+      initialLogoUrl.value = uploadedMedia.url ?? null;
+      logoFile.value = null;
+    }
+    populate(savedPartner);
+    emit("partner-saved", savedPartner);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+watch(() => props.active, (isActive) => {
+  if (isActive && !loaded.value) loadData();
+}, { immediate: true });
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss";
+
+.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;
+}
+</style>

+ 198 - 0
src/pages/parceiros-convenios/components/ParceiroCadastroEnderecoPanel.vue

@@ -0,0 +1,198 @@
+<template>
+  <q-form ref="formEnderecoRef" @submit="saveEndereco">
+    <q-card flat class="cadastro-card q-pa-md">
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="violet-normal" size="40px" />
+      </div>
+
+      <div v-else 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>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { getPartnerAgreementEndereco, updatePartnerAgreement } from "src/api/partnerAgreement";
+import { getCepData } from "src/api/cep";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
+
+const props = defineProps({
+  partnerId: { type: Number, required: true },
+  active:    { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["partner-saved"]);
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const formEnderecoRef = ref(null);
+
+const selectedState = ref(null);
+const selectedCity  = ref(null);
+const loadingCep    = ref(false);
+const loading       = ref(false);
+const loaded        = 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 });
+
+const populate = (p) => {
+  formEndereco.zip_code     = p.zip_code     ?? "";
+  formEndereco.address      = p.address      ?? "";
+  formEndereco.neighborhood = p.neighborhood ?? "";
+  formEndereco.state_id     = p.state_id     ?? null;
+  formEndereco.city_id      = p.city_id      ?? null;
+
+  selectedState.value = p.state
+    ? { label: p.state.name, value: p.state.id, code: p.state.code, country_id: p.state.country_id }
+    : null;
+  selectedCity.value = p.city
+    ? { label: p.city.name, value: p.city.id, state_id: p.city.state_id }
+    : null;
+  // setUdateFormAsOriginal();
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const p = await getPartnerAgreementEndereco(props.partnerId);
+    populate(p);
+    loaded.value = true;
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onCepChange = async (val) => {
+  const digits = (val ?? "").replace(/\D/g, "");
+  if (digits.length !== 8) return;
+  loadingCep.value = true;
+  try {
+    const result = await getCepData(digits);
+
+    if (result.address)      formEndereco.address      = result.address;
+    if (result.neighborhood) formEndereco.neighborhood = result.neighborhood;
+
+    if (result.city) {
+      selectedCity.value = { label: result.city.name, value: result.city.id, state_id: result.state?.id };
+      formEndereco.city_id = result.city.id;
+    }
+
+    if (result.state) {
+      selectedState.value = { label: result.state.name, value: result.state.id };
+      formEndereco.state_id = result.state.id;
+    }
+  } catch {
+    console.log('error fetching cep')
+  } finally {
+    loadingCep.value = false;
+  }
+};
+
+const saveEndereco = async () => {
+  await execEndereco(async () => {
+    const saved = await updatePartnerAgreement(props.partnerId, updatedEndereco.value);
+    await populate(saved);
+    emit("partner-saved", saved);
+    $q.notify({ type: "positive", message: t("http.success") });
+  });
+};
+
+watch(() => props.active, (isActive) => {
+  if (isActive && !loaded.value) loadData();
+}, { immediate: true });
+
+watch(selectedState, (val) => { formEndereco.state_id = val?.value ?? null; });
+
+watch(selectedCity,  (val) => { formEndereco.city_id  = val?.value ?? null; });
+</script>
+
+<style lang="scss" scoped>
+.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;
+}
+</style>

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

@@ -0,0 +1,133 @@
+<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>

+ 1 - 1
src/pages/version/data/versions.js

@@ -1,7 +1,7 @@
 export default [
   {
     version: "0.0.1",
-    date: "09/02/2026",
+    date: "14/04/2026",
     changes: [
       {
         type: "Funcionalidades",

+ 42 - 0
src/router/routes/loja-admin.route.js

@@ -0,0 +1,42 @@
+export default [
+  {
+    path: "/loja",
+    name: "LojaPage",
+    component: () => import("pages/loja/LojaPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.loja", translate: true },
+      description: { value: "page.loja.description", translate: true },
+      requireAuth: true,
+      requiredPermission: "loja",
+      breadcrumbs: [{ name: "LojaPage", title: "ui.navigation.loja", translate: true }],
+    },
+  },
+  {
+    path: "/loja/produto/novo",
+    name: "StoreItemNewPage",
+    component: () => import("pages/loja/StoreItemFormPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.loja", translate: true },
+      requireAuth: true,
+      requiredPermission: "loja",
+      breadcrumbs: [
+        { name: "LojaPage", title: "ui.navigation.loja", translate: true },
+        { name: "StoreItemNewPage", title: "common.actions.new", translate: true },
+      ],
+    },
+  },
+  {
+    path: "/loja/produto/:id",
+    name: "StoreItemEditPage",
+    component: () => import("pages/loja/StoreItemFormPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.loja", translate: true },
+      requireAuth: true,
+      requiredPermission: "loja",
+      breadcrumbs: [
+        { name: "LojaPage", title: "ui.navigation.loja", translate: true },
+        { name: "StoreItemEditPage", title: "common.actions.edit", translate: true },
+      ],
+    },
+  },
+];