Explorar o código

feat: adiciona sociedade para unidade

ebagabee hai 2 semanas
pai
achega
ff470bbb1b

+ 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;
+};

+ 42 - 28
src/components/shared/PartnerCardComponent.vue

@@ -1,9 +1,9 @@
 <template>
-  <div class="partner-card column items-center q-pa-md">
+  <div class="partner-card column items-center q-pa-md cursor-pointer" @click="emit('click')">
     <div class="partner-avatar flex flex-center q-mb-md">
       <img
-        v-if="partner.avatarUrl"
-        :src="partner.avatarUrl"
+        v-if="partner.avatar_url"
+        :src="partner.avatar_url"
         class="avatar-img"
       />
       <span v-else class="avatar-initials text-white text-weight-bold">
@@ -11,45 +11,47 @@
       </span>
     </div>
 
-    <div class="full-width column q-gutter-sm">
-      <DefaultInput
-        :model-value="partner.social_name"
-        label="Nome social"
-        outlined
-        disable
-        bg-color="suface"
-      />
+    <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>
 
-      <DefaultInput
-        :model-value="partner.role"
-        label="Função"
-        outlined
-        disable
-        bg-color="suface"
-      />
+      <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>
   </div>
 </template>
 
 <script setup>
 import { computed } from "vue";
-import DefaultInput from "../defaults/DefaultInput.vue";
+
+const emit = defineEmits(["click"]);
 
 const { partner } = defineProps({
   partner: {
     type: Object,
-    default: () => ({
-      social_name: null,
-      role: null,
-      avatarUrl: null,
-      color: "#ff8340",
-    }),
+    required: true,
   },
 });
 
 const initials = computed(() => {
-  if (!partner.social_name) return "";
-  return partner.social_name
+  if (!partner.name) return "?";
+  return partner.name
     .split(" ")
     .slice(0, 2)
     .map((w) => w[0]?.toUpperCase())
@@ -61,13 +63,19 @@ const initials = computed(() => {
 .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%;
-  background-color: v-bind("partner.color || '#ff8340'");
+  background-color: #ff8340;
   overflow: hidden;
   flex-shrink: 0;
 }
@@ -81,4 +89,10 @@ const initials = computed(() => {
 .avatar-initials {
   font-size: 28px;
 }
+
+.ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 </style>

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

@@ -5,7 +5,7 @@
     <CustomTabComponent v-model:active-tab="activeTab" :tabs />
 
     <UnitDataTab v-if="activeTab === 'unit_data'" :unit-id="unitId" />
-    <PartnersTab v-if="activeTab === 'partners'" />
+    <PartnersTab v-if="activeTab === 'partners'" :unit-id="unitId" />
     <ContractsTab v-if="activeTab === 'contracts'" />
     <FinancialTab v-if="activeTab === 'financial'" />
     <HistoryTab v-if="activeTab === 'history'" />

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

@@ -0,0 +1,298 @@
+<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-3"
+              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-3"
+              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 } = defineProps({
+  partner: {
+    type: Object,
+    default: null,
+  },
+  unitId: {
+    type: Number,
+    required: true,
+  },
+});
+
+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);
+
+// 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;
+  form.avatar = file;
+}
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+});
+
+async function onOKClick() {
+  await execute(() => {
+    const formData = getFormAsFormData();
+
+    if (avatarChanged.value) {
+      formData.append("avatar", form.avatar ?? "");
+    }
+
+    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>

+ 85 - 155
src/pages/unit/tabs/PartnersTab.vue

@@ -1,137 +1,42 @@
 <template>
   <div class="q-pa-md">
-    <template v-if="view === 'list'">
-      <div class="row justify-end q-mb-md">
-        <q-btn
-          icon="add"
-          color="primary-2"
-          style="height: 40px; width: 40px"
-          @click="view = 'form'"
-        />
-      </div>
-
-      <div class="row q-col-gutter-md">
-        <div v-for="(partner, index) in partners" :key="index" class="col-3">
-          <PartnerCardComponent :partner />
-        </div>
-      </div>
-    </template>
+    <div class="row justify-end q-mb-md">
+      <q-btn
+        icon="add"
+        color="primary-2"
+        style="height: 40px; width: 40px"
+        :disable="!unitId"
+        @click="openDialog(null)"
+      />
+    </div>
+
+    <div v-if="loading" class="row justify-center q-pa-xl">
+      <q-spinner color="primary" size="40px" />
+    </div>
 
     <template v-else>
-      <div class="column justify-center items-center q-mb-lg">
-        <AvatarImageComponent @update:file="onAvatarChange" />
-
-        <div class="row full-width q-mt-md q-col-gutter-sm">
-          <DefaultInput
-            v-model="form.full_name"
-            label="Nome completo"
-            class="col-12"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.social_name"
-            label="Nome social"
-            class="col-6"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.role"
-            label="Função"
-            class="col-6"
-            outlined
-          />
-
-          <DefaultInput v-model="form.cpf" label="CPF" class="col-6" outlined />
-
-          <DefaultInput v-model="form.rg" label="RG" class="col-6" outlined />
-
-          <DefaultInput
-            v-model="form.address"
-            label="Endereço"
-            class="col-8"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.address_number"
-            label="Número"
-            class="col-4"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.zip_code"
-            label="CEP"
-            class="col-3"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.neighborhood"
-            label="Bairro"
-            class="col-5"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.city_state"
-            label="Cidade / Estado"
-            class="col-4"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.complement"
-            label="Complemento"
-            class="col-12"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.primary_email"
-            label="E-mail Principal"
-            class="col-6"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.secondary_email"
-            label="E-mail Secundário"
-            class="col-6"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.phone"
-            label="Telefone"
-            class="col-6"
-            outlined
-          />
+      <div v-if="partners.length === 0" class="text-center text-grey-6 q-pa-xl">
+        Nenhum sócio cadastrado para esta unidade.
+      </div>
 
-          <DefaultInput
-            v-model="form.cellphone_number"
-            label="Celular"
-            class="col-6"
-            outlined
-          />
-        </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)" />
 
-        <div class="row justify-end q-mt-md items-end full-width q-px-xs">
-          <div class="row q-gutter-sm">
             <q-btn
-              label="Cancelar"
-              color="primary"
-              outline
-              @click="view = 'list'"
-            />
-            <q-btn label="Salvar" color="primary" />
-            <q-btn
-              icon="mdi-paperclip-plus"
-              color="primary"
-              style="height: 40px; width: 40px"
-            />
+              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>
@@ -140,37 +45,62 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
 import PartnerCardComponent from "src/components/shared/PartnerCardComponent.vue";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-
-const view = ref("list");
+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 = ref([
-  { social_name: null, role: null, avatarUrl: null, color: "#ff8340" },
-]);
+const $q = useQuasar();
+const partners = ref([]);
+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;
+  }
+}
 
-const { form } = useFormUpdateTracker({
-  full_name: null,
-  social_name: null,
-  role: null,
-  cpf: null,
-  rg: null,
-  address: null,
-  address_number: null,
-  zip_code: null,
-  neighborhood: null,
-  city_state: null,
-  complement: null,
-  primary_email: null,
-  secondary_email: null,
-  phone: null,
-  cellphone_number: null,
-});
+function openDialog(partner) {
+  $q.dialog({
+    component: AddEditPartnerDialog,
+    componentProps: {
+      partner,
+      unitId: props.unitId,
+    },
+  }).onOk(() => {
+    fetchPartners();
+  });
+}
 
-function onAvatarChange(file) {
-  console.log("Avatar file selected:", file);
+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);
 </script>