Просмотр исходного кода

Merge branch 'feat/GINC-GAB-cadastro-unidades' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development

Gabriel Alves 2 недель назад
Родитель
Сommit
9573ae2da6

+ 36 - 4
README.md

@@ -3,6 +3,7 @@
 A skeleton for future projects
 
 ## Install the dependencies
+
 ```bash
 yarn
 # or
@@ -10,32 +11,63 @@ npm install
 ```
 
 ### Start the app in development mode (hot-code reloading, error reporting, etc.)
+
 ```bash
 quasar dev
 ```
 
-
 ### Lint the files
+
 ```bash
 yarn lint
 # or
 npm run lint
 ```
 
-
 ### Format the files
+
 ```bash
 yarn format
 # or
 npm run format
 ```
 
-
-
 ### Build the app for production
+
 ```bash
 quasar build
 ```
 
 ### Customize the configuration
+
 See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
+
+## PR Reference:
+
+```md
+## O que foi feito Descreva resumidamente o que mudou e por quê.
+
+## Tipo de mudança
+
+- [ ] Nova feature
+- [ ] Correção de bug
+- [ ] Refatoração
+- [ ] Documentação
+- [ ] Outro:
+
+## Como testar
+
+1. Acesse ...
+2. Faça ...
+3. Verifique que ...
+
+## Checklist antes do merge
+
+- [ ] Testei localmente
+- [ ] Não quebrei nenhum fluxo existente
+- [ ] Adicionei/atualizei testes se necessário
+- [ ] Variáveis de ambiente documentadas
+- [ ] Sem console.log ou código de debug
+
+## Ticket relacionado Refs:
+```

+ 6 - 0
src/api/franchisee.js

@@ -0,0 +1,6 @@
+import api from "src/api";
+
+export const getFranchisee = async () => {
+  const { data } = await api.get("/franchisee");
+  return data.payload;
+};

+ 0 - 2
src/boot/defaultPropsComponents.js

@@ -20,12 +20,10 @@ export default defineBoot(() => {
     transitionHide: "slide-down",
   });
   SetComponentDefaults(QInput, {
-    rounded: true,
     standout: true,
     dense: true,
   });
   SetComponentDefaults(QSelect, {
-    rounded: true,
     standout: true,
     dense: true,
   });

+ 41 - 24
src/components/defaults/DefaultInput.vue

@@ -5,11 +5,15 @@
         ref="inputRef"
         v-model="model"
         v-bind="inputAttrs"
+        hide-bottom-space
+        label-color="secondary"
+        color="secondary"
         :label
         :error="!!error"
         :error-message="errorMessage"
         :rules
-        hide-bottom-space
+        :outlined
+        :bg-color
         :class="inputClass"
         :input-class="nativeInputClass"
         @update:model-value="error = null"
@@ -40,28 +44,37 @@ defineOptions({
   inheritAttrs: false,
 });
 
-const { label, nativeInputClass, inputClass, rules, icon } = defineProps({
-  label: {
-    type: String,
-    default: "",
-  },
-  icon: {
-    type: String,
-    default: "",
-  },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
-  nativeInputClass: {
-    type: String,
-    default: null,
-  },
-  inputClass: {
-    type: String,
-    default: null,
-  },
-});
+const { label, nativeInputClass, inputClass, rules, icon, bgColor, outlined } =
+  defineProps({
+    label: {
+      type: String,
+      default: "",
+    },
+    icon: {
+      type: String,
+      default: "",
+    },
+    rules: {
+      type: Array,
+      default: () => [],
+    },
+    nativeInputClass: {
+      type: String,
+      default: null,
+    },
+    inputClass: {
+      type: String,
+      default: null,
+    },
+    bgColor: {
+      type: String,
+      default: "white",
+    },
+    outlined: {
+      type: Boolean,
+      default: false,
+    },
+  });
 
 const attrs = useAttrs();
 
@@ -110,4 +123,8 @@ defineExpose({
 });
 </script>
 
-<style scoped></style>
+<style scoped lang="scss">
+:deep(.q-field--outlined.q-field--rounded .q-field__control) {
+  border-radius: 8px;
+}
+</style>

+ 30 - 7
src/components/defaults/DefaultSelect.vue

@@ -1,27 +1,30 @@
 <template>
   <div class="column" :class="attrs.class" :style="attrs.style">
-    <div v-if="label || $slots.label" class="q-pl-xs">
-      <slot name="label">
-        <span>{{ label }}</span>
-      </slot>
-      <span v-if="required" class="text-negative q-ml-xs">*</span>
-    </div>
     <div class="col">
       <q-select
         ref="selectRef"
         v-model="model"
+        :label
+        label-color="secondary"
         v-bind="selectAttrs"
         :error="!!error"
         :error-message="errorMessage"
         :rules
+        :outlined
         hide-bottom-space
+        :bg-color
         :class="inputClass"
         :popup-content-class="popupContentClass"
+        hide-dropdown-icon
         @update:model-value="error = null"
       >
         <template v-for="(_, slotName) in $slots" #[slotName]="scope">
           <slot :name="slotName" v-bind="scope" />
         </template>
+
+        <template #append>
+          <q-icon :name="dropdownIcon" color="secondary" />
+        </template>
       </q-select>
     </div>
   </div>
@@ -34,7 +37,15 @@ defineOptions({
   inheritAttrs: false,
 });
 
