add employee

This commit is contained in:
Nyavokevin 2025-11-05 17:08:08 +03:00
parent aa306f5d19
commit 0ea8f1866b
31 changed files with 5764 additions and 305 deletions

View File

@ -0,0 +1,310 @@
<template>
<div class="container-fluid py-4">
<!-- Header -->
<!-- Form -->
<div class="row">
<div class="col-12">
<div class="card mt-4">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<p class="font-weight-bold mb-0">Informations de l'Employé</p>
</div>
</div>
<div class="card-body">
<!-- Success Message -->
<div v-if="success" class="alert alert-success" role="alert">
<strong>Succès!</strong> L'employé a été créé avec succès.
Redirection en cours...
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Création en cours...</span>
</div>
<p class="mt-2">Création de l'employé...</p>
</div>
<!-- Form -->
<form v-else novalidate @submit.prevent="handleSubmit">
<!-- Basic Information -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="first_name" class="form-label">Prénom *</label>
<soft-input
id="first_name"
v-model="form.first_name"
type="text"
:class="{ 'is-invalid': validationErrors.first_name }"
required
placeholder="Entrez le prénom"
/>
<div
v-if="validationErrors.first_name"
class="invalid-feedback"
>
{{ validationErrors.first_name[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="last_name" class="form-label">Nom *</label>
<soft-input
id="last_name"
v-model="form.last_name"
type="text"
:class="{ 'is-invalid': validationErrors.last_name }"
required
placeholder="Entrez le nom"
/>
<div
v-if="validationErrors.last_name"
class="invalid-feedback"
>
{{ validationErrors.last_name[0] }}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="email" class="form-label">Email</label>
<soft-input
id="email"
v-model="form.email"
type="email"
:class="{ 'is-invalid': validationErrors.email }"
placeholder="entreprise@exemple.com"
/>
<div v-if="validationErrors.email" class="invalid-feedback">
{{ validationErrors.email[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="phone" class="form-label">Téléphone</label>
<soft-input
id="phone"
v-model="form.phone"
type="tel"
:class="{ 'is-invalid': validationErrors.phone }"
placeholder="06 12 34 56 78"
/>
<div v-if="validationErrors.phone" class="invalid-feedback">
{{ validationErrors.phone[0] }}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="hire_date" class="form-label"
>Date d'Embauche *</label
>
<soft-input
id="hire_date"
v-model="form.hire_date"
type="date"
:class="{ 'is-invalid': validationErrors.hire_date }"
required
:max="new Date().toISOString().split('T')[0]"
/>
<div
v-if="validationErrors.hire_date"
class="invalid-feedback"
>
{{ validationErrors.hire_date[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="job_title" class="form-label">Poste</label>
<soft-input
id="job_title"
v-model="form.job_title"
type="text"
:class="{ 'is-invalid': validationErrors.job_title }"
placeholder="Entrez le poste occupé"
/>
<div
v-if="validationErrors.job_title"
class="invalid-feedback"
>
{{ validationErrors.job_title[0] }}
</div>
</div>
</div>
</div>
<!-- Salary and Status -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="salary" class="form-label">Salaire ()</label>
<soft-input
id="salary"
v-model.number="form.salary"
type="number"
step="0.01"
min="0"
:class="{ 'is-invalid': validationErrors.salary }"
placeholder="0.00"
/>
<div
v-if="validationErrors.salary"
class="invalid-feedback"
>
{{ validationErrors.salary[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="active" class="form-label">Statut</label>
<select
id="active"
v-model="form.active"
class="form-control"
:class="{ 'is-invalid': validationErrors.active }"
>
<option value="true">Actif</option>
<option value="false">Inactif</option>
</select>
<div
v-if="validationErrors.active"
class="invalid-feedback"
>
{{ validationErrors.active[0] }}
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="row mt-4">
<div class="col-12 d-flex justify-content-end">
<soft-button
type="soft-button"
class="btn btn-light me-3"
@click="$router.go(-1)"
>
Annuler
</soft-button>
<soft-button
type="submit"
class="btn bg-gradient-success"
:disabled="loading"
>
<span
v-if="loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
Créer l'Employé
</soft-button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
import { ref, reactive, defineProps, defineEmits } from "vue";
// Props
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
// Emits
const emit = defineEmits(["createEmployee"]);
// Form data
const form = reactive({
first_name: "",
last_name: "",
email: "",
phone: "",
hire_date: new Date().toISOString().split("T")[0], // Default to today
job_title: "",
salary: 0,
active: true,
});
// Methods
const handleSubmit = () => {
// Clean up the form data
const formData = { ...form };
// Convert empty strings to null for optional fields
Object.keys(formData).forEach((key) => {
if (formData[key] === "") {
formData[key] = null;
}
});
// Convert active string to boolean
if (typeof formData.active === "string") {
formData.active = formData.active === "true";
}
emit("createEmployee", formData);
};
</script>
<style scoped>
.form-label {
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.invalid-feedback {
display: block;
}
.card {
border: none;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.075);
}
.form-control:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.alert {
border: none;
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,228 @@
<template>
<employee-template>
<template #employee-new-action>
<add-button text="Ajouter" @click="goToEmployee" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #employee-other-action>
<table-action />
</template>
<template #employee-table>
<employee-table
:data="employeeData"
:loading="loadingData"
@view="goToDetails"
@delete="deleteEmployee"
/>
<!-- Pagination Component -->
<div v-if="pagination && pagination.total > 0" class="mt-4 px-4">
<nav aria-label="Employee pagination">
<ul class="pagination justify-content-center mb-0">
<li
class="page-item"
:class="{ disabled: pagination.current_page === 1 }"
>
<button
class="page-link"
@click="changePage(pagination.current_page - 1)"
:disabled="pagination.current_page === 1"
>
<i class="fas fa-chevron-left"></i>
</button>
</li>
<li
v-for="page in visiblePages"
:key="page"
class="page-item"
:class="{ active: page === pagination.current_page }"
>
<button class="page-link" @click="changePage(page)">
{{ page }}
</button>
</li>
<li
class="page-item"
:class="{
disabled: pagination.current_page === pagination.last_page,
}"
>
<button
class="page-link"
@click="changePage(pagination.current_page + 1)"
:disabled="pagination.current_page === pagination.last_page"
>
<i class="fas fa-chevron-right"></i>
</button>
</li>
</ul>
</nav>
<!-- Pagination Info -->
<div class="text-center mt-2">
<small class="text-muted">
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
{{ pagination.total }} employés
</small>
</div>
</div>
</template>
</employee-template>
</template>
<script setup>
import EmployeeTemplate from "@/components/templates/CRM/EmployeeTemplate.vue";
import EmployeeTable from "@/components/molecules/Employees/EmployeeTable.vue";
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 { computed } from "vue";
import { defineProps, defineEmits } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const emit = defineEmits(["pushDetails", "deleteEmployee"]);
const props = defineProps({
employeeData: {
type: Array,
default: [],
},
loadingData: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: null,
},
});
const goToEmployee = () => {
router.push({
name: "Creation employé",
});
};
const goToDetails = (employeeId) => {
emit("pushDetails", employeeId);
};
const deleteEmployee = (employeeId) => {
emit("deleteEmployee", employeeId);
};
// Computed property for visible page numbers
const visiblePages = computed(() => {
if (!props.pagination) return [];
const current = props.pagination.current_page;
const last = props.pagination.last_page;
const delta = 2; // Number of pages to show on each side of current page
const range = [];
const rangeWithDots = [];
// Calculate range around current page
for (
let i = Math.max(2, current - delta);
i <= Math.min(last - 1, current + delta);
i++
) {
range.push(i);
}
// Add first page
if (current - delta > 2) {
rangeWithDots.push(1, "...");
} else {
rangeWithDots.push(1);
}
// Add calculated range
rangeWithDots.push(...range);
// Add last page
if (current + delta < last - 1) {
rangeWithDots.push("...", last);
} else if (last > 1) {
rangeWithDots.push(last);
}
return rangeWithDots;
});
const changePage = (page) => {
if (page >= 1 && page <= props.pagination.last_page) {
// Emit event for parent component to handle page change
emit("changePage", page);
}
};
</script>
<style scoped>
.pagination {
margin-bottom: 0;
}
.page-link {
border: 1px solid #dee2e6;
color: #5e72e4;
text-decoration: none;
background-color: #fff;
transition: all 0.15s ease-in-out;
}
.page-link:hover {
color: #5e72e4;
background-color: #e9ecef;
border-color: #dee2e6;
}
.page-item.active .page-link {
background-color: #5e72e4;
border-color: #5e72e4;
color: white;
}
.page-item.disabled .page-link {
color: #6c757d;
background-color: #fff;
border-color: #dee2e6;
pointer-events: none;
}
.page-item:first-child .page-link {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.page-item:last-child .page-link {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.mt-4 {
margin-top: 1.5rem;
}
.px-4 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.text-center {
text-align: center;
}
.mb-0 {
margin-bottom: 0;
}
.text-muted {
color: #6c757d;
}
</style>

View File

@ -0,0 +1,175 @@
<template>
<product-category-template>
<template #product-category-new-action>
<add-button text="Ajouter" @click="openAddModal" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #product-category-other-action>
<table-action />
</template>
<template #product-category-table>
<product-category-table
:data="categoryData"
:loading="loadingData"
@view="goToDetails"
@edit="openEditModal"
@delete="deleteCategory"
/>
</template>
</product-category-template>
<!-- Add/Edit Product Category Modal -->
<product-category-modal
:is-visible="modalIsVisible"
:is-modification="isModification"
:category="selectedCategory"
:parent-categories="parentCategories"
:is-loading="isLoading"
@close="closeModal"
@category-created="handleCategoryCreated"
@category-modified="handleCategoryModified"
/>
</template>
<script setup>
import ProductCategoryTemplate from "@/components/templates/Stock/ProductCategoryTemplate.vue";
import ProductCategoryTable from "@/components/molecules/Tables/Stock/ProductCategoryTable.vue";
import ProductCategoryModal from "@/components/molecules/Stock/ProductCategoryModal.vue";
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 { useProductCategoryStore } from "@/stores/productCategoryStore";
import { useNotificationStore } from "@/stores/notification";
import { defineProps, defineEmits, ref, computed } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const productCategoryStore = useProductCategoryStore();
const notificationStore = useNotificationStore();
const emit = defineEmits(["pushDetails", "deleteCategory"]);
const props = defineProps({
categoryData: {
type: Array,
default: [],
},
loadingData: {
type: Boolean,
default: false,
},
});
// Modal state
const modalIsVisible = ref(false);
const isModification = ref(false);
const selectedCategory = ref(null);
const isLoading = ref(false);
// Get parent categories for dropdown (excluding the current category when editing)
const parentCategories = computed(() => {
if (isModification.value && selectedCategory.value) {
return props.categoryData.filter(
(category) => category.id !== selectedCategory.value.id
);
}
return props.categoryData;
});
// Modal handlers
const openAddModal = () => {
isModification.value = false;
selectedCategory.value = null;
modalIsVisible.value = true;
};
const openEditModal = (category) => {
if (typeof category === "string") {
// Find category by ID
const foundCategory = props.categoryData.find(
(cat) => cat.id.toString() === category
);
if (foundCategory) {
selectedCategory.value = foundCategory;
} else {
notificationStore.error("Erreur", "Catégorie non trouvée");
return;
}
} else {
selectedCategory.value = category;
}
isModification.value = true;
modalIsVisible.value = true;
};
const closeModal = () => {
modalIsVisible.value = false;
isModification.value = false;
selectedCategory.value = null;
};
const handleCategoryCreated = async (formData) => {
try {
isLoading.value = true;
await productCategoryStore.createProductCategory(formData);
notificationStore.created("Catégorie de produit");
} catch (error) {
console.error("Error creating category:", error);
if (error.response && error.response.data) {
notificationStore.error(
"Erreur",
error.response.data.message || "Erreur lors de la création"
);
} else {
notificationStore.error(
"Erreur",
"Erreur lors de la création de la catégorie"
);
}
} finally {
isLoading.value = false;
}
};
const handleCategoryModified = async (formData) => {
try {
isLoading.value = true;
await productCategoryStore.updateProductCategory(formData);
notificationStore.updated("Catégorie de produit");
} catch (error) {
console.error("Error updating category:", error);
if (error.response && error.response.data) {
notificationStore.error(
"Erreur",
error.response.data.message || "Erreur lors de la modification"
);
} else {
notificationStore.error(
"Erreur",
"Erreur lors de la modification de la catégorie"
);
}
} finally {
isLoading.value = false;
}
};
// Original handlers
const goToDetails = (category) => {
if (typeof category === "string") {
emit("pushDetails", category);
} else {
emit("pushDetails", category.id);
}
};
const deleteCategory = (category) => {
if (typeof category === "string") {
emit("deleteCategory", category);
} else {
emit("deleteCategory", category.id);
}
};
</script>

View File

@ -0,0 +1,507 @@
<template>
<div class="table-container">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>ID</th>
<th>Nom & Prénom</th>
<th>Email</th>
<th>Téléphone</th>
<th>Poste</th>
<th>Date d'embauche</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- ID Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-checkbox"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
<!-- Name Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Email Column Skeleton -->
<td>
<div class="skeleton-text long"></div>
</td>
<!-- Phone Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Position Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Hire Date Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Status Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon small"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="employee-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>ID</th>
<th>Nom & Prénom</th>
<th>Email</th>
<th>Téléphone</th>
<th>Poste</th>
<th>Date d'embauche</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in data" :key="employee.id">
<!-- ID Column -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ employee.id }}
</p>
</div>
</td>
<!-- Name Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
size="xs"
class="me-2"
alt="user image"
circular
/>
<div>
<span
>{{ employee.last_name }} {{ employee.first_name }}</span
>
<div
v-if="employee.thanatopractitioner"
class="text-xs text-info"
>
Thanatopractitioner
</div>
</div>
</div>
</td>
<!-- Email Column -->
<td class="text-xs font-weight-bold">
<span class="text-xs">{{ employee.email || "N/A" }}</span>
</td>
<!-- Phone Column -->
<td class="text-xs font-weight-bold">
<span class="text-xs">{{ employee.phone || "N/A" }}</span>
</td>
<!-- Position Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getPositionColor(employee.job_title)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getPositionIcon(employee.job_title)"
aria-hidden="true"
></i>
</soft-button>
<span>{{ employee.job_title || "N/A" }}</span>
</div>
</td>
<!-- Hire Date Column -->
<td class="text-xs font-weight-bold">
<span class="text-xs">{{ formatDate(employee.hire_date) }}</span>
</td>
<!-- Status Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="employee.active ? 'success' : 'danger'"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="employee.active ? 'fas fa-check' : 'fas fa-times'"
aria-hidden="true"
></i>
</soft-button>
<span>{{ employee.active ? "Actif" : "Inactif" }}</span>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button
color="info"
variant="outline"
title="Voir l'employé"
:data-employee-id="employee.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<!-- Delete Button -->
<soft-button
color="danger"
variant="outline"
title="Supprimer l'employé"
:data-employee-id="employee.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-users fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun employé trouvé</h5>
<p class="empty-text text-muted">
Aucun employé à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import { DataTable } from "simple-datatables";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps, defineEmits } from "vue";
const emit = defineEmits(["view", "delete"]);
// Sample avatar images
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
// Reactive data
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
// Methods
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const formatDate = (dateString) => {
if (!dateString) return "N/A";
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR");
};
const getPositionColor = (position) => {
const colors = {
Manager: "info",
Technician: "success",
Supervisor: "warning",
Assistant: "secondary",
Director: "danger",
};
return colors[position] || "secondary";
};
const getPositionIcon = (position) => {
const icons = {
Manager: "fas fa-user-tie",
Technician: "fas fa-tools",
Supervisor: "fas fa-user-cog",
Assistant: "fas fa-user",
Director: "fas fa-crown",
};
return icons[position] || "fas fa-user";
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("employee-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 15,
perPageSelect: [5, 10, 15, 20, 25],
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const employeeId = button.getAttribute("data-employee-id");
if (
button.title === "Supprimer l'employé" ||
button.querySelector(".fa-trash")
) {
emit("delete", employeeId);
} else if (
button.title === "Voir l'employé" ||
button.querySelector(".fa-eye")
) {
emit("view", employeeId);
}
};
// Watch for data changes to reinitialize datatable
watch(
() => props.data,
() => {
if (!props.loading) {
// Small delay to ensure DOM is updated
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
const dataTableEl = document.getElementById("employee-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
// Initialize data
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-checkbox {
width: 18px;
height: 18px;
border-radius: 3px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 40px;
}
.skeleton-text.medium {
width: 80px;
}
.skeleton-text.long {
width: 120px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
</style>

View File

@ -0,0 +1,473 @@
<template>
<!-- Modal Component -->
<div
class="modal fade"
:class="{ show: isVisible, 'd-block': isVisible }"
tabindex="-1"
role="dialog"
:aria-hidden="!isVisible"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<!-- Header -->
<div class="modal-header">
<h5 class="modal-title">
<i
:class="isModification ? 'fas fa-edit me-2' : 'fas fa-tags me-2'"
></i>
{{
isModification ? "Modifier la catégorie" : "Ajouter une catégorie"
}}
</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
@click="closeModal"
></button>
</div>
<!-- Body -->
<div class="modal-body">
<form @submit.prevent="submitForm">
<!-- Code -->
<div class="mb-3">
<label class="form-label"
>Code <span class="text-danger">*</span></label
>
<input
v-model="formData.code"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.code }"
placeholder="Code de la catégorie"
maxlength="50"
required
/>
<div v-if="errors.code" class="invalid-feedback">
{{ errors.code }}
</div>
</div>
<!-- Name -->
<div class="mb-3">
<label class="form-label"
>Nom <span class="text-danger">*</span></label
>
<input
v-model="formData.name"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.name }"
placeholder="Nom de la catégorie"
maxlength="191"
required
/>
<div v-if="errors.name" class="invalid-feedback">
{{ errors.name }}
</div>
</div>
<!-- Parent Category -->
<div class="mb-3">
<label class="form-label">Catégorie parent</label>
<select
v-model="formData.parent_id"
class="form-control"
:class="{ 'is-invalid': errors.parent_id }"
>
<option value="">Catégorie racine</option>
<option
v-for="category in parentCategories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<div v-if="errors.parent_id" class="invalid-feedback">
{{ errors.parent_id }}
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label class="form-label">Description</label>
<textarea
v-model="formData.description"
class="form-control"
:class="{ 'is-invalid': errors.description }"
placeholder="Description de la catégorie"
rows="3"
maxlength="500"
></textarea>
<div v-if="errors.description" class="invalid-feedback">
{{ errors.description }}
</div>
</div>
<!-- Active Status -->
<div class="mb-3">
<div class="form-check form-switch">
<input
v-model="formData.active"
class="form-check-input"
type="checkbox"
id="activeSwitch"
/>
<label class="form-check-label" for="activeSwitch">
Catégorie active
</label>
</div>
<div v-if="errors.active" class="invalid-feedback d-block">
{{ errors.active }}
</div>
</div>
<!-- General Error -->
<div v-if="errors.general" class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ errors.general }}
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-footer">
<button
type="button"
class="btn btn-outline-secondary"
:disabled="isLoading"
@click="closeModal"
>
<i class="fas fa-times me-1"></i>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="isLoading"
@click="submitForm"
>
<i class="fas fa-save me-1"></i>
{{
isLoading
? isModification
? "Modification..."
: "Création..."
: isModification
? "Modifier la catégorie"
: "Créer la catégorie"
}}
</button>
</div>
</div>
</div>
</div>
<!-- Backdrop -->
<div
v-if="isVisible"
class="modal-backdrop fade show"
@click="closeModal"
></div>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { defineProps, defineEmits } from "vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
// Props
const props = defineProps({
isVisible: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isModification: {
type: Boolean,
default: false,
},
category: {
type: Object,
default: null,
},
parentCategories: {
type: Array,
default: () => [],
},
});
// Emits
const emit = defineEmits(["close", "category-created", "category-modified"]);
// Store
const productCategoryStore = useProductCategoryStore();
// State
const errors = reactive({});
const formData = reactive({
code: "",
name: "",
parent_id: "",
description: "",
active: true,
});
// Watch for category changes (for modification mode)
watch(
() => props.category,
(newCategory) => {
if (newCategory && props.isModification) {
formData.code = newCategory.code || "";
formData.name = newCategory.name || "";
formData.parent_id = newCategory.parent_id || "";
formData.description = newCategory.description || "";
formData.active =
newCategory.active !== undefined ? newCategory.active : true;
}
},
{ immediate: true }
);
// Reset form when modal closes
watch(
() => props.isVisible,
(isVisible) => {
if (!isVisible) {
setTimeout(resetForm, 150); // Wait for modal animation
}
}
);
// Methods
const closeModal = () => {
emit("close");
};
const resetForm = () => {
formData.code = "";
formData.name = "";
formData.parent_id = "";
formData.description = "";
formData.active = true;
Object.keys(errors).forEach((key) => delete errors[key]);
};
const validateForm = () => {
// Clear previous errors
Object.keys(errors).forEach((key) => delete errors[key]);
let isValid = true;
// Code validation
if (!formData.code || formData.code.trim() === "") {
errors.code = "Le code est obligatoire.";
isValid = false;
} else if (formData.code.length > 50) {
errors.code = "Le code ne peut pas dépasser 50 caractères.";
isValid = false;
}
// Name validation
if (!formData.name || formData.name.trim() === "") {
errors.name = "Le nom est obligatoire.";
isValid = false;
} else if (formData.name.length > 191) {
errors.name = "Le nom ne peut pas dépasser 191 caractères.";
isValid = false;
}
// Description validation
if (formData.description && formData.description.length > 500) {
errors.description = "La description ne peut pas dépasser 500 caractères.";
isValid = false;
}
// Parent category validation
if (formData.parent_id && formData.parent_id === props.category?.id) {
errors.parent_id = "Une catégorie ne peut pas être sa propre parente.";
isValid = false;
}
// Check for duplicate code in parent categories (excluding current category for edit)
const existingCategory = props.parentCategories.find(
(cat) => cat.code === formData.code && cat.id !== props.category?.id
);
if (existingCategory) {
errors.code = "Ce code de catégorie existe déjà.";
isValid = false;
}
return isValid;
};
const submitForm = async () => {
if (!validateForm()) {
return;
}
try {
// Prepare data for API
const submitData = { ...formData };
// Convert empty strings to null for nullable fields
if (submitData.parent_id === "") {
submitData.parent_id = null;
}
// Emit event - parent will handle the API call and loading state
if (props.isModification) {
submitData.id = props.category.id;
emit("category-modified", submitData);
} else {
emit("category-created", submitData);
}
// Close modal and reset form
closeModal();
} catch (error) {
console.error("Erreur lors de la sauvegarde de la catégorie:", error);
// Handle API errors
if (error.response && error.response.data && error.response.data.errors) {
Object.assign(errors, error.response.data.errors);
} else {
errors.general = "Une erreur est survenue lors de la sauvegarde.";
}
}
};
// Keyboard event listener for ESC key
const handleKeydown = (event) => {
if (event.key === "Escape" && props.isVisible) {
closeModal();
}
};
// Add event listener when component is mounted
import { onMounted, onUnmounted } from "vue";
onMounted(() => {
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<style scoped>
.modal {
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
border: none;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.modal-title {
color: #495057;
font-weight: 600;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
border-top: 1px solid #e9ecef;
background-color: #f8f9fa;
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.form-label {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
.form-label .text-danger {
color: #dc3545;
}
.form-control {
border: 1px solid #dce1e6;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
transition: all 0.2s ease;
}
.form-control:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
}
.form-select {
border: 1px solid #dce1e6;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
transition: all 0.2s ease;
}
.form-select:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 0.2rem rgba(203, 12, 159, 0.25);
}
.btn-primary {
background-color: #cb0c9f;
border-color: #cb0c9f;
border-radius: 0.375rem;
}
.btn-primary:hover:not(:disabled) {
background-color: #a90982;
border-color: #a90982;
transform: translateY(-1px);
}
.btn-outline-secondary {
border-radius: 0.375rem;
}
.invalid-feedback {
font-size: 0.875rem;
}
.alert {
border: none;
border-radius: 0.375rem;
padding: 0.75rem 1rem;
}
.fade {
transition: opacity 0.15s linear;
}
.modal-backdrop {
opacity: 0.5;
}
.form-check-input:checked {
background-color: #cb0c9f;
border-color: #cb0c9f;
}
.form-check-input:focus {
border-color: #cb0c9f;
box-shadow: 0 0 0 0.25rem rgba(203, 12, 159, 0.25);
}
</style>

View File

@ -0,0 +1,516 @@
<template>
<div class="table-container">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Code</th>
<th>Nom</th>
<th>Catégorie parent</th>
<th>Description</th>
<th>Statut</th>
<th>Produits</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Code Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Name Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text long ms-2"></div>
</div>
</td>
<!-- Parent Category Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Description Column Skeleton -->
<td>
<div class="skeleton-text very-long"></div>
</td>
<!-- Status Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
<!-- Products Count Column Skeleton -->
<td>
<div class="skeleton-text short"></div>
</td>
<!-- Actions Column Skeleton -->
<td>
<div class="d-flex gap-1">
<div class="skeleton-icon small"></div>
<div class="skeleton-icon small"></div>
<div class="skeleton-icon small"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="product-category-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Code</th>
<th>Nom</th>
<th>Catégorie parent</th>
<th>Description</th>
<th>Statut</th>
<th>Produits</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="category in data" :key="category.id">
<!-- Code Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<span>{{ category.code }}</span>
</div>
</td>
<!-- Name Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<div>
<div class="text-sm font-weight-bold">
{{ category.name }}
</div>
<div class="text-xs text-muted">ID: {{ category.id }}</div>
</div>
</div>
</td>
<!-- Parent Category Column -->
<td class="text-xs font-weight-bold">
<div v-if="category.parent" class="d-flex align-items-center">
<soft-button
color="info"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-folder" aria-hidden="true"></i>
</soft-button>
<span>{{ category.parent.name }}</span>
</div>
<div v-else class="text-muted">
<soft-button
color="secondary"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-folder-open" aria-hidden="true"></i>
</soft-button>
Catégorie racine
</div>
</td>
<!-- Description Column -->
<td class="text-xs font-weight-bold">
<div class="description-cell">
<span
v-if="category.description"
class="text-truncate d-inline-block"
style="max-width: 200px"
>
{{ category.description }}
</span>
<span v-else class="text-muted">Aucune description</span>
</div>
</td>
<!-- Status Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<!-- Status Badge -->
<span :class="getStatusClass(category)">
<i
:class="category.active ? 'fas fa-check me-1' : 'fas fa-times me-1'"
></i>
{{ category.active ? "Active" : "Inactive" }}
</span>
<!-- Children Badge -->
<span
v-if="category.has_children"
class="badge badge-info mt-1"
>
<i class="fas fa-sitemap me-1"></i>
{{ category.children_count || 0 }} enfants
</span>
</div>
</td>
<!-- Products Count Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-badge
:color="category.has_products ? 'success' : 'secondary'"
class="me-2"
>
{{ category.products_count || 0 }}
</soft-badge>
<span class="text-muted">produits</span>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button
color="info"
variant="outline"
title="Voir la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('view', category)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<!-- Edit Button -->
<soft-button
color="warning"
variant="outline"
title="Modifier la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('edit', category)"
>
<i class="fas fa-edit" aria-hidden="true"></i>
</soft-button>
<!-- Delete Button -->
<soft-button
color="danger"
variant="outline"
title="Supprimer la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('delete', category)"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-tags fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucune catégorie de produit trouvée</h5>
<p class="empty-text text-muted">
Aucune catégorie de produit à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import { defineProps, defineEmits } from "vue";
const emit = defineEmits(["view", "edit", "delete"]);
// Sample avatar images for categories
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
// Reactive data
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
// Methods
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const getStatusClass = (category) => {
return category.active ? "badge badge-success" : "badge badge-danger";
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("product-category-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 10,
perPageSelect: [5, 10, 15, 20],
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const categoryId = button.getAttribute("data-category-id");
if (
button.title === "Supprimer la catégorie" ||
button.querySelector(".fa-trash")
) {
emit("delete", categoryId);
} else if (
button.title === "Modifier la catégorie" ||
button.querySelector(".fa-edit")
) {
emit("edit", categoryId);
} else if (
button.title === "Voir la catégorie" ||
button.querySelector(".fa-eye")
) {
emit("view", categoryId);
}
};
// Watch for data changes to reinitialize datatable
watch(
() => props.data,
() => {
if (!props.loading) {
// Small delay to ensure DOM is updated
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
const dataTableEl = document.getElementById("product-category-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
// Initialize data
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 40px;
}
.skeleton-text.medium {
width: 80px;
}
.skeleton-text.long {
width: 120px;
}
.skeleton-text.very-long {
width: 160px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.description-cell {
max-width: 200px;
word-wrap: break-word;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.very-long {
width: 100px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
</style>

View File

@ -0,0 +1,532 @@
<template>
<div class="table-container">
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Code</th>
<th>Nom</th>
<th>Catégorie parent</th>
<th>Description</th>
<th>Statut</th>
<th>Produits</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Code Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<!-- Name Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text long ms-2"></div>
</div>
</td>
<!-- Parent Category Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Description Column Skeleton -->
<td>
<div class="skeleton-text very-long"></div>
</td>
<!-- Status Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
<!-- Products Count Column Skeleton -->
<td>
<div class="skeleton-text short"></div>
</td>
<!-- Action Column Skeleton -->
<td>
<div class="d-flex gap-1">
<div class="skeleton-icon small"></div>
<div class="skeleton-icon small"></div>
<div class="skeleton-icon small"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="product-category-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Code</th>
<th>Nom</th>
<th>Catégorie parent</th>
<th>Description</th>
<th>Statut</th>
<th>Produits</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="category in data" :key="category.id">
<!-- Code Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
color="primary"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-tag" aria-hidden="true"></i>
</soft-button>
<span>{{ category.code }}</span>
</div>
</td>
<!-- Name Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
size="xs"
class="me-2"
alt="category image"
circular
/>
<div>
<div class="text-sm font-weight-bold">
{{ category.name }}
</div>
<div class="text-xs text-muted">ID: {{ category.id }}</div>
</div>
</div>
</td>
<!-- Parent Category Column -->
<td class="text-xs font-weight-bold">
<div v-if="category.parent" class="d-flex align-items-center">
<soft-button
color="info"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-folder" aria-hidden="true"></i>
</soft-button>
<span>{{ category.parent.name }}</span>
</div>
<div v-else class="text-muted">
<i class="fas fa-folder-open me-1"></i>
Catégorie racine
</div>
</td>
<!-- Description Column -->
<td class="text-xs font-weight-bold">
<div class="description-cell">
<span
v-if="category.description"
class="text-truncate d-inline-block"
style="max-width: 200px"
>
{{ category.description }}
</span>
<span v-else class="text-muted">Aucune description</span>
</div>
</td>
<!-- Status Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<!-- Has Children Badge -->
<soft-button
v-if="category.has_children"
color="warning"
variant="outline"
class="btn-sm mb-1"
>
<i class="fas fa-sitemap me-1"></i>
Parent
</soft-button>
<!-- Has Products Badge -->
<soft-button
v-if="category.has_products"
color="info"
variant="outline"
class="btn-sm"
>
<i class="fas fa-boxes me-1"></i>
Utilisée
</soft-button>
<!-- Normal Status -->
<span
v-if="!category.has_children && !category.has_products"
class="badge badge-success"
>
<i class="fas fa-check me-1"></i>
Libre
</span>
</div>
</td>
<!-- Products Count Column -->
<td class="text-xs font-weight-bold">
<div class="products-info">
<div>{{ category.products_count || 0 }}</div>
<div class="text-xs text-muted">produits</div>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button
color="info"
variant="outline"
title="Voir la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('view', category)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<!-- Edit Button -->
<soft-button
color="warning"
variant="outline"
title="Modifier la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('edit', category)"
>
<i class="fas fa-edit" aria-hidden="true"></i>
</soft-button>
<!-- Delete Button -->
<soft-button
color="danger"
variant="outline"
title="Supprimer la catégorie"
:data-category-id="category.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('delete', category)"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-tags fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucune catégorie de produit trouvée</h5>
<p class="empty-text text-muted">
Aucune catégorie de produit à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps, defineEmits } from "vue";
// Sample avatar images for categories
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
// Reactive data
const dataTableInstance = ref(null);
const emit = defineEmits(["view", "edit", "delete"]);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
});
// Methods
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("product-category-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
perPage: 10,
perPageSelect: [5, 10, 15, 20],
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const categoryId = button.getAttribute("data-category-id");
if (
button.title === "Supprimer la catégorie" ||
button.querySelector(".fa-trash")
) {
emit("delete", categoryId);
} else if (
button.title === "Modifier la catégorie" ||
button.querySelector(".fa-edit")
) {
emit("edit", categoryId);
} else if (
button.title === "Voir la catégorie" ||
button.querySelector(".fa-eye")
) {
emit("view", categoryId);
}
};
// Watch for data changes to reinitialize datatable
watch(
() => props.data,
() => {
if (!props.loading) {
// Small delay to ensure DOM is updated
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
const dataTableEl = document.getElementById("product-category-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
// Initialize data
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 40px;
}
.skeleton-text.medium {
width: 80px;
}
.skeleton-text.long {
width: 120px;
}
.skeleton-text.very-long {
width: 160px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.description-cell {
max-width: 200px;
word-wrap: break-word;
}
.products-info {
line-height: 1.2;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.very-long {
width: 100px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
</style>

View File

@ -0,0 +1,311 @@
<template>
<div class="card p-3 border-radius-xl bg-white" data-animation="FadeIn">
<h5 class="font-weight-bolder mb-0">Nouvel Employé</h5>
<p class="mb-0 text-sm">Informations de l'employé</p>
<div class="multisteps-form__content">
<!-- Nom & Prénom -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label"
>Prénom <span class="text-danger">*</span></label
>
<soft-input
:value="form.first_name"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.first_name }"
type="text"
placeholder="ex. Jean"
maxlength="191"
@input="form.first_name = $event.target.value"
/>
<div v-if="fieldErrors.first_name" class="invalid-feedback">
{{ fieldErrors.first_name }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label"
>Nom de famille <span class="text-danger">*</span></label
>
<soft-input
:value="form.last_name"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.last_name }"
type="text"
placeholder="ex. Dupont"
maxlength="191"
@input="form.last_name = $event.target.value"
/>
<div v-if="fieldErrors.last_name" class="invalid-feedback">
{{ fieldErrors.last_name }}
</div>
</div>
</div>
<!-- Email & Téléphone -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Email</label>
<soft-input
:value="form.email"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.email }"
type="email"
placeholder="ex. jean.dupont@entreprise.com"
maxlength="191"
@input="form.email = $event.target.value"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +261341234567"
maxlength="50"
@input="form.phone = $event.target.value"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
</div>
</div>
</div>
<!-- Intitulé du poste -->
<div class="row mt-3">
<div class="col-12">
<label class="form-label">Intitulé du poste</label>
<soft-input
:value="form.job_title"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.job_title }"
type="text"
placeholder="ex. Développeur Full-Stack"
maxlength="191"
@input="form.job_title = $event.target.value"
/>
<div v-if="fieldErrors.job_title" class="invalid-feedback">
{{ fieldErrors.job_title }}
</div>
</div>
</div>
<!-- Date d'embauche -->
<div class="row mt-3">
<div class="col-12 col-sm-6">
<label class="form-label">Date d'embauche</label>
<soft-input
:value="form.hire_date"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.hire_date }"
type="date"
@input="form.hire_date = $event.target.value"
/>
<div v-if="fieldErrors.hire_date" class="invalid-feedback">
{{ fieldErrors.hire_date }}
</div>
</div>
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
<label class="form-label">Salaire</label>
<soft-input
:value="form.salary"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.salary }"
type="number"
placeholder="ex. 45000"
step="0.01"
min="0"
@input="form.salary = $event.target.value"
/>
<div v-if="fieldErrors.salary" class="invalid-feedback">
{{ fieldErrors.salary }}
</div>
</div>
</div>
<!-- Statut actif -->
<div class="row mt-3">
<div class="col-12">
<div class="form-check form-switch">
<input
id="isActive"
class="form-check-input"
type="checkbox"
:checked="form.active"
@change="form.active = $event.target.checked"
/>
<label class="form-check-label" for="isActive">
Employé actif
</label>
</div>
</div>
</div>
<!-- Boutons -->
<div class="button-row d-flex mt-4">
<soft-button
type="button"
color="secondary"
variant="outline"
class="me-2 mb-0"
@click="resetForm"
>
Réinitialiser
</soft-button>
<soft-button
type="button"
color="dark"
variant="gradient"
class="ms-auto mb-0"
:disabled="props.loading"
@click="submitForm"
>
<span
v-if="props.loading"
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{{ props.loading ? "Création..." : "Créer l'employé" }}
</soft-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
// Props
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
// Emits
const emit = defineEmits(["createEmployee"]);
// Reactive data
const errors = ref([]);
const fieldErrors = ref({});
const form = ref({
first_name: "",
last_name: "",
email: "",
phone: "",
job_title: "",
hire_date: "",
salary: null,
active: true,
});
// Watch for validation errors from parent
watch(
() => props.validationErrors,
(newErrors) => {
fieldErrors.value = { ...newErrors };
},
{ deep: true }
);
// Watch for success from parent
watch(
() => props.success,
(newSuccess) => {
if (newSuccess) {
resetForm();
}
}
);
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Clean up form data: convert empty strings to null
const cleanedForm = {};
const formData = form.value;
for (const [key, value] of Object.entries(formData)) {
if (value === "" || value === null || value === undefined) {
cleanedForm[key] = null;
} else {
cleanedForm[key] = value;
}
}
// Ensure active is boolean
cleanedForm.active = Boolean(formData.active);
// Convert salary to number if provided
if (cleanedForm.salary !== null) {
cleanedForm.salary = parseFloat(cleanedForm.salary);
}
console.log("Form data being emitted:", cleanedForm);
// Emit the cleaned form data to parent
emit("createEmployee", cleanedForm);
};
const resetForm = () => {
form.value = {
first_name: "",
last_name: "",
email: "",
phone: "",
job_title: "",
hire_date: "",
salary: null,
active: true,
};
clearErrors();
};
const clearErrors = () => {
errors.value = [];
fieldErrors.value = {};
};
</script>
<style scoped>
.form-label {
font-weight: 600;
margin-bottom: 0.5rem;
}
.text-danger {
color: #f5365c;
}
.invalid-feedback {
display: block;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
.alert {
border-radius: 0.75rem;
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="employee-new-action"></slot>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="employee-other-action"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card mt-4">
<slot name="employee-table"></slot>
</div>
</div>
</div>
</div>
</template>
<script></script>

View File

@ -0,0 +1,23 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="employee-new-action"></slot>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="employee-other-action"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card mt-4">
<slot name="employee-table"></slot>
</div>
</div>
</div>
</div>
</template>
<script></script>

View File

@ -0,0 +1,21 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="multisteps-form mb-5">
<div class="row">
<div class="col-12 col-lg-8 mx-auto my-5">
<slot name="multi-step" />
</div>
</div>
<!--form panels-->
<div class="row">
<div class="col-12 col-lg-8 m-auto">
<slot name="employee-form" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,141 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="card mb-4">
<div class="card-header pb-0">
<div class="d-flex align-items-center">
<p class="mb-0 font-weight-bold text-lg">Gestion des Employés</p>
</div>
</div>
<div class="card-body px-0 pt-0 pb-2">
<div class="table-responsive p-0">
<!-- Filter and Action Bar -->
<div
class="d-flex justify-content-between align-items-center mb-4 px-4"
>
<div class="d-flex align-items-center">
<slot name="select-filter">
<filter-table />
</slot>
</div>
<div class="d-flex align-items-center gap-2">
<slot name="employee-other-action">
<table-action />
</slot>
<slot name="employee-new-action">
<add-button text="Ajouter" @click="goToEmployee" />
</slot>
</div>
</div>
<!-- Main Content Area -->
<div class="employee-content">
<slot name="employee-table">
<!-- Default table slot - will be overridden by specific implementations -->
</slot>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
import TableAction from "@/components/molecules/Tables/TableAction.vue";
import addButton from "@/components/molecules/new-button/addButton.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const goToEmployee = () => {
router.push({
name: "Creation employé",
});
};
</script>
<style scoped>
.container-fluid {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.card {
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
border: none;
}
.card-header {
background-color: transparent;
border-bottom: 1px solid #e9ecef;
padding: 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
}
.employee-content {
position: relative;
}
.d-flex {
display: flex;
}
.align-items-center {
align-items: center;
}
.justify-content-between {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.mb-4 {
margin-bottom: 1.5rem;
}
.px-4 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.font-weight-bold {
font-weight: 600;
}
.text-primary {
color: #5e72e4 !important;
}
@media (max-width: 768px) {
.container-fluid {
padding-left: 1rem;
padding-right: 1rem;
}
.d-flex {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.d-flex.gap-2 {
flex-direction: row;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="container-fluid py-4">
<div class="d-sm-flex justify-content-between">
<div>
<slot name="product-category-new-action"></slot>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<slot name="select-filter"></slot>
</div>
<slot name="product-category-other-action"></slot>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card mt-4">
<slot name="product-category-table"></slot>
</div>
</div>
</div>
</div>
</template>
<script></script>

View File

@ -4,298 +4,54 @@
class="w-auto h-auto collapse navbar-collapse max-height-vh-100 h-100"
>
<ul class="navbar-nav">
<!-- Dashboard -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="dashboardsExamples"
nav-text="Dashboards"
:class="getRoute() === 'dashboards' ? 'active' : ''"
>
<template #icon>
<Shop />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Default' }"
mini-icon="D"
text="Default"
/>
<sidenav-item :to="{ name: 'CRM' }" mini-icon="C" text="CRM" />
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Agenda -->
<li class="nav-item">
<sidenav-item :to="{ name: 'Agenda' }" mini-icon="A" text="Agenda">
<template #icon>
<Office />
</template>
</sidenav-item>
</li>
<!-- Courriel -->
<li class="nav-item">
<sidenav-item :to="{ name: 'Courriel' }" mini-icon="C" text="Courriel">
<template #icon>
<Office />
</template>
</sidenav-item>
</li>
<!-- Contacts -->
<li class="nav-item">
<sidenav-item
:to="{ name: 'Gestion contacts' }"
mini-icon="C"
text="Contacts"
>
<template #icon>
<Office />
</template>
</sidenav-item>
</li>
<li class="mt-3 nav-item">
<!-- Render navigation items from data array -->
<li
v-for="item in navigationItems"
:key="item.id"
:class="{
'nav-item': true,
'mt-3': item.type === 'section-header',
}"
>
<!-- Section Header -->
<h6
v-if="item.type === 'section-header'"
class="text-xs ps-4 text-uppercase font-weight-bolder opacity-6"
:class="isRTL ? 'me-4' : 'ms-2'"
>
GESTION
{{ item.text }}
</h6>
</li>
<!-- Clients -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="clientsMenu"
nav-text="Clients"
:class="getRoute() === 'clients' ? 'active' : ''"
<!-- Single Navigation Item -->
<sidenav-item
v-else-if="item.type === 'single'"
:to="item.route"
:mini-icon="item.miniIcon"
:text="item.text"
>
<template #icon>
<Office />
<component :is="getIconComponent(item.icon)" />
</template>
</sidenav-item>
<!-- Collapsible Navigation Item -->
<sidenav-collapse
v-else-if="item.type === 'collapse'"
:collapse-ref="item.collapseRef"
:nav-text="item.text"
:class="isItemActive(item) ? 'active' : ''"
>
<template #icon>
<component :is="getIconComponent(item.icon)" />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion clients' }"
mini-icon="C"
text="Clients"
/>
<sidenav-item
:to="{ name: 'Localisation clients' }"
mini-icon="L"
text="Lieux"
/>
<sidenav-item
:to="{ name: 'Statistiques clients' }"
mini-icon="S"
text="Statistiques"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Fournisseurs -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="fournisseursMenu"
nav-text="Fournisseurs"
:class="getRoute() === 'fournisseurs' ? 'active' : ''"
>
<template #icon>
<Office />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion fournisseurs' }"
mini-icon="F"
text="Fournisseurs"
/>
<sidenav-item
:to="{ name: 'Commandes fournisseurs' }"
mini-icon="C"
text="Commandes"
/>
<sidenav-item
:to="{ name: 'Factures fournisseurs' }"
mini-icon="F"
text="Factures"
/>
<sidenav-item
:to="{ name: 'Statistiques fournisseurs' }"
mini-icon="S"
text="Statistiques"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Sous-Traitants -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="sousTraitantsMenu"
nav-text="Sous-Traitants"
:class="getRoute() === 'sous-traitants' ? 'active' : ''"
>
<template #icon>
<Office />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion sous-traitants' }"
mini-icon="S"
text="Sous-Traitants"
/>
<sidenav-item
:to="{ name: 'Commandes sous-traitants' }"
mini-icon="C"
text="Commandes"
/>
<sidenav-item
:to="{ name: 'Factures sous-traitants' }"
mini-icon="F"
text="Factures"
/>
<sidenav-item
:to="{ name: 'Statistiques sous-traitants' }"
mini-icon="S"
text="Statistiques"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Ventes -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="ventesMenu"
nav-text="Ventes"
:class="getRoute() === 'ventes' ? 'active' : ''"
>
<template #icon>
<Shop />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Devis' }"
mini-icon="D"
text="Devis"
/>
<sidenav-item
:to="{ name: 'Factures ventes' }"
mini-icon="F"
text="Factures"
/>
<sidenav-item
:to="{ name: 'Statistiques ventes' }"
mini-icon="S"
text="Statistiques"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Stock -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="stockMenu"
nav-text="Stock"
:class="getRoute() === 'stock' ? 'active' : ''"
>
<template #icon>
<Office />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion de produits' }"
mini-icon="R"
text="Produits"
/>
<sidenav-item
:to="{ name: 'Reception stock' }"
mini-icon="R"
text="Réception"
/>
<sidenav-item
:to="{ name: 'Gestion stock' }"
mini-icon="S"
text="Stock"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Employés -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="employesMenu"
nav-text="Employés"
:class="getRoute() === 'employes' ? 'active' : ''"
>
<template #icon>
<Office />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion employes' }"
mini-icon="E"
text="Employés"
/>
<sidenav-item :to="{ name: 'NDF' }" mini-icon="N" text="NDF" />
<sidenav-item
:to="{ name: 'Vehicules' }"
mini-icon="V"
text="Véhicules"
/>
<sidenav-item
:to="{ name: 'Absences' }"
mini-icon="A"
text="Absences"
/>
</ul>
</template>
</sidenav-collapse>
</li>
<!-- Paramétrage -->
<li class="nav-item">
<sidenav-collapse
collapse-ref="parametrageMenu"
nav-text="Paramétrage"
:class="getRoute() === 'parametrage' ? 'active' : ''"
>
<template #icon>
<Office />
</template>
<template #list>
<ul class="nav ms-4 ps-3">
<sidenav-item
:to="{ name: 'Gestion droits' }"
mini-icon="D"
text="Gestion des droits"
/>
<sidenav-item
:to="{ name: 'Gestion emails' }"
mini-icon="E"
text="Gestion des emails"
/>
<sidenav-item
:to="{ name: 'Gestion modeles' }"
mini-icon="M"
text="Gestion des modèles"
v-for="subItem in item.children"
:key="subItem.id"
:to="subItem.route"
:mini-icon="subItem.miniIcon"
:text="subItem.text"
/>
</ul>
</template>
@ -304,6 +60,7 @@
</ul>
</div>
</template>
<script>
import SidenavItem from "./SidenavItem.vue";
import SidenavCollapse from "./SidenavCollapse.vue";
@ -312,6 +69,7 @@ import Shop from "../../components/Icon/Shop.vue";
import Office from "../../components/Icon/Office.vue";
import { mapState } from "vuex";
export default {
name: "SidenavList",
components: {
@ -328,12 +86,303 @@ export default {
},
computed: {
...mapState(["isRTL"]),
// Navigation data structure
navigationItems() {
return [
{
id: "dashboard",
type: "collapse",
text: "Dashboards",
icon: "Shop",
collapseRef: "dashboardsExamples",
children: [
{
id: "default",
route: { name: "Default" },
miniIcon: "D",
text: "Default",
},
{
id: "crm-dashboard",
route: { name: "CRM" },
miniIcon: "C",
text: "CRM",
},
],
},
{
id: "agenda",
type: "single",
text: "Agenda",
icon: "Office",
miniIcon: "A",
route: { name: "Agenda" },
},
{
id: "courriel",
type: "single",
text: "Courriel",
icon: "Office",
miniIcon: "C",
route: { name: "Courriel" },
},
{
id: "contacts",
type: "single",
text: "Contacts",
icon: "Office",
miniIcon: "C",
route: { name: "Gestion contacts" },
},
{
id: "gestion-section",
type: "section-header",
text: "GESTION",
},
{
id: "clients",
type: "collapse",
text: "Clients",
icon: "Office",
collapseRef: "clientsMenu",
routeKey: "clients",
children: [
{
id: "clients-list",
route: { name: "Gestion clients" },
miniIcon: "C",
text: "Clients",
},
{
id: "clients-locations",
route: { name: "Localisation clients" },
miniIcon: "L",
text: "Lieux",
},
{
id: "clients-stats",
route: { name: "Statistiques clients" },
miniIcon: "S",
text: "Statistiques",
},
],
},
{
id: "fournisseurs",
type: "collapse",
text: "Fournisseurs",
icon: "Office",
collapseRef: "fournisseursMenu",
routeKey: "fournisseurs",
children: [
{
id: "fournisseurs-list",
route: { name: "Gestion fournisseurs" },
miniIcon: "F",
text: "Fournisseurs",
},
{
id: "fournisseurs-orders",
route: { name: "Commandes fournisseurs" },
miniIcon: "C",
text: "Commandes",
},
{
id: "fournisseurs-invoices",
route: { name: "Factures fournisseurs" },
miniIcon: "F",
text: "Factures",
},
{
id: "fournisseurs-stats",
route: { name: "Statistiques fournisseurs" },
miniIcon: "S",
text: "Statistiques",
},
],
},
{
id: "sous-traitants",
type: "collapse",
text: "Sous-Traitants",
icon: "Office",
collapseRef: "sousTraitantsMenu",
routeKey: "sous-traitants",
children: [
{
id: "sous-traitants-list",
route: { name: "Gestion sous-traitants" },
miniIcon: "S",
text: "Sous-Traitants",
},
{
id: "sous-traitants-orders",
route: { name: "Commandes sous-traitants" },
miniIcon: "C",
text: "Commandes",
},
{
id: "sous-traitants-invoices",
route: { name: "Factures sous-traitants" },
miniIcon: "F",
text: "Factures",
},
{
id: "sous-traitants-stats",
route: { name: "Statistiques sous-traitants" },
miniIcon: "S",
text: "Statistiques",
},
],
},
{
id: "ventes",
type: "collapse",
text: "Ventes",
icon: "Shop",
collapseRef: "ventesMenu",
routeKey: "ventes",
children: [
{
id: "devis",
route: { name: "Devis" },
miniIcon: "D",
text: "Devis",
},
{
id: "factures-ventes",
route: { name: "Factures ventes" },
miniIcon: "F",
text: "Factures",
},
{
id: "ventes-stats",
route: { name: "Statistiques ventes" },
miniIcon: "S",
text: "Statistiques",
},
],
},
{
id: "stock",
type: "collapse",
text: "Stock",
icon: "Office",
collapseRef: "stockMenu",
routeKey: "stock",
children: [
{
id: "produits",
route: { name: "Gestion de produits" },
miniIcon: "R",
text: "Produits",
},
{
id: "reception",
route: { name: "Reception stock" },
miniIcon: "R",
text: "Réception",
},
{
id: "stock-gestion",
route: { name: "Gestion stock" },
miniIcon: "S",
text: "Stock",
},
],
},
{
id: "employes",
type: "collapse",
text: "Employés",
icon: "Office",
collapseRef: "employesMenu",
routeKey: "employes",
children: [
{
id: "employes-list",
route: { name: "Gestion employes" },
miniIcon: "E",
text: "Employés",
},
{
id: "employes-thanatopracteurs",
route: { name: "Employés Thanatopracteurs" },
miniIcon: "T",
text: "Employés & Thanatopracteurs",
},
{
id: "ndf",
route: { name: "NDF" },
miniIcon: "N",
text: "NDF",
},
{
id: "vehicules",
route: { name: "Véhicules" },
miniIcon: "V",
text: "Véhicules",
},
{
id: "absences",
route: { name: "Absences" },
miniIcon: "A",
text: "Absences",
},
],
},
{
id: "parametrage",
type: "collapse",
text: "Paramétrage",
icon: "Office",
collapseRef: "parametrageMenu",
routeKey: "parametrage",
children: [
{
id: "droits",
route: { name: "Gestion droits" },
miniIcon: "D",
text: "Gestion des droits",
},
{
id: "emails",
route: { name: "Gestion emails" },
miniIcon: "E",
text: "Gestion des emails",
},
{
id: "modeles",
route: { name: "Gestion modeles" },
miniIcon: "M",
text: "Gestion des modèles",
},
],
},
];
},
},
methods: {
getRoute() {
const routeArr = this.$route.path.split("/");
return routeArr[1];
},
getIconComponent(iconName) {
const iconMap = {
Shop: "Shop",
Office: "Office",
};
return iconMap[iconName] || "Office";
},
isItemActive(item) {
if (item.type === "collapse" && item.routeKey) {
return this.getRoute() === item.routeKey;
}
return false;
},
},
};
</script>

View File

@ -522,7 +522,12 @@ const routes = [
{
path: "/employes",
name: "Gestion employes",
component: () => import("@/views/pages/Employes/Employes.vue"),
component: () => import("@/views/pages/Employes/Employees.vue"),
},
{
path: "/employes/new",
name: "Creation employé",
component: () => import("@/views/pages/CRM/AddEmployee.vue"),
},
{
path: "/employes/ndf",
@ -531,7 +536,7 @@ const routes = [
},
{
path: "/employes/vehicules",
name: "Vehicules",
name: "Véhicules",
component: () => import("@/views/pages/Employes/Vehicules.vue"),
},
{
@ -539,6 +544,12 @@ const routes = [
name: "Absences",
component: () => import("@/views/pages/Employes/Absences.vue"),
},
{
path: "/employes/thanatopracteurs",
name: "Employés Thanatopracteurs",
component: () =>
import("@/views/pages/Employes/EmployesThanatopracteurs.vue"),
},
// Paramétrage
{
path: "/parametrage/droits",

View File

@ -0,0 +1,293 @@
import { request } from "./http";
export interface EmployeeAddress {
line1: string | null;
line2: string | null;
postal_code: string | null;
city: string | null;
country_code: string | null;
full_address?: string;
}
export interface Employee {
id: number;
first_name: string;
last_name: string;
full_name: string;
email: string | null;
phone: string | null;
hire_date: string;
job_title: string | null; // Changed from position to job_title
salary?: number | null; // Optional since API doesn't always return it
active: boolean; // Changed from is_active to active
created_at: string;
updated_at: string;
// Relations
thanatopractitioner?: {
id: number;
license_number: string;
authorization_number: string;
authorization_valid_until: string;
created_at: string;
updated_at: string;
} | null;
}
export interface EmployeeListResponse {
data: Employee[];
pagination: {
current_page: number;
per_page: number;
total: number;
last_page: number;
from: number;
to: number;
};
message: string;
}
// For nested response structure
export interface NestedEmployeeListResponse {
data: {
data: Employee[];
pagination: {
current_page: number;
per_page: number;
total: number;
last_page: number;
from: number;
to: number;
};
message: string;
};
}
export interface EmployeeResponse {
data: Employee;
message: string;
}
export interface CreateEmployeePayload {
first_name: string;
last_name: string;
email?: string | null;
phone?: string | null;
hire_date: string;
job_title?: string | null; // Changed from position to job_title
salary?: number | null;
active?: boolean; // Changed from is_active to active
}
export interface UpdateEmployeePayload extends Partial<CreateEmployeePayload> {
id: number;
}
export const EmployeeService = {
/**
* Get all employees with pagination and filtering
*/
async getAllEmployees(params?: {
page?: number;
per_page?: number;
search?: string;
active?: boolean;
sort_by?: string;
sort_direction?: string;
}): Promise<NestedEmployeeListResponse> {
const response = await request<NestedEmployeeListResponse>({
url: "/api/employees",
method: "get",
params,
});
return response;
},
/**
* Get a specific employee by ID
*/
async getEmployee(id: number): Promise<EmployeeResponse> {
const response = await request<EmployeeResponse>({
url: `/api/employees/${id}`,
method: "get",
});
return response;
},
/**
* Create a new employee
*/
async createEmployee(
payload: CreateEmployeePayload
): Promise<EmployeeResponse> {
const formattedPayload = this.transformEmployeePayload(payload);
const response = await request<EmployeeResponse>({
url: "/api/employees",
method: "post",
data: formattedPayload,
});
return response;
},
/**
* Update an existing employee
*/
async updateEmployee(
payload: UpdateEmployeePayload
): Promise<EmployeeResponse> {
const { id, ...updateData } = payload;
const formattedPayload = this.transformEmployeePayload(updateData);
const response = await request<EmployeeResponse>({
url: `/api/employees/${id}`,
method: "put",
data: formattedPayload,
});
return response;
},
/**
* Delete an employee
*/
async deleteEmployee(
id: number
): Promise<{ success: boolean; message: string }> {
const response = await request<{ success: boolean; message: string }>({
url: `/api/employees/${id}`,
method: "delete",
});
return response;
},
/**
* Get active employees only
*/
async getActiveEmployees(params?: {
page?: number;
per_page?: number;
}): Promise<NestedEmployeeListResponse> {
const response = await request<NestedEmployeeListResponse>({
url: "/api/employees",
method: "get",
params: {
active: true,
...params,
},
});
return response;
},
/**
* Get employees with thanatopractitioner data
*/
async getEmployeesWithThanatopractitioner(): Promise<{
data: Employee[];
}> {
const response = await request<{
data: Employee[];
}>({
url: "/api/employees/with-thanatopractitioner",
method: "get",
});
return response;
},
/**
* Get employee statistics
*/
async getStatistics(): Promise<{
data: {
total_employees: number;
active_employees: number;
inactive_employees: number;
total_salary: number;
average_salary: number;
};
}> {
const response = await request<{
data: {
total_employees: number;
active_employees: number;
inactive_employees: number;
total_salary: number;
average_salary: number;
};
}>({
url: "/api/employees/statistics",
method: "get",
});
return response;
},
/**
* Transform employee payload to match Laravel form request structure
*/
transformEmployeePayload(payload: Partial<CreateEmployeePayload>): any {
const transformed: any = { ...payload };
// Ensure boolean values are properly formatted
if (typeof transformed.active === "boolean") {
transformed.active = transformed.active ? 1 : 0;
}
// Format salary if present
if (transformed.salary !== undefined && transformed.salary !== null) {
transformed.salary = parseFloat(transformed.salary.toString());
}
// Remove undefined values to avoid sending them
Object.keys(transformed).forEach((key) => {
if (transformed[key] === undefined) {
delete transformed[key];
}
});
return transformed;
},
/**
* Search employees by name, email, or other criteria
*/
async searchEmployees(query: string): Promise<Employee[]> {
const response = await request<{
data: {
data: Employee[];
};
}>({
url: "/api/employees",
method: "get",
params: {
search: query,
},
});
return response.data.data;
},
/**
* Toggle employee active status
*/
async toggleEmployeeStatus(
id: number,
isActive: boolean
): Promise<EmployeeResponse> {
const response = await request<EmployeeResponse>({
url: `/api/employees/${id}`,
method: "put",
data: {
active: isActive,
},
});
return response;
},
};
export default EmployeeService;

View File

@ -5,7 +5,12 @@ export interface Product {
id: number;
nom: string;
reference: string;
categorie: string;
categorie_id: number;
category: {
id: number;
name: string;
code: string;
};
fabricant: string | null;
stock_actuel: number;
stock_minimum: number;
@ -41,7 +46,7 @@ export interface Product {
export interface ProductFormData {
nom: string;
reference: string;
categorie: string;
categorie_id: number;
fabricant?: string | null;
stock_actuel: number;
stock_minimum: number;
@ -88,7 +93,7 @@ class ProductService {
page?: number;
per_page?: number;
search?: string;
categorie?: string;
categorie_id?: number;
fournisseur_id?: number;
low_stock?: boolean;
expiring_soon?: boolean;
@ -185,7 +190,7 @@ class ProductService {
// Get products by category
async getProductsByCategory(
category: string,
category: number,
params: { per_page?: number } = {}
): Promise<ProductListResponse> {
const response = await request<ProductListResponse>({

View File

@ -0,0 +1,287 @@
import { request } from "./http";
// Type definitions
export interface ProductCategorie {
id: number;
nom: string;
reference: string;
categorie: string;
fabricant: string | null;
stock_actuel: number;
stock_minimum: number;
unite: string;
prix_unitaire: number;
date_expiration: string | null;
numero_lot: string | null;
conditionnement_nom: string | null;
conditionnement_quantite: number | null;
conditionnement_unite: string | null;
photo_url: string | null;
fiche_technique_url: string | null;
fournisseur_id: number | null;
is_low_stock: boolean;
created_at: string;
updated_at: string;
fournisseur?: {
id: number;
name: string;
email: string;
};
conditionnement?: {
nom: string | null;
quantite: number | null;
unite: string | null;
};
media?: {
photo_url: string | null;
fiche_technique_url: string | null;
};
}
export interface ProductCategorieFormData {
nom: string;
reference: string;
categorie: string;
fabricant?: string | null;
stock_actuel: number;
stock_minimum: number;
unite: string;
prix_unitaire: number;
date_expiration?: string | null;
numero_lot?: string | null;
conditionnement_nom?: string | null;
conditionnement_quantite?: number | null;
conditionnement_unite?: string | null;
photo_url?: string | null;
fiche_technique_url?: string | null;
fournisseur_id?: number | null;
}
export interface ProductCategorieListResponse {
data: ProductCategorie[];
pagination: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
};
summary: {
total_productCategories: number;
low_stock_productCategories: number;
total_value: number;
};
}
export interface ProductCategorieStatistics {
total_productCategories: number;
low_stock_productCategories: number;
expiring_productCategories: number;
total_value: number;
}
class ProductCategorieService {
// Get all productCategories with pagination and filters
async getAllProductCategories(
params: {
page?: number;
per_page?: number;
search?: string;
categorie?: string;
fournisseur_id?: number;
low_stock?: boolean;
expiring_soon?: boolean;
sort_by?: string;
sort_direction?: "asc" | "desc";
} = {}
): Promise<ProductCategorieListResponse> {
const response = await request<ProductCategorieListResponse>({
url: "/api/productCategories",
method: "get",
params,
});
return response;
}
// Get a single productCategorie by ID
async getProductCategorie(id: number): Promise<{ data: ProductCategorie }> {
const response = await request<{ data: ProductCategorie }>({
url: `/api/productCategories/${id}`,
method: "get",
});
return response;
}
// Create a new productCategorie
async createProductCategorie(
productCategorieData: ProductCategorieFormData | FormData
): Promise<{ data: ProductCategorie }> {
let formattedPayload: any;
// Check if data is FormData (for file uploads)
if (productCategorieData instanceof FormData) {
formattedPayload = productCategorieData;
} else {
formattedPayload = this.transformProductCategoriePayload(
productCategorieData
);
}
const response = await request<{ data: ProductCategorie }>({
url: "/api/productCategories",
method: "post",
data: formattedPayload,
});
return response;
}
// Update an existing productCategorie
async updateProductCategorie(
id: number,
productCategorieData: ProductCategorieFormData | FormData
): Promise<{ data: ProductCategorie }> {
let formattedPayload: any;
// Check if data is FormData (for file uploads)
if (productCategorieData instanceof FormData) {
formattedPayload = productCategorieData;
} else {
formattedPayload = this.transformProductCategoriePayload(
productCategorieData
);
}
const response = await request<{ data: ProductCategorie }>({
url: `/api/productCategories/${id}`,
method: "put",
data: formattedPayload,
});
return response;
}
// Delete a productCategorie
async deleteProductCategorie(id: number): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: `/api/productCategories/${id}`,
method: "delete",
});
return response;
}
// Search productCategories by name
async searchProductCategories(
searchTerm: string,
exactMatch: boolean = false
): Promise<{ data: ProductCategorie[]; count: number; message: string }> {
const response = await request<{
data: ProductCategorie[];
count: number;
message: string;
}>({
url: "/api/productCategories/searchBy",
method: "get",
params: {
name: searchTerm,
exact: exactMatch,
},
});
return response;
}
// Get productCategories with low stock
async getLowStockProductCategories(
params: { per_page?: number } = {}
): Promise<ProductCategorieListResponse> {
const response = await request<ProductCategorieListResponse>({
url: "/api/productCategories/low-stock",
method: "get",
params,
});
return response;
}
// Get productCategories by category
async getProductCategoriesByCategory(
category: string,
params: { per_page?: number } = {}
): Promise<ProductCategorieListResponse> {
const response = await request<ProductCategorieListResponse>({
url: "/api/productCategories/by-category",
method: "get",
params: {
category,
...params,
},
});
return response;
}
// Get productCategorie statistics
async getProductCategorieStatistics(): Promise<{
data: ProductCategorieStatistics;
message: string;
}> {
const response = await request<{
data: ProductCategorieStatistics;
message: string;
}>({
url: "/api/productCategories/statistics",
method: "get",
});
return response;
}
// Update stock quantity for a productCategorie
async updateStock(
productCategorieId: number,
newStock: number
): Promise<{ data: ProductCategorie; message: string }> {
const response = await request<{ data: ProductCategorie; message: string }>(
{
url: `/api/productCategories/${productCategorieId}/stock`,
method: "patch",
data: {
stock_actuel: newStock,
},
}
);
return response;
}
/**
* Transform productCategorie payload to match Laravel form request structure
*/
transformProductCategoriePayload(
payload: Partial<ProductCategorieFormData>
): any {
const transformed: any = { ...payload };
// Remove undefined values to avoid sending them
Object.keys(transformed).forEach((key) => {
if (transformed[key] === undefined) {
delete transformed[key];
}
});
return transformed;
}
// Utility methods for productCategorie expiration checks
static isExpired(productCategorie: ProductCategorie): boolean {
if (!productCategorie.date_expiration) return false;
return new Date(productCategorie.date_expiration) < new Date();
}
static isExpiringSoon(
productCategorie: ProductCategorie,
days: number = 30
): boolean {
if (!productCategorie.date_expiration) return false;
const expirationDate = new Date(productCategorie.date_expiration);
const soonDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return expirationDate <= soonDate && expirationDate >= new Date();
}
}
export default ProductCategorieService;

View File

@ -0,0 +1,190 @@
import { request } from "./http";
// Type definitions
export interface ProductCategory {
id: number;
parent_id: number | null;
code: string;
name: string;
description: string | null;
active: boolean;
path: string;
has_children: boolean;
has_products: boolean;
children_count: number;
products_count: number;
parent?: ProductCategory;
children?: ProductCategory[];
created_at: string;
updated_at: string;
}
export interface ProductCategoryFormData {
parent_id?: number | null;
code: string;
name: string;
description?: string | null;
active?: boolean;
}
export interface ProductCategoryListResponse {
data: ProductCategory[];
pagination: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
};
}
export interface ProductCategoryStatistics {
total_categories: number;
active_categories: number;
inactive_categories: number;
categories_with_children: number;
root_categories: number;
}
class ProductCategoryService {
// Get all product categories with pagination and filters
async getAllProductCategories(
params: {
page?: number;
per_page?: number;
search?: string;
active?: boolean;
parent_id?: number;
sort_by?: string;
sort_direction?: "asc" | "desc";
} = {}
): Promise<ProductCategoryListResponse> {
const response = await request<ProductCategoryListResponse>({
url: "/api/product-categories",
method: "get",
params,
});
return response;
}
// Get a single product category by ID
async getProductCategory(id: number): Promise<{ data: ProductCategory }> {
const response = await request<{ data: ProductCategory }>({
url: `/api/product-categories/${id}`,
method: "get",
});
return response;
}
// Create a new product category
async createProductCategory(
categoryData: ProductCategoryFormData
): Promise<{ data: ProductCategory }> {
const response = await request<{ data: ProductCategory }>({
url: "/api/product-categories",
method: "post",
data: categoryData,
});
return response;
}
// Update an existing product category
async updateProductCategory(
id: number,
categoryData: ProductCategoryFormData
): Promise<{ data: ProductCategory }> {
const response = await request<{ data: ProductCategory }>({
url: `/api/product-categories/${id}`,
method: "put",
data: categoryData,
});
return response;
}
// Delete a product category
async deleteProductCategory(id: number): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: `/api/product-categories/${id}`,
method: "delete",
});
return response;
}
// Search product categories
async searchProductCategories(
term: string,
params: { per_page?: number } = {}
): Promise<ProductCategoryListResponse> {
const response = await request<ProductCategoryListResponse>({
url: "/api/product-categories/search",
method: "get",
params: {
term,
...params,
},
});
return response;
}
// Get active product categories only
async getActiveProductCategories(): Promise<{ data: ProductCategory[] }> {
const response = await request<{ data: ProductCategory[] }>({
url: "/api/product-categories/active",
method: "get",
});
return response;
}
// Get root product categories (no parent)
async getRootProductCategories(): Promise<{ data: ProductCategory[] }> {
const response = await request<{ data: ProductCategory[] }>({
url: "/api/product-categories/roots",
method: "get",
});
return response;
}
// Get hierarchical structure
async getHierarchicalProductCategories(): Promise<{
data: ProductCategory[];
}> {
const response = await request<{ data: ProductCategory[] }>({
url: "/api/product-categories/hierarchical",
method: "get",
});
return response;
}
// Get product category statistics
async getProductCategoryStatistics(): Promise<{
data: ProductCategoryStatistics;
message: string;
}> {
const response = await request<{
data: ProductCategoryStatistics;
message: string;
}>({
url: "/api/product-categories/statistics",
method: "get",
});
return response;
}
// Toggle category active status
async toggleProductCategoryStatus(
id: number,
active: boolean
): Promise<{ data: ProductCategory; message: string }> {
const response = await request<{ data: ProductCategory; message: string }>({
url: `/api/product-categories/${id}/toggle-active`,
method: "patch",
data: {
active,
},
});
return response;
}
}
export default ProductCategoryService;

View File

@ -0,0 +1,373 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import EmployeeService from "@/services/employee";
import type {
Employee,
CreateEmployeePayload,
UpdateEmployeePayload,
EmployeeListResponse,
NestedEmployeeListResponse,
} from "@/services/employee";
export const useEmployeeStore = defineStore("employee", () => {
// State
const employees = ref<Employee[]>([]);
const currentEmployee = ref<Employee | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const searchResults = ref<Employee[]>([]);
// Pagination state
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
from: 0,
to: 0,
});
// Getters
const allEmployees = computed(() => employees.value);
const activeEmployees = computed(() =>
employees.value.filter((employee: Employee) => employee.active)
);
const inactiveEmployees = computed(() =>
employees.value.filter((employee: Employee) => !employee.active)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getEmployeeById = computed(() => (id: number) =>
employees.value.find((employee: Employee) => employee.id === id)
);
const getPagination = computed(() => pagination.value);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setEmployees = (newEmployees: Employee[]) => {
employees.value = newEmployees;
};
const setCurrentEmployee = (employee: Employee | null) => {
currentEmployee.value = employee;
};
const setSearchEmployee = (searchEmployee: Employee[]) => {
searchResults.value = searchEmployee;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
current_page: meta.current_page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || 10,
total: meta.total || 0,
from: meta.from || 0,
to: meta.to || 0,
};
}
};
/**
* Fetch all employees with optional pagination and filters
*/
const fetchEmployees = async (params?: {
page?: number;
per_page?: number;
search?: string;
active?: boolean;
sort_by?: string;
sort_direction?: string;
}) => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.getAllEmployees(params);
setEmployees(response.data.data);
setPagination(response.data.pagination);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch employees";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Fetch a single employee by ID
*/
const fetchEmployee = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.getEmployee(id);
setCurrentEmployee(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch employee";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Create a new employee
*/
const createEmployee = async (payload: CreateEmployeePayload) => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.createEmployee(payload);
// Add the new employee to the list
employees.value.push(response.data);
setCurrentEmployee(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to create employee";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Update an existing employee
*/
const updateEmployee = async (payload: UpdateEmployeePayload) => {
setLoading(true);
setError(null);
try {
console.log(payload);
const response = await EmployeeService.updateEmployee(payload);
const updatedEmployee = response.data;
// Update in the employees list
const index = employees.value.findIndex(
(employee: Employee) => employee.id === updatedEmployee.id
);
if (index !== -1) {
employees.value[index] = updatedEmployee;
}
// Update current employee if it's the one being edited
if (
currentEmployee.value &&
currentEmployee.value.id === updatedEmployee.id
) {
setCurrentEmployee(updatedEmployee);
}
return updatedEmployee;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to update employee";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Delete an employee
*/
const deleteEmployee = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.deleteEmployee(id);
// Remove from the employees list
employees.value = employees.value.filter(
(employee: Employee) => employee.id !== id
);
// Clear current employee if it's the one being deleted
if (currentEmployee.value && currentEmployee.value.id === id) {
setCurrentEmployee(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to delete employee";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Search employees
*/
const searchEmployees = async (query: string) => {
setLoading(true);
error.value = null;
try {
const results = await EmployeeService.searchEmployees(query);
setSearchEmployee(results);
return results;
} catch (err) {
error.value = "Erreur lors de la recherche des employés";
console.error("Error searching employees:", err);
setSearchEmployee([]);
throw err;
} finally {
setLoading(false);
}
};
/**
* Toggle employee active status
*/
const toggleEmployeeStatus = async (id: number, isActive: boolean) => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.toggleEmployeeStatus(id, isActive);
const updatedEmployee = response.data;
// Update in the employees list
const index = employees.value.findIndex(
(employee: Employee) => employee.id === id
);
if (index !== -1) {
employees.value[index] = updatedEmployee;
}
// Update current employee if it's the one being toggled
if (currentEmployee.value && currentEmployee.value.id === id) {
setCurrentEmployee(updatedEmployee);
}
return updatedEmployee;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to toggle employee status";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Get employee statistics
*/
const fetchStatistics = async () => {
setLoading(true);
setError(null);
try {
const response = await EmployeeService.getStatistics();
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Failed to fetch statistics";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Clear current employee
*/
const clearCurrentEmployee = () => {
setCurrentEmployee(null);
};
/**
* Clear all state
*/
const clearStore = () => {
employees.value = [];
currentEmployee.value = null;
error.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
from: 0,
to: 0,
};
};
return {
// State
employees,
currentEmployee,
loading,
error,
searchResults,
// Getters
allEmployees,
activeEmployees,
inactiveEmployees,
isLoading,
hasError,
getError,
getEmployeeById,
getPagination,
// Actions
fetchEmployees,
fetchEmployee,
createEmployee,
updateEmployee,
deleteEmployee,
searchEmployees,
toggleEmployeeStatus,
fetchStatistics,
clearCurrentEmployee,
clearStore,
clearError,
};
});

View File

@ -0,0 +1,510 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import ProductCategoryService from "@/services/productCategory";
import type {
ProductCategory,
ProductCategoryFormData,
ProductCategoryListResponse,
} from "@/services/productCategory";
// Create an instance of productCategoryService
const productCategoryService = new ProductCategoryService();
export const useProductCategoryStore = defineStore("productCategory", () => {
// State
const productCategories = ref<ProductCategory[]>([]);
const currentProductCategory = ref<ProductCategory | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Pagination state
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
});
// Statistics state
const statistics = ref({
total_categories: 0,
active_categories: 0,
inactive_categories: 0,
categories_with_children: 0,
root_categories: 0,
});
// Getters
const allProductCategories = computed(() => productCategories.value);
const activeProductCategories = computed(() =>
productCategories.value.filter((category) => category.active)
);
const inactiveProductCategories = computed(() =>
productCategories.value.filter((category) => !category.active)
);
const rootProductCategories = computed(() =>
productCategories.value.filter((category) => !category.parent_id)
);
const parentProductCategories = computed(() =>
productCategories.value.filter((category) => category.has_children)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getProductCategoryById = computed(() => (id: number) =>
productCategories.value.find((category) => category.id === id)
);
const getPagination = computed(() => pagination.value);
const getStatistics = computed(() => statistics.value);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setProductCategories = (newProductCategories: ProductCategory[]) => {
productCategories.value = newProductCategories;
};
const setCurrentProductCategory = (category: ProductCategory | null) => {
currentProductCategory.value = category;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
current_page: meta.current_page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || 10,
total: meta.total || 0,
};
}
};
const setStatistics = (stats: any) => {
if (stats) {
statistics.value = {
total_categories: stats.total_categories || 0,
active_categories: stats.active_categories || 0,
inactive_categories: stats.inactive_categories || 0,
categories_with_children: stats.categories_with_children || 0,
root_categories: stats.root_categories || 0,
};
}
};
/**
* Récupérer toutes les catégories de produits avec pagination et filtres optionnels
*/
const fetchProductCategories = async (params?: {
page?: number;
per_page?: number;
search?: string;
active?: boolean;
parent_id?: number;
sort_by?: string;
sort_direction?: "asc" | "desc";
}) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getAllProductCategories(
params
);
setProductCategories(response.data);
if (response.pagination) {
setPagination(response.pagination);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories de produits";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer une seule catégorie de produit par ID
*/
const fetchProductCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getProductCategory(id);
setCurrentProductCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Créer une nouvelle catégorie de produit
*/
const createProductCategory = async (payload: ProductCategoryFormData) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.createProductCategory(
payload
);
// Ajouter la nouvelle catégorie à la liste
productCategories.value.push(response.data);
setCurrentProductCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la création de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Mettre à jour une catégorie de produit existante
*/
const updateProductCategory = async (
payload: ProductCategoryFormData & { id: number }
) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.updateProductCategory(
payload.id,
payload
);
const updatedCategory = response.data;
// Mettre à jour dans la liste des catégories
const index = productCategories.value.findIndex(
(category) => category.id === updatedCategory.id
);
if (index !== -1) {
productCategories.value[index] = updatedCategory;
}
// Mettre à jour la catégorie actuelle si c'est celle en cours d'édition
if (
currentProductCategory.value &&
currentProductCategory.value.id === updatedCategory.id
) {
setCurrentProductCategory(updatedCategory);
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Supprimer une catégorie de produit
*/
const deleteProductCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.deleteProductCategory(id);
// Retirer de la liste des catégories
productCategories.value = productCategories.value.filter(
(category) => category.id !== id
);
// Effacer la catégorie actuelle si c'est celle en cours de suppression
if (
currentProductCategory.value &&
currentProductCategory.value.id === id
) {
setCurrentProductCategory(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la suppression de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Rechercher des catégories de produits
*/
const searchProductCategories = async (
term: string,
params?: {
per_page?: number;
}
) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.searchProductCategories(
term,
params
);
setProductCategories(response.data);
if (response.pagination) {
setPagination(response.pagination);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la recherche de catégories de produits";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer uniquement les catégories actives
*/
const fetchActiveProductCategories = async (params?: {
per_page?: number;
}) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getActiveProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories actives";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les catégories racine
*/
const fetchRootProductCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getRootProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories racine";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer la structure hiérarchique
*/
const fetchHierarchicalProductCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getHierarchicalProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement de la structure hiérarchique";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les statistiques des catégories
*/
const fetchProductCategoryStatistics = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getProductCategoryStatistics();
setStatistics(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des statistiques";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Basculer le statut actif/inactif d'une catégorie
*/
const toggleProductCategoryStatus = async (id: number, active: boolean) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.toggleProductCategoryStatus(
id,
active
);
const updatedCategory = response.data;
// Mettre à jour dans la liste
const index = productCategories.value.findIndex(
(category) => category.id === updatedCategory.id
);
if (index !== -1) {
productCategories.value[index] = updatedCategory;
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour du statut";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Réinitialiser l'état
*/
const resetState = () => {
productCategories.value = [];
currentProductCategory.value = null;
loading.value = false;
error.value = null;
pagination.value = {
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
};
statistics.value = {
total_categories: 0,
active_categories: 0,
inactive_categories: 0,
categories_with_children: 0,
root_categories: 0,
};
};
return {
// State
productCategories,
currentProductCategory,
loading,
error,
pagination,
statistics,
// Getters
allProductCategories,
activeProductCategories,
inactiveProductCategories,
rootProductCategories,
parentProductCategories,
isLoading,
hasError,
getError,
getProductCategoryById,
getPagination,
getStatistics,
// Actions
setLoading,
setError,
clearError,
setProductCategories,
setCurrentProductCategory,
setPagination,
setStatistics,
fetchProductCategories,
fetchProductCategory,
createProductCategory,
updateProductCategory,
deleteProductCategory,
searchProductCategories,
fetchActiveProductCategories,
fetchRootProductCategories,
fetchHierarchicalProductCategories,
fetchProductCategoryStatistics,
toggleProductCategoryStatus,
resetState,
};
});
export default useProductCategoryStore;

View File

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import ProductService from "@/services/product";
import type { Product } from "@/services/product";
import { Product } from "@/services/product";
interface Meta {
current_page: number;
@ -43,7 +43,7 @@ export const useProductStore = defineStore("product", {
),
categories: (state) => {
const categorySet = new Set(
state.products.map((product) => product.categorie).filter(Boolean)
state.products.map((product) => product.categorie_id).filter(Boolean)
);
return Array.from(categorySet).sort();
},
@ -212,12 +212,12 @@ export const useProductStore = defineStore("product", {
}
},
async fetchProductsByCategory(category: string) {
async fetchProductsByCategory(categoryId: number) {
this.loading = true;
this.error = null;
try {
const products = await productService.getProductsByCategory(category);
const products = await productService.getProductsByCategory(categoryId);
return products.data;
} catch (error: any) {
this.error =
@ -269,9 +269,11 @@ export const useProductStore = defineStore("product", {
},
// Local filtering functions
filterByCategory(category: string) {
if (!category) return this.products;
return this.products.filter((product) => product.categorie === category);
filterByCategory(categoryId: number) {
if (!categoryId) return this.products;
return this.products.filter(
(product) => product.categorie_id === categoryId
);
},
filterByLowStock() {

View File

@ -0,0 +1,59 @@
<template>
<add-employee-presentation
:loading="employeeStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-employee="handleCreateEmployee"
/>
</template>
<script setup>
import AddEmployeePresentation from "@/components/Organism/CRM/AddEmployeePresentation.vue";
import { useEmployeeStore } from "@/stores/employeeStore";
import { useNotificationStore } from "@/stores/notification";
import { ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const employeeStore = useEmployeeStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
const handleCreateEmployee = async (form) => {
try {
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create employee
const employee = await employeeStore.createEmployee(form);
// Show success notification
notificationStore.created("Employé");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Gestion employés" });
}, 2000);
} catch (error) {
console.error("Error creating employee:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,53 @@
<template>
<employee-presentation
:employee-data="employeeStore.employees"
:loading-data="employeeStore.loading"
:pagination="employeeStore.pagination"
@push-details="goDetails"
@delete-employee="deleteEmployee"
@change-page="changePage"
/>
</template>
<script setup>
import EmployeePresentation from "@/components/Organism/Employee/EmployeePresentation.vue";
import { useEmployeeStore } from "@/stores/employeeStore";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
const employeeStore = useEmployeeStore();
const router = useRouter();
onMounted(async () => {
await employeeStore.fetchEmployees();
});
const goDetails = (id) => {
router.push({
name: "Employee details",
params: {
id: id,
},
});
};
const deleteEmployee = async (employeeId) => {
try {
if (confirm("Êtes-vous sûr de vouloir supprimer cet employé ?")) {
await employeeStore.deleteEmployee(employeeId);
alert("Employé supprimé avec succès");
}
} catch (error) {
console.error("Error deleting employee:", error);
alert("Erreur lors de la suppression de l'employé");
}
};
const changePage = async (page) => {
try {
await employeeStore.fetchEmployees({ page });
} catch (error) {
console.error("Error changing page:", error);
alert("Erreur lors du changement de page");
}
};
</script>

View File

@ -1,11 +0,0 @@
<template>
<div>
<h1>Gestion employes</h1>
</div>
</template>
<script>
export default {
name: "GestionEmployes",
};
</script>

View File

@ -0,0 +1,45 @@
<template>
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header pb-0">
<div class="d-lg-flex">
<div>
<h1 class="mb-0">Employés & Thanatopracteurs</h1>
<p class="text-sm mb-0">
Gestion des employés et thanatopracticiens de l'entreprise
</p>
</div>
<div class="ms-auto my-auto mt-lg-0 mt-4">
<div class="ms-auto my-auto">
<button class="btn btn-primary btn-sm mb-0">
<i class="fas fa-plus"></i>
Ajouter un employé
</button>
</div>
</div>
</div>
</div>
<div class="card-body px-0 pb-0">
<div class="table-responsive">
<div class="text-center py-5">
<h3>Module en développement</h3>
<p class="text-sm text-muted">
Cette section sera bientôt disponible avec toutes les
fonctionnalités de gestion des employés et thanatopracticiens.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "EmployesThanatopracteurs",
};
</script>

View File

@ -4,6 +4,7 @@
:loading="productStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
:categories="productCategoryStore.productCategories"
@create-product="handleCreateProduct"
/>
</template>
@ -13,6 +14,7 @@ import AddProductPresentation from "@/components/Organism/Stock/AddProductPresen
import { useFournisseurStore } from "@/stores/fournisseurStore";
import { useProductStore } from "@/stores/productStore";
import { useNotificationStore } from "@/stores/notification";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
@ -20,12 +22,13 @@ const router = useRouter();
const fournisseurStore = useFournisseurStore();
const productStore = useProductStore();
const notificationStore = useNotificationStore();
const productCategoryStore = useProductCategoryStore();
const validationErrors = ref({});
const showSuccess = ref(false);
onMounted(async () => {
// Load fournisseurs for the supplier dropdown
await fournisseurStore.fetchFournisseurs();
await productCategoryStore.fetchProductCategories();
});
const handleCreateProduct = async (form) => {

View File

@ -0,0 +1,72 @@
<template>
<add-product-category-presentation
:parent-categories="parentCategories"
:loading="productCategoryStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-category="handleCreateCategory"
/>
</template>
<script setup>
import AddProductCategoryPresentation from "@/components/Organism/Stock/AddProductCategoryPresentation.vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref, computed } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const productCategoryStore = useProductCategoryStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
// Get parent categories for dropdown selection
const parentCategories = computed(
() => productCategoryStore.rootProductCategories
);
onMounted(async () => {
// Load parent categories for the dropdown
await productCategoryStore.fetchRootProductCategories();
});
const handleCreateCategory = async (form) => {
try {
console.log(form);
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create category
const category = await productCategoryStore.createProductCategory(form);
// Show success notification
notificationStore.created("Catégorie de produit");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Gestion catégories de produits" });
}, 2000);
} catch (error) {
console.error("Error creating product category:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,98 @@
<template>
<edit-product-category-presentation
:category-data="categoryData"
:parent-categories="parentCategories"
:loading="productCategoryStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@update-category="handleUpdateCategory"
/>
</template>
<script setup>
import EditProductCategoryPresentation from "@/components/Organism/Stock/EditProductCategoryPresentation.vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
const router = useRouter();
const route = useRoute();
const productCategoryStore = useProductCategoryStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
const categoryData = ref(null);
// Get parent categories for dropdown selection (excluding current category to avoid circular reference)
const parentCategories = computed(() => {
if (!categoryData.value) return [];
return productCategoryStore.rootProductCategories.filter(
(category) => category.id !== categoryData.value.id
);
});
onMounted(async () => {
const categoryId = route.params.id;
try {
// Load the category data to edit
categoryData.value = await productCategoryStore.fetchProductCategory(
categoryId
);
// Load parent categories for the dropdown
await productCategoryStore.fetchRootProductCategories();
} catch (error) {
console.error("Error loading product category:", error);
notificationStore.error("Erreur", "Impossible de charger la catégorie");
router.push({ name: "Gestion catégories de produits" });
}
});
const handleUpdateCategory = async (form) => {
try {
console.log(form);
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to update category
const category = await productCategoryStore.updateProductCategory({
...form,
id: categoryData.value.id,
});
// Show success notification
notificationStore.updated("Catégorie de produit");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({
name: "Détails catégorie produit",
params: { id: categoryData.value.id },
});
}, 2000);
} catch (error) {
console.error("Error updating product category:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,42 @@
<template>
<product-category-presentation
:category-data="productCategoryStore.productCategories"
:loading-data="productCategoryStore.loading"
@push-details="goDetails"
@delete-category="deleteCategory"
/>
</template>
<script setup>
import ProductCategoryPresentation from "@/components/Organism/Stock/ProductCategoryPresentation.vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
const productCategoryStore = useProductCategoryStore();
const router = useRouter();
onMounted(async () => {
await productCategoryStore.fetchProductCategories();
});
const goDetails = (id) => {
router.push({
name: "Détails catégorie produit",
params: {
id: id,
},
});
};
const deleteCategory = async (id) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette catégorie ?")) {
try {
await productCategoryStore.deleteProductCategory(id);
alert("Catégorie supprimée avec succès");
} catch (error) {
console.error("Erreur lors de la suppression:", error);
alert("Erreur lors de la suppression de la catégorie");
}
}
};
</script>

View File

@ -0,0 +1,95 @@
<template>
<product-category-details-presentation
v-if="categoryData"
:category-data="categoryData"
:loading="productCategoryStore.isLoading"
:statistics="productCategoryStore.getStatistics"
@edit="goToEdit"
@delete="deleteCategory"
@back="goBack"
/>
<div v-else-if="productCategoryStore.isLoading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else class="text-center py-4">
<p>Catégorie non trouvée</p>
<button @click="goBack" class="btn btn-primary">Retour</button>
</div>
</template>
<script setup>
import ProductCategoryDetailsPresentation from "@/components/Organism/Stock/ProductCategoryDetailsPresentation.vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
const router = useRouter();
const route = useRoute();
const productCategoryStore = useProductCategoryStore();
const notificationStore = useNotificationStore();
const categoryData = ref(null);
onMounted(async () => {
const categoryId = route.params.id;
try {
// Load the category data
categoryData.value = await productCategoryStore.fetchProductCategory(
categoryId
);
// Load statistics
await productCategoryStore.fetchProductCategoryStatistics();
} catch (error) {
console.error("Error loading product category:", error);
notificationStore.error("Erreur", "Impossible de charger la catégorie");
// Redirect back to categories list if category not found
if (error.response && error.response.status === 404) {
router.push({ name: "Gestion catégories de produits" });
}
}
});
const goToEdit = (id) => {
router.push({
name: "Modification catégorie produit",
params: { id: id },
});
};
const deleteCategory = async (id) => {
if (
confirm(
"Êtes-vous sûr de vouloir supprimer cette catégorie ? Cette action est irréversible."
)
) {
try {
await productCategoryStore.deleteProductCategory(id);
notificationStore.deleted("Catégorie de produit");
router.push({ name: "Gestion catégories de produits" });
} catch (error) {
console.error("Error deleting product category:", error);
if (error.response && error.response.status === 422) {
notificationStore.error(
"Suppression impossible",
"Cette catégorie contient des produits ou des sous-catégories"
);
} else {
notificationStore.error(
"Erreur",
"Impossible de supprimer la catégorie"
);
}
}
}
};
const goBack = () => {
router.push({ name: "Gestion catégories de produits" });
};
</script>