CRM: refonte clients, fournisseurs et groupes clients
This commit is contained in:
parent
083f78673e
commit
ecfe25d3ca
@ -36,7 +36,7 @@ import { storeToRefs } from "pinia";
|
||||
|
||||
const router = useRouter();
|
||||
const clientStore = useClientStore();
|
||||
// Use storeToRefs to keep reactivity for pagination if it was passed as a prop,
|
||||
// Use storeToRefs to keep reactivity for pagination if it was passed as a prop,
|
||||
// but since we are using the store directly for actions, we can also extract it here if needed.
|
||||
// However, the common pattern is that the parent view passes the data.
|
||||
// Let's check where clientData comes from. It comes from props.
|
||||
@ -54,9 +54,9 @@ const props = defineProps({
|
||||
},
|
||||
// We need to accept pagination as a prop if it is passed from the view
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const goToClient = () => {
|
||||
@ -74,14 +74,18 @@ const deleteClient = (client) => {
|
||||
};
|
||||
|
||||
const onPageChange = (page) => {
|
||||
clientStore.fetchClients({ page: page, per_page: props.pagination.per_page });
|
||||
clientStore.fetchClients({ page: page, per_page: props.pagination.per_page });
|
||||
};
|
||||
|
||||
const onPerPageChange = (perPage) => {
|
||||
clientStore.fetchClients({ page: 1, per_page: perPage });
|
||||
clientStore.fetchClients({ page: 1, per_page: perPage });
|
||||
};
|
||||
|
||||
const onSearch = (query) => {
|
||||
clientStore.fetchClients({ page: 1, per_page: props.pagination.per_page, search: query });
|
||||
clientStore.fetchClients({
|
||||
page: 1,
|
||||
per_page: props.pagination.per_page,
|
||||
search: query,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,5 +1,61 @@
|
||||
<template>
|
||||
<fournisseur-detail-template>
|
||||
<template #header-right>
|
||||
<span class="badge bg-white text-primary px-3 py-2">
|
||||
{{ fournisseur.type_label || "Fournisseur" }}
|
||||
</span>
|
||||
<span
|
||||
class="badge px-3 py-2"
|
||||
:class="
|
||||
fournisseur.is_active
|
||||
? 'bg-white text-success'
|
||||
: 'bg-white text-danger'
|
||||
"
|
||||
>
|
||||
{{ fournisseur.is_active ? "Actif" : "Inactif" }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #summary-cards>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Contacts</p>
|
||||
<h5 class="mb-0">{{ filteredContactsCount }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Localisations</p>
|
||||
<h5 class="mb-0">{{ locations.length }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Email</p>
|
||||
<h6
|
||||
class="mb-0 text-truncate"
|
||||
:title="fournisseur.email || 'Non renseigné'"
|
||||
>
|
||||
{{ fournisseur.email || "Non renseigné" }}
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Téléphone</p>
|
||||
<h6 class="mb-0">{{ fournisseur.phone || "Non renseigné" }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #button-return>
|
||||
<div class="col-12">
|
||||
<router-link
|
||||
@ -204,3 +260,9 @@ const triggerFileInput = () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,34 @@
|
||||
<template>
|
||||
<fournisseur-template>
|
||||
|
||||
|
||||
<template #summary-cards>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Fournisseurs</p>
|
||||
<h5 class="mb-0">{{ totalFournisseurs }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Actifs</p>
|
||||
<h5 class="mb-0 text-success">{{ activeFournisseurs }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card stat-card border-0">
|
||||
<div class="card-body py-3">
|
||||
<p class="text-sm text-secondary mb-1">Inactifs</p>
|
||||
<h5 class="mb-0 text-danger">{{ inactiveFournisseurs }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fournisseur-new-action>
|
||||
<add-button text="Ajouter" @click="goToFournisseur" />
|
||||
</template>
|
||||
@ -25,14 +54,14 @@ import FournisseurTable from "@/components/molecules/Tables/CRM/FournisseurTable
|
||||
import addButton from "@/components/molecules/new-button/addButton.vue";
|
||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import { computed, defineProps, defineEmits } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const emit = defineEmits(["pushDetails", "deleteFournisseur"]);
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
fournisseurData: {
|
||||
type: Array,
|
||||
default: [],
|
||||
@ -43,6 +72,17 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const totalFournisseurs = computed(() => props.fournisseurData?.length || 0);
|
||||
const activeFournisseurs = computed(
|
||||
() => props.fournisseurData?.filter((f) => f?.is_active).length || 0
|
||||
);
|
||||
const inactiveFournisseurs = computed(
|
||||
() => totalFournisseurs.value - activeFournisseurs.value
|
||||
);
|
||||
const fournisseursWithEmail = computed(
|
||||
() => props.fournisseurData?.filter((f) => !!f?.email).length || 0
|
||||
);
|
||||
|
||||
const goToFournisseur = () => {
|
||||
router.push({
|
||||
name: "Creation fournisseur",
|
||||
@ -62,3 +102,9 @@ const deleteFournisseur = (fournisseur) => {
|
||||
emit("deleteFournisseur", fournisseur);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -11,53 +11,76 @@
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addChildModalLabel">Ajouter un sous-client</h5>
|
||||
<h5 id="addChildModalLabel" class="modal-title">
|
||||
Ajouter un sous-client
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close text-dark"
|
||||
@click="closeModal"
|
||||
aria-label="Close"
|
||||
@click="closeModal"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rechercher un client</label>
|
||||
<div v-if="selectedClient" class="d-flex align-items-center justify-content-between p-2 border rounded mb-3 bg-light">
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
alt="selected client"
|
||||
/>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="font-weight-bold">{{ selectedClient.name }}</span>
|
||||
<span class="text-xs text-muted">{{ selectedClient.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-link text-danger mb-0" @click="selectedClient = null">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientSearchInput
|
||||
v-else
|
||||
:exclude-ids="excludeIds"
|
||||
@select="handleSelect"
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rechercher un client</label>
|
||||
<div
|
||||
v-if="selectedClient"
|
||||
class="d-flex align-items-center justify-content-between p-2 border rounded mb-3 bg-light"
|
||||
>
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
alt="selected client"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="font-weight-bold">{{
|
||||
selectedClient.name
|
||||
}}</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
selectedClient.email
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-link text-danger mb-0"
|
||||
@click="selectedClient = null"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientSearchInput
|
||||
v-else
|
||||
:exclude-ids="excludeIds"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="closeModal">Annuler</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="closeModal"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
:disabled="!selectedClient || loading"
|
||||
@click="confirmAdd"
|
||||
>
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="spinner-border spinner-border-sm me-1"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
@ -80,7 +103,7 @@ const props = defineProps({
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "add"]);
|
||||
@ -93,18 +116,18 @@ const handleSelect = (client) => {
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
selectedClient.value = null;
|
||||
emit("close");
|
||||
selectedClient.value = null;
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const confirmAdd = async () => {
|
||||
if (!selectedClient.value) return;
|
||||
loading.value = true;
|
||||
emit("add", selectedClient.value);
|
||||
// Loading state is handled by parent usually, but here we emit and wait?
|
||||
// Ideally parent handles the async and closes modal.
|
||||
// For now simple emit.
|
||||
loading.value = false;
|
||||
closeModal();
|
||||
if (!selectedClient.value) return;
|
||||
loading.value = true;
|
||||
emit("add", selectedClient.value);
|
||||
// Loading state is handled by parent usually, but here we emit and wait?
|
||||
// Ideally parent handles the async and closes modal.
|
||||
// For now simple emit.
|
||||
loading.value = false;
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -23,7 +23,11 @@
|
||||
>
|
||||
Retour
|
||||
</soft-button>
|
||||
<soft-button color="info" variant="gradient" @click="handleEdit">
|
||||
<soft-button
|
||||
color="info"
|
||||
variant="gradient"
|
||||
@click="handleEdit"
|
||||
>
|
||||
Modifier
|
||||
</soft-button>
|
||||
</div>
|
||||
@ -42,7 +46,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<h6 class="text-sm text-uppercase text-muted">Date de création</h6>
|
||||
<h6 class="text-sm text-uppercase text-muted">
|
||||
Date de création
|
||||
</h6>
|
||||
<p class="text-sm">{{ formatDate(clientGroup.created_at) }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-4">{{ isEdit ? "Modifier le groupe" : "Nouveau groupe" }}</h5>
|
||||
<h5 class="mb-4">
|
||||
{{ isEdit ? "Modifier le groupe" : "Nouveau groupe" }}
|
||||
</h5>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@ -39,8 +41,19 @@
|
||||
>
|
||||
Annuler
|
||||
</soft-button>
|
||||
<soft-button type="submit" color="success" variant="gradient" :disabled="loading">
|
||||
{{ loading ? "Enregistrement..." : isEdit ? "Mettre à jour" : "Créer" }}
|
||||
<soft-button
|
||||
type="submit"
|
||||
color="success"
|
||||
variant="gradient"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
? "Enregistrement..."
|
||||
: isEdit
|
||||
? "Mettre à jour"
|
||||
: "Créer"
|
||||
}}
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,19 +15,23 @@
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
</select>
|
||||
<span class="text-secondary text-xs font-weight-bold">éléments par page</span>
|
||||
<span class="text-secondary text-xs font-weight-bold"
|
||||
>éléments par page</span
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-body"><i class="fas fa-search" aria-hidden="true"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Rechercher..."
|
||||
@input="onSearch"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text text-body"
|
||||
><i class="fas fa-search" aria-hidden="true"></i
|
||||
></span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Rechercher..."
|
||||
@input="onSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -227,39 +231,66 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Footer -->
|
||||
<div v-if="!loading && data.length > 0" class="d-flex justify-content-between align-items-center mt-3 px-3">
|
||||
<!-- Pagination Footer -->
|
||||
<div
|
||||
v-if="!loading && data.length > 0"
|
||||
class="d-flex justify-content-between align-items-center mt-3 px-3"
|
||||
>
|
||||
<div class="text-xs text-secondary font-weight-bold">
|
||||
Affichage de {{ pagination.from }} à {{ pagination.to }} sur {{ pagination.total }} clients
|
||||
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
|
||||
{{ pagination.total }} clients
|
||||
</div>
|
||||
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination pagination-sm pagination-success mb-0">
|
||||
<li class="page-item" :class="{ disabled: pagination.current_page === 1 }">
|
||||
<a class="page-link" href="#" aria-label="Previous" @click.prevent="changePage(pagination.current_page - 1)">
|
||||
<span aria-hidden="true"><i class="fa fa-angle-left" aria-hidden="true"></i></span>
|
||||
<li
|
||||
class="page-item"
|
||||
:class="{ disabled: pagination.current_page === 1 }"
|
||||
>
|
||||
<a
|
||||
class="page-link"
|
||||
href="#"
|
||||
aria-label="Previous"
|
||||
@click.prevent="changePage(pagination.current_page - 1)"
|
||||
>
|
||||
<span aria-hidden="true"
|
||||
><i class="fa fa-angle-left" aria-hidden="true"></i
|
||||
></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-for="page in displayedPages"
|
||||
:key="page"
|
||||
class="page-item"
|
||||
|
||||
<li
|
||||
v-for="page in displayedPages"
|
||||
:key="page"
|
||||
class="page-item"
|
||||
:class="{ active: pagination.current_page === page }"
|
||||
>
|
||||
<a class="page-link" href="#" @click.prevent="changePage(page)">{{ page }}</a>
|
||||
<a class="page-link" href="#" @click.prevent="changePage(page)">{{
|
||||
page
|
||||
}}</a>
|
||||
</li>
|
||||
|
||||
<li class="page-item" :class="{ disabled: pagination.current_page === pagination.last_page }">
|
||||
<a class="page-link" href="#" aria-label="Next" @click.prevent="changePage(pagination.current_page + 1)">
|
||||
<span aria-hidden="true"><i class="fa fa-angle-right" aria-hidden="true"></i></span>
|
||||
<li
|
||||
class="page-item"
|
||||
:class="{
|
||||
disabled: pagination.current_page === pagination.last_page,
|
||||
}"
|
||||
>
|
||||
<a
|
||||
class="page-link"
|
||||
href="#"
|
||||
aria-label="Next"
|
||||
@click.prevent="changePage(pagination.current_page + 1)"
|
||||
>
|
||||
<span aria-hidden="true"
|
||||
><i class="fa fa-angle-right" aria-hidden="true"></i
|
||||
></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && data.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
@ -282,7 +313,13 @@ import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
const emit = defineEmits(["view", "delete", "page-change", "per-page-change", "search-change"]);
|
||||
const emit = defineEmits([
|
||||
"view",
|
||||
"delete",
|
||||
"page-change",
|
||||
"per-page-change",
|
||||
"search-change",
|
||||
]);
|
||||
|
||||
// Sample avatar images
|
||||
import img1 from "@/assets/img/team-2.jpg";
|
||||
@ -326,24 +363,31 @@ const displayedPages = computed(() => {
|
||||
const current = props.pagination.current_page;
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
|
||||
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) {
|
||||
|
||||
for (
|
||||
let i = Math.max(2, current - delta);
|
||||
i <= Math.min(total - 1, current + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
|
||||
if (current - delta > 2) {
|
||||
range.unshift("...");
|
||||
}
|
||||
if (current + delta < total - 1) {
|
||||
range.push("...");
|
||||
}
|
||||
|
||||
|
||||
range.unshift(1);
|
||||
if (total > 1) {
|
||||
range.push(total);
|
||||
}
|
||||
|
||||
return range.filter((val, index, self) => val !== "..." || (val === "..." && self[index - 1] !== "..."));
|
||||
|
||||
return range.filter(
|
||||
(val, index, self) =>
|
||||
val !== "..." || (val === "..." && self[index - 1] !== "...")
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
@ -359,7 +403,7 @@ const onPerPageChange = (event) => {
|
||||
};
|
||||
|
||||
const onSearch = debounce((event) => {
|
||||
emit("search-change", event.target.value);
|
||||
emit("search-change", event.target.value);
|
||||
}, 300);
|
||||
|
||||
const getRandomAvatar = () => {
|
||||
|
||||
@ -31,7 +31,9 @@
|
||||
|
||||
<!-- Created At -->
|
||||
<td class="text-sm font-weight-bold">
|
||||
<span class="my-2 text-xs">{{ formatDate(group.created_at) }}</span>
|
||||
<span class="my-2 text-xs">{{
|
||||
formatDate(group.created_at)
|
||||
}}</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
@ -71,7 +73,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted, defineProps, defineEmits } from "vue";
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
watch,
|
||||
onUnmounted,
|
||||
defineProps,
|
||||
defineEmits,
|
||||
} from "vue";
|
||||
import { DataTable } from "simple-datatables";
|
||||
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
||||
|
||||
|
||||
@ -158,21 +158,31 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const filteredActivities = computed(() => {
|
||||
if (activeFilter.value === "all") {
|
||||
return activities.value;
|
||||
}
|
||||
// Filter locally based on event_type mapping to filter categories
|
||||
return activities.value.filter((a) => {
|
||||
const type = a.event_type;
|
||||
switch (activeFilter.value) {
|
||||
case 'call': return type === 'call';
|
||||
case 'email': return ['email_sent', 'email_received'].includes(type);
|
||||
case 'invoice': return ['invoice_created', 'invoice_sent', 'invoice_paid'].includes(type);
|
||||
case 'file': return ['file_uploaded', 'attachment_sent', 'attachment_received'].includes(type);
|
||||
default: return false;
|
||||
}
|
||||
const type = a.event_type;
|
||||
switch (activeFilter.value) {
|
||||
case "call":
|
||||
return type === "call";
|
||||
case "email":
|
||||
return ["email_sent", "email_received"].includes(type);
|
||||
case "invoice":
|
||||
return ["invoice_created", "invoice_sent", "invoice_paid"].includes(
|
||||
type
|
||||
);
|
||||
case "file":
|
||||
return [
|
||||
"file_uploaded",
|
||||
"attachment_sent",
|
||||
"attachment_received",
|
||||
].includes(type);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -6,82 +6,95 @@
|
||||
<h6 class="mb-0">Gestion des sous-comptes</h6>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<!-- Toggle Is Parent -->
|
||||
<div class="form-check form-switch d-inline-block ms-auto">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="isParentToggle"
|
||||
:checked="client.is_parent"
|
||||
@change="toggleParentStatus"
|
||||
/>
|
||||
<label class="form-check-label" for="isParentToggle">Compte Parent</label>
|
||||
</div>
|
||||
<!-- Add Child Button -->
|
||||
<soft-button
|
||||
v-if="client.is_parent"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
color="success"
|
||||
class="ms-3"
|
||||
@click="showAddModal = true"
|
||||
<!-- Toggle Is Parent -->
|
||||
<div class="form-check form-switch d-inline-block ms-auto">
|
||||
<input
|
||||
id="isParentToggle"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:checked="client.is_parent"
|
||||
@change="toggleParentStatus"
|
||||
/>
|
||||
<label class="form-check-label" for="isParentToggle"
|
||||
>Compte Parent</label
|
||||
>
|
||||
<i class="fas fa-plus me-2"></i>Ajouter un sous-client
|
||||
</soft-button>
|
||||
</div>
|
||||
<!-- Add Child Button -->
|
||||
<soft-button
|
||||
v-if="client.is_parent"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
color="success"
|
||||
class="ms-3"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
<i class="fas fa-plus me-2"></i>Ajouter un sous-client
|
||||
</soft-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div v-if="!client.is_parent" class="text-center py-4">
|
||||
<p class="text-muted">
|
||||
Ce client n'est pas défini comme compte parent. Activez l'option ci-dessus pour gérer des sous-comptes.
|
||||
</p>
|
||||
<div v-if="!client.is_parent" class="text-center py-4">
|
||||
<p class="text-muted">
|
||||
Ce client n'est pas défini comme compte parent. Activez l'option
|
||||
ci-dessus pour gérer des sous-comptes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="loading" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="loading" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="children.length === 0" class="text-center py-4">
|
||||
<p class="text-muted">Aucun sous-compte associé.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="list-group">
|
||||
<li v-for="child in children" :key="child.id" class="list-group-item border-0 d-flex justify-content-between ps-0 mb-2 border-radius-lg">
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
:img="getAvatar(child.name)"
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
alt="child client"
|
||||
/>
|
||||
<div class="d-flex flex-column">
|
||||
<h6 class="mb-1 text-dark text-sm">{{ child.name }}</h6>
|
||||
<span class="text-xs">{{ child.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center text-sm">
|
||||
<button class="btn btn-link text-dark text-sm mb-0 px-0 ms-4" @click="goToClient(child.id)">
|
||||
<i class="fas fa-eye text-lg me-1"></i> Voir
|
||||
</button>
|
||||
<button class="btn btn-link text-danger text-gradient px-3 mb-0" @click="confirmRemoveChild(child)">
|
||||
<i class="far fa-trash-alt me-2"></i> Détacher
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else-if="children.length === 0" class="text-center py-4">
|
||||
<p class="text-muted">Aucun sous-compte associé.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="list-group">
|
||||
<li
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
class="list-group-item border-0 d-flex justify-content-between ps-0 mb-2 border-radius-lg"
|
||||
>
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
:img="getAvatar(child.name)"
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
alt="child client"
|
||||
/>
|
||||
<div class="d-flex flex-column">
|
||||
<h6 class="mb-1 text-dark text-sm">{{ child.name }}</h6>
|
||||
<span class="text-xs">{{ child.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center text-sm">
|
||||
<button
|
||||
class="btn btn-link text-dark text-sm mb-0 px-0 ms-4"
|
||||
@click="goToClient(child.id)"
|
||||
>
|
||||
<i class="fas fa-eye text-lg me-1"></i> Voir
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link text-danger text-gradient px-3 mb-0"
|
||||
@click="confirmRemoveChild(child)"
|
||||
>
|
||||
<i class="far fa-trash-alt me-2"></i> Détacher
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<AddChildClientModal
|
||||
:show="showAddModal"
|
||||
:exclude-ids="[client.id, client.parent_id, ...children.map(c => c.id)]"
|
||||
@close="showAddModal = false"
|
||||
@add="handleAddChild"
|
||||
:show="showAddModal"
|
||||
:exclude-ids="[client.id, client.parent_id, ...children.map((c) => c.id)]"
|
||||
@close="showAddModal = false"
|
||||
@add="handleAddChild"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -92,8 +105,8 @@ import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import AddChildClientModal from "@/components/Organism/CRM/client/AddChildClientModal.vue";
|
||||
import { useClientStore } from "@/stores/clientStore";
|
||||
import { useRouter } from 'vue-router';
|
||||
import Swal from 'sweetalert2';
|
||||
import { useRouter } from "vue-router";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const props = defineProps({
|
||||
client: {
|
||||
@ -102,7 +115,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-client']);
|
||||
const emit = defineEmits(["update-client"]);
|
||||
const clientStore = useClientStore();
|
||||
const router = useRouter();
|
||||
const children = ref([]);
|
||||
@ -110,89 +123,93 @@ const loading = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
|
||||
const fetchChildren = async () => {
|
||||
if (!props.client.is_parent) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await clientStore.fetchChildClients(props.client.id);
|
||||
children.value = res.data || res; // handle potential array vs response structure
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch children", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (!props.client.is_parent) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await clientStore.fetchChildClients(props.client.id);
|
||||
children.value = res.data || res; // handle potential array vs response structure
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch children", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChildren();
|
||||
fetchChildren();
|
||||
});
|
||||
|
||||
watch(() => props.client.is_parent, (newVal) => {
|
||||
watch(
|
||||
() => props.client.is_parent,
|
||||
(newVal) => {
|
||||
if (newVal) fetchChildren();
|
||||
});
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
/* eslint-disable require-atomic-updates */
|
||||
const toggleParentStatus = async (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
// Optimistic update
|
||||
// emit('update-client', { ...props.client, is_parent: isChecked });
|
||||
|
||||
try {
|
||||
// We'll update the client via store, passing just the id and field to update
|
||||
await clientStore.updateClient({
|
||||
id: props.client.id,
|
||||
name: props.client.name,
|
||||
is_parent: isChecked
|
||||
});
|
||||
// The parent component should react to store changes if it watches it, or we emit updated
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
e.target.checked = !isChecked;
|
||||
Swal.fire('Erreur', 'Impossible de mettre à jour le statut parent.', 'error');
|
||||
}
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
// Optimistic update
|
||||
// emit('update-client', { ...props.client, is_parent: isChecked });
|
||||
|
||||
try {
|
||||
// We'll update the client via store, passing just the id and field to update
|
||||
await clientStore.updateClient({
|
||||
id: props.client.id,
|
||||
name: props.client.name,
|
||||
is_parent: isChecked,
|
||||
});
|
||||
// The parent component should react to store changes if it watches it, or we emit updated
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
e.target.checked = !isChecked;
|
||||
Swal.fire(
|
||||
"Erreur",
|
||||
"Impossible de mettre à jour le statut parent.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChild = async (selectedClient) => {
|
||||
try {
|
||||
await clientStore.addChildClient(props.client.id, selectedClient.id);
|
||||
Swal.fire('Succès', 'Le client a été ajouté comme sous-compte.', 'success');
|
||||
fetchChildren();
|
||||
} catch (e) {
|
||||
Swal.fire('Erreur', "Impossible d'ajouter le sous-compte.", 'error');
|
||||
}
|
||||
try {
|
||||
await clientStore.addChildClient(props.client.id, selectedClient.id);
|
||||
Swal.fire("Succès", "Le client a été ajouté comme sous-compte.", "success");
|
||||
fetchChildren();
|
||||
} catch (e) {
|
||||
Swal.fire("Erreur", "Impossible d'ajouter le sous-compte.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const confirmRemoveChild = async (child) => {
|
||||
const result = await Swal.fire({
|
||||
title: 'Confirmer le détachement',
|
||||
text: `Voulez-vous vraiment détacher ${child.name} ?`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Oui, détacher',
|
||||
cancelButtonText: 'Annuler'
|
||||
});
|
||||
const result = await Swal.fire({
|
||||
title: "Confirmer le détachement",
|
||||
text: `Voulez-vous vraiment détacher ${child.name} ?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Oui, détacher",
|
||||
cancelButtonText: "Annuler",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await clientStore.removeChildClient(props.client.id, child.id);
|
||||
Swal.fire('Détaché!', 'Le client a été détaché.', 'success');
|
||||
children.value = children.value.filter(c => c.id !== child.id);
|
||||
} catch (e) {
|
||||
Swal.fire('Erreur', "Impossible de détacher le client.", 'error');
|
||||
}
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await clientStore.removeChildClient(props.client.id, child.id);
|
||||
Swal.fire("Détaché!", "Le client a été détaché.", "success");
|
||||
children.value = children.value.filter((c) => c.id !== child.id);
|
||||
} catch (e) {
|
||||
Swal.fire("Erreur", "Impossible de détacher le client.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goToClient = (id) => {
|
||||
router.push(`/crm/clients/${id}`); // Adjust route as needed
|
||||
router.push(`/crm/clients/${id}`); // Adjust route as needed
|
||||
};
|
||||
|
||||
// Helper for initials/avatar
|
||||
const getAvatar = (name) => {
|
||||
// placeholder logic, replace with actual avatar logic or component usage
|
||||
return null;
|
||||
// placeholder logic, replace with actual avatar logic or component usage
|
||||
return null;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
@ -9,28 +9,32 @@
|
||||
placeholder="Rechercher un client (nom, email...)"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="btn btn-outline-secondary mb-0"
|
||||
type="button"
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="btn btn-outline-secondary mb-0"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm" style="z-index: 1000; top: 100%;">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm"
|
||||
style="z-index: 1000; top: 100%"
|
||||
>
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Dropdown -->
|
||||
<div
|
||||
v-else-if="results.length > 0 && showResults"
|
||||
class="list-group position-absolute w-100 mt-1 shadow-lg"
|
||||
style="z-index: 1000; max-height: 300px; overflow-y: auto;"
|
||||
style="z-index: 1000; max-height: 300px; overflow-y: auto"
|
||||
>
|
||||
<button
|
||||
v-for="client in results"
|
||||
@ -39,27 +43,29 @@
|
||||
class="list-group-item list-group-item-action d-flex align-items-center p-2"
|
||||
@click="handleSelect(client)"
|
||||
>
|
||||
<soft-avatar
|
||||
:img="getAvatar(client)"
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
:alt="client.name"
|
||||
<soft-avatar
|
||||
:img="getAvatar(client)"
|
||||
size="sm"
|
||||
border-radius="md"
|
||||
class="me-3"
|
||||
:alt="client.name"
|
||||
/>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="font-weight-bold text-sm">{{ client.name }}</span>
|
||||
<span class="text-xs text-muted">{{ client.email || 'Pas d\'email' }}</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
client.email || "Pas d'email"
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- No Results -->
|
||||
<div
|
||||
v-else-if="searchQuery && !loading && showResults"
|
||||
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||
style="z-index: 1000;"
|
||||
<div
|
||||
v-else-if="searchQuery && !loading && showResults"
|
||||
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||
style="z-index: 1000"
|
||||
>
|
||||
Aucun client trouvé.
|
||||
Aucun client trouvé.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -70,10 +76,10 @@ import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import { useClientStore } from "@/stores/clientStore";
|
||||
|
||||
const props = defineProps({
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
@ -86,46 +92,46 @@ const showResults = ref(false);
|
||||
let debounceTimeout = null;
|
||||
|
||||
const handleInput = () => {
|
||||
showResults.value = true;
|
||||
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
return;
|
||||
}
|
||||
showResults.value = true;
|
||||
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||
|
||||
loading.value = true;
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await clientStore.searchClients(searchQuery.value);
|
||||
// Filter out excluded IDs (e.g. self, parent)
|
||||
results.value = res.filter(c => !props.excludeIds.includes(c.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
results.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, 300);
|
||||
if (!searchQuery.value.trim()) {
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await clientStore.searchClients(searchQuery.value);
|
||||
// Filter out excluded IDs (e.g. self, parent)
|
||||
results.value = res.filter((c) => !props.excludeIds.includes(c.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
results.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSelect = (client) => {
|
||||
emit("select", client);
|
||||
searchQuery.value = "";
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
emit("select", client);
|
||||
searchQuery.value = "";
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = "";
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
searchQuery.value = "";
|
||||
results.value = [];
|
||||
showResults.value = false;
|
||||
};
|
||||
|
||||
// Helper for avatar (placeholder logic)
|
||||
const getAvatar = (client) => {
|
||||
// If client has an avatar property use it, else return null/placeholder logic handled by SoftAvatar or similar
|
||||
return null;
|
||||
// If client has an avatar property use it, else return null/placeholder logic handled by SoftAvatar or similar
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -22,10 +22,7 @@
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="fieldErrors.client_category_id"
|
||||
class="invalid-feedback"
|
||||
>
|
||||
<div v-if="fieldErrors.client_category_id" class="invalid-feedback">
|
||||
{{ errorMessage("client_category_id") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -294,7 +294,7 @@ watch(
|
||||
(newErrors) => {
|
||||
fieldErrors.value = { ...newErrors };
|
||||
},
|
||||
{ deep: true },
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch for success from parent
|
||||
@ -304,7 +304,7 @@ watch(
|
||||
if (newSuccess) {
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const submitForm = async () => {
|
||||
|
||||
@ -1,16 +1,51 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="container-fluid py-4 fournisseur-detail-shell">
|
||||
<div class="card border-0 mb-4 hero-shell">
|
||||
<div class="card-body p-4">
|
||||
<div
|
||||
class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-white mb-1">
|
||||
<slot name="page-title">Détail fournisseur</slot>
|
||||
</h4>
|
||||
<p class="text-white-50 mb-0">
|
||||
<slot name="page-subtitle"
|
||||
>Consultez et gérez toutes les informations du
|
||||
fournisseur.</slot
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<slot name="summary-cards" />
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<slot name="button-return" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-3 col-lg-4">
|
||||
<slot name="fournisseur-detail-sidebar" />
|
||||
<slot name="file-input" />
|
||||
</div>
|
||||
<div class="col-lg-9 mt-lg-0 mt-4">
|
||||
<div class="col-xl-9 col-lg-8 mt-lg-0 mt-2">
|
||||
<slot name="fournisseur-detail-content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-shell {
|
||||
background: linear-gradient(135deg, #344767 0%, #5e72e4 100%);
|
||||
box-shadow: 0 10px 30px rgba(52, 71, 103, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,19 +1,49 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-sm-flex justify-content-between">
|
||||
<div>
|
||||
<slot name="fournisseur-new-action"></slot>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="dropdown d-inline">
|
||||
<slot name="select-filter"></slot>
|
||||
<div class="container-fluid py-4 fournisseur-shell">
|
||||
<div class="card border-0 mb-4 hero-shell">
|
||||
<div class="card-body p-4">
|
||||
<div
|
||||
class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-white mb-1">
|
||||
<slot name="page-title">Gestion des fournisseurs</slot>
|
||||
</h4>
|
||||
<p class="text-white-50 mb-0">
|
||||
<slot name="page-subtitle"
|
||||
>Pilotez vos fournisseurs et centralisez leurs
|
||||
informations.</slot
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="fournisseur-other-action"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mt-4">
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<slot name="summary-cards" />
|
||||
</div>
|
||||
|
||||
<div class="card border-0 content-shell">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<div
|
||||
class="d-sm-flex justify-content-between align-items-center gap-2 action-row"
|
||||
>
|
||||
<div>
|
||||
<slot name="fournisseur-new-action"></slot>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="dropdown d-inline">
|
||||
<slot name="select-filter"></slot>
|
||||
</div>
|
||||
<slot name="fournisseur-other-action"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 table-shell">
|
||||
<slot name="fournisseur-table"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@ -21,3 +51,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script></script>
|
||||
|
||||
<style scoped>
|
||||
.hero-shell {
|
||||
background: linear-gradient(135deg, #344767 0%, #5e72e4 100%);
|
||||
box-shadow: 0 10px 30px rgba(52, 71, 103, 0.2);
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -269,7 +269,7 @@ export const ClientService = {
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get child clients for a parent
|
||||
*/
|
||||
async getChildClients(parentId: number): Promise<ClientListResponse> {
|
||||
|
||||
@ -27,7 +27,8 @@ export interface CreateClientGroupPayload {
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateClientGroupPayload extends Partial<CreateClientGroupPayload> {
|
||||
export interface UpdateClientGroupPayload
|
||||
extends Partial<CreateClientGroupPayload> {
|
||||
id: number;
|
||||
}
|
||||
|
||||
@ -64,7 +65,9 @@ export const ClientGroupService = {
|
||||
/**
|
||||
* Create a new client group
|
||||
*/
|
||||
async createClientGroup(payload: CreateClientGroupPayload): Promise<ClientGroupResponse> {
|
||||
async createClientGroup(
|
||||
payload: CreateClientGroupPayload
|
||||
): Promise<ClientGroupResponse> {
|
||||
const response = await request<ClientGroupResponse>({
|
||||
url: "/api/client-groups",
|
||||
method: "post",
|
||||
@ -77,7 +80,9 @@ export const ClientGroupService = {
|
||||
/**
|
||||
* Update an existing client group
|
||||
*/
|
||||
async updateClientGroup(payload: UpdateClientGroupPayload): Promise<ClientGroupResponse> {
|
||||
async updateClientGroup(
|
||||
payload: UpdateClientGroupPayload
|
||||
): Promise<ClientGroupResponse> {
|
||||
const { id, ...updateData } = payload;
|
||||
|
||||
const response = await request<ClientGroupResponse>({
|
||||
|
||||
@ -79,7 +79,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to fetch client groups";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to fetch client groups";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -100,7 +102,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to fetch client group";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to fetch client group";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -123,7 +127,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to create client group";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to create client group";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -151,14 +157,19 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
}
|
||||
|
||||
// Update current group if it's the one being edited
|
||||
if (currentClientGroup.value && currentClientGroup.value.id === updatedGroup.id) {
|
||||
if (
|
||||
currentClientGroup.value &&
|
||||
currentClientGroup.value.id === updatedGroup.id
|
||||
) {
|
||||
setCurrentClientGroup(updatedGroup);
|
||||
}
|
||||
|
||||
return updatedGroup;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to update client group";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to update client group";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -177,7 +188,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
const response = await ClientGroupService.deleteClientGroup(id);
|
||||
|
||||
// Remove from the groups list
|
||||
clientGroups.value = clientGroups.value.filter((group) => group.id !== id);
|
||||
clientGroups.value = clientGroups.value.filter(
|
||||
(group) => group.id !== id
|
||||
);
|
||||
|
||||
// Clear current group if it's the one being deleted
|
||||
if (currentClientGroup.value && currentClientGroup.value.id === id) {
|
||||
@ -187,7 +200,9 @@ export const useClientGroupStore = defineStore("clientGroup", () => {
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to delete client group";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to delete client group";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
|
||||
@ -99,7 +99,7 @@ export const useClientStore = defineStore("client", () => {
|
||||
|
||||
try {
|
||||
const response = await ClientService.getAllClients(params);
|
||||
console.log('API Response:', response);
|
||||
console.log("API Response:", response);
|
||||
setClients(response.data);
|
||||
if (response.meta) {
|
||||
setPagination(response.meta);
|
||||
@ -300,13 +300,15 @@ export const useClientStore = defineStore("client", () => {
|
||||
// Ideally backend returns the updated parent or child list.
|
||||
// Assuming we need to refresh the current client if it is the parent
|
||||
if (currentClient.value && currentClient.value.id === parentId) {
|
||||
// Optionally refetch or update manually if we knew the structure
|
||||
await fetchClient(parentId);
|
||||
// Optionally refetch or update manually if we knew the structure
|
||||
await fetchClient(parentId);
|
||||
}
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to add child client";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to add child client";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -314,7 +316,6 @@ export const useClientStore = defineStore("client", () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Remove child client
|
||||
*/
|
||||
@ -324,12 +325,14 @@ export const useClientStore = defineStore("client", () => {
|
||||
|
||||
try {
|
||||
await ClientService.removeChildClient(parentId, childId);
|
||||
if (currentClient.value && currentClient.value.id === parentId) {
|
||||
await fetchClient(parentId);
|
||||
if (currentClient.value && currentClient.value.id === parentId) {
|
||||
await fetchClient(parentId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to remove child client";
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to remove child client";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
@ -337,23 +340,25 @@ export const useClientStore = defineStore("client", () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get children
|
||||
*/
|
||||
const fetchChildClients = async (parentId: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await ClientService.getChildClients(parentId);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message || err.message || "Failed to fetch child clients";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await ClientService.getChildClients(parentId);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Failed to fetch child clients";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -8,7 +8,7 @@ export const useClientTimelineStore = defineStore("clientTimeline", () => {
|
||||
const activities = ref<TimelineActivity[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
@ -18,13 +18,13 @@ export const useClientTimelineStore = defineStore("clientTimeline", () => {
|
||||
|
||||
const setPagination = (meta: any) => {
|
||||
if (meta) {
|
||||
const getValue = (val: any) => (Array.isArray(val) ? val[0] : val);
|
||||
pagination.value = {
|
||||
current_page: Number(getValue(meta.current_page)) || 1,
|
||||
last_page: Number(getValue(meta.last_page)) || 1,
|
||||
per_page: Number(getValue(meta.per_page)) || 10,
|
||||
total: Number(getValue(meta.total)) || 0,
|
||||
};
|
||||
const getValue = (val: any) => (Array.isArray(val) ? val[0] : val);
|
||||
pagination.value = {
|
||||
current_page: Number(getValue(meta.current_page)) || 1,
|
||||
last_page: Number(getValue(meta.last_page)) || 1,
|
||||
per_page: Number(getValue(meta.per_page)) || 10,
|
||||
total: Number(getValue(meta.total)) || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -36,11 +36,14 @@ export const useClientTimelineStore = defineStore("clientTimeline", () => {
|
||||
const queryParams = {
|
||||
page: pagination.value.current_page,
|
||||
per_page: pagination.value.per_page,
|
||||
...params
|
||||
...params,
|
||||
};
|
||||
|
||||
const response = await ClientTimelineService.getTimeline(clientId, queryParams);
|
||||
|
||||
|
||||
const response = await ClientTimelineService.getTimeline(
|
||||
clientId,
|
||||
queryParams
|
||||
);
|
||||
|
||||
activities.value = response.data;
|
||||
if (response.meta) {
|
||||
setPagination(response.meta);
|
||||
|
||||
@ -53,7 +53,7 @@ onMounted(async () => {
|
||||
client_id
|
||||
);
|
||||
locations_client.value = locationsResponse || [];
|
||||
|
||||
|
||||
if (clientStore.currentClient.is_parent) {
|
||||
children_client.value = await clientStore.fetchChildClients(client_id);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user