-const { label, inputClass, popupContentClass, rules } = defineProps({
+const {
+  label,
+  inputClass,
+  popupContentClass,
+  rules,
+  bgColor,
+  outlined,
+  dropdownIcon,
+} = defineProps({
   label: {
     type: String,
     default: "",
@@ -51,6 +62,18 @@ const { label, inputClass, popupContentClass, rules } = defineProps({
     type: String,
     default: null,
   },
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+  outlined: {
+    type: Boolean,
+    default: false,
+  },
+  dropdownIcon: {
+    type: String,
+    default: "mdi-chevron-down",
+  },
 });
 
 const attrs = useAttrs();

+ 33 - 15
src/components/defaults/DefaultTable.vue

@@ -23,21 +23,41 @@
         <div v-if="title" class="column text-h6">
           <span>{{ title }}</span>
           <span class="text-body2">{{
-            rows.length + " " + $t("common.ui.table.records_found")
+            `${rows.length} ${descricao} ${feminino ? "cadastradas" : "cadastrados"}`
           }}</span>
         </div>
+
+        <slot name="top" :rows="rows" />
+
+        <q-space />
+
+        <q-btn
+          v-if="addItem"
+          color="primary"
+          style="width: 40px; height: 40px"
+          icon="mdi-plus"
+          :outline="outlineAdd"
+          @click="onAddItem"
+        >
+        </q-btn>
+      </div>
+
+      <div class="flex full-width align-center q-mb-md" style="gap: 1rem">
         <DefaultInput
           v-if="showSearchField"
           v-model="filter"
           debounce="250"
+          label="Busque por Unidade, status ou Responsavel"
           :placeholder="$t('common.actions.search')"
           clearable
-          autofocus
-          class="q-mt-sm q-ml-sm"
+          class="q-mt-sm full-width search-input"
           color="primary"
+          bg-color="transparent"
+          dense
+          input-class="q-pl-xs"
         >
-          <template #append>
-            <q-icon name="mdi-magnify" />
+          <template #prepend>
+            <q-icon name="mdi-magnify" color="grey-6" />
           </template>
         </DefaultInput>
         <DefaultSelect
@@ -52,16 +72,6 @@
           style="width: 150px"
           options-selected-class="text-bold"
         />
-        <slot name="top" :rows="rows" />
-        <q-btn
-          v-if="addItem"
-          color="primary"
-          padding="10px 16px"
-          :outline="outlineAdd"
-          :label="$t('common.actions.add')"
-          @click="onAddItem"
-        >
-        </q-btn>
       </div>
     </template>
 
@@ -173,6 +183,14 @@ const {
     type: Function,
     default: null,
   },
+  descricao: {
+    type: String,
+    default: "linhas",
+  },
+  feminino: {
+    type: Boolean,
+    default: true,
+  },
   outlineAdd: {
     type: Boolean,
     default: false,

+ 93 - 25
src/components/layout/DefaultHeaderPage.vue

@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="q-pa-sm">
     <q-breadcrumbs
       v-if="displayBreadcrumbs != null"
       class="q-mb-xs text-secondary flex items-center"
@@ -26,11 +26,8 @@
         class="flex items-center q-pl-xs"
         :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
       >
-        <span
-          v-if="displayTitle"
-          class="text-h6 text-primary text-weight-regular"
-        >
-          {{ displayTitle }}
+        <span v-if="title" class="text-h6 text-primary text-weight-regular">
+          {{ title }}
         </span>
         <div v-else style="width: 280px">
           <q-skeleton type="text" height="40px" />
@@ -48,6 +45,66 @@
         class="flex items-center q-pr-sm"
         :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
       >
+        <div class="flex items-center no-wrap" style="gap: 12px">
+          <q-select
+            v-if="$q.screen.gt.xs"
+            v-model="selectedUnit"
+            dense
+            :options="[]"
+            label="Unidade"
+            style="width: 250px; flex-shrink: 0"
+            color="secondary"
+            hide-dropdown-icon
+          >
+            <template #append>
+              <q-icon name="mdi-map-marker-outline" color="secondary" />
+            </template>
+          </q-select>
+
+          <div
+            class="flex items-center no-wrap q-gutter-x-md q-px-sm q-ml-md"
+            style="flex-shrink: 0"
+          >
+            <q-img src="icons/user-icon.jpg" class="avatar-circle" />
+
+            <div
+              v-if="$q.screen.gt.xs"
+              class="column q-gutter-y-none"
+              style="white-space: nowrap"
+            >
+              <span class="text-body2 text-center">{{ user?.name }}</span>
+            </div>
+          </div>
+
+          <template v-if="$q.screen.gt.sm && lastLoginFormatted">
+            <q-separator
+              vertical
+              style="height: 36px; width: 2px; flex-shrink: 0"
+              color="dark"
+            />
+
+            <div
+              class="column"
+              style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
+            >
+              <span class="text-caption text-grey-6 text-primary text-center"
+                >Ultimo acesso</span
+              >
+              <span class="text-caption text-primary text-center">{{
+                lastLoginFormatted
+              }}</span>
+            </div>
+          </template>
+
+          <div
+            class="flex items-center no-wrap"
+            style="gap: 2px; flex-shrink: 0"
+          >
+            <q-btn flat round dense icon="mdi-bell-badge" color="secondary" />
+            <q-btn flat round dense icon="mdi-account" color="secondary" />
+            <q-btn flat round dense icon="mdi-cog-outline" color="secondary" />
+          </div>
+        </div>
         <slot name="after" />
       </div>
     </div>
@@ -56,13 +113,14 @@
 </template>
 
 <script setup>
-import { computed } from "vue";
+import { computed, ref } from "vue";
 import { useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
 
 const { title, breadcrumbs } = defineProps({
   title: {
-    type: Object,
+    type: String,
     default: null,
   },
   breadcrumbs: {
@@ -75,26 +133,27 @@ const { title, breadcrumbs } = defineProps({
   },
 });
 
-const route = useRoute();
-const { t } = useI18n();
+const store = userStore();
+const user = computed(() => store.user);
+const selectedUnit = ref(null);
 
-const displayTitle = computed(() => {
-  if (title) {
-    if (title.translate) {
-      return t(title.value);
-    } else {
-      return title.value;
-    }
-  } else if (route.meta?.title) {
-    if (route.meta?.title.translate) {
-      return t(route.meta?.title.value);
-    } else {
-      return route.meta?.title.value;
-    }
-  }
-  return null;
+const lastLoginFormatted = computed(() => {
+  const raw = store.user?.last_login_at;
+  if (!raw) return null;
+  const d = new Date(raw.replace(" ", "T") + "Z");
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+    timeZone: "America/Sao_Paulo",
+  }).format(d);
 });
 
+const route = useRoute();
+const { t } = useI18n();
+
 const displayBreadcrumbs = computed(() => {
   if (!breadcrumbs && breadcrumbs?.length <= 0) {
     return null;
@@ -127,3 +186,12 @@ const displayBreadcrumbs = computed(() => {
   return null;
 });
 </script>
+
+<style scoped>
+.avatar-circle {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+</style>

+ 124 - 0
src/components/shared/AvatarImageComponent.vue

@@ -0,0 +1,124 @@
+<template>
+  <div
+    class="avatar-wrapper relative-position"
+    :class="{ 'drag-over': isDragOver }"
+    @dragover.prevent="isDragOver = true"
+    @dragleave="isDragOver = false"
+    @drop.prevent="onDrop"
+  >
+    <div
+      v-if="!imageUrl"
+      class="full-width full-height flex flex-center column gap-xs no-image-state"
+      @click="openChangeDialog"
+    >
+      <q-icon name="add_photo_alternate" size="32px" color="grey-5" />
+      <span class="text-grey-6" style="font-size: 12px">adicione uma imagem</span>
+    </div>
+
+    <template v-else>
+      <img :src="imageUrl" class="avatar-image" />
+
+      <div class="actions-overlay absolute row no-wrap" style="top: 6px; right: 6px; gap: 4px">
+        <q-btn
+          round
+          unelevated
+          size="xs"
+          icon="edit"
+          color="grey-8"
+          text-color="white"
+          @click.stop="openChangeDialog"
+        >
+          <q-tooltip>Trocar imagem</q-tooltip>
+        </q-btn>
+        <q-btn
+          round
+          unelevated
+          size="xs"
+          icon="delete"
+          color="negative"
+          text-color="white"
+          @click.stop="removeImage"
+        >
+          <q-tooltip>Remover imagem</q-tooltip>
+        </q-btn>
+      </div>
+    </template>
+
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useQuasar } from "quasar";
+import ChangeImageDialog from "src/components/shared/ChangeImageDialog.vue";
+
+const emit = defineEmits(["update:file"]);
+
+const $q = useQuasar();
+const imageUrl = ref(null);
+const isDragOver = ref(false);
+
+function openChangeDialog() {
+  $q.dialog({ component: ChangeImageDialog }).onOk(({ file, previewUrl }) => {
+    imageUrl.value = previewUrl;
+    emit("update:file", file);
+  });
+}
+
+function removeImage() {
+  imageUrl.value = null;
+  emit("update:file", null);
+}
+
+function onDrop(event) {
+  isDragOver.value = false;
+  const file = event.dataTransfer.files[0];
+  if (file && file.type.startsWith("image/")) {
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      imageUrl.value = e.target.result;
+    };
+    reader.readAsDataURL(file);
+    emit("update:file", file);
+  }
+}
+</script>
+
+<style scoped>
+.avatar-wrapper {
+  width: 174px;
+  height: 149px;
+  border-radius: 8px;
+  border: 2px dashed #ccc;
+  overflow: hidden;
+  background-color: #f5f5f5;
+  cursor: pointer;
+  transition: border-color 0.2s, background-color 0.2s;
+}
+
+.avatar-wrapper:hover,
+.avatar-wrapper.drag-over {
+  border-color: #ff8340;
+  background-color: #fff5ef;
+}
+
+.no-image-state {
+  height: 100%;
+}
+
+.avatar-image {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.actions-overlay {
+  opacity: 0;
+  transition: opacity 0.2s;
+}
+
+.avatar-wrapper:hover .actions-overlay {
+  opacity: 1;
+}
+</style>

+ 81 - 0
src/components/shared/ChangeImageDialog.vue

@@ -0,0 +1,81 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="min-width: 400px">
+      <DefaultDialogHeader :title="() => 'Trocar Imagem'" @close="onDialogCancel" />
+
+      <q-card-section class="q-pt-none q-pb-sm">
+        <div class="text-caption text-grey-6 q-mb-xs">Personalizar</div>
+        <q-file
+          v-model="selectedFile"
+          accept="image/*"
+          outlined
+          dense
+          placeholder="Buscar no Desktop"
+          @update:model-value="onFileSelected"
+        >
+          <template #append>
+            <q-icon name="search" />
+          </template>
+        </q-file>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none">
+        <div class="text-caption text-grey-6 q-mb-xs">Pré - Visualização</div>
+        <div class="preview-area flex flex-center">
+          <img v-if="previewUrl" :src="previewUrl" class="preview-image" />
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right">
+        <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
+        <q-btn color="primary" label="Salvar" :disable="!selectedFile" @click="onSave" />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const selectedFile = ref(null);
+const previewUrl = ref(null);
+
+function onFileSelected(file) {
+  if (!file) {
+    previewUrl.value = null;
+    return;
+  }
+  const reader = new FileReader();
+  reader.onload = (e) => {
+    previewUrl.value = e.target.result;
+  };
+  reader.readAsDataURL(file);
+}
+
+function onSave() {
+  onDialogOK({ file: selectedFile.value, previewUrl: previewUrl.value });
+}
+</script>
+
+<style scoped>
+.preview-area {
+  width: 100%;
+  height: 200px;
+  border: 1px solid #e0e0e0;
+  border-radius: 4px;
+  background-color: #fafafa;
+  overflow: hidden;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+</style>

+ 52 - 0
src/components/shared/CustomTabComponent.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="row no-wrap bg-secondary-3" style="border-radius: 8px; gap: 4px">
+    <div
+      v-for="tab in tabs"
+      :key="tab.name"
+      class="col flex items-center justify-center cursor-pointer tab-item"
+      :class="activeTab === tab.name ? 'tab-active' : 'tab-inactive'"
+      style="height: 36px; border-radius: 6px"
+      @click="emit('update:activeTab', tab.name)"
+    >
+      <span class="text-center text-weight-medium" style="font-size: 13px">
+        {{ tab.label }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  tabs: {
+    type: Array,
+    required: true,
+  },
+  activeTab: {
+    type: String,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["update:activeTab"]);
+</script>
+
+<style scoped>
+.tab-item {
+  transition:
+    background-color 0.2s,
+    color 0.2s;
+}
+
+.tab-active {
+  background-color: #ff8340;
+}
+
+.tab-inactive {
+  background-color: transparent;
+  color: #555;
+}
+
+.tab-inactive:hover {
+  background-color: rgba(0, 0, 0, 0.06);
+}
+</style>

+ 84 - 0
src/components/shared/PartnerCardComponent.vue

@@ -0,0 +1,84 @@
+<template>
+  <div class="partner-card column items-center q-pa-md">
+    <div class="partner-avatar flex flex-center q-mb-md">
+      <img
+        v-if="partner.avatarUrl"
+        :src="partner.avatarUrl"
+        class="avatar-img"
+      />
+      <span v-else class="avatar-initials text-white text-weight-bold">
+        {{ initials }}
+      </span>
+    </div>
+
+    <div class="full-width column q-gutter-sm">
+      <DefaultInput
+        :model-value="partner.social_name"
+        label="Nome social"
+        outlined
+        disable
+        bg-color="suface"
+      />
+
+      <DefaultInput
+        :model-value="partner.role"
+        label="Função"
+        outlined
+        disable
+        bg-color="suface"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import DefaultInput from "../defaults/DefaultInput.vue";
+
+const { partner } = defineProps({
+  partner: {
+    type: Object,
+    default: () => ({
+      social_name: null,
+      role: null,
+      avatarUrl: null,
+      color: "#ff8340",
+    }),
+  },
+});
+
+const initials = computed(() => {
+  if (!partner.social_name) return "";
+  return partner.social_name
+    .split(" ")
+    .slice(0, 2)
+    .map((w) => w[0]?.toUpperCase())
+    .join("");
+});
+</script>
+
+<style scoped>
+.partner-card {
+  border: 1px solid #e0e0e0;
+  border-radius: 10px;
+}
+
+.partner-avatar {
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
+  background-color: v-bind("partner.color || '#ff8340'");
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-initials {
+  font-size: 28px;
+}
+</style>

+ 6 - 4
src/css/app.scss

@@ -104,14 +104,12 @@ input[type="number"]::-webkit-outer-spin-button {
   }
 }
 
-.q-field__control {
-  background: #fff !important;
-}
+
 
 
 .q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
-  box-shadow: 0 0 0 1px #c0c0c0c0;
+  
 }
 
 .q-btn--rectangle {
@@ -132,3 +130,7 @@ input[type="number"]::-webkit-outer-spin-button {
   color: $secondary;
 }
 
+.q-field--standout.q-field--highlighted .q-field__control {
+  box-shadow: none;
+  border-radius: 8px;
+}

+ 2 - 0
src/css/quasar.variables.scss

@@ -59,6 +59,8 @@ $colors: (
   "foreground": #505050,
 
   "btn-badge": #554EF4,
+
+  "transparent": transparent
 );
 
 @each $name, $color in $colors {

+ 3 - 1
src/css/table.scss

@@ -1,7 +1,7 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
 .softpar-table {
-  --table-bg-color: #{map.get($colors, "surface")};
+
   --table-border-color: #{map.get($colors, "surface-light")};
   --table-header-color: #{map.get($colors, "text")};
   --table-ring-color: #c0c0c0c0;
@@ -11,6 +11,8 @@
   border-radius: 8px !important;
   box-shadow: 0 0 0 1px var(--table-ring-color);
 
+  background-color: transparent;
+
   :deep(.q-table) {
     thead tr:first-child th {
       background-color: $primary !important;

+ 3 - 92
src/pages/dashboard/DashboardPage.vue

@@ -1,69 +1,6 @@
 <template>
   <div>
-    <DefaultHeaderPage show-filter-icon class="q-pa-sm">
-      <template #after>
-        <div class="flex items-center no-wrap" style="gap: 12px">
-          <q-select
-            v-if="$q.screen.gt.xs"
-            v-model="selectedUnit"
-            dense
-            :options="[]"
-            label="Unidade"
-            style="width: 250px; flex-shrink: 0"
-            color="secondary"
-            hide-dropdown-icon
-          >
-            <template #append>
-              <q-icon name="mdi-map-marker-outline" color="secondary" />
-            </template>
-          </q-select>
-
-          <div
-            class="flex items-center no-wrap q-gutter-x-md q-px-sm q-ml-md"
-            style="flex-shrink: 0"
-          >
-            <q-img src="icons/user-icon.jpg" class="avatar-circle" />
-
-            <div
-              v-if="$q.screen.gt.xs"
-              class="column q-gutter-y-none"
-              style="white-space: nowrap"
-            >
-              <span class="text-body2 text-center">{{ user?.name }}</span>
-            </div>
-          </div>
-
-          <template v-if="$q.screen.gt.sm && lastLoginFormatted">
-            <q-separator
-              vertical
-              style="height: 36px; width: 2px; flex-shrink: 0"
-              color="dark"
-            />
-
-            <div
-              class="column"
-              style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
-            >
-              <span class="text-caption text-grey-6 text-primary text-center"
-                >Ultimo acesso</span
-              >
-              <span class="text-caption text-primary text-center">{{
-                lastLoginFormatted
-              }}</span>
-            </div>
-          </template>
-
-          <div
-            class="flex items-center no-wrap"
-            style="gap: 2px; flex-shrink: 0"
-          >
-            <q-btn flat round dense icon="mdi-bell-badge" color="secondary" />
-            <q-btn flat round dense icon="mdi-account" color="secondary" />
-            <q-btn flat round dense icon="mdi-cog-outline" color="secondary" />
-          </div>
-        </div>
-      </template>
-    </DefaultHeaderPage>
+    <DefaultHeaderPage show-filter-icon title="Dashboard" />
 
     <div class="q-pa-sm">
       <div class="filter-row">
@@ -253,15 +190,13 @@
     <div v-else class="flex flex-center full-width q-pa-xl">
       <q-spinner color="primary" size="50px" />
     </div>
-
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, ref, watch } from "vue";
+import { onMounted, ref, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
-import { userStore } from "src/stores/user";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
@@ -277,23 +212,7 @@ import TicketsAbertoDialog from "src/pages/dashboard/components/TicketsAbertoDia
 const { t } = useI18n();
 
 const $q = useQuasar();
-const store = userStore();
-
-const user = computed(() => store.user);
-
-const lastLoginFormatted = computed(() => {
-  const raw = store.user?.last_login_at;
-  if (!raw) return null;
-  const d = new Date(raw.replace(" ", "T") + "Z");
-  return new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-    year: "numeric",
-    hour: "2-digit",
-    minute: "2-digit",
-    timeZone: "America/Sao_Paulo",
-  }).format(d);
-});
+
 const isLoading = ref(true);
 
 const openAlunosDialog = () => {
@@ -319,7 +238,6 @@ const openEstoqueProdutosDialog = () => {
 const openTicketsAbertoDialog = () => {
   $q.dialog({ component: TicketsAbertoDialog });
 };
-const selectedUnit = ref(null);
 const defaultPeriod = ref("month");
 const defaultEventId = ref(1);
 
@@ -560,13 +478,6 @@ onMounted(async () => {
   gap: 16px;
 }
 
-.avatar-circle {
-  width: 36px;
-  height: 36px;
-  border-radius: 50%;
-  flex-shrink: 0;
-}
-
 .stat-cards-row {
   display: flex;
   flex-wrap: nowrap;

+ 94 - 0
src/pages/franchisee/FranchiseePage.vue

@@ -0,0 +1,94 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Franqueados" show-filter-icon />
+
+    <div class="row q-col-gutter-x-md q-pa-sm">
+      <q-select
+        v-model="unitSelected"
+        label="Selecione a Unidade"
+        class="col-3"
+        color="secondary"
+        emit-value
+        map-options
+        :options="unitOptions"
+      />
+
+      <q-select
+        v-model="statusSelected"
+        color="secondary"
+        label="Selecione o status"
+        class="col-3"
+        emit-value
+        map-options
+        :options="statusOptions"
+      />
+    </div>
+
+    <div class="q-px-sm">
+      <DefaultTable
+        title="Lista de Unidades"
+        :columns
+        :rows
+        add-item
+        :api-call="getFranchisee"
+        add-item-route="UnitAddPage"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { getFranchisee } from "src/api/franchisee";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import { ref } from "vue";
+
+const statusSelected = ref(null);
+const unitSelected = ref(null);
+
+const rows = ref([]);
+const columns = ref([
+  {
+    name: "responsible",
+    label: "Responsável",
+    field: "responsible",
+    align: "left",
+  },
+
+  {
+    name: "unit_name",
+    label: "Unidade",
+    field: "unit_name",
+    align: "left",
+  },
+
+  {
+    name: "created_at",
+    label: "Desde",
+    field: "created_at",
+    align: "left",
+  },
+
+  {
+    name: "status",
+    label: "Status",
+    field: "status",
+    align: "center",
+  },
+
+  {
+    name: "actions",
+    label: "Ações",
+    field: null,
+    align: "center",
+  },
+]);
+
+const statusOptions = ref([
+  { label: "Todos", value: null },
+  { label: "Ativo", value: "active" },
+  { label: "Inativo", value: "inactive" },
+]);
+
+const unitOptions = ref([{ label: "Todas", value: null }]);
+</script>

+ 37 - 0
src/pages/unit/UnitActionPage.vue

@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Cadastro de Unidade" />
+
+    <CustomTabComponent v-model:active-tab="activeTab" :tabs />
+
+    <UnitDataTab v-if="activeTab === 'unit_data'" />
+    <PartnersTab v-if="activeTab === 'partners'" />
+    <ContractsTab v-if="activeTab === 'contracts'" />
+    <FinancialTab v-if="activeTab === 'financial'" />
+    <HistoryTab v-if="activeTab === 'history'" />
+    <MediasTab v-if="activeTab === 'medias'" />
+  </div>
+</template>
+
+<script setup>
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import UnitDataTab from "src/pages/unit/tabs/UnitDataTab.vue";
+import PartnersTab from "src/pages/unit/tabs/PartnersTab.vue";
+import ContractsTab from "src/pages/unit/tabs/ContractsTab.vue";
+import FinancialTab from "src/pages/unit/tabs/FinancialTab.vue";
+import HistoryTab from "src/pages/unit/tabs/HistoryTab.vue";
+import MediasTab from "src/pages/unit/tabs/MediasTab.vue";
+import { ref } from "vue";
+
+const activeTab = ref("unit_data");
+
+const tabs = ref([
+  { name: "unit_data", label: "Dados da Unidade" },
+  { name: "partners", label: "Sócios" },
+  { name: "contracts", label: "Contratos" },
+  { name: "financial", label: "Financeiro" },
+  { name: "history", label: "Histórico" },
+  { name: "medias", label: "Mídias" },
+]);
+</script>

+ 192 - 0
src/pages/unit/tabs/ContractsTab.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row q-col-gutter-md">
+      <!-- Coluna esquerda: tabela + aditivos -->
+      <div class="col-12 col-md-5">
+        <q-table
+          :rows="contracts"
+          :columns="columns"
+          row-key="id"
+          flat
+          hide-bottom
+          class="bg-transparent"
+          :row-class="
+            (_, index) =>
+              index === selectedIndex
+                ? 'contract-item-active cursor-pointer'
+                : 'cursor-pointer'
+          "
+          @row-click="(_, __, index) => (selectedIndex = index)"
+        >
+          <template #body-cell-period="{ row }">
+            <q-td align="left">
+              {{ row.startDate }} - {{ row.endDate }}
+            </q-td>
+          </template>
+
+          <template #body-cell-status="{ row }">
+            <q-td align="center">
+              <q-badge
+                :color="row.status === 'Ativo' ? 'teal' : 'negative'"
+                :label="row.status"
+                style="border-radius: 12px; padding: 4px 10px; font-size: 12px"
+              />
+            </q-td>
+          </template>
+
+          <template #body-cell-actions>
+            <q-td align="center">
+              <div class="flex items-center justify-center q-gutter-x-xs">
+                <q-icon
+                  name="mdi-file-document-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="border: 1px solid #c9c9c9; border-radius: 8px; padding: 4px"
+                  @click.stop="console.log('view')"
+                />
+                <q-icon
+                  name="mdi-trash-can-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="border: 1px solid #c9c9c9; border-radius: 8px; padding: 4px"
+                  @click.stop="console.log('trash')"
+                />
+              </div>
+            </q-td>
+          </template>
+        </q-table>
+
+        <!-- Aditivos de Contrato -->
+        <div class="q-mt-md">
+          <p class="text-weight-medium q-mb-sm">Aditivos de Contrato</p>
+          <q-list separator>
+            <q-item
+              v-for="(additive, i) in additives"
+              :key="i"
+              clickable
+              dense
+              class="q-py-sm"
+            >
+              <q-item-section avatar>
+                <q-avatar
+                  size="24px"
+                  color="primary-2"
+                  text-color="white"
+                  style="font-size: 12px"
+                >
+                  {{ i + 1 }}
+                </q-avatar>
+              </q-item-section>
+              <q-item-section>{{ additive.title }}</q-item-section>
+              <q-item-section side>
+                <q-icon
+                  name="mdi-file-document-outline"
+                  size="sm"
+                  color="grey-6"
+                  class="cursor-pointer"
+                  @click.stop="console.log('additive', i)"
+                />
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </div>
+      </div>
+
+      <!-- Coluna direita: pré-visualização -->
+      <div class="col-12 col-md-7">
+        <div class="row justify-end q-mb-sm">
+          <q-btn
+            icon="add"
+            color="primary-2"
+            style="height: 40px; width: 40px; border-radius: 8px"
+          />
+        </div>
+        <div class="preview-box q-pa-md">
+          <span v-if="selectedIndex === null" class="text-grey-5">
+            Pré - Visualização
+          </span>
+          <div v-else>
+            <p class="text-weight-medium q-mb-xs">
+              Contrato {{ contracts[selectedIndex].id }}
+            </p>
+            <p class="text-grey-7 q-mb-sm">
+              {{ contracts[selectedIndex].startDate }} até
+              {{ contracts[selectedIndex].endDate }}
+            </p>
+            <q-badge
+              :color="contracts[selectedIndex].status === 'Ativo' ? 'teal' : 'negative'"
+              :label="contracts[selectedIndex].status"
+              style="border-radius: 12px; padding: 4px 10px; font-size: 12px"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+
+const selectedIndex = ref(null);
+
+const columns = [
+  { name: "id", label: "Contrato", field: "id", align: "left" },
+  {
+    name: "period",
+    label: "Data Inicial - Final",
+    field: "startDate",
+    align: "left",
+  },
+  {
+    name: "status",
+    label: "Status Contrato",
+    field: "status",
+    align: "center",
+    style: "width: 130px",
+  },
+  {
+    name: "actions",
+    label: "Ações",
+    field: "actions",
+    align: "center",
+    style: "width: 90px",
+  },
+];
+
+const contracts = ref([
+  {
+    id: "5789128",
+    startDate: "17/02/2025",
+    endDate: "17/02/2026",
+    status: "Ativo",
+  },
+  {
+    id: "5789128",
+    startDate: "17/02/2025",
+    endDate: "17/02/2026",
+    status: "Inativo",
+  },
+]);
+
+const additives = ref([
+  { title: "Aditivo 1" },
+  { title: "Aditivo 2" },
+  { title: "Aditivo  3" },
+  { title: "Aditivo 4" },
+]);
+</script>
+
+<style scoped>
+.preview-box {
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  min-height: 400px;
+}
+
+.contract-item-active {
+  background-color: rgba(255, 131, 64, 0.08);
+}
+</style>

+ 225 - 0
src/pages/unit/tabs/FinancialTab.vue

@@ -0,0 +1,225 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row full-width q-col-gutter-md">
+      <!-- Coluna esquerda -->
+      <div class="col-12 col-md-6 column q-gutter-md">
+        <!-- Dados Bancários -->
+        <div class="row q-col-gutter-sm">
+          <div class="col-12">
+            <span class="text-subtitle1 text-weight-medium"
+              >Dados Bancários</span
+            >
+          </div>
+
+          <DefaultSelect
+            v-model="form.tax_regime"
+            label="Regime Tributário"
+            :options="taxRegimeOptions"
+            class="col-12"
+            outlined
+            emit-value
+            map-options
+          />
+
+          <DefaultInput
+            v-model="form.bank"
+            label="Banco"
+            class="col-12"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.agency"
+            label="Agência"
+            class="col-12"
+            outlined
+          />
+          <DefaultInput
+            v-model="form.account"
+            label="Conta"
+            class="col-12"
+            outlined
+          />
+
+          <DefaultSelect
+            v-model="form.account_type"
+            label="Tipo de Conta"
+            :options="accountTypeOptions"
+            class="col-12"
+            outlined
+            emit-value
+            map-options
+          />
+
+          <DefaultInput
+            v-model="form.account_holder"
+            label="Titular da Conta"
+            class="col-12"
+            outlined
+          />
+          <DefaultInput
+            v-model="form.pix_key"
+            label="Chave Pix"
+            class="col-12"
+            outlined
+          />
+        </div>
+
+        <!-- Dados para Faturamento -->
+        <div class="row q-col-gutter-sm">
+          <div class="col-12">
+            <span class="text-subtitle1 text-weight-medium"
+              >Dados para Faturamento</span
+            >
+          </div>
+
+          <DefaultSelect
+            v-model="form.billing_method"
+            label="Forma de Cobrança"
+            :options="billingMethodOptions"
+            class="col-12"
+            outlined
+            emit-value
+            map-options
+          />
+
+          <DefaultInput
+            v-model="form.due_date"
+            label="Data de Vencimento"
+            class="col-12"
+            outlined
+          />
+          <DefaultInput
+            v-model="form.financial_email"
+            label="E-mail Financeiro"
+            class="col-12"
+            outlined
+          />
+        </div>
+      </div>
+
+      <!-- Coluna direita -->
+      <div class="col-12 col-md-6 column q-gutter-md">
+        <!-- Dados do Contrato -->
+        <div class="row q-col-gutter-sm">
+          <div class="col-12">
+            <span class="text-subtitle1 text-weight-medium"
+              >Dados do Contrato</span
+            >
+          </div>
+
+          <DefaultSelect
+            v-model="form.group"
+            label="Grupo"
+            :options="groupOptions"
+            class="col-12"
+            outlined
+            emit-value
+            map-options
+          />
+
+          <DefaultInput
+            v-model="form.maintenance_fee"
+            label="Taxa de Manutenção"
+            class="col-12"
+            outlined
+          />
+          <DefaultInput
+            v-model="form.marketing_fund"
+            label="Fundo de Marketing"
+            class="col-12"
+            outlined
+          />
+          <DefaultInput
+            v-model="form.tbr"
+            label="TBR"
+            class="col-12"
+            outlined
+          />
+        </div>
+
+        <div class="row q-col-gutter-sm">
+          <div class="col-12">
+            <span class="text-subtitle1 text-weight-medium"
+              >Dados de Contato</span
+            >
+          </div>
+
+          <div
+            v-for="(partner, index) in contactPartners"
+            :key="index"
+            class="col-6"
+          >
+            <PartnerCardComponent :partner />
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="row justify-end q-mt-md items-end full-width q-px-xs">
+      <div class="row q-gutter-sm">
+        <q-btn label="Cancelar" color="primary" outline />
+        <q-btn label="Salvar" color="primary-2" />
+        <q-btn
+          icon="mdi-paperclip-plus"
+          color="primary-2"
+          style="height: 40px; width: 40px"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import PartnerCardComponent from "src/components/shared/PartnerCardComponent.vue";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+const taxRegimeOptions = [
+  { label: "Selecione", value: null },
+  { label: "Simples Nacional", value: "simples_nacional" },
+  { label: "Lucro Presumido", value: "lucro_presumido" },
+  { label: "Lucro Real", value: "lucro_real" },
+  { label: "MEI", value: "mei" },
+];
+
+const accountTypeOptions = [
+  { label: "Selecione", value: null },
+  { label: "Conta Corrente", value: "corrente" },
+  { label: "Conta Poupança", value: "poupanca" },
+];
+
+const billingMethodOptions = [
+  { label: "Selecione", value: null },
+  { label: "Boleto", value: "boleto" },
+  { label: "Débito Automático", value: "debit" },
+  { label: "PIX", value: "pix" },
+  { label: "Cartão de Crédito", value: "credit_card" },
+];
+
+const groupOptions = [{ label: "Selecione", value: null }];
+
+const contactPartners = ref([
+  { social_name: null, role: null, avatarUrl: null, color: "#ff8340" },
+  { social_name: null, role: null, avatarUrl: null, color: "#4caf50" },
+]);
+
+const { form } = useFormUpdateTracker({
+  tax_regime: null,
+  bank: null,
+  agency: null,
+  account: null,
+  account_type: null,
+  account_holder: null,
+  pix_key: null,
+  billing_method: null,
+  due_date: null,
+  financial_email: null,
+  group: null,
+  maintenance_fee: null,
+  marketing_fund: null,
+  tbr: null,
+});
+</script>

+ 151 - 0
src/pages/unit/tabs/HistoryTab.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row q-col-gutter-md">
+      <div class="col-12 col-md-5">
+        <q-table
+          :rows="history"
+          :columns="columns"
+          row-key="title"
+          flat
+          hide-bottom
+          class="bg-transparent"
+          :row-class="
+            (_, index) =>
+              index === selectedIndex
+                ? 'history-item-active cursor-pointer'
+                : 'cursor-pointer'
+          "
+          @row-click="(_, __, index) => (selectedIndex = index)"
+        >
+          <template #body-cell-actions>
+            <q-td align="center">
+              <div class="flex items-center justify-center q-gutter-x-xs">
+                <q-icon
+                  name="mdi-file-edit-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="
+                    border: 1px solid #c9c9c9;
+                    border-radius: 8px;
+                    padding: 4px;
+                  "
+                  @click.stop="console.log('edit')"
+                />
+                <q-icon
+                  name="mdi-trash-can-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="
+                    border: 1px solid #c9c9c9;
+                    border-radius: 8px;
+                    padding: 4px;
+                  "
+                  @click.stop="console.log('trash')"
+                />
+              </div>
+            </q-td>
+          </template>
+
+          <template #body-cell-franchisee>
+            <q-td align="center">
+              <div class="flex items-center justify-center q-gutter-x-xs">
+                <q-icon
+                  name="mdi-check"
+                  size="sm"
+                  color="suface"
+                  class="cursor-pointer bg-approved"
+                  style="border: 1px solid #c9c9c9; border-radius: 8px"
+                  @click.stop="console.log('edit')"
+                />
+                <q-icon
+                  name="mdi-close-circle-outline"
+                  size="sm"
+                  color="declined"
+                  class="cursor-pointer"
+                  style="border: 1px solid red; border-radius: 8px"
+                  @click.stop="console.log('trash')"
+                />
+              </div>
+            </q-td>
+          </template>
+        </q-table>
+      </div>
+
+      <div class="col-12 col-md-7">
+        <div class="row justify-end q-mb-sm">
+          <q-btn
+            icon="add"
+            color="primary-2"
+            style="height: 40px; width: 40px; border-radius: 8px"
+          />
+        </div>
+        <div class="preview-box q-pa-md">
+          <span v-if="selectedIndex === null" class="text-grey-5"
+            >Pré - Visualização</span
+          >
+          <div v-else>
+            <p class="text-weight-medium q-mb-sm">
+              {{ history[selectedIndex].title }}
+            </p>
+            <p class="text-grey-7" style="white-space: pre-wrap">
+              {{ history[selectedIndex].content }}
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+
+const selectedIndex = ref(null);
+
+const columns = [
+  {
+    name: "date",
+    label: "Data",
+    field: "date",
+    align: "left",
+    style: "width: 90px",
+  },
+  { name: "title", label: "Título", field: "title", align: "left" },
+  {
+    name: "actions",
+    label: "Ações",
+    field: "actions",
+    align: "center",
+    style: "width: 90px",
+  },
+  {
+    name: "franchisee",
+    label: "Franqueado",
+    field: "franchisee",
+    align: "left",
+    style: "width: 100px",
+  },
+];
+
+const history = ref([
+  {
+    date: "15/01/2026",
+    title: "Alteração de Razão Social",
+    content: "",
+  },
+]);
+</script>
+
+<style scoped>
+.preview-box {
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  min-height: 400px;
+}
+
+.history-item-active {
+  background-color: rgba(255, 131, 64, 0.08);
+}
+</style>

+ 157 - 0
src/pages/unit/tabs/MediasTab.vue

@@ -0,0 +1,157 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row q-col-gutter-md">
+      <div class="col-12 col-md-5">
+        <q-table
+          :rows="medias"
+          :columns="columns"
+          row-key="title"
+          flat
+          hide-bottom
+          class="bg-transparent"
+          :row-class="
+            (_, index) =>
+              index === selectedIndex
+                ? 'media-item-active cursor-pointer'
+                : 'cursor-pointer'
+          "
+          @row-click="(_, __, index) => (selectedIndex = index)"
+        >
+          <template #body-cell-actions>
+            <q-td align="center">
+              <div class="flex items-center justify-center q-gutter-x-xs">
+                <q-icon
+                  name="mdi-file-edit-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="
+                    border: 1px solid #c9c9c9;
+                    border-radius: 8px;
+                    padding: 4px;
+                  "
+                  @click.stop="console.log('edit')"
+                />
+                <q-icon
+                  name="mdi-trash-can-outline"
+                  size="sm"
+                  color="grey-8"
+                  class="cursor-pointer"
+                  style="
+                    border: 1px solid #c9c9c9;
+                    border-radius: 8px;
+                    padding: 4px;
+                  "
+                  @click.stop="console.log('trash')"
+                />
+              </div>
+            </q-td>
+          </template>
+
+          <template #body-cell-franchisee>
+            <q-td align="center">
+              <div class="flex items-center justify-center q-gutter-x-xs">
+                <q-icon
+                  name="mdi-check"
+                  size="sm"
+                  color="suface"
+                  class="cursor-pointer bg-approved"
+                  style="border: 1px solid #c9c9c9; border-radius: 8px"
+                  @click.stop="console.log('edit')"
+                />
+                <q-icon
+                  name="mdi-close-circle-outline"
+                  size="sm"
+                  color="declined"
+                  class="cursor-pointer"
+                  style="border: 1px solid red; border-radius: 8px"
+                  @click.stop="console.log('trash')"
+                />
+              </div>
+            </q-td>
+          </template>
+        </q-table>
+      </div>
+
+      <div class="col-12 col-md-7">
+        <div class="row justify-end q-mb-sm">
+          <q-btn
+            icon="add"
+            color="primary-2"
+            style="height: 40px; width: 40px; border-radius: 8px"
+          />
+        </div>
+        <div class="preview-box q-pa-md">
+          <span v-if="selectedIndex === null" class="text-grey-5"
+            >Pré - Visualização</span
+          >
+          <div v-else>
+            <p class="text-weight-medium q-mb-sm">
+              {{ medias[selectedIndex].title }}
+            </p>
+            <img
+              v-if="medias[selectedIndex].url"
+              :src="medias[selectedIndex].url"
+              style="max-width: 100%; border-radius: 4px"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+
+const selectedIndex = ref(null);
+
+const columns = [
+  {
+    name: "date",
+    label: "Data",
+    field: "date",
+    align: "left",
+    style: "width: 90px",
+  },
+  { name: "title", label: "Título", field: "title", align: "left" },
+  {
+    name: "actions",
+    label: "Ações",
+    field: "actions",
+    align: "center",
+    style: "width: 90px",
+  },
+  {
+    name: "franchisee",
+    label: "Franqueado",
+    field: "franchisee",
+    align: "center",
+    style: "width: 100px",
+  },
+];
+
+const medias = ref([
+  {
+    date: "15/01/2026",
+    title: "Imagens Sede",
+    url: "",
+  },
+]);
+</script>
+
+<style scoped>
+.preview-box {
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  min-height: 400px;
+}
+
+.media-item-active {
+  background-color: rgba(255, 131, 64, 0.08);
+}
+
+.transparent-header :deep(thead tr th) {
+  background-color: transparent;
+}
+</style>

+ 176 - 0
src/pages/unit/tabs/PartnersTab.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="q-pa-md">
+    <template v-if="view === 'list'">
+      <div class="row justify-end q-mb-md">
+        <q-btn
+          icon="add"
+          color="primary-2"
+          style="height: 40px; width: 40px"
+          @click="view = 'form'"
+        />
+      </div>
+
+      <div class="row q-col-gutter-md">
+        <div v-for="(partner, index) in partners" :key="index" class="col-3">
+          <PartnerCardComponent :partner />
+        </div>
+      </div>
+    </template>
+
+    <template v-else>
+      <div class="column justify-center items-center q-mb-lg">
+        <AvatarImageComponent @update:file="onAvatarChange" />
+
+        <div class="row full-width q-mt-md q-col-gutter-sm">
+          <DefaultInput
+            v-model="form.full_name"
+            label="Nome completo"
+            class="col-12"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.social_name"
+            label="Nome social"
+            class="col-6"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.role"
+            label="Função"
+            class="col-6"
+            outlined
+          />
+
+          <DefaultInput v-model="form.cpf" label="CPF" class="col-6" outlined />
+
+          <DefaultInput v-model="form.rg" label="RG" class="col-6" outlined />
+
+          <DefaultInput
+            v-model="form.address"
+            label="Endereço"
+            class="col-8"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.address_number"
+            label="Número"
+            class="col-4"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.zip_code"
+            label="CEP"
+            class="col-3"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.neighborhood"
+            label="Bairro"
+            class="col-5"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.city_state"
+            label="Cidade / Estado"
+            class="col-4"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.complement"
+            label="Complemento"
+            class="col-12"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.primary_email"
+            label="E-mail Principal"
+            class="col-6"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.secondary_email"
+            label="E-mail Secundário"
+            class="col-6"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.phone"
+            label="Telefone"
+            class="col-6"
+            outlined
+          />
+
+          <DefaultInput
+            v-model="form.cellphone_number"
+            label="Celular"
+            class="col-6"
+            outlined
+          />
+        </div>
+
+        <div class="row justify-end q-mt-md items-end full-width q-px-xs">
+          <div class="row q-gutter-sm">
+            <q-btn
+              label="Cancelar"
+              color="primary"
+              outline
+              @click="view = 'list'"
+            />
+            <q-btn label="Salvar" color="primary" />
+            <q-btn
+              icon="mdi-paperclip-plus"
+              color="primary"
+              style="height: 40px; width: 40px"
+            />
+          </div>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import PartnerCardComponent from "src/components/shared/PartnerCardComponent.vue";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+const view = ref("list");
+
+const partners = ref([
+  { social_name: null, role: null, avatarUrl: null, color: "#ff8340" },
+]);
+
+const { form } = useFormUpdateTracker({
+  full_name: null,
+  social_name: null,
+  role: null,
+  cpf: null,
+  rg: null,
+  address: null,
+  address_number: null,
+  zip_code: null,
+  neighborhood: null,
+  city_state: null,
+  complement: null,
+  primary_email: null,
+  secondary_email: null,
+  phone: null,
+  cellphone_number: null,
+});
+
+function onAvatarChange(file) {
+  console.log("Avatar file selected:", file);
+}
+</script>

+ 150 - 0
src/pages/unit/tabs/UnitDataTab.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="q-pa-md">
+    <div class="column justify-center items-center q-mb-lg">
+      <AvatarImageComponent @update:file="onAvatarChange" />
+
+      <div class="row full-width q-mt-md q-col-gutter-sm">
+        <DefaultInput
+          v-model="form.social_reason"
+          label="Razão Social"
+          class="col-12"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.fantasy_name"
+          label="Nome Fantasia"
+          class="col-12"
+          outlined
+        />
+
+        <DefaultInput v-model="form.cnpj" label="CNPJ" class="col-4" outlined />
+
+        <DefaultInput
+          v-model="form.state_registration"
+          label="Inscrição Estadual"
+          class="col-4"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.responsible"
+          label="Responsável"
+          class="col-4"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.address"
+          label="Endereço"
+          class="col-8"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.address_number"
+          label="Número"
+          class="col-4"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.zip_code"
+          label="CEP"
+          class="col-3"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.neighborhood"
+          label="Bairro"
+          class="col-5"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.city_state"
+          label="Cidade / Estado"
+          class="col-4"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.complement"
+          label="Complemento"
+          class="col-12"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.primary_email"
+          label="E-mail Principal"
+          class="col-6"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.secondary_email"
+          label="E-mail Secundário"
+          class="col-6"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.phone"
+          label="Telefone"
+          class="col-6"
+          outlined
+        />
+
+        <DefaultInput
+          v-model="form.cellphone_number"
+          label="Celular"
+          class="col-6"
+          outlined
+        />
+      </div>
+
+      <div class="row justify-end q-mt-md items-end full-width q-px-xs">
+        <div class="row q-gutter-sm">
+          <q-btn label="Cancelar" color="primary" outline />
+          <q-btn label="Salvar" color="primary-2" />
+
+          <q-btn
+            icon="mdi-paperclip-plus"
+            color="primary-2"
+            style="height: 40px; width: 40px"
+          />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+const { form } = useFormUpdateTracker({
+  social_reason: null,
+  fantasy_name: null,
+  cnpj: null,
+  state_registration: null,
+  address: null,
+  address_number: null,
+  zip_code: null,
+  neighborhood: null,
+  city_state: null,
+  complement: null,
+  responsible: null,
+  primary_email: null,
+  secondary_email: null,
+  phone: null,
+  cellphone_number: null,
+});
+
+function onAvatarChange(file) {
+  console.log("Avatar file selected:", file);
+}
+</script>

+ 9 - 0
src/pages/users/UserPage.vue

@@ -0,0 +1,9 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Usuários" show-filter-icon />
+  </div>
+</template>
+
+<script setup>
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+</script>

+ 0 - 128
src/pages/users/UsersPage.vue

@@ -1,128 +0,0 @@
-<template>
-  <div>
-    <DefaultHeaderPage>
-      <template #after>
-        <q-btn
-          color="primary"
-          padding="8px 8px"
-          :label="$t('common.actions.add')"
-          icon="mdi-plus"
-          class="q-mt-md"
-          @click="onAddItem"
-        />
-      </template>
-    </DefaultHeaderPage>
-    <div>
-      <DefaultTable
-        ref="tableRef"
-        :columns="columns"
-        :api-call="getUsers"
-        :delete-function="deleteUser"
-        :show-columns-select="false"
-        :title="
-          $t('common.terms.list') +
-          ' ' +
-          $t('common.ui.table.of') +
-          ' ' +
-          $t('user.plural')
-        "
-      >
-        <template #body-cell-actions="{ row }">
-          <q-btn
-            outline
-            style="width: 36px"
-            class="q-ml-auto q-mr-sm"
-            @click.prevent.stop="onRowClick(row)"
-          >
-            <q-icon name="mdi-file-edit-outline" />
-          </q-btn>
-        </template>
-      </DefaultTable>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
-import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
-import { permissionStore } from "src/stores/permission";
-import { getUsers, deleteUser } from "src/api/user";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-
-const AddEditUserDialog = defineAsyncComponent(
-  () => import("src/pages/users/components/AddEditUserDialog.vue"),
-);
-
-const permission_store = permissionStore();
-const $q = useQuasar();
-const { t } = useI18n();
-const tableRef = useTemplateRef("tableRef");
-
-const columns = [
-  {
-    name: "name",
-    label: t("common.terms.name"),
-    field: "name",
-    align: "left",
-    required: true,
-    sortable: true,
-  },
-  {
-    name: "email",
-    label: "Email",
-    field: "email",
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "actions",
-    label: t("common.terms.actions"),
-    align: "left",
-    required: true,
-  },
-];
-
-const onRowClick = (row) => {
-  if (permission_store.getAccess("config.user", "view") === false) {
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.view"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditUserDialog,
-    componentProps: {
-      user: row,
-      title: () => t("common.actions.edit") + " " + t("user.singular"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-
-const onAddItem = () => {
-  if (permission_store.getAccess("config.user", "add") === false) {
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.add"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditUserDialog,
-    componentProps: {
-      title: () => t("common.actions.add") + " " + t("user.singular"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-</script>

+ 0 - 143
src/pages/users/components/AddEditUserDialog.vue

@@ -1,143 +0,0 @@
-<template>
-  <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
-      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
-      <q-form ref="formRef" @submit="onOKClick">
-        <q-card-section class="row q-col-gutter-sm q-pt-none">
-          <DefaultInput
-            v-model="form.name"
-            v-model:error="validationErrors.name"
-            :rules="[inputRules.required]"
-            :label="$t('common.terms.name')"
-            :placeholder="$t('user.profile.name_and_surname')"
-            class="col-md-6 col-12"
-          />
-          <UserTypeSelect
-            v-model="selectedUserType"
-            v-model:error="validationErrors.type"
-            :rules="[inputRules.required]"
-            :type="form.type"
-            :label="$t('common.ui.misc.type')"
-            :placeholder="'O tipo delimita as permissões'"
-            class="col-md-6 col-12"
-          />
-          <DefaultInput
-            v-model="form.email"
-            v-model:error="validationErrors.email"
-            :rules="[inputRules.email, inputRules.required]"
-            label="Email"
-            :placeholder="'Ex. email@email.com'"
-            class="col-12"
-          />
-          <DefaultPasswordInput
-            v-model="form.password"
-            v-model:error="validationErrors.password"
-            :rules="
-              user
-                ? [inputRules.password]
-                : [inputRules.required, inputRules.password]
-            "
-            :label="$t('common.terms.password')"
-            :placeholder="'Digite uma senha segura'"
-            class="col-md-6 col-12"
-          />
-          <DefaultPasswordInput
-            v-model="confirmPassword"
-            :rules="
-              user
-                ? [inputRules.samePassword(form.password)]
-                : [inputRules.required, inputRules.samePassword(form.password)]
-            "
-            :label="$t('auth.confirm_password')"
-            class="col-md-6 col-12"
-          />
-        </q-card-section>
-        <q-card-actions>
-          <q-space />
-          <q-btn
-            outline
-            color="negative"
-            :label="$t('common.actions.cancel')"
-            @click="onDialogCancel"
-          />
-          <q-btn
-            color="primary"
-            :label="user ? $t('common.actions.save') : $t('common.actions.add')"
-            :type="'submit'"
-            :loading="loading"
-            :disable="!hasUpdatedFields"
-          />
-        </q-card-actions>
-      </q-form>
-    </q-card>
-  </q-dialog>
-</template>
-<script setup>
-import { ref, useTemplateRef, watch } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useDialogPluginComponent } from "quasar";
-import { useI18n } from "vue-i18n";
-import { createUser, updateUser } from "src/api/user";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-import UserTypeSelect from "src/components/selects/UserTypeSelect.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-
-defineEmits([
-  // REQUIRED; need to specify some events that your
-  // component will emit through useDialogPluginComponent()
-  ...useDialogPluginComponent.emits,
-]);
-
-const { user, title } = defineProps({
-  user: {
-    type: Object,
-    default: null,
-  },
-  title: {
-    type: Function,
-    default: () => useI18n().t("common.terms.title"),
-  },
-});
-
-const { inputRules } = useInputRules();
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
-  useDialogPluginComponent();
-
-const formRef = useTemplateRef("formRef");
-
-const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
-  name: user ? user?.name : "",
-  email: user ? user?.email : "",
-  type: user ? user?.type : "",
-  password: "",
-});
-
-const selectedUserType = ref(null);
-const confirmPassword = ref("");
-
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  onSuccess: () => onDialogOK(true),
-  formRef: formRef,
-});
-
-const onOKClick = async () => {
-  if (user) {
-    await submitForm(() => updateUser(getUpdatedFields.value, user.id));
-  } else {
-    await submitForm(() => createUser({ ...form }));
-  }
-};
-
-watch(selectedUserType, () => {
-  form.type = selectedUserType.value.value;
-});
-</script>

+ 25 - 0
src/router/routes/franchisee.route.js

@@ -0,0 +1,25 @@
+export default [
+  {
+    path: "/franchisee",
+    name: "FranchiseePage",
+    component: () => import("pages/franchisee/FranchiseePage.vue"),
+    meta: {
+      title: {
+        value: "Franqueados",
+        translate: false,
+      },
+      requireAuth: true,
+      requiredPermission: "config.city",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "Dashboard",
+        },
+        {
+          name: "FranchiseePage",
+          title: "Franqueados"
+        }
+      ],
+    },
+  },
+];

+ 26 - 0
src/router/routes/unit.route.js

@@ -0,0 +1,26 @@
+export default [
+  {
+    path: "/unit/create",
+    name: "UnitAddPage",
+    component: () => import("pages/unit/UnitActionPage.vue"),
+    meta: {
+      title: {
+        value: "Cadastro de Unidades",
+        translate: false,
+      },
+      requireAuth: true,
+      // TODO: Verificar permissao, fazendo seeder e substituindo
+      requiredPermission: "config.city",
+      breadcrumbs: [
+        {
+          name: "FranchiseePage",
+          title: "Franqueados",
+        },
+        {
+          name: "UnitAddPage",
+          title: "Cadastro de Unidade"
+        }
+      ],
+    },
+  },
+];

+ 22 - 0
src/router/routes/user.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: "/users",
+    name: "UserPage",
+    component: () => import("pages/users/UserPage.vue"),
+    meta: {
+      title: {
+        value: "Usuários",
+        translate: false,
+      },
+      requireAuth: true,
+      // TODO: Verificar permissao, fazendo seeder e substituindo
+      requiredPermission: "config.city",
+      breadcrumbs: [
+        {
+          name: "UserPage",
+          title: "Usuários",
+        },
+      ],
+    },
+  },
+];

+ 27 - 8
src/stores/navigation.js

@@ -4,14 +4,6 @@ import { permissionStore } from "src/stores/permission";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
-    // {
-    //   type: "single",
-    //   title: "ui.navigation.home",
-    //   name: "HomePage",
-    //   icon: "mdi-home-outline",
-    //   disable: false,
-    //   permission: true,
-    // },
     {
       type: "single",
       title: "ui.navigation.dashboard",
@@ -21,6 +13,33 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "dashboard",
     },
+    {
+      type: "single",
+      title: "Usuários",
+      name: "UserPage",
+      icon: "mdi-account-multiple-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard",
+    },
+    {
+      type: "single",
+      title: "Franqueados",
+      name: "FranchiseePage",
+      icon: "mdi-home-variant-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard"
+    },
+    {
+      type: "single",
+      title: "Cadastro de Unidade",
+      name: "UnitAddPage",
+      icon: "mdi-school-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard"
+    },
   ]);
 
   const getNavigationAccess = () => {