浏览代码

feat: dados-unidade

Gustavo Mantovani 1 月之前
父节点
当前提交
bcac1a2517

+ 9 - 0
src/api/franchisee_contract.js

@@ -0,0 +1,9 @@
+import api from "src/api";
+
+export const getFranchiseeContractsByUnit = async (unitId) => {
+  const { data } = await api.get("/franchisee-contract", {
+    params: { unit_id: unitId },
+  });
+
+  return data.payload;
+};

+ 58 - 0
src/api/unit.js

@@ -0,0 +1,58 @@
+import api from "src/api";
+
+export const getUnits = async () => {
+  const { data } = await api.get("/unit");
+  return data.payload;
+};
+
+export const getUnit = async (id) => {
+  const { data } = await api.get(`/unit/${id}`);
+  return data.payload;
+};
+
+//
+
+export const getUnitMe = async () => {
+  const { data } = await api.get("/unit/me");
+  return data.payload;
+};
+
+export const updateUnitMe = async (formData) => {
+  formData.append("_method", "PUT");
+
+  const { data } = await api.post("/unit/me", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+
+  return data.payload;
+};
+
+//
+
+export const createUnit = async (formData) => {
+  const { data } = await api.post("/unit", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  
+  return data.payload;
+};
+
+export const updateUnit = async (id, formData) => {
+  formData.append("_method", "PUT");
+
+  const { data } = await api.post(`/unit/${id}`, formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+
+  return data.payload;
+};
+
+export const deleteUnit = async (id) => {
+  const { data } = await api.delete(`/unit/${id}`);
+  return data;
+};
+
+export const getUnitsForSelect = async () => {
+  const { data } = await api.get("/unit/all/select");
+  return data.payload;
+};

+ 6 - 0
src/api/unit_financial.js

@@ -0,0 +1,6 @@
+import api from "src/api";
+
+export const getFinancialByUnit = async (unitId) => {
+  const { data } = await api.get("/unit-financial", { params: { unit_id: unitId } });
+  return data.payload;
+};

+ 6 - 0
src/api/unit_history.js

@@ -0,0 +1,6 @@
+import api from "src/api";
+
+export const getHistoriesByUnit = async (unitId) => {
+  const { data } = await api.get("/unit-history", { params: { unit_id: unitId } });
+  return data.payload;
+};

+ 18 - 0
src/api/unit_media.js

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

+ 26 - 0
src/api/unit_partner.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getPartnersByUnit = async (unitId) => {
+  const { data } = await api.get("/unit-partner", { params: { unit_id: unitId } });
+  return data.payload;
+};
+
+export const createPartner = async (formData) => {
+  const { data } = await api.post("/unit-partner", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const updatePartner = async (id, formData) => {
+  formData.append("_method", "PUT");
+  const { data } = await api.post(`/unit-partner/${id}`, formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deletePartner = async (id) => {
+  const { data } = await api.delete(`/unit-partner/${id}`);
+  return data;
+};

+ 5 - 0
src/api/user.js

@@ -20,6 +20,11 @@ export const updateUser = async (user, id) => {
   return data.payload;
 };
 
+export const updateUserMe = async (user) => {
+  const { data } = await api.put("/user/me", user);
+  return data.payload;
+};
+
 export const deleteUser = async (id) => {
   const { data } = await api.delete(`/user/${id}`);
   return data.payload;

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

@@ -0,0 +1,129 @@
+<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);
+  }
+}
+
+defineExpose({
+  setImageUrl(url) {
+    imageUrl.value = url;
+  },
+});
+</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>

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

@@ -0,0 +1,132 @@
+<template>
+  <div
+    class="partner-card column items-center q-pa-md"
+    :class="{ 'cursor-pointer': !editable }"
+    @click="!editable && emit('click')"
+  >
+    <div
+      class="partner-avatar flex flex-center q-mb-md"
+      :style="partner.avatar_url ? {} : { backgroundColor: '#ff8340' }"
+    >
+      <img
+        v-if="partner.avatar_url"
+        :src="partner.avatar_url"
+        class="avatar-img"
+      />
+      <span v-else class="avatar-initials text-white text-weight-bold">
+        {{ initials }}
+      </span>
+    </div>
+
+    <template v-if="editable">
+      <div class="full-width column q-gutter-sm">
+        <DefaultInput
+          :model-value="partner.social_name"
+          label="Nome Social"
+          outlined
+          @update:model-value="update('social_name', $event)"
+        />
+        <DefaultInput
+          :model-value="partner.role"
+          label="Cargo"
+          outlined
+          @update:model-value="update('role', $event)"
+        />
+      </div>
+    </template>
+
+    <template v-else>
+      <div class="full-width column items-center q-gutter-xs">
+        <span class="text-weight-medium text-body1 text-center ellipsis full-width">
+          {{ partner.name }}
+        </span>
+
+        <span v-if="partner.social_name" class="text-caption text-grey-6 text-center ellipsis full-width">
+          {{ partner.social_name }}
+        </span>
+
+        <q-chip
+          v-if="partner.role"
+          dense
+          color="primary"
+          text-color="white"
+          class="q-mt-xs"
+        >
+          {{ partner.role }}
+        </q-chip>
+
+        <span v-if="partner.participation != null" class="text-caption text-grey-7 q-mt-xs">
+          {{ partner.participation }}% de participação
+        </span>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+const emit = defineEmits(["click", "update:partner"]);
+
+const { partner, editable } = defineProps({
+  partner: {
+    type: Object,
+    required: true,
+  },
+  editable: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+function update(field, value) {
+  emit("update:partner", { ...partner, [field]: value });
+}
+
+const initials = computed(() => {
+  if (!partner.name) return "?";
+  return partner.name
+    .split(" ")
+    .slice(0, 2)
+    .map((w) => w[0]?.toUpperCase())
+    .join("");
+});
+</script>
+
+<style scoped>
+.partner-card {
+  border: 1px solid #e0e0e0;
+  border-radius: 10px;
+  transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.partner-card:hover {
+  border-color: #ff8340;
+  box-shadow: 0 2px 12px rgba(255, 131, 64, 0.15);
+}
+
+.partner-avatar {
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-initials {
+  font-size: 28px;
+}
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 63 - 1
src/pages/unit/UnitActionPage.vue

@@ -1,9 +1,71 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Editar Unidade" />
+    <DefaultHeaderPage title="Dados da Unidade" />
+
+    <CustomTabComponent v-model:active-tab="activeTab" :tabs="allTabs" />
+
+    <UnitDataTab
+      v-show="activeTab === 'unit_data'"
+      v-model:form="form"
+      :unit-id="unitId"
+      :get-form-as-form-data="getFormAsFormData"
+      :set-update-form-as-original="setUpdateFormAsOriginal"
+    />
+
+    <template v-if="unitId">
+      <PartnersTab v-show="activeTab === 'partners'" :unit-id="unitId" />
+      <ContractsTab v-show="activeTab === 'contracts'" :unit-id="unitId" />
+      <FinancialTab v-show="activeTab === 'financial'" :unit-id="unitId" />
+      <HistoryTab v-show="activeTab === 'history'" :unit-id="unitId" />
+      <MediasTab v-show="activeTab === 'medias'" :unit-id="unitId" />
+    </template>
   </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, computed, onMounted } from "vue";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { userStore } from "src/stores/user";
+
+const store = userStore();
+const unitId = computed(() => store.user?.unit_id ?? null);
+
+const { form, getFormAsFormData, setUpdateFormAsOriginal } =
+  useFormUpdateTracker({
+    fantasy_name: null,
+    social_reason: null,
+    cnpj: null,
+    state_registration: null,
+    name_responsible: null,
+    street: null,
+    address_number: null,
+    postal_code: null,
+    neighborhood: null,
+    complement: null,
+    city_id: null,
+    state_id: null,
+    email: null,
+    secondary_email: null,
+    phone_number: null,
+    cell_number: null,
+  });
+
+const activeTab = ref("unit_data");
+
+const allTabs = [
+  { 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>

+ 87 - 0
src/pages/unit/components/AddEditHistoryDialog.vue

@@ -0,0 +1,87 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 560px; max-width: 95vw">
+      <DefaultDialogHeader
+        :title="() => (history ? 'Editar Histórico' : 'Novo Histórico')"
+        @close="onDialogCancel"
+      />
+
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="q-pt-none">
+          <div class="column q-gutter-sm">
+            <DefaultInput
+              v-model="form.title"
+              label="Título"
+              outlined
+              :rules="[inputRules.required]"
+            />
+            <q-input
+              v-model="form.content"
+              label="Conteúdo"
+              outlined
+              type="textarea"
+              rows="5"
+              autogrow
+            />
+            <q-toggle
+              v-model="form.visible_to_franchisee"
+              label="Visível ao franqueado"
+              color="positive"
+            />
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-pa-md">
+          <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
+          <q-btn color="primary-2" :label="history ? 'Salvar' : 'Adicionar'" type="submit" :loading="loading" />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { createHistory, updateHistory } from "src/api/unit_history";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { history, unitId } = defineProps({
+  history: { type: Object, default: null },
+  unitId: { type: Number, required: true },
+});
+
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const formRef = ref(null);
+
+const form = ref({
+  title: history?.title ?? "",
+  content: history?.content ?? "",
+  visible_to_franchisee: history?.visible_to_franchisee ?? false,
+});
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+});
+
+async function onOKClick() {
+  await execute(() => {
+    const payload = {
+      unit_id: unitId,
+      title: form.value.title,
+      content: form.value.content,
+      visible_to_franchisee: form.value.visible_to_franchisee,
+    };
+
+    return history ? updateHistory(history.id, payload) : createHistory(payload);
+  });
+}
+</script>

+ 328 - 0
src/pages/unit/components/AddEditPartnerDialog.vue

@@ -0,0 +1,328 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 860px; max-width: 95vw"
+    >
+      <DefaultDialogHeader
+        :title="() => (partner ? 'Editar Sócio' : 'Adicionar Sócio')"
+        @close="onDialogCancel"
+      />
+
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="q-pt-none">
+          <div class="column items-center q-mb-md">
+            <AvatarImageComponent
+              ref="avatarRef"
+              @update:file="onAvatarChange"
+            />
+          </div>
+
+          <div class="row q-col-gutter-sm">
+            <DefaultInput
+              v-model="form.name"
+              label="Nome completo"
+              class="col-8"
+              outlined
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.role"
+              label="Função"
+              class="col-4"
+              outlined
+            />
+
+            <DefaultInput
+              v-model="form.social_name"
+              label="Nome social"
+              class="col-6"
+              outlined
+            />
+
+            <DefaultInput
+              v-model="form.cpf"
+              label="CPF"
+              class="col-3"
+              outlined
+              :mask="masks.Brasil.cpf"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput v-model="form.rg" label="RG" class="col-3" outlined />
+
+            <DefaultInput
+              v-model="birthDateDisplay"
+              label="Data de Nascimento"
+              class="col-3"
+              outlined
+              :mask="masks.Brasil.date"
+              placeholder="DD/MM/AAAA"
+            />
+
+            <DefaultInput
+              v-model="form.participation"
+              label="Participação (%)"
+              class="col-3"
+              outlined
+              type="number"
+              min="0"
+              max="100"
+            />
+
+            <DefaultInput
+              v-model="form.email"
+              label="E-mail"
+              class="col-6"
+              outlined
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.secondary_email"
+              label="E-mail Secundário"
+              class="col-6"
+              outlined
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.phone_number"
+              label="Telefone"
+              class="col-6"
+              outlined
+              :mask="masks.Brasil.telefone"
+            />
+
+            <DefaultInput
+              v-model="form.cell_number"
+              label="Celular"
+              class="col-6"
+              outlined
+              :mask="masks.Brasil.celular"
+            />
+
+            <DefaultCepInput
+              v-model="form.postal_code"
+              class="col-6"
+              outlined
+              @rua="form.street = $event"
+              @bairro="form.neighborhood = $event"
+              @uf="stateSelectRef?.selectStateByCode($event)"
+              @cidade="citySelectRef?.selectCityByName($event)"
+            />
+
+            <DefaultInput
+              v-model="form.street"
+              label="Endereço"
+              class="col-6"
+              outlined
+            />
+
+            <DefaultInput
+              v-model="form.address_number"
+              label="Número"
+              class="col-6"
+              outlined
+            />
+
+            <DefaultInput
+              v-model="form.neighborhood"
+              label="Bairro"
+              class="col-4"
+              outlined
+            />
+
+            <StateSelect
+              ref="stateSelectRef"
+              v-model="selectedState"
+              label="Estado"
+              class="col-4"
+              outlined
+            />
+
+            <CitySelect
+              ref="citySelectRef"
+              v-model="selectedCity"
+              label="Cidade"
+              class="col-4"
+              outlined
+              :state="selectedState"
+            />
+
+            <DefaultInput
+              v-model="form.complement"
+              label="Complemento"
+              class="col-12"
+              outlined
+            />
+          </div>
+        </q-card-section>
+
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            label="Cancelar"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="primary-2"
+            :label="partner ? 'Salvar' : 'Adicionar'"
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { createPartner, updatePartner } from "src/api/unit_partner";
+import masks from "src/helpers/masks";
+import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { partner, unitId, offlineMode } = defineProps({
+  partner: {
+    type: Object,
+    default: null,
+  },
+  unitId: {
+    type: Number,
+    default: null,
+  },
+  offlineMode: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = ref(null);
+const avatarRef = ref(null);
+const stateSelectRef = ref(null);
+const citySelectRef = ref(null);
+
+const selectedState = ref(null);
+const selectedCity = ref(null);
+const avatarChanged = ref(false);
+const avatarFile = ref(null);
+
+// Exibe DD/MM/YYYY para o usuário; form.birth_date armazena YYYY-MM-DD para o backend
+const birthDateDisplay = ref(
+  partner?.birth_date ? formatDateYMDtoDMY(partner.birth_date) : null,
+);
+
+watch(birthDateDisplay, (val) => {
+  try {
+    form.birth_date = val?.length === 10 ? formatDateDMYtoYMD(val) : null;
+  } catch {
+    form.birth_date = null;
+  }
+});
+
+const { form, getFormAsFormData } = useFormUpdateTracker({
+  unit_id: unitId,
+  name: partner?.name ?? null,
+  social_name: partner?.social_name ?? null,
+  role: partner?.role ?? null,
+  cpf: partner?.cpf ?? null,
+  rg: partner?.rg ?? null,
+  birth_date: partner?.birth_date ?? null,
+  participation: partner?.participation ?? null,
+  email: partner?.email ?? null,
+  secondary_email: partner?.secondary_email ?? null,
+  phone_number: partner?.phone_number ?? null,
+  cell_number: partner?.cell_number ?? null,
+  postal_code: partner?.postal_code ?? null,
+  street: partner?.street ?? null,
+  address_number: partner?.address_number ?? null,
+  neighborhood: partner?.neighborhood ?? null,
+  complement: partner?.complement ?? null,
+  city_id: partner?.city_id ?? null,
+  state_id: partner?.state_id ?? null,
+});
+
+watch(selectedState, (state) => {
+  form.state_id = state?.value ?? null;
+});
+
+watch(selectedCity, (city) => {
+  form.city_id = city?.value ?? null;
+});
+
+function onAvatarChange(file) {
+  avatarChanged.value = true;
+  avatarFile.value = file;
+}
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+});
+
+async function onOKClick() {
+  if (offlineMode) {
+    const partnerData = { ...form };
+    if (avatarFile.value instanceof File) {
+      if (partner?.avatar_url?.startsWith("blob:")) {
+        URL.revokeObjectURL(partner.avatar_url);
+      }
+      partnerData.avatar = avatarFile.value;
+      partnerData.avatar_url = URL.createObjectURL(avatarFile.value);
+    } else if (partner?.avatar_url) {
+      partnerData.avatar_url = partner.avatar_url;
+    }
+    onDialogOK(partnerData);
+    return;
+  }
+
+  await execute(() => {
+    const formData = getFormAsFormData();
+
+    if (avatarChanged.value) {
+      formData.append("avatar", avatarFile.value ?? "");
+    }
+
+    if (partner) {
+      return updatePartner(partner.id, formData);
+    }
+
+    return createPartner(formData);
+  });
+}
+
+onMounted(() => {
+  if (!partner) return;
+
+  if (partner.avatar_url) {
+    avatarRef.value?.setImageUrl(partner.avatar_url);
+  }
+  if (partner.state_id) {
+    stateSelectRef.value?.selectStateById(partner.state_id);
+  }
+  if (partner.city_id) {
+    citySelectRef.value?.selectCityById(partner.city_id);
+  }
+});
+</script>

+ 86 - 0
src/pages/unit/components/AddMediaDialog.vue

@@ -0,0 +1,86 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 480px; max-width: 95vw">
+      <DefaultDialogHeader :title="() => 'Adicionar Mídia'" @close="onDialogCancel" />
+
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="q-pt-none">
+          <div class="column q-gutter-sm">
+            <DefaultInput
+              v-model="form.title"
+              label="Título"
+              outlined
+              :rules="[inputRules.required]"
+            />
+
+            <q-file
+              v-model="selectedFile"
+              label="Arquivo"
+              outlined
+              accept="image/*,video/*,.pdf"
+              :rules="[inputRules.required]"
+            >
+              <template #prepend>
+                <q-icon name="attach_file" />
+              </template>
+            </q-file>
+
+            <q-toggle
+              v-model="form.visible_to_franchisee"
+              label="Visível ao franqueado"
+              color="positive"
+            />
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-pa-md">
+          <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
+          <q-btn color="primary-2" label="Adicionar" type="submit" :loading="loading" />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { createMedia } from "src/api/unit_media";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { unitId } = defineProps({
+  unitId: { type: Number, required: true },
+});
+
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const formRef = ref(null);
+const selectedFile = ref(null);
+
+const form = ref({
+  title: "",
+  visible_to_franchisee: false,
+});
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+});
+
+async function onOKClick() {
+  await execute(() => {
+    const formData = new FormData();
+    formData.append("unit_id", unitId);
+    formData.append("title", form.value.title);
+    formData.append("file", selectedFile.value);
+    formData.append("visible_to_franchisee", form.value.visible_to_franchisee ? 1 : 0);
+    return createMedia(formData);
+  });
+}
+</script>

+ 276 - 0
src/pages/unit/components/CreateContractDialog.vue

@@ -0,0 +1,276 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        :title="() => 'Criar novo contrato'"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Dados da Unidade</div>
+
+        <div class="row q-col-gutter-x-sm">
+          <DefaultInput
+            :model-value="unitData.id"
+            label="ID"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_name"
+            label="Nome do Franqueado"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_document"
+            label="CPF/CNH"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_birthday"
+            label="Data de Nascimento"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+        </div>
+      </q-card-section>
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Definir Valores e TBR</div>
+
+        <div class="row q-col-gutter-sm">
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.start_date"
+            label="Data de Início"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.end_date"
+            label="Data de Fim"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultCurrencyInput
+            v-model="contractForm.tbr_fixed_value"
+            label="TBR $"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultInput
+            v-model="contractForm.invoice_due_date"
+            label="Dia de Vencimento do Boleto"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+          </DefaultInput>
+
+          <DefaultSelect
+            v-model="contractForm.inhabitant_classification_id"
+            label="Faixa de Habitante"
+            color="secondary"
+            label-color="secondary"
+            :options="inhabitantOptions"
+            emit-value
+            map-options
+            use-input
+            fill-input
+            hide-selected
+            input-debounce="0"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultInput
+            v-model="contractForm.tax_base_royalts"
+            label="Taxa Base Royalties"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="contractForm.tax_base_fnm"
+            label="Taxa Base FMN"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="contractForm.tax_base_maintenance"
+            label="Taxa Base Manutenção"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right">
+        <q-btn
+          outline
+          color="primary"
+          label="Cancelar"
+          @click="onDialogCancel"
+        />
+        <q-btn color="primary" label="Salvar" :loading="saving" @click="save" />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import { getUnit } from "src/api/unit";
+import { getLatestTbr } from "src/api/tbr";
+import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
+import { createFranchiseeContract } from "src/api/franchisee_contract";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  unitId: {
+    type: Number,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
+  useDialogPluginComponent();
+
+const saving = ref(false);
+const inhabitantOptions = ref([]);
+
+const unitData = reactive({
+  id: null,
+  franchisee_name: null,
+  franchisee_document: null,
+  franchisee_birthday: null,
+});
+
+const contractForm = reactive({
+  start_date: null,
+  end_date: null,
+  tbr_fixed_value: null,
+  invoice_due_date: null,
+  inhabitant_classification_id: null,
+  tax_base_royalts: null,
+  tax_base_fnm: null,
+  tax_base_maintenance: null,
+});
+
+async function loadData() {
+  const [unit, latestTbr, classifications] = await Promise.all([
+    getUnit(props.unitId),
+    getLatestTbr(),
+    getInhabitantClassificationsForSelect(),
+  ]);
+
+  unitData.id = unit.id;
+  unitData.franchisee_name = unit.name_responsible;
+
+  const firstPartner = unit.partners?.[0];
+  if (firstPartner) {
+    unitData.franchisee_document = firstPartner.cpf;
+    unitData.franchisee_birthday = firstPartner.birth_date;
+  }
+
+  if (latestTbr) {
+    contractForm.tbr_fixed_value = parseFloat(latestTbr.tbr_value);
+    contractForm.tax_base_royalts = parseFloat(
+      (latestTbr.royalties_percentage * 100).toFixed(4),
+    );
+    contractForm.tax_base_fnm = parseFloat(
+      (latestTbr.fnm_percentage * 100).toFixed(4),
+    );
+    contractForm.tax_base_maintenance = parseFloat(
+      (latestTbr.maintenance_percentage * 100).toFixed(4),
+    );
+  }
+
+  inhabitantOptions.value = classifications.map((c) => ({
+    label: `${c.description} (${c.acronym})`,
+    value: c.id,
+  }));
+}
+
+async function save() {
+  saving.value = true;
+  try {
+    await createFranchiseeContract({
+      unit_id: props.unitId,
+      start_date: contractForm.start_date,
+      end_date: contractForm.end_date,
+      tbr_fixed_value: contractForm.tbr_fixed_value,
+      invoice_due_date: contractForm.invoice_due_date,
+      inhabitant_classification_id: contractForm.inhabitant_classification_id,
+      tbr_fixed_value_percentage:
+        contractForm.tax_base_royalts != null
+          ? contractForm.tax_base_royalts / 100
+          : null,
+      marketing_fund_percentage:
+        contractForm.tax_base_fnm != null
+          ? contractForm.tax_base_fnm / 100
+          : null,
+      maintance_tax_percentage:
+        contractForm.tax_base_maintenance != null
+          ? contractForm.tax_base_maintenance / 100
+          : null,
+    });
+    onDialogOK();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    saving.value = false;
+  }
+}
+
+onMounted(loadData);
+</script>

+ 338 - 0
src/pages/unit/components/EditContractDialog.vue

@@ -0,0 +1,338 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        :title="() => 'Editar Contrato'"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Dados da Unidade</div>
+
+        <div class="row q-col-gutter-x-sm">
+          <DefaultInput
+            :model-value="unitData.id"
+            label="ID"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_name"
+            label="Nome do Franqueado"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_document"
+            label="CPF / CNH"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_birthday"
+            label="Data de Nascimento"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+        </div>
+      </q-card-section>
+
+      <q-card-section>
+        <div class="row q-col-gutter-sm">
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.start_date"
+            label="Data de Início"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.end_date"
+            label="Data de Fim"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            v-model="contractForm.invoice_due_date"
+            label="Dia de Vencimento do Boleto"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultSelect
+            v-model="contractForm.inhabitant_classification_id"
+            label="Faixa de Habitantes"
+            color="secondary"
+            label-color="secondary"
+            :options="inhabitantOptions"
+            emit-value
+            map-options
+            use-input
+            fill-input
+            hide-selected
+            input-debounce="0"
+            class="col-md-3 col-12"
+            disable
+          />
+        </div>
+
+        <div class="row q-col-gutter-sm q-mt-xs">
+          <DefaultCurrencyInput
+            v-model="contractForm.tbr_fixed_value"
+            label="TBR $"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            v-model="contractForm.tax_base_royalts"
+            label="Taxa Base Royalties"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="contractForm.tax_base_fnm"
+            label="Fundo Nacional de Marketing"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <div
+            class="col-md-3 col-12 row no-wrap items-center"
+            style="gap: 8px"
+          >
+            <DefaultInput
+              v-model="contractForm.tax_base_maintenance"
+              label="Taxa de Manutenção"
+              color="secondary"
+              label-color="secondary"
+              type="number"
+              class="col"
+              disable
+            >
+              <template #append>
+                <span class="text-secondary">%</span>
+              </template>
+            </DefaultInput>
+
+            <q-btn
+              color="primary"
+              icon="mdi-pencil"
+              text-color="white"
+              style="width: 40px; min-width: 40px; height: 40px"
+              @click="openTaxesDialog"
+            />
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">
+          Histórico de Reajuste de Valores e TBR
+        </div>
+
+        <q-table
+          flat
+          dense
+          :rows="tbrHistory"
+          :columns="tbrColumns"
+          row-key="id"
+          :loading="loadingHistory"
+          :pagination="{ rowsPerPage: 5 }"
+          no-data-label="Nenhum histórico disponível"
+        />
+      </q-card-section>
+
+      <q-card-actions align="right">
+        <q-btn
+          outline
+          color="primary"
+          label="Cancelar"
+          @click="onDialogCancel"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import { getUnit } from "src/api/unit";
+import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
+import { getFranchiseeContractTaxHistory } from "src/api/franchisee_contract";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  unitId: {
+    type: Number,
+    required: true,
+  },
+  contract: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
+  useDialogPluginComponent();
+
+function openTaxesDialog() {
+  onDialogOK({ openTaxes: true, contract: props.contract });
+}
+
+const loadingHistory = ref(false);
+const inhabitantOptions = ref([]);
+const tbrHistory = ref([]);
+
+const unitData = reactive({
+  id: null,
+  franchisee_name: null,
+  franchisee_document: null,
+  franchisee_birthday: null,
+});
+
+const contractForm = reactive({
+  start_date: props.contract.start_date ?? null,
+  end_date: props.contract.end_date ?? null,
+  tbr_fixed_value: props.contract.tbr_fixed_value
+    ? parseFloat(props.contract.tbr_fixed_value)
+    : null,
+  invoice_due_date: props.contract.invoice_due_date ?? null,
+  inhabitant_classification_id:
+    props.contract.inhabitant_classification_id ?? null,
+  tax_base_royalts:
+    props.contract.tbr_fixed_value_percentage != null
+      ? parseFloat((props.contract.tbr_fixed_value_percentage * 100).toFixed(4))
+      : null,
+  tax_base_fnm:
+    props.contract.marketing_fund_percentage != null
+      ? parseFloat((props.contract.marketing_fund_percentage * 100).toFixed(4))
+      : null,
+  tax_base_maintenance:
+    props.contract.maintance_tax_percentage != null
+      ? parseFloat((props.contract.maintance_tax_percentage * 100).toFixed(4))
+      : null,
+});
+
+const tbrColumns = [
+  { name: "id", label: "Id", field: "id", align: "left" },
+  {
+    name: "year",
+    label: "Ano",
+    field: (row) => new Date(row.created_at).getFullYear(),
+    align: "left",
+  },
+  {
+    name: "inhabitant_classification",
+    label: "Faixa de Habitantes",
+    field: "inhabitant_classification",
+    align: "left",
+  },
+  {
+    name: "tbr_fixed_value",
+    label: "TBR",
+    field: "tbr_fixed_value",
+    align: "left",
+  },
+  {
+    name: "marketing_fund_percentage",
+    label: "FNM",
+    field: (row) =>
+      row.marketing_fund_percentage != null
+        ? `${(row.marketing_fund_percentage * 100).toFixed(0)}%`
+        : "-",
+    align: "left",
+  },
+  {
+    name: "maintance_tax_percentage",
+    label: "Manutenção",
+    field: (row) =>
+      row.maintance_tax_percentage != null
+        ? `${(row.maintance_tax_percentage * 100).toFixed(0)}%`
+        : "-",
+    align: "left",
+  },
+];
+
+async function loadData() {
+  const [unit, classifications] = await Promise.all([
+    getUnit(props.unitId),
+    getInhabitantClassificationsForSelect(),
+  ]);
+
+  unitData.id = unit.id;
+  unitData.franchisee_name = unit.name_responsible;
+
+  const firstPartner = unit.partners?.[0];
+  if (firstPartner) {
+    unitData.franchisee_document = firstPartner.cpf;
+    unitData.franchisee_birthday = firstPartner.birth_date;
+  }
+
+  inhabitantOptions.value = classifications.map((c) => ({
+    label: `${c.description} (${c.acronym})`,
+    value: c.id,
+  }));
+
+  await loadTaxHistory();
+}
+
+async function loadTaxHistory() {
+  loadingHistory.value = true;
+  try {
+    tbrHistory.value = await getFranchiseeContractTaxHistory(props.contract.id);
+  } finally {
+    loadingHistory.value = false;
+  }
+}
+
+onMounted(loadData);
+</script>

+ 162 - 0
src/pages/unit/components/EditContractTaxesDialog.vue

@@ -0,0 +1,162 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        :title="() => 'Editar Taxas'"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Definir Valores</div>
+
+        <div class="row q-col-gutter-sm">
+          <DefaultSelect
+            v-model="form.inhabitant_classification_id"
+            label="Faixa de Habitantes"
+            color="secondary"
+            label-color="secondary"
+            :options="inhabitantOptions"
+            emit-value
+            map-options
+            use-input
+            fill-input
+            hide-selected
+            input-debounce="0"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultCurrencyInput
+            v-model="form.tbr_fixed_value"
+            label="TBR $"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+          />
+
+          <DefaultInput
+            v-model="form.tax_base_fnm"
+            label="Fundo Nacional de Marketing"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="form.tax_base_maintenance"
+            label="Taxa de Manutenção"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right">
+        <q-btn
+          outline
+          color="primary"
+          label="Cancelar"
+          @click="onDialogCancel"
+        />
+        <q-btn color="primary" label="Salvar" :loading="saving" @click="confirmSave" />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
+import { updateFranchiseeContract } from "src/api/franchisee_contract";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  contract: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const saving = ref(false);
+const inhabitantOptions = ref([]);
+
+const form = reactive({
+  inhabitant_classification_id:
+    props.contract.inhabitant_classification_id ?? null,
+  tbr_fixed_value: props.contract.tbr_fixed_value
+    ? parseFloat(props.contract.tbr_fixed_value)
+    : null,
+  tax_base_fnm:
+    props.contract.marketing_fund_percentage != null
+      ? parseFloat((props.contract.marketing_fund_percentage * 100).toFixed(4))
+      : null,
+  tax_base_maintenance:
+    props.contract.maintance_tax_percentage != null
+      ? parseFloat((props.contract.maintance_tax_percentage * 100).toFixed(4))
+      : null,
+});
+
+async function loadData() {
+  const classifications = await getInhabitantClassificationsForSelect();
+  inhabitantOptions.value = classifications.map((c) => ({
+    label: `${c.description} (${c.acronym})`,
+    value: c.id,
+  }));
+}
+
+function confirmSave() {
+  $q.dialog({
+    message: "Confirmar alteração nas Taxas?",
+    ok: { label: "Confirmar", color: "primary" },
+    cancel: { label: "Cancelar", color: "primary", outline: true },
+  }).onOk(save);
+}
+
+async function save() {
+  saving.value = true;
+  try {
+    await updateFranchiseeContract(props.contract.id, {
+      inhabitant_classification_id: form.inhabitant_classification_id,
+      tbr_fixed_value: form.tbr_fixed_value,
+      marketing_fund_percentage:
+        form.tax_base_fnm != null ? form.tax_base_fnm / 100 : null,
+      maintance_tax_percentage:
+        form.tax_base_maintenance != null
+          ? form.tax_base_maintenance / 100
+          : null,
+    });
+    onDialogOK();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    saving.value = false;
+  }
+}
+
+onMounted(loadData);
+</script>

+ 308 - 0
src/pages/unit/components/ViewContractDialog.vue

@@ -0,0 +1,308 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        :title="() => 'Visualizar Contrato'"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Dados da Unidade</div>
+
+        <div class="row q-col-gutter-x-sm">
+          <DefaultInput
+            :model-value="unitData.id"
+            label="ID"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_name"
+            label="Nome do Franqueado"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_document"
+            label="CPF / CNH"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            :model-value="unitData.franchisee_birthday"
+            label="Data de Nascimento"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+        </div>
+      </q-card-section>
+
+      <q-card-section>
+        <div class="row q-col-gutter-sm">
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.start_date"
+            label="Data de Início"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInputDatePicker
+            v-model:untreated-date="contractForm.end_date"
+            label="Data de Fim"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            v-model="contractForm.invoice_due_date"
+            label="Dia de Vencimento do Boleto"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultSelect
+            v-model="contractForm.inhabitant_classification_id"
+            label="Faixa de Habitantes"
+            color="secondary"
+            label-color="secondary"
+            :options="inhabitantOptions"
+            emit-value
+            map-options
+            use-input
+            fill-input
+            hide-selected
+            input-debounce="0"
+            class="col-md-3 col-12"
+            disable
+          />
+        </div>
+
+        <div class="row q-col-gutter-sm q-mt-xs">
+          <DefaultCurrencyInput
+            v-model="contractForm.tbr_fixed_value"
+            label="TBR $"
+            color="secondary"
+            label-color="secondary"
+            class="col-md-3 col-12"
+            disable
+          />
+
+          <DefaultInput
+            v-model="contractForm.tax_base_royalts"
+            label="Taxa Base Royalties"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="contractForm.tax_base_fnm"
+            label="Fundo Nacional de Marketing"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+
+          <DefaultInput
+            v-model="contractForm.tax_base_maintenance"
+            label="Taxa de Manutenção"
+            color="secondary"
+            label-color="secondary"
+            type="number"
+            class="col-md-3 col-12"
+            disable
+          >
+            <template #append>
+              <span class="text-secondary">%</span>
+            </template>
+          </DefaultInput>
+        </div>
+      </q-card-section>
+
+      <q-card-section>
+        <div class="text-body2 q-mb-sm">Histórico de Reajuste de Valores e TBR</div>
+
+        <q-table
+          flat
+          dense
+          :rows="tbrHistory"
+          :columns="historyColumns"
+          row-key="id"
+          :loading="loadingHistory"
+          :pagination="{ rowsPerPage: 5 }"
+          no-data-label="Nenhum histórico disponível"
+        />
+      </q-card-section>
+
+      <q-card-actions align="right">
+        <q-btn
+          color="primary"
+          label="Fechar"
+          @click="onDialogCancel"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import { getUnit } from "src/api/unit";
+import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
+import { getFranchiseeContractTaxHistory } from "src/api/franchisee_contract";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  unitId: {
+    type: Number,
+    required: true,
+  },
+  contract: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const loadingHistory = ref(false);
+const inhabitantOptions = ref([]);
+const tbrHistory = ref([]);
+
+const unitData = reactive({
+  id: null,
+  franchisee_name: null,
+  franchisee_document: null,
+  franchisee_birthday: null,
+});
+
+const contractForm = reactive({
+  start_date: props.contract.start_date ?? null,
+  end_date: props.contract.end_date ?? null,
+  tbr_fixed_value: props.contract.tbr_fixed_value
+    ? parseFloat(props.contract.tbr_fixed_value)
+    : null,
+  invoice_due_date: props.contract.invoice_due_date ?? null,
+  inhabitant_classification_id:
+    props.contract.inhabitant_classification_id ?? null,
+  tax_base_royalts:
+    props.contract.tbr_fixed_value_percentage != null
+      ? parseFloat((props.contract.tbr_fixed_value_percentage * 100).toFixed(4))
+      : null,
+  tax_base_fnm:
+    props.contract.marketing_fund_percentage != null
+      ? parseFloat((props.contract.marketing_fund_percentage * 100).toFixed(4))
+      : null,
+  tax_base_maintenance:
+    props.contract.maintance_tax_percentage != null
+      ? parseFloat((props.contract.maintance_tax_percentage * 100).toFixed(4))
+      : null,
+});
+
+const historyColumns = [
+  { name: "id", label: "Id", field: "id", align: "left" },
+  {
+    name: "year",
+    label: "Ano",
+    field: (row) => new Date(row.created_at).getFullYear(),
+    align: "left",
+  },
+  {
+    name: "inhabitant_classification",
+    label: "Faixa de Habitantes",
+    field: "inhabitant_classification",
+    align: "left",
+  },
+  { name: "tbr_fixed_value", label: "TBR", field: "tbr_fixed_value", align: "left" },
+  {
+    name: "marketing_fund_percentage",
+    label: "FNM",
+    field: (row) =>
+      row.marketing_fund_percentage != null
+        ? `${(row.marketing_fund_percentage * 100).toFixed(0)}%`
+        : "-",
+    align: "left",
+  },
+  {
+    name: "maintance_tax_percentage",
+    label: "Manutenção",
+    field: (row) =>
+      row.maintance_tax_percentage != null
+        ? `${(row.maintance_tax_percentage * 100).toFixed(0)}%`
+        : "-",
+    align: "left",
+  },
+];
+
+async function loadData() {
+  const [unit, classifications] = await Promise.all([
+    getUnit(props.unitId),
+    getInhabitantClassificationsForSelect(),
+  ]);
+
+  unitData.id = unit.id;
+  unitData.franchisee_name = unit.name_responsible;
+
+  const firstPartner = unit.partners?.[0];
+  if (firstPartner) {
+    unitData.franchisee_document = firstPartner.cpf;
+    unitData.franchisee_birthday = firstPartner.birth_date;
+  }
+
+  inhabitantOptions.value = classifications.map((c) => ({
+    label: `${c.description} (${c.acronym})`,
+    value: c.id,
+  }));
+
+  loadingHistory.value = true;
+  try {
+    tbrHistory.value = await getFranchiseeContractTaxHistory(props.contract.id);
+  } finally {
+    loadingHistory.value = false;
+  }
+}
+
+onMounted(loadData);
+</script>

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

@@ -0,0 +1,95 @@
+<template>
+  <div class="q-pa-md">
+    <DefaultTable
+      ref="tableRef"
+      :columns="columns"
+      :api-call="loadContracts"
+      :show-search-field="true"
+      :add-item="false"
+      :female="false"
+      title="Contratos"
+      description="Contratos"
+    >
+      <template #body-cell-contract_dates="{ row }">
+        <q-td
+          >{{ formatDateYMDtoDMY(row.start_date) }} -
+          {{ formatDateYMDtoDMY(row.end_date) }}</q-td
+        >
+      </template>
+
+      <template #body-cell-tbr_fixed_value="{ row }">
+        <q-td>{{ formatToBRLCurrency(row.tbr_fixed_value) }}</q-td>
+      </template>
+
+      <template #body-cell-actions="{ row: contract }">
+        <q-td align="center">
+          <q-btn
+            flat
+            round
+            dense
+            icon="mdi-eye-outline"
+            @click="openViewDialog(contract)"
+          />
+        </q-td>
+      </template>
+    </DefaultTable>
+  </div>
+</template>
+
+<script setup>
+import { ref, defineAsyncComponent } from "vue";
+import { useQuasar } from "quasar";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { getFranchiseeContractsByUnit } from "src/api/franchisee_contract";
+import { formatDateYMDtoDMY, formatToBRLCurrency } from "src/helpers/utils";
+
+const ViewContractDialog = defineAsyncComponent(
+  () => import("src/pages/unit/components/ViewContractDialog.vue"),
+);
+
+const props = defineProps({
+  unitId: {
+    type: Number,
+    default: null,
+  },
+});
+
+const $q = useQuasar();
+const tableRef = ref(null);
+
+const loadContracts = () => getFranchiseeContractsByUnit(props.unitId);
+
+function openViewDialog(contract) {
+  $q.dialog({
+    component: ViewContractDialog,
+    componentProps: { unitId: props.unitId, contract },
+  });
+}
+
+const columns = [
+  {
+    name: "protocol",
+    label: "Contrato",
+    field: "protocol",
+    align: "left",
+  },
+  {
+    name: "contract_dates",
+    label: "Data Inicial - Final",
+    field: "start_date",
+    align: "left",
+  },
+  {
+    name: "tbr_fixed_value",
+    label: "TBR",
+    field: "tbr_fixed_value",
+    align: "left",
+  },
+  {
+    name: "actions",
+    label: "Ações",
+    field: "actions",
+    align: "center",
+  },
+];
+</script>

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

@@ -0,0 +1,214 @@
+<template>
+  <div class="q-pa-md column q-gutter-lg">
+    <!-- Dados de Contato -->
+    <div>
+      <div class="text-h6 q-mb-md">Dados de Contato</div>
+
+      <div v-if="loadingPartners" class="row justify-center q-pa-md">
+        <q-spinner color="primary" size="32px" />
+      </div>
+
+      <template v-else>
+        <div class="row q-col-gutter-md">
+          <div
+            v-for="(partner, index) in partners"
+            :key="index"
+            class="col-12 col-md-4"
+          >
+            <PartnerCardComponent
+              :partner="partner"
+              :editable="false"
+            />
+          </div>
+          <div
+            v-if="partners.length === 0"
+            class="col-12 text-grey-6 text-center q-pa-md"
+          >
+            Nenhum sócio cadastrado.
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <!-- Dados Bancários -->
+    <div>
+      <div class="text-subtitle1 text-weight-medium q-mb-sm">
+        Dados Bancários
+      </div>
+
+      <div class="row q-col-gutter-sm">
+        <DefaultInput
+          v-model="form.account_holder"
+          label="Titular da Conta"
+          class="col-12 col-md-4"
+          outlined
+          disable
+        />
+        <DefaultInput
+          v-model="form.agency"
+          label="Agência"
+          class="col-12 col-md-4"
+          outlined
+          disable
+        />
+        <DefaultInput
+          v-model="form.account"
+          label="Conta"
+          class="col-12 col-md-4"
+          outlined
+          disable
+        />
+
+        <DefaultInput
+          v-model="form.bank"
+          label="Banco"
+          class="col-12 col-md-3"
+          outlined
+          disable
+        />
+        <DefaultInput
+          v-model="form.pix_key"
+          label="Chave Pix"
+          class="col-12 col-md-3"
+          outlined
+          disable
+        />
+        <DefaultSelect
+          v-model="form.tax_regime"
+          label="Regime Tributário"
+          :options="taxRegimeOptions"
+          class="col-12 col-md-3"
+          outlined
+          emit-value
+          map-options
+          disable
+        />
+        <DefaultSelect
+          v-model="form.account_type"
+          label="Tipo de Conta"
+          :options="accountTypeOptions"
+          class="col-12 col-md-3"
+          outlined
+          emit-value
+          map-options
+          disable
+        />
+      </div>
+    </div>
+
+    <!-- Dados para Faturamento -->
+    <div>
+      <div class="text-subtitle1 text-weight-medium q-mb-sm">
+        Dados para Faturamento
+      </div>
+
+      <div class="row q-col-gutter-sm">
+        <DefaultSelect
+          v-model="form.billing_method"
+          label="Forma de Cobrança"
+          :options="billingMethodOptions"
+          class="col-12 col-md-6"
+          outlined
+          emit-value
+          map-options
+          disable
+        />
+        <DefaultInputDatePicker
+          v-model="form.due_date"
+          label="Data de Vencimento"
+          class="col-12 col-md-6"
+          disable
+        />
+        <DefaultInput
+          v-model="form.financial_email"
+          label="E-mail Financeiro"
+          class="col-12"
+          outlined
+          disable
+        />
+      </div>
+    </div>
+
+    <!-- (read-only) -->
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import PartnerCardComponent from "src/components/shared/PartnerCardComponent.vue";
+import { getFinancialByUnit } from "src/api/unit_financial";
+import { getPartnersByUnit } from "src/api/unit_partner";
+
+const props = defineProps({
+  unitId: { type: Number, default: null },
+});
+
+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 defaultForm = () => ({
+  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,
+});
+
+const form = ref(defaultForm());
+const partners = ref([]);
+const loadingPartners = ref(false);
+
+async function fetchData() {
+  if (!props.unitId) return;
+  await Promise.allSettled([loadFinancial(), loadPartners()]);
+}
+
+async function loadFinancial() {
+  try {
+    const data = await getFinancialByUnit(props.unitId);
+    if (data) Object.assign(form.value, data);
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+async function loadPartners() {
+  loadingPartners.value = true;
+  try {
+    partners.value = await getPartnersByUnit(props.unitId);
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loadingPartners.value = false;
+  }
+}
+
+onMounted(fetchData);
+</script>

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

@@ -0,0 +1,135 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row q-col-gutter-md">
+      <!-- Coluna esquerda: lista -->
+      <div class="col-12 col-md-5">
+        <div class="row justify-between items-center q-mb-md">
+          <span class="text-subtitle1 text-weight-medium">Histórico</span>
+        </div>
+
+        <div v-if="loading" class="row justify-center q-pa-xl">
+          <q-spinner color="primary" size="40px" />
+        </div>
+
+        <template v-else>
+          <div v-if="histories.length === 0" class="text-center text-grey-6 q-pa-xl">
+            <q-icon name="mdi-history" size="48px" color="grey-4" />
+            <div class="q-mt-sm">Nenhum histórico registrado.</div>
+          </div>
+
+          <q-list v-else separator>
+            <q-item
+              v-for="(item, index) in histories"
+              :key="item.id"
+              clickable
+              :active="selectedIndex === index"
+              active-class="history-item-active"
+              @click="selectedIndex = index"
+            >
+              <q-item-section avatar>
+                <q-icon name="mdi-text-box-outline" color="primary-2" size="md" />
+              </q-item-section>
+
+              <q-item-section>
+                <q-item-label class="ellipsis" style="max-width: 180px">
+                  {{ item.title }}
+                </q-item-label>
+                <q-item-label caption>
+                  {{ formatDate(item.created_at) }}
+                </q-item-label>
+              </q-item-section>
+
+              <q-item-section side>
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </template>
+      </div>
+
+      <!-- Coluna direita: pré-visualização -->
+      <div class="col-12 col-md-7">
+        <div class="preview-box q-pa-md">
+          <div
+            v-if="selectedIndex === null || !histories[selectedIndex]"
+            class="flex flex-center full-height text-grey-5"
+            style="min-height: 400px"
+          >
+            <div class="column items-center q-gutter-sm">
+              <q-icon name="mdi-history" size="64px" color="grey-3" />
+              <span>Selecione um item para visualizar</span>
+            </div>
+          </div>
+
+          <div v-else>
+            <div class="row justify-between items-start q-mb-sm">
+              <p class="text-weight-medium text-subtitle1 q-mb-none">
+                {{ histories[selectedIndex].title }}
+              </p>
+              <q-badge
+                v-if="histories[selectedIndex].visible_to_franchisee"
+                color="positive"
+                label="Visível ao franqueado"
+              />
+            </div>
+            <p class="text-caption text-grey-6 q-mb-md">
+              {{ formatDate(histories[selectedIndex].created_at) }}
+            </p>
+            <p class="text-grey-8" style="white-space: pre-wrap">
+              {{ histories[selectedIndex].content }}
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getHistoriesByUnit } from "src/api/unit_history";
+
+const props = defineProps({
+  unitId: { type: Number, default: null },
+});
+
+const histories = ref([]);
+const selectedIndex = ref(null);
+const loading = ref(false);
+
+async function fetchHistories() {
+  if (!props.unitId) return;
+  loading.value = true;
+  try {
+    histories.value = await getHistoriesByUnit(props.unitId);
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return "";
+  return new Date(dateStr).toLocaleDateString("pt-BR");
+}
+
+onMounted(fetchHistories);
+</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);
+}
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

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

@@ -0,0 +1,199 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row q-col-gutter-md">
+      <!-- Coluna esquerda: lista -->
+      <div class="col-12 col-md-5">
+        <div class="row justify-between items-center q-mb-md">
+          <span class="text-subtitle1 text-weight-medium">Mídias</span>
+          <q-btn
+            icon="add"
+            color="primary-2"
+            style="height: 40px; width: 40px; border-radius: 8px"
+            :disable="!unitId"
+            @click="openAddDialog"
+          />
+        </div>
+
+        <div v-if="loading" class="row justify-center q-pa-xl">
+          <q-spinner color="primary" size="40px" />
+        </div>
+
+        <template v-else>
+          <div v-if="medias.length === 0" class="text-center text-grey-6 q-pa-xl">
+            <q-icon name="mdi-image-multiple-outline" size="48px" color="grey-4" />
+            <div class="q-mt-sm">Nenhuma mídia adicionada.</div>
+          </div>
+
+          <q-list v-else separator>
+            <q-item
+              v-for="(item, index) in medias"
+              :key="item.id"
+              clickable
+              :active="selectedIndex === index"
+              active-class="media-item-active"
+              @click="selectedIndex = index"
+            >
+              <q-item-section avatar>
+                <q-icon :name="getFileIcon(item.mime_type)" :color="getFileColor(item.mime_type)" size="md" />
+              </q-item-section>
+
+              <q-item-section>
+                <q-item-label class="ellipsis" style="max-width: 180px">
+                  {{ item.title }}
+                </q-item-label>
+                <q-item-label caption>
+                  {{ formatDate(item.created_at) }}
+                </q-item-label>
+              </q-item-section>
+
+              <q-item-section side>
+                <q-btn
+                  flat round dense icon="delete"
+                  color="negative" size="sm"
+                  @click.stop="onRemove(item, index)"
+                />
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </template>
+      </div>
+
+      <!-- Coluna direita: pré-visualização -->
+      <div class="col-12 col-md-7">
+        <div class="preview-box">
+          <div
+            v-if="selectedIndex === null || !medias[selectedIndex]"
+            class="flex flex-center full-height text-grey-5"
+            style="min-height: 500px"
+          >
+            <div class="column items-center q-gutter-sm">
+              <q-icon name="mdi-image-multiple-outline" size="64px" color="grey-3" />
+              <span>Selecione uma mídia para visualizar</span>
+            </div>
+          </div>
+
+          <template v-else>
+            <img
+              v-if="isImage(medias[selectedIndex].mime_type)"
+              :src="medias[selectedIndex].file_url"
+              style="width: 100%; border-radius: 8px; display: block"
+            />
+            <video
+              v-else-if="isVideo(medias[selectedIndex].mime_type)"
+              :src="medias[selectedIndex].file_url"
+              controls
+              style="width: 100%; border-radius: 8px; display: block"
+            />
+            <iframe
+              v-else
+              :src="medias[selectedIndex].file_url"
+              style="width: 100%; min-height: 500px; border: none; border-radius: 8px"
+            />
+          </template>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import { getMediasByUnit, deleteMedia } from "src/api/unit_media";
+import AddMediaDialog from "src/pages/unit/components/AddMediaDialog.vue";
+
+const props = defineProps({
+  unitId: { type: Number, default: null },
+});
+
+const $q = useQuasar();
+const medias = ref([]);
+const selectedIndex = ref(null);
+const loading = ref(false);
+
+async function fetchMedias() {
+  if (!props.unitId) return;
+  loading.value = true;
+  try {
+    medias.value = await getMediasByUnit(props.unitId);
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function openAddDialog() {
+  $q.dialog({
+    component: AddMediaDialog,
+    componentProps: { unitId: props.unitId },
+  }).onOk((result) => {
+    medias.value.unshift(result);
+    selectedIndex.value = 0;
+  });
+}
+
+function onRemove(item, index) {
+  $q.dialog({
+    title: "Remover mídia",
+    message: `Deseja remover a mídia "${item.title}"?`,
+    ok: { color: "negative", label: "Remover" },
+    cancel: { color: "primary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    try {
+      await deleteMedia(item.id);
+      medias.value.splice(index, 1);
+      if (selectedIndex.value === index) selectedIndex.value = null;
+      else if (selectedIndex.value > index) selectedIndex.value--;
+    } catch (e) {
+      console.error(e);
+    }
+  });
+}
+
+function isImage(mimeType) {
+  return mimeType?.startsWith("image/");
+}
+
+function isVideo(mimeType) {
+  return mimeType?.startsWith("video/");
+}
+
+function getFileIcon(mimeType) {
+  if (isImage(mimeType)) return "mdi-image-outline";
+  if (isVideo(mimeType)) return "mdi-video-outline";
+  return "mdi-file-pdf-box";
+}
+
+function getFileColor(mimeType) {
+  if (isImage(mimeType)) return "teal";
+  if (isVideo(mimeType)) return "blue";
+  return "negative";
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return "";
+  return new Date(dateStr).toLocaleDateString("pt-BR");
+}
+
+onMounted(fetchMedias);
+</script>
+
+<style scoped>
+.preview-box {
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  overflow: hidden;
+  min-height: 500px;
+}
+
+.media-item-active {
+  background-color: rgba(255, 131, 64, 0.08);
+}
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

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

@@ -0,0 +1,195 @@
+<template>
+  <div class="q-pa-md">
+    <div class="row justify-end q-mb-md">
+      <q-btn
+        icon="add"
+        color="primary-2"
+        style="height: 40px; width: 40px"
+        @click="onAddClick"
+      />
+    </div>
+
+    <div v-if="loading" class="row justify-center q-pa-xl">
+      <q-spinner color="primary" size="40px" />
+    </div>
+
+    <template v-else>
+      <!-- Modo criação: lista de sócios pendentes -->
+      <template v-if="!unitId">
+        <div
+          v-if="partners.length === 0"
+          class="text-center text-grey-6 q-pa-xl"
+        >
+          Nenhum sócio adicionado. Os sócios serão criados junto com a unidade.
+        </div>
+
+        <div v-else class="row q-col-gutter-md">
+          <div
+            v-for="(partner, index) in partners"
+            :key="index"
+            class="col-xs-12 col-sm-6 col-md-3"
+          >
+            <div class="relative-position">
+              <PartnerCardComponent
+                :partner
+                @click="onPendingClick(partner, index)"
+              />
+
+              <q-btn
+                round
+                unelevated
+                size="xs"
+                icon="delete"
+                color="negative"
+                text-color="white"
+                class="absolute"
+                style="top: 8px; right: 8px"
+                @click.stop="removePendingPartner(index)"
+              >
+                <q-tooltip>Remover sócio</q-tooltip>
+              </q-btn>
+            </div>
+          </div>
+        </div>
+      </template>
+
+      <!-- Modo edição: sócios salvos -->
+      <template v-else>
+        <div
+          v-if="partners.length === 0"
+          class="text-center text-grey-6 q-pa-xl"
+        >
+          Nenhum sócio cadastrado para esta unidade.
+        </div>
+
+        <div v-else class="row q-col-gutter-md">
+          <div
+            v-for="partner in partners"
+            :key="partner.id"
+            class="col-xs-12 col-sm-6 col-md-3"
+          >
+            <div class="relative-position">
+              <PartnerCardComponent :partner @click="openDialog(partner)" />
+
+              <q-btn
+                round
+                unelevated
+                size="xs"
+                icon="delete"
+                color="negative"
+                text-color="white"
+                class="absolute"
+                style="top: 8px; right: 8px"
+                @click.stop="confirmDelete(partner)"
+              >
+                <q-tooltip>Remover sócio</q-tooltip>
+              </q-btn>
+            </div>
+          </div>
+        </div>
+      </template>
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from "vue";
+import { useQuasar } from "quasar";
+import PartnerCardComponent from "src/components/shared/PartnerCardComponent.vue";
+import AddEditPartnerDialog from "src/pages/unit/components/AddEditPartnerDialog.vue";
+import { getPartnersByUnit, deletePartner } from "src/api/unit_partner";
+
+const props = defineProps({
+  unitId: {
+    type: Number,
+    default: null,
+  },
+});
+
+const partners = defineModel("partners", { type: Array, default: () => [] });
+
+const $q = useQuasar();
+
+const loading = ref(false);
+
+async function fetchPartners() {
+  if (!props.unitId) return;
+  loading.value = true;
+  try {
+    partners.value = await getPartnersByUnit(props.unitId);
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function revokeBlobUrl(partner) {
+  if (partner.avatar_url?.startsWith("blob:")) {
+    URL.revokeObjectURL(partner.avatar_url);
+  }
+}
+
+function removePendingPartner(index) {
+  revokeBlobUrl(partners.value[index]);
+  partners.value.splice(index, 1);
+}
+
+function onAddClick() {
+  if (props.unitId) {
+    openDialog(null);
+  } else {
+    $q.dialog({
+      component: AddEditPartnerDialog,
+      componentProps: { offlineMode: true },
+    }).onOk((partnerData) => {
+      partners.value.push(partnerData);
+    });
+  }
+}
+
+function onPendingClick(partner, index) {
+  $q.dialog({
+    component: AddEditPartnerDialog,
+    componentProps: { partner, offlineMode: true },
+  }).onOk((updatedData) => {
+    partners.value.splice(index, 1, updatedData);
+  });
+}
+
+function openDialog(partner) {
+  $q.dialog({
+    component: AddEditPartnerDialog,
+    componentProps: {
+      partner,
+      unitId: props.unitId,
+    },
+  }).onOk(() => {
+    fetchPartners();
+  });
+}
+
+function confirmDelete(partner) {
+  $q.dialog({
+    title: "Remover sócio",
+    message: `Deseja remover o sócio "${partner.name}"?`,
+    ok: { color: "negative", label: "Remover" },
+    cancel: { color: "primary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    try {
+      await deletePartner(partner.id);
+      partners.value = partners.value.filter((p) => p.id !== partner.id);
+    } catch (e) {
+      console.error(e);
+    }
+  });
+}
+
+onMounted(fetchPartners);
+
+onUnmounted(() => {
+  if (!props.unitId) {
+    partners.value.forEach(revokeBlobUrl);
+  }
+});
+</script>

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

@@ -0,0 +1,251 @@
+<template>
+  <div class="q-pa-md">
+    <q-form ref="formRef">
+      <div class="column justify-center items-center q-mb-lg">
+        <AvatarImageComponent ref="avatarRef" @update:file="onAvatarChange" />
+
+        <div class="row full-width q-mt-md q-col-gutter-sm">
+          <DefaultInput
+            :model-value="form.social_reason"
+            label="Razão Social"
+            class="col-12"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.fantasy_name"
+            label="Nome Fantasia"
+            class="col-12"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.cnpj"
+            label="CNPJ"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.state_registration"
+            label="Inscrição Estadual"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.name_responsible"
+            label="Responsável"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.postal_code"
+            label="CEP"
+            class="col-3"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.street"
+            label="Endereço"
+            class="col-6"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.address_number"
+            label="Número"
+            class="col-3"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.neighborhood"
+            label="Bairro"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="stateName"
+            label="Estado"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="cityName"
+            label="Cidade"
+            class="col-4"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.complement"
+            label="Complemento"
+            class="col-12"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.email"
+            label="E-mail Principal"
+            class="col-6"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.secondary_email"
+            label="E-mail Secundário"
+            class="col-6"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.phone_number"
+            label="Telefone"
+            class="col-6"
+            outlined
+            disable
+          />
+
+          <DefaultInput
+            :model-value="form.cell_number"
+            label="Celular"
+            class="col-6"
+            outlined
+            disable
+          />
+
+          <div class="col-12 q-mt-sm">
+            <div class="text-subtitle2 text-grey-7 q-mb-sm">Alterar Senha</div>
+          </div>
+
+          <DefaultPasswordInput
+            v-model="password"
+            label="Nova Senha"
+            class="col-6"
+            outlined
+            :rules="password ? [inputRules.password] : []"
+          />
+
+          <DefaultPasswordInput
+            v-model="passwordConfirmation"
+            label="Confirmar Nova Senha"
+            class="col-6"
+            outlined
+            :rules="password ? [inputRules.samePassword(password)] : []"
+          />
+        </div>
+
+        <div class="row justify-end q-mt-md items-end full-width q-px-xs">
+          <q-btn
+            label="Salvar"
+            color="primary-2"
+            :loading="loading"
+            :disable="!hasChanges"
+            @click="onSave"
+          />
+        </div>
+      </div>
+    </q-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useInputRules } from "src/composables/useInputRules";
+import { getUnitMe, updateUnitMe } from "src/api/unit";
+import { updateUserMe } from "src/api/user.js";
+import { userStore } from "src/stores/user";
+
+const props = defineProps({
+  unitId: { type: Number, default: null },
+  getFormAsFormData: { type: Function, required: true },
+  setUpdateFormAsOriginal: { type: Function, required: true },
+});
+
+const form = defineModel("form", { type: Object, required: true });
+const store = userStore();
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+const avatarRef = ref(null);
+const newAvatarFile = ref(null);
+const password = ref(null);
+const passwordConfirmation = ref(null);
+const stateName = ref("");
+const cityName = ref("");
+
+const hasChanges = computed(() => !!newAvatarFile.value || !!password.value);
+
+function onAvatarChange(file) {
+  newAvatarFile.value = file;
+}
+
+onMounted(async () => {
+  try {
+    const unit = await getUnitMe();
+    form.value.fantasy_name = unit.fantasy_name;
+    form.value.social_reason = unit.social_reason;
+    form.value.cnpj = unit.cnpj;
+    form.value.state_registration = unit.state_registration;
+    form.value.name_responsible = unit.name_responsible;
+    form.value.street = unit.street;
+    form.value.address_number = unit.address_number;
+    form.value.postal_code = unit.postal_code;
+    form.value.neighborhood = unit.neighborhood;
+    form.value.complement = unit.complement;
+    form.value.email = unit.email;
+    form.value.secondary_email = unit.secondary_email;
+    form.value.phone_number = unit.phone_number;
+    form.value.cell_number = unit.cell_number;
+    stateName.value = unit.state?.name ?? "";
+    cityName.value = unit.city?.name ?? "";
+    if (unit.avatar_url) {
+      avatarRef.value?.setImageUrl(unit.avatar_url);
+    }
+    props.setUpdateFormAsOriginal();
+  } catch (e) {
+    console.error(e);
+  }
+});
+
+const { loading, execute } = useSubmitHandler({ formRef });
+
+async function onSave() {
+  await execute(async () => {
+    if (newAvatarFile.value) {
+      const formData = new FormData();
+      formData.append("avatar", newAvatarFile.value);
+      await updateUnitMe(formData);
+      newAvatarFile.value = null;
+    }
+    if (password.value) {
+      await updateUserMe({ password: password.value });
+      password.value = null;
+      passwordConfirmation.value = null;
+    }
+  });
+}
+</script>

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

@@ -1,4 +1,13 @@
 export default [
+  {
+    path: "/unit/me",
+    name: "UnitDataPage",
+    component: () => import("pages/unit/UnitActionPage.vue"),
+    meta: {
+      title: { value: "Dados da Unidade", translate: false },
+      requireAuth: true,
+    },
+  },
   {
     path: "/unit/:id/edit",
     name: "UnitEditPage",

+ 8 - 8
src/stores/navigation.js

@@ -36,14 +36,14 @@ export const navigationStore = defineStore("navigation", () => {
     //   disable: false,
     //   permission: true,
     // },
-    // {
-    //   type: "single",
-    //   title: "Dados da Unidade",
-    //   name: "UnitEditPage",
-    //   icon: "mdi-cog-outline",
-    //   disable: false,
-    //   permission: true,
-    // },
+    {
+      type: "single",
+      title: "Dados da Unidade",
+      name: "UnitDataPage",
+      icon: "mdi-cog-outline",
+      disable: false,
+      permission: true,
+    },
   ]);
 
   const getNavigationAccess = () => {