add employee
This commit is contained in:
parent
aa306f5d19
commit
0ea8f1866b
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
23
thanasoft-front/src/components/templates/CRM/Employee
Normal file
23
thanasoft-front/src/components/templates/CRM/Employee
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
293
thanasoft-front/src/services/employee.ts
Normal file
293
thanasoft-front/src/services/employee.ts
Normal 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;
|
||||
@ -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>({
|
||||
|
||||
287
thanasoft-front/src/services/productCategorie.ts
Normal file
287
thanasoft-front/src/services/productCategorie.ts
Normal 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;
|
||||
190
thanasoft-front/src/services/productCategory.ts
Normal file
190
thanasoft-front/src/services/productCategory.ts
Normal 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;
|
||||
373
thanasoft-front/src/stores/employeeStore.ts
Normal file
373
thanasoft-front/src/stores/employeeStore.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
510
thanasoft-front/src/stores/productCategoryStore.ts
Normal file
510
thanasoft-front/src/stores/productCategoryStore.ts
Normal 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;
|
||||
@ -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() {
|
||||
|
||||
59
thanasoft-front/src/views/pages/CRM/AddEmployee.vue
Normal file
59
thanasoft-front/src/views/pages/CRM/AddEmployee.vue
Normal 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>
|
||||
53
thanasoft-front/src/views/pages/Employes/Employees.vue
Normal file
53
thanasoft-front/src/views/pages/Employes/Employees.vue
Normal 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>
|
||||
@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Gestion employes</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "GestionEmployes",
|
||||
};
|
||||
</script>
|
||||
@ -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>
|
||||
@ -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) => {
|
||||
|
||||
72
thanasoft-front/src/views/pages/Stock/AddProductCategory.vue
Normal file
72
thanasoft-front/src/views/pages/Stock/AddProductCategory.vue
Normal 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>
|
||||
@ -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>
|
||||
42
thanasoft-front/src/views/pages/Stock/ProductCategories.vue
Normal file
42
thanasoft-front/src/views/pages/Stock/ProductCategories.vue
Normal 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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user