add product

This commit is contained in:
Nyavokevin 2025-10-31 15:19:04 +03:00
parent 4b056038d6
commit edb9c87c1e
49 changed files with 3430 additions and 110 deletions

259
thanas Normal file
View File

@ -0,0 +1,259 @@
import { defineStore } from "pinia";
import ProductService from "@/services/product";
export const useProductStore = defineStore("product", {
state: () => ({
products: [],
currentProduct: null,
loading: false,
isLoading: false,
error: null,
meta: {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
from: 1,
to: 0,
},
}),
getters: {
lowStockProducts: (state) =>
state.products.filter((product) => product.is_low_stock),
expiringProducts: (state) =>
state.products.filter((product) =>
product.date_expiration &&
new Date(product.date_expiration) <= new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
),
categories: (state) => {
const categorySet = new Set(state.products.map(product => product.categorie).filter(Boolean));
return Array.from(categorySet).sort();
},
totalProducts: (state) => state.meta.total,
totalValue: (state) =>
state.products.reduce((total, product) =>
total + (product.stock_actuel * product.prix_unitaire), 0
),
},
actions: {
async fetchProducts(params = {}) {
this.loading = true;
this.error = null;
try {
const response = await ProductService.getAllProducts(params);
this.products = response.data;
this.meta = {
current_page: response.pagination.current_page,
last_page: response.pagination.last_page,
per_page: response.pagination.per_page,
total: response.pagination.total,
from: response.pagination.from,
to: response.pagination.to,
};
return response;
} catch (error: any) {
this.error = error?.message || "Erreur lors du chargement des produits";
throw error;
} finally {
this.loading = false;
}
},
async createProduct(productData: any) {
this.isLoading = true;
this.error = null;
try {
const response = await ProductService.createProduct(productData);
const product = response.data;
// Add the new product to the beginning of the list
this.products.unshift(product);
this.meta.total += 1;
return product;
} catch (error: any) {
this.error = error?.message || "Erreur lors de la création du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async updateProduct(id: number, productData: any) {
this.isLoading = true;
this.error = null;
try {
const response = await ProductService.updateProduct(id, productData);
const updatedProduct = response.data;
// Update the product in the list
const index = this.products.findIndex((p) => p.id === id);
if (index !== -1) {
this.products[index] = updatedProduct;
}
// Update current product if it matches
if (this.currentProduct?.id === id) {
this.currentProduct = updatedProduct;
}
return updatedProduct;
} catch (error: any) {
this.error = error?.message || "Erreur lors de la mise à jour du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async deleteProduct(id: number) {
this.isLoading = true;
this.error = null;
try {
await ProductService.deleteProduct(id);
// Remove the product from the list
this.products = this.products.filter((p) => p.id !== id);
this.meta.total -= 1;
// Clear current product if it was deleted
if (this.currentProduct?.id === id) {
this.currentProduct = null;
}
return true;
} catch (error: any) {
this.error = error?.message || "Erreur lors de la suppression du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async fetchProduct(id: number) {
this.loading = true;
this.error = null;
try {
const response = await ProductService.getProduct(id);
this.currentProduct = response.data;
return response.data;
} catch (error: any) {
this.error = error?.message || "Erreur lors du chargement du produit";
throw error;
} finally {
this.loading = false;
}
},
async searchProducts(searchTerm: string, exact = false) {
this.loading = true;
this.error = null;
try {
const response = await ProductService.searchProducts(searchTerm, exact);
// Update current products list with search results
this.products = response.data;
return response.data;
} catch (error: any) {
this.error = error?.message || "Erreur lors de la recherche";
throw error;
} finally {
this.loading = false;
}
},
async fetchLowStockProducts() {
this.loading = true;
this.error = null;
try {
const response = await ProductService.getLowStockProducts();
return response;
} catch (error: any) {
this.error = error?.message || "Erreur lors du chargement des produits à stock faible";
throw error;
} finally {
this.loading = false;
}
},
async fetchProductsByCategory(category: string) {
this.loading = true;
this.error = null;
try {
const response = await ProductService.getProductsByCategory(category);
return response;
} catch (error: any) {
this.error = error?.message || "Erreur lors du chargement des produits par catégorie";
throw error;
} finally {
this.loading = false;
}
},
async getProductStatistics() {
try {
const response = await ProductService.getProductStatistics();
return response.data;
} catch (error: any) {
this.error = error?.message || "Erreur lors du chargement des statistiques";
throw error;
}
},
async updateStock(productId: number, newStock: number) {
try {
const response = await ProductService.updateStock(productId, newStock);
// Update the product in the list
const index = this.products.findIndex((p) => p.id === productId);
if (index !== -1) {
this.products[index] = response.data;
}
return response.data;
} catch (error: any) {
this.error = error?.message || "Erreur lors de la mise à jour du stock";
throw error;
}
},
resetState() {
this.products = [];
this.currentProduct = null;
this.error = null;
this.loading = false;
this.isLoading = false;
},
// Local filtering functions
filterByCategory(category: string) {
if (!category) return this.products;
return this.products.filter((product: any) => product.categorie === category);
},
filterByLowStock() {
return this.products.filter((product: any) => product.is_low_stock);
},
filterByExpiration(days = 30) {
const cutoffDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return this.products.filter((product: any) =>
product.date_expiration &&
new Date(product.date_expiration) <= cutoffDate
);
},
},
});

View File

@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\Product\ProductResource;
use App\Http\Resources\Product\ProductCollection;
use App\Repositories\ProductRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository
) {
}
/**
* Display a listing of products.
*/
public function index(Request $request): ProductCollection|JsonResponse
{
try {
$perPage = $request->get('per_page', 15);
$filters = [
'search' => $request->get('search'),
'categorie' => $request->get('categorie'),
'fournisseur_id' => $request->get('fournisseur_id'),
'low_stock' => $request->get('low_stock'),
'expiring_soon' => $request->get('expiring_soon'),
'sort_by' => $request->get('sort_by', 'created_at'),
'sort_direction' => $request->get('sort_direction', 'desc'),
];
// Remove null filters
$filters = array_filter($filters, function ($value) {
return $value !== null && $value !== '';
});
$products = $this->productRepository->paginate($perPage, $filters);
return new ProductCollection($products);
} catch (\Exception $e) {
Log::error('Error fetching products: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des produits.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created product.
*/
public function store(StoreProductRequest $request): ProductResource|JsonResponse
{
try {
$product = $this->productRepository->create($request->validated());
return new ProductResource($product);
} catch (\Exception $e) {
Log::error('Error creating product: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création du produit.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified product.
*/
public function show(string $id): ProductResource|JsonResponse
{
try {
$product = $this->productRepository->find($id);
if (!$product) {
return response()->json([
'message' => 'Produit non trouvé.',
], 404);
}
return new ProductResource($product);
} catch (\Exception $e) {
Log::error('Error fetching product: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'product_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération du produit.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Search products by name.
*/
public function searchBy(Request $request): JsonResponse
{
try {
$name = $request->get('name', '');
$exact = $request->boolean('exact', false);
if (empty($name)) {
return response()->json([
'message' => 'Le paramètre "name" est requis.',
], 400);
}
$products = $this->productRepository->searchByName($name, 15, $exact);
return response()->json([
'data' => $products,
'count' => $products->count(),
'message' => $products->count() > 0
? 'Produits trouvés avec succès.'
: 'Aucun produit trouvé.',
], 200);
} catch (\Exception $e) {
Log::error('Error searching products by name: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'search_term' => $name,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recherche des produits.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get products with low stock.
*/
public function lowStock(Request $request): ProductCollection|JsonResponse
{
try {
$perPage = $request->get('per_page', 15);
$products = $this->productRepository->getLowStockProducts($perPage);
return new ProductCollection($products);
} catch (\Exception $e) {
Log::error('Error fetching low stock products: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des produits à stock faible.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get products by category.
*/
public function byCategory(Request $request): ProductCollection|JsonResponse
{
try {
$category = $request->get('category');
$perPage = $request->get('per_page', 15);
if (empty($category)) {
return response()->json([
'message' => 'Le paramètre "category" est requis.',
], 400);
}
$products = $this->productRepository->getByCategory($category, $perPage);
return new ProductCollection($products);
} catch (\Exception $e) {
Log::error('Error fetching products by category: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'category' => $category,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des produits par catégorie.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Get products statistics.
*/
public function statistics(): JsonResponse
{
try {
$stats = $this->productRepository->getStatistics();
return response()->json([
'data' => $stats,
'message' => 'Statistiques des produits récupérées avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error fetching product statistics: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des statistiques.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified product.
*/
public function update(UpdateProductRequest $request, string $id): ProductResource|JsonResponse
{
try {
$updated = $this->productRepository->update($id, $request->validated());
if (!$updated) {
return response()->json([
'message' => 'Produit non trouvé ou échec de la mise à jour.',
], 404);
}
$product = $this->productRepository->find($id);
return new ProductResource($product);
} catch (\Exception $e) {
Log::error('Error updating product: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'product_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du produit.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified product.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->productRepository->delete($id);
if (!$deleted) {
return response()->json([
'message' => 'Produit non trouvé ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Produit supprimé avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error deleting product: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'product_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression du produit.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update stock quantity for a product.
*/
public function updateStock(Request $request, string $id): JsonResponse
{
try {
$request->validate([
'stock_actuel' => 'required|numeric|min:0',
]);
$updated = $this->productRepository->updateStock((int) $id, $request->stock_actuel);
if (!$updated) {
return response()->json([
'message' => 'Produit non trouvé ou échec de la mise à jour du stock.',
], 404);
}
$product = $this->productRepository->find($id);
return response()->json([
'data' => new ProductResource($product),
'message' => 'Stock mis à jour avec succès.',
], 200);
} catch (\Exception $e) {
Log::error('Error updating product stock: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
'product_id' => $id,
'stock_data' => $request->all(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour du stock.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'nom' => 'required|string|max:255',
'reference' => 'required|string|max:100|unique:products,reference',
'categorie' => 'required|string|max:191',
'fabricant' => 'nullable|string|max:191',
'stock_actuel' => 'required|numeric|min:0',
'stock_minimum' => 'required|numeric|min:0',
'unite' => 'required|string|max:50',
'prix_unitaire' => 'required|numeric|min:0',
'date_expiration' => 'nullable|date|after:today',
'numero_lot' => 'nullable|string|max:100',
'conditionnement_nom' => 'nullable|string|max:191',
'conditionnement_quantite' => 'nullable|numeric|min:0',
'conditionnement_unite' => 'nullable|string|max:50',
'photo_url' => 'nullable|url|max:500',
'fiche_technique_url' => 'nullable|url|max:500',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
];
}
public function messages(): array
{
return [
'nom.required' => 'Le nom du produit est obligatoire.',
'nom.string' => 'Le nom du produit doit être une chaîne de caractères.',
'nom.max' => 'Le nom du produit ne peut pas dépasser 255 caractères.',
'reference.required' => 'La référence du produit est obligatoire.',
'reference.string' => 'La référence du produit doit être une chaîne de caractères.',
'reference.max' => 'La référence du produit ne peut pas dépasser 100 caractères.',
'reference.unique' => 'Cette référence de produit existe déjà.',
'categorie.required' => 'La catégorie est obligatoire.',
'categorie.string' => 'La catégorie doit être une chaîne de caractères.',
'categorie.max' => 'La catégorie ne peut pas dépasser 191 caractères.',
'fabricant.string' => 'Le fabricant doit être une chaîne de caractères.',
'fabricant.max' => 'Le fabricant ne peut pas dépasser 191 caractères.',
'stock_actuel.required' => 'Le stock actuel est obligatoire.',
'stock_actuel.numeric' => 'Le stock actuel doit être un nombre.',
'stock_actuel.min' => 'Le stock actuel doit être supérieur ou égal à 0.',
'stock_minimum.required' => 'Le stock minimum est obligatoire.',
'stock_minimum.numeric' => 'Le stock minimum doit être un nombre.',
'stock_minimum.min' => 'Le stock minimum doit être supérieur ou égal à 0.',
'unite.required' => 'L\'unité est obligatoire.',
'unite.string' => 'L\'unité doit être une chaîne de caractères.',
'unite.max' => 'L\'unité ne peut pas dépasser 50 caractères.',
'prix_unitaire.required' => 'Le prix unitaire est obligatoire.',
'prix_unitaire.numeric' => 'Le prix unitaire doit être un nombre.',
'prix_unitaire.min' => 'Le prix unitaire doit être supérieur ou égal à 0.',
'date_expiration.date' => 'La date d\'expiration doit être une date valide.',
'date_expiration.after' => 'La date d\'expiration doit être postérieure à aujourd\'hui.',
'numero_lot.string' => 'Le numéro de lot doit être une chaîne de caractères.',
'numero_lot.max' => 'Le numéro de lot ne peut pas dépasser 100 caractères.',
'conditionnement_nom.string' => 'Le nom du conditionnement doit être une chaîne de caractères.',
'conditionnement_nom.max' => 'Le nom du conditionnement ne peut pas dépasser 191 caractères.',
'conditionnement_quantite.numeric' => 'La quantité du conditionnement doit être un nombre.',
'conditionnement_quantite.min' => 'La quantité du conditionnement doit être supérieure ou égal à 0.',
'conditionnement_unite.string' => 'L\'unité du conditionnement doit être une chaîne de caractères.',
'conditionnement_unite.max' => 'L\'unité du conditionnement ne peut pas dépasser 50 caractères.',
'photo_url.url' => 'L\'URL de la photo doit être une URL valide.',
'photo_url.max' => 'L\'URL de la photo ne peut pas dépasser 500 caractères.',
'fiche_technique_url.url' => 'L\'URL de la fiche technique doit être une URL valide.',
'fiche_technique_url.max' => 'L\'URL de la fiche technique ne peut pas dépasser 500 caractères.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',
];
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProductRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'nom' => 'required|string|max:255',
'reference' => 'required|string|max:100|unique:products,reference,' . $this->product?->id,
'categorie' => 'required|string|max:191',
'fabricant' => 'nullable|string|max:191',
'stock_actuel' => 'required|numeric|min:0',
'stock_minimum' => 'required|numeric|min:0',
'unite' => 'required|string|max:50',
'prix_unitaire' => 'required|numeric|min:0',
'date_expiration' => 'nullable|date|after:today',
'numero_lot' => 'nullable|string|max:100',
'conditionnement_nom' => 'nullable|string|max:191',
'conditionnement_quantite' => 'nullable|numeric|min:0',
'conditionnement_unite' => 'nullable|string|max:50',
'photo_url' => 'nullable|url|max:500',
'fiche_technique_url' => 'nullable|url|max:500',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
];
}
public function messages(): array
{
return [
'nom.required' => 'Le nom du produit est obligatoire.',
'nom.string' => 'Le nom du produit doit être une chaîne de caractères.',
'nom.max' => 'Le nom du produit ne peut pas dépasser 255 caractères.',
'reference.required' => 'La référence du produit est obligatoire.',
'reference.string' => 'La référence du produit doit être une chaîne de caractères.',
'reference.max' => 'La référence du produit ne peut pas dépasser 100 caractères.',
'reference.unique' => 'Cette référence de produit existe déjà.',
'categorie.required' => 'La catégorie est obligatoire.',
'categorie.string' => 'La catégorie doit être une chaîne de caractères.',
'categorie.max' => 'La catégorie ne peut pas dépasser 191 caractères.',
'fabricant.string' => 'Le fabricant doit être une chaîne de caractères.',
'fabricant.max' => 'Le fabricant ne peut pas dépasser 191 caractères.',
'stock_actuel.required' => 'Le stock actuel est obligatoire.',
'stock_actuel.numeric' => 'Le stock actuel doit être un nombre.',
'stock_actuel.min' => 'Le stock actuel doit être supérieur ou égal à 0.',
'stock_minimum.required' => 'Le stock minimum est obligatoire.',
'stock_minimum.numeric' => 'Le stock minimum doit être un nombre.',
'stock_minimum.min' => 'Le stock minimum doit être supérieur ou égal à 0.',
'unite.required' => 'L\'unité est obligatoire.',
'unite.string' => 'L\'unité doit être une chaîne de caractères.',
'unite.max' => 'L\'unité ne peut pas dépasser 50 caractères.',
'prix_unitaire.required' => 'Le prix unitaire est obligatoire.',
'prix_unitaire.numeric' => 'Le prix unitaire doit être un nombre.',
'prix_unitaire.min' => 'Le prix unitaire doit être supérieur ou égal à 0.',
'date_expiration.date' => 'La date d\'expiration doit être une date valide.',
'date_expiration.after' => 'La date d\'expiration doit être postérieure à aujourd\'hui.',
'numero_lot.string' => 'Le numéro de lot doit être une chaîne de caractères.',
'numero_lot.max' => 'Le numéro de lot ne peut pas dépasser 100 caractères.',
'conditionnement_nom.string' => 'Le nom du conditionnement doit être une chaîne de caractères.',
'conditionnement_nom.max' => 'Le nom du conditionnement ne peut pas dépasser 191 caractères.',
'conditionnement_quantite.numeric' => 'La quantité du conditionnement doit être un nombre.',
'conditionnement_quantite.min' => 'La quantité du conditionnement doit être supérieure ou égal à 0.',
'conditionnement_unite.string' => 'L\'unité du conditionnement doit être une chaîne de caractères.',
'conditionnement_unite.max' => 'L\'unité du conditionnement ne peut pas dépasser 50 caractères.',
'photo_url.url' => 'L\'URL de la photo doit être une URL valide.',
'photo_url.max' => 'L\'URL de la photo ne peut pas dépasser 500 caractères.',
'fiche_technique_url.url' => 'L\'URL de la fiche technique doit être une URL valide.',
'fiche_technique_url.max' => 'L\'URL de la fiche technique ne peut pas dépasser 500 caractères.',
'fournisseur_id.exists' => 'Le fournisseur sélectionné n\'existe pas.',
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Resources\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProductCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'pagination' => [
'current_page' => $this->currentPage(),
'from' => $this->firstItem(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'to' => $this->lastItem(),
'total' => $this->total(),
],
'summary' => [
'total_products' => $this->collection->count(),
'low_stock_products' => $this->collection->filter(function ($product) {
return $product->stock_actuel <= $product->stock_minimum;
})->count(),
'total_value' => $this->collection->sum(function ($product) {
return $product->stock_actuel * $product->prix_unitaire;
}),
],
];
}
public function with(Request $request): array
{
return [
'status' => 'success',
'message' => 'Produits récupérés avec succès',
];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Resources\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nom' => $this->nom,
'reference' => $this->reference,
'categorie' => $this->categorie,
'fabricant' => $this->fabricant,
'stock_actuel' => $this->stock_actuel,
'stock_minimum' => $this->stock_minimum,
'unite' => $this->unite,
'prix_unitaire' => $this->prix_unitaire,
'date_expiration' => $this->date_expiration?->format('Y-m-d'),
'numero_lot' => $this->numero_lot,
'conditionnement' => [
'nom' => $this->conditionnement_nom,
'quantite' => $this->conditionnement_quantite,
'unite' => $this->conditionnement_unite,
],
'media' => [
'photo_url' => $this->photo_url,
'fiche_technique_url' => $this->fiche_technique_url,
],
'is_low_stock' => $this->stock_actuel <= $this->stock_minimum,
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
// Relations
'fournisseur' => $this->whenLoaded('fournisseur', function() {
return $this->fournisseur ? [
'id' => $this->fournisseur->id,
'name' => $this->fournisseur->name,
'email' => $this->fournisseur->email,
] : null;
}),
];
}
public function with(Request $request): array
{
return [
'status' => 'success',
'message' => 'Produit récupéré avec succès',
];
}
}

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Fournisseur extends Model
{
@ -32,6 +33,11 @@ class Fournisseur extends Model
return $this->belongsTo(User::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function commercial(): ?string
{
return $this->user ? $this->user->name : 'Système';

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
protected $fillable = [
'nom',
'reference',
'categorie',
'fabricant',
'stock_actuel',
'stock_minimum',
'unite',
'prix_unitaire',
'date_expiration',
'numero_lot',
'conditionnement_nom',
'conditionnement_quantite',
'conditionnement_unite',
'photo_url',
'fiche_technique_url',
'fournisseur_id',
];
protected $casts = [
'stock_actuel' => 'decimal:2',
'stock_minimum' => 'decimal:2',
'prix_unitaire' => 'decimal:2',
'conditionnement_quantite' => 'decimal:2',
'date_expiration' => 'date',
];
/**
* Get the fournisseur that owns the product.
*/
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
}

View File

@ -35,6 +35,10 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(\App\Repositories\FournisseurRepositoryInterface::class, function ($app) {
return new \App\Repositories\FournisseurRepository($app->make(\App\Models\Fournisseur::class));
});
$this->app->bind(\App\Repositories\ProductRepositoryInterface::class, function ($app) {
return new \App\Repositories\ProductRepository($app->make(\App\Models\Product::class));
});
}
/**

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductRepository extends BaseRepository implements ProductRepositoryInterface
{
public function __construct(Product $model)
{
parent::__construct($model);
}
/**
* Get paginated products with filters
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery()->with('fournisseur');
// Apply filters
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('nom', 'like', '%' . $filters['search'] . '%')
->orWhere('reference', 'like', '%' . $filters['search'] . '%')
->orWhere('categorie', 'like', '%' . $filters['search'] . '%')
->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
});
}
if (!empty($filters['categorie'])) {
$query->where('categorie', $filters['categorie']);
}
if (!empty($filters['fournisseur_id'])) {
$query->where('fournisseur_id', $filters['fournisseur_id']);
}
if (isset($filters['low_stock'])) {
$query->whereRaw('stock_actuel <= stock_minimum');
}
if (isset($filters['expiring_soon'])) {
$query->where('date_expiration', '<=', now()->addDays(30)->toDateString())
->where('date_expiration', '>=', now()->toDateString());
}
// Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($perPage);
}
/**
* Get products with low stock
*/
public function getLowStockProducts(int $perPage = 15): LengthAwarePaginator
{
return $this->model->newQuery()
->with('fournisseur')
->whereRaw('stock_actuel <= stock_minimum')
->orderBy('stock_actuel', 'asc')
->paginate($perPage);
}
/**
* Search products by name
*/
public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false)
{
$query = $this->model->newQuery()->with('fournisseur');
if ($exactMatch) {
$query->where('nom', $name);
} else {
$query->where('nom', 'like', '%' . $name . '%');
}
return $query->paginate($perPage);
}
/**
* Get products by category
*/
public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator
{
return $this->model->newQuery()
->with('fournisseur')
->where('categorie', $category)
->orderBy('nom')
->paginate($perPage);
}
/**
* Get products by fournisseur
*/
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator
{
return $this->model->newQuery()
->where('fournisseur_id', $fournisseurId)
->orderBy('nom')
->paginate(15);
}
/**
* Update stock quantity
*/
public function updateStock(int $productId, float $newQuantity): bool
{
return $this->model->where('id', $productId)
->update(['stock_actuel' => $newQuantity]) > 0;
}
/**
* Get product statistics
*/
public function getStatistics(): array
{
$totalProducts = $this->model->count();
$lowStockProducts = $this->model->whereRaw('stock_actuel <= stock_minimum')->count();
$expiringProducts = $this->model->where('date_expiration', '<=', now()->addDays(30)->toDateString())
->where('date_expiration', '>=', now()->toDateString())
->count();
$totalValue = $this->model->sum(\DB::raw('stock_actuel * prix_unitaire'));
return [
'total_products' => $totalProducts,
'low_stock_products' => $lowStockProducts,
'expiring_products' => $expiringProducts,
'total_value' => $totalValue,
];
}
}

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductRepository extends BaseRepository implements ProductRepositoryInterface
{
public function __construct(Product $model)
{
parent::__construct($model);
}
/**
* Get paginated products with filters
*/
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
{
$query = $this->model->newQuery()->with('fournisseur');
// Apply filters
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('nom', 'like', '%' . $filters['search'] . '%')
->orWhere('reference', 'like', '%' . $filters['search'] . '%')
->orWhere('categorie', 'like', '%' . $filters['search'] . '%')
->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
});
}
if (!empty($filters['categorie'])) {
$query->where('categorie', $filters['categorie']);
}
if (!empty($filters['fournisseur_id'])) {
$query->where('fournisseur_id', $filters['fournisseur_id']);
}
if (isset($filters['low_stock'])) {
$query->whereRaw('stock_actuel <= stock_minimum');
}
if (isset($filters['expiring_soon'])) {
$query->where('date_expiration', '<=', now()->addDays(30)->toDateString())
->where('date_expiration', '>=', now()->toDateString());
}
// Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
return $query->paginate($perPage);
}
/**
* Get products with low stock
*/
public function getLowStockProducts(int $perPage = 15): LengthAwarePaginator
{
return $this->model->newQuery()
->with('fournisseur')
->whereRaw('stock_actuel <= stock_minimum')
->orderBy('stock_actuel', 'asc')
->paginate($perPage);
}
/**
* Search products by name
*/
public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false)
{
$query = $this->model->newQuery()->with('fournisseur');
if ($exactMatch) {
$query->where('nom', $name);
} else {
$query->where('nom', 'like', '%' . $name . '%');
}
return $query->paginate($perPage);
}
/**
* Get products by category
*/
public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator
{
return $this->model->newQuery()
->with('fournisseur')
->where('categorie', $category)
->orderBy('nom')
->paginate($perPage);
}
/**
* Get products by fournisseur
*/
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator
{
return $this->model->newQuery()
->where('fournisseur_id', $fournisseurId)
->orderBy('nom')
->paginate(15);
}
/**
* Update stock quantity
*/
public function updateStock(int $productId, float $newQuantity): bool
{
return $this->model->where('id', $productId)
->update(['stock_actuel' => $newQuantity]) > 0;
}
/**
* Get product statistics
*/
public function getStatistics(): array
{
$totalProducts = $this->model->count();
$lowStockProducts = $this->model->whereRaw('stock_actuel <= stock_minimum')->count();
$expiringProducts = $this->model->where('date_expiration', '<=', now()->addDays(30)->toDateString())
->where('date_expiration', '>=', now()->toDateString())
->count();
$totalValue = $this->model->sum(\DB::raw('stock_actuel * prix_unitaire'));
return [
'total_products' => $totalProducts,
'low_stock_products' => $lowStockProducts,
'expiring_products' => $expiringProducts,
'total_value' => $totalValue,
];
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ProductRepositoryInterface extends BaseRepositoryInterface
{
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
public function getLowStockProducts(int $perPage = 15): LengthAwarePaginator;
public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false);
public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator;
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator;
}

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('nom');
$table->string('reference')->nullable();
$table->string('categorie');
$table->string('fabricant')->nullable();
$table->decimal('stock_actuel', 10, 2);
$table->decimal('stock_minimum', 10, 2)->nullable();
$table->string('unite');
$table->decimal('prix_unitaire', 10, 2)->nullable();
$table->date('date_expiration')->nullable();
$table->string('numero_lot')->nullable();
$table->string('conditionnement_nom')->nullable();
$table->decimal('conditionnement_quantite', 10, 2)->nullable();
$table->string('conditionnement_unite')->nullable();
$table->string('photo_url')->nullable();
$table->string('fiche_technique_url')->nullable();
$table->foreignId('fournisseur_id')->nullable()->constrained('fournisseurs')->onDelete('set null');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
}
};

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Api\ClientLocationController;
use App\Http\Controllers\Api\ContactController;
use App\Http\Controllers\Api\ClientCategoryController;
use App\Http\Controllers\Api\FournisseurController;
use App\Http\Controllers\Api\ProductController;
/*
|--------------------------------------------------------------------------
@ -54,4 +55,12 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
Route::apiResource('fournisseurs', FournisseurController::class);
Route::get('fournisseurs/{fournisseurId}/contacts', [ContactController::class, 'getContactsByFournisseur']);
// Product management
Route::get('/products/searchBy', [ProductController::class, 'searchBy']);
Route::get('/products/low-stock', [ProductController::class, 'lowStock']);
Route::get('/products/by-category', [ProductController::class, 'byCategory']);
Route::get('/products/statistics', [ProductController::class, 'statistics']);
Route::apiResource('products', ProductController::class);
Route::patch('/products/{id}/stock', [ProductController::class, 'updateStock']);
});

View File

@ -24,7 +24,6 @@ module.exports = {
// Relax console/debugger in development
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
},
ignorePatterns: [
@ -43,5 +42,4 @@ module.exports = {
},
},
],
};

View File

@ -10,9 +10,13 @@
<table-action />
</template>
<template #contact-table>
<contact-table :data="contacts" :loading="loading" @delete="handleDelete" />
</template>
</contact-template>s
<contact-table
:data="contacts"
:loading="loading"
@delete="handleDelete"
/>
</template> </contact-template
>s
</template>
<script setup>
import ContactTemplate from "@/components/templates/CRM/ContactTemplate.vue";

View File

@ -0,0 +1,560 @@
<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 du Produit</p>
</div>
</div>
<div class="card-body">
<!-- Success Message -->
<div v-if="success" class="alert alert-success" role="alert">
<strong>Succès!</strong> Le produit 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 du produit...</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="nom" class="form-label">Nom du Produit *</label>
<soft-input
id="nom"
v-model="form.nom"
type="text"
:class="{ 'is-invalid': validationErrors.nom }"
required
placeholder="Entrez le nom du produit"
/>
<div v-if="validationErrors.nom" class="invalid-feedback">
{{ validationErrors.nom[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="reference" class="form-label"
>Référence *</label
>
<soft-input
id="reference"
v-model="form.reference"
type="text"
:class="{ 'is-invalid': validationErrors.reference }"
required
placeholder="Entrez la référence du produit"
/>
<div
v-if="validationErrors.reference"
class="invalid-feedback"
>
{{ validationErrors.reference[0] }}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="categorie" class="form-label"
>Catégorie *</label
>
<select
id="categorie"
v-model="form.categorie"
class="form-control"
:class="{ 'is-invalid': validationErrors.categorie }"
required
>
<option value="">Sélectionnez une catégorie</option>
<option value="Alimentaire">Alimentaire</option>
<option value="Médical">Médical</option>
<option value="Cosmétique">Cosmétique</option>
<option value="Ménage">Ménage</option>
<option value="Électronique">Électronique</option>
<option value="Vêtements">Vêtements</option>
<option value="Jouets">Jouets</option>
<option value="Livre">Livre</option>
<option value="Autre">Autre</option>
</select>
<div
v-if="validationErrors.categorie"
class="invalid-feedback"
>
{{ validationErrors.categorie[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="fabricant" class="form-label">Fabricant</label>
<soft-input
id="fabricant"
v-model="form.fabricant"
type="text"
:class="{ 'is-invalid': validationErrors.fabricant }"
placeholder="Entrez le nom du fabricant"
/>
<div
v-if="validationErrors.fabricant"
class="invalid-feedback"
>
{{ validationErrors.fabricant[0] }}
</div>
</div>
</div>
</div>
<!-- Stock Information -->
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label for="stock_actuel" class="form-label"
>Stock Actuel *</label
>
<soft-input
id="stock_actuel"
v-model.number="form.stock_actuel"
type="number"
step="0.01"
min="0"
:class="{ 'is-invalid': validationErrors.stock_actuel }"
required
placeholder="0.00"
/>
<div
v-if="validationErrors.stock_actuel"
class="invalid-feedback"
>
{{ validationErrors.stock_actuel[0] }}
</div>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="stock_minimum" class="form-label"
>Stock Minimum *</label
>
<soft-input
id="stock_minimum"
v-model.number="form.stock_minimum"
type="number"
step="0.01"
min="0"
:class="{ 'is-invalid': validationErrors.stock_minimum }"
required
placeholder="0.00"
/>
<div
v-if="validationErrors.stock_minimum"
class="invalid-feedback"
>
{{ validationErrors.stock_minimum[0] }}
</div>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="unite" class="form-label">Unité *</label>
<select
id="unite"
v-model="form.unite"
class="form-control"
:class="{ 'is-invalid': validationErrors.unite }"
required
>
<option value="">Sélectionnez une unité</option>
<option value="pièce">pièce(s)</option>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="l">l</option>
<option value="ml">ml</option>
<option value="m">m</option>
<option value="cm">cm</option>
<option value="boîte">boîte(s)</option>
<option value="sachet">sachet(s)</option>
<option value="bouteille">bouteille(s)</option>
</select>
<div v-if="validationErrors.unite" class="invalid-feedback">
{{ validationErrors.unite[0] }}
</div>
</div>
</div>
</div>
<!-- Price and Dates -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="prix_unitaire" class="form-label"
>Prix Unitaire () *</label
>
<div class="soft-input-group">
<soft-input
id="prix_unitaire"
v-model.number="form.prix_unitaire"
type="number"
step="0.01"
min="0"
:class="{
'is-invalid': validationErrors.prix_unitaire,
}"
required
placeholder="0.00"
/>
<div
v-if="validationErrors.prix_unitaire"
class="invalid-feedback"
>
{{ validationErrors.prix_unitaire[0] }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="date_expiration" class="form-label"
>Date d'Expiration</label
>
<soft-input
id="date_expiration"
v-model="form.date_expiration"
type="date"
:class="{
'is-invalid': validationErrors.date_expiration,
}"
:min="new Date().toISOString().split('T')[0]"
/>
<div
v-if="validationErrors.date_expiration"
class="invalid-feedback"
>
{{ validationErrors.date_expiration[0] }}
</div>
</div>
</div>
</div>
<!-- Lot Number -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="numero_lot" class="form-label"
>Numéro de Lot</label
>
<soft-input
id="numero_lot"
v-model="form.numero_lot"
type="text"
:class="{ 'is-invalid': validationErrors.numero_lot }"
placeholder="Entrez le numéro de lot"
/>
<div
v-if="validationErrors.numero_lot"
class="invalid-feedback"
>
{{ validationErrors.numero_lot[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="fournisseur_id" class="form-label"
>Fournisseur</label
>
<select
id="fournisseur_id"
v-model="form.fournisseur_id"
class="form-control"
:class="{ 'is-invalid': validationErrors.fournisseur_id }"
>
<option value="">Sélectionnez un fournisseur</option>
<option
v-for="fournisseur in fournisseurs"
:key="fournisseur.id"
:value="fournisseur.id"
>
{{ fournisseur.name }}
</option>
</select>
<div
v-if="validationErrors.fournisseur_id"
class="invalid-feedback"
>
{{ validationErrors.fournisseur_id[0] }}
</div>
</div>
</div>
</div>
<!-- Packaging Information -->
<div class="row">
<div class="col-md-4">
<div class="form-group">
<label for="conditionnement_nom" class="form-label"
>Nom Conditionnement</label
>
<soft-input
id="conditionnement_nom"
v-model="form.conditionnement_nom"
type="text"
:class="{
'is-invalid': validationErrors.conditionnement_nom,
}"
placeholder="Ex: Carton, Pack..."
/>
<div
v-if="validationErrors.conditionnement_nom"
class="invalid-feedback"
>
{{ validationErrors.conditionnement_nom[0] }}
</div>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="conditionnement_quantite" class="form-label"
>Quantité Conditionnement</label
>
<soft-input
id="conditionnement_quantite"
v-model.number="form.conditionnement_quantite"
type="number"
step="0.01"
min="0"
:class="{
'is-invalid': validationErrors.conditionnement_quantite,
}"
placeholder="0.00"
/>
<div
v-if="validationErrors.conditionnement_quantite"
class="invalid-feedback"
>
{{ validationErrors.conditionnement_quantite[0] }}
</div>
</div>
</div>
<div class="col-md-4">
<div class="form-group">
<label for="conditionnement_unite" class="form-label"
>Unité Conditionnement</label
>
<soft-input
id="conditionnement_unite"
v-model="form.conditionnement_unite"
type="text"
:class="{
'is-invalid': validationErrors.conditionnement_unite,
}"
placeholder="Ex: pièce, kg..."
/>
<div
v-if="validationErrors.conditionnement_unite"
class="invalid-feedback"
>
{{ validationErrors.conditionnement_unite[0] }}
</div>
</div>
</div>
</div>
<!-- URLs -->
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="photo_url" class="form-label"
>URL de la Photo</label
>
<soft-input
id="photo_url"
v-model="form.photo_url"
type="url"
:class="{ 'is-invalid': validationErrors.photo_url }"
placeholder="https://exemple.com/photo.jpg"
/>
<div
v-if="validationErrors.photo_url"
class="invalid-feedback"
>
{{ validationErrors.photo_url[0] }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="fiche_technique_url" class="form-label"
>URL Fiche Technique</label
>
<soft-input
id="fiche_technique_url"
v-model="form.fiche_technique_url"
type="url"
:class="{
'is-invalid': validationErrors.fiche_technique_url,
}"
placeholder="https://exemple.com/fiche.pdf"
/>
<div
v-if="validationErrors.fiche_technique_url"
class="invalid-feedback"
>
{{ validationErrors.fiche_technique_url[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 le Produit
</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({
fournisseurs: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
validationErrors: {
type: Object,
default: () => ({}),
},
success: {
type: Boolean,
default: false,
},
});
// Emits
const emit = defineEmits(["create-product"]);
// Form data
const form = reactive({
nom: "",
reference: "",
categorie: "",
fabricant: "",
stock_actuel: 0,
stock_minimum: 0,
unite: "",
prix_unitaire: 0,
date_expiration: "",
numero_lot: "",
conditionnement_nom: "",
conditionnement_quantite: 0,
conditionnement_unite: "",
photo_url: "",
fiche_technique_url: "",
fournisseur_id: "",
});
// 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;
}
});
emit("create-product", formData);
};
</script>
<style scoped>
.form-label {
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.invalid-feedback {
display: block;
}
.soft-input-group-text {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
}
.card {
border: none;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.075);
}
.form-control:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.alert {
border: none;
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<product-template>
<template #product-new-action>
<add-button text="Ajouter" @click="goToProduct" />
</template>
<template #select-filter>
<filter-table />
</template>
<template #product-other-action>
<table-action />
</template>
<template #product-table>
<product-table
:data="productData"
:loading="loadingData"
@view="goToDetails"
@edit="goToEdit"
@delete="deleteProduct"
/>
</template>
</product-template>
</template>
<script setup>
import ProductTemplate from "@/components/templates/Stock/ProductTemplate.vue";
import ProductTable from "@/components/molecules/Tables/Stock/ProductTable.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 { defineProps, defineEmits } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const emit = defineEmits(["pushDetails", "deleteProduct"]);
defineProps({
productData: {
type: Array,
default: [],
},
loadingData: {
type: Boolean,
default: false,
},
});
const goToProduct = () => {
router.push({
name: "Creation produit",
});
};
const goToDetails = (product) => {
emit("pushDetails", product);
};
const goToEdit = (product) => {
router.push({
name: "Modification produit",
params: {
id: product.id,
},
});
};
const deleteProduct = (product) => {
emit("deleteProduct", product);
};
</script>

View File

@ -10,9 +10,13 @@
class="form-control"
:class="getClasses(size, success, error)"
:name="name"
:value="value"
:value="modelValue"
:placeholder="placeholder"
:isRequired="isRequired"
:min="min"
:step="step"
@input="handleInput"
@blur="handleBlur"
/>
<span v-if="iconDir === 'right'" class="input-group-text">
<i :class="getIcon(icon)"></i>
@ -53,8 +57,8 @@ export default {
type: String,
default: "",
},
value: {
type: String,
modelValue: {
type: [String, Number],
default: "",
},
placeholder: {
@ -69,7 +73,16 @@ export default {
type: Boolean,
default: false,
},
min: {
type: [String, Number],
default: undefined,
},
step: {
type: [String, Number],
default: undefined,
},
},
emits: ["update:modelValue", "blur"],
methods: {
getClasses: (size, success, error) => {
let sizeValue, isValidValue;
@ -88,6 +101,14 @@ export default {
},
getIcon: (icon) => (icon ? icon : null),
hasIcon: (icon) => (icon ? "input-group" : null),
handleInput(event) {
const value = event.target.value;
// Emit the value directly - let the parent handle number conversion with v-model.number
this.$emit("update:modelValue", value);
},
handleBlur(event) {
this.$emit("blur", event);
},
},
};
</script>

View File

@ -101,7 +101,7 @@
<!-- Email Column -->
<td class="text-xs">
<div class="text-secondary">
{{ contact.email || '-' }}
{{ contact.email || "-" }}
</div>
</td>
@ -114,13 +114,17 @@
<div v-if="contact.mobile">
<i class="fas fa-mobile-alt me-1"></i>{{ contact.mobile }}
</div>
<span v-if="!contact.phone && !contact.mobile" class="text-muted">-</span>
<span
v-if="!contact.phone && !contact.mobile"
class="text-muted"
>-</span
>
</div>
</td>
<!-- Position Column -->
<td class="text-xs">
{{ contact.position || '-' }}
{{ contact.position || "-" }}
</td>
<!-- Status Column (Primary Contact Badge) -->
@ -259,15 +263,21 @@ const initializeDataTable = () => {
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const contactId = button.getAttribute("data-contact-id");
if (!contactId) return;
if (button.title === "Delete Contact" || button.querySelector(".fa-trash")) {
emit("delete", contactId);
} else if (button.title === "Edit Contact" || button.querySelector(".fa-edit")) {
} else if (
button.title === "Edit Contact" ||
button.querySelector(".fa-edit")
) {
emit("edit", contactId);
} else if (button.title === "View Contact" || button.querySelector(".fa-eye")) {
} else if (
button.title === "View Contact" ||
button.querySelector(".fa-eye")
) {
emit("view", contactId);
}
};

View File

@ -0,0 +1,610 @@
<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>Produit</th>
<th>Référence</th>
<th>Catégorie</th>
<th>Fabricant</th>
<th>Stock</th>
<th>Prix Unitaire</th>
<th>Expiration</th>
<th>Fournisseur</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Product 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>
<!-- Reference Column Skeleton -->
<td>
<div class="skeleton-text short"></div>
</td>
<!-- Category 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>
<!-- Manufacturer Column Skeleton -->
<td>
<div class="skeleton-text long"></div>
</td>
<!-- Stock Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Price Column Skeleton -->
<td>
<div class="skeleton-text medium"></div>
</td>
<!-- Expiration Column Skeleton -->
<td>
<div class="skeleton-text short"></div>
</td>
<!-- Supplier Column Skeleton -->
<td>
<div class="skeleton-text 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>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Data State -->
<div v-else class="table-responsive">
<table id="product-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Produit</th>
<th>Référence</th>
<th>Catégorie</th>
<th>Fabricant</th>
<th>Stock</th>
<th>Prix Unitaire</th>
<th>Expiration</th>
<th>Fournisseur</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="product in data" :key="product.id">
<!-- Product Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="product.media.photo_url || getRandomAvatar()"
size="xs"
class="me-2"
alt="product image"
circular
/>
<span>{{ product.nom }}</span>
</div>
</td>
<!-- Reference Column -->
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ product.reference }}</span>
</td>
<!-- Category Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getCategoryColor(product.categorie)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getCategoryIcon(product.categorie)"
aria-hidden="true"
></i>
</soft-button>
<span>{{ product.categorie }}</span>
</div>
</td>
<!-- Manufacturer Column -->
<td class="text-xs font-weight-bold">
{{ product.fabricant || "N/A" }}
</td>
<!-- Stock Column -->
<td class="text-xs font-weight-bold">
<div class="stock-info">
<div :class="getStockClass(product)">
{{ product.stock_actuel }} {{ product.unite }}
</div>
<div class="text-xs text-muted">
Min: {{ product.stock_minimum }} {{ product.unite }}
</div>
</div>
</td>
<!-- Price Column -->
<td class="text-xs font-weight-bold">
<div class="price-info">
<div>{{ formatPrice(product.prix_unitaire) }}</div>
<div class="text-xs text-muted">/{{ product.unite }}</div>
</div>
</td>
<!-- Expiration Column -->
<td class="text-xs font-weight-bold">
<div class="expiration-info">
<div
v-if="product.date_expiration"
:class="getExpirationClass(product)"
>
{{ formatDate(product.date_expiration) }}
</div>
<div v-else class="text-muted">N/A</div>
</div>
</td>
<!-- Supplier Column -->
<td class="text-xs font-weight-bold">
{{ product.fournisseur?.name || "N/A" }}
</td>
<!-- Status Column -->
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<!-- Low Stock Badge -->
<soft-button
v-if="product.is_low_stock"
color="warning"
variant="outline"
class="btn-sm mb-1"
>
<i class="fas fa-exclamation-triangle me-1"></i>
Stock faible
</soft-button>
<!-- Expiration Badge -->
<soft-button
v-if="isExpiringSoon(product)"
color="danger"
variant="outline"
class="btn-sm"
>
<i class="fas fa-clock me-1"></i>
Expire bientôt
</soft-button>
<!-- Normal Status -->
<span
v-if="!product.is_low_stock && !isExpiringSoon(product)"
class="badge badge-success"
>
<i class="fas fa-check me-1"></i>
Normal
</span>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button
color="info"
variant="outline"
title="Voir le produit"
:data-product-id="product.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('view', product)"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<!-- Edit Button -->
<soft-button
color="warning"
variant="outline"
title="Modifier le produit"
:data-product-id="product.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('edit', product)"
>
<i class="fas fa-edit" aria-hidden="true"></i>
</soft-button>
<!-- Delete Button -->
<soft-button
color="danger"
variant="outline"
title="Supprimer le produit"
:data-product-id="product.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('delete', product)"
>
<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-boxes fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun produit trouvé</h5>
<p class="empty-text text-muted">
Aucun produit à 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";
import ProductService from "@/services/product";
// Sample avatar images for products without photos
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 formatPrice = (price) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(price);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString("fr-FR");
};
const isExpiringSoon = (product) => {
return ProductService.isExpiringSoon(product);
};
const getStockClass = (product) => {
if (product.is_low_stock) {
return "text-warning fw-bold";
}
return "text-success";
};
const getExpirationClass = (product) => {
if (!product.date_expiration) return "";
if (ProductService.isExpired(product)) {
return "text-danger fw-bold";
} else if (ProductService.isExpiringSoon(product)) {
return "text-warning fw-bold";
}
return "text-success";
};
const getCategoryColor = (category) => {
const colors = {
Alimentaire: "success",
Médical: "danger",
Cosmétique: "info",
Ménage: "warning",
Électronique: "primary",
Vêtements: "secondary",
Jouets: "warning",
Livre: "info",
};
return colors[category] || "secondary";
};
const getCategoryIcon = (category) => {
const icons = {
Alimentaire: "fas fa-apple-alt",
Médical: "fas fa-pills",
Cosmétique: "fas fa-spa",
Ménage: "fas fa-broom",
Électronique: "fas fa-mobile-alt",
Vêtements: "fas fa-tshirt",
Jouets: "fas fa-puzzle-piece",
Livre: "fas fa-book",
};
return icons[category] || "fas fa-tag";
};
const initializeDataTable = () => {
// Destroy existing instance if it exists
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("product-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 productId = button.getAttribute("data-product-id");
if (
button.title === "Supprimer le produit" ||
button.querySelector(".fa-trash")
) {
emit("delete", productId);
} else if (
button.title === "Modifier le produit" ||
button.querySelector(".fa-edit")
) {
emit("edit", productId);
} else if (
button.title === "Voir le produit" ||
button.querySelector(".fa-eye")
) {
emit("view", productId);
}
};
// 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-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-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;
}
.stock-info,
.price-info,
.expiration-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.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
</style>

View File

@ -31,7 +31,11 @@
>
Souvenez de moi
</SoftSwitch>
<div v-if="errorMessage" class="alert alert-danger text-white" role="alert">
<div
v-if="errorMessage"
class="alert alert-danger text-white"
role="alert"
>
{{ errorMessage }}
</div>
<div class="text-center">

View File

@ -145,7 +145,11 @@ const contactModalIsVisible = ref(false);
const isModification = ref(false);
const selectedContact = ref(null);
const emit = defineEmits(["add-contact", "contact-created", "contact-modified"]);
const emit = defineEmits([
"add-contact",
"contact-created",
"contact-modified",
]);
const getInitials = (name) => {
if (!name) return "?";

View File

@ -19,8 +19,8 @@
</button>
<button
class="btn btn-success btn-sm"
@click="saveChanges"
:disabled="isSaving"
@click="saveChanges"
>
<i class="fas fa-save me-1"></i>
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}

View File

@ -8,29 +8,29 @@
<div class="col-md-4 text-end">
<i
v-if="!isEditing"
@click="toggleEditMode"
class="text-sm fas fa-user-edit text-secondary cursor-pointer"
data-bs-toggle="tooltip"
data-bs-placement="top"
:title="action.tooltip"
style="cursor: pointer;"
style="cursor: pointer"
@click="toggleEditMode"
></i>
<div v-else>
<i
@click="saveChanges"
class="text-sm fas fa-save text-success cursor-pointer me-2"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Sauvegarder"
style="cursor: pointer;"
style="cursor: pointer"
@click="saveChanges"
></i>
<i
@click="cancelEdit"
class="text-sm fas fa-times text-danger cursor-pointer"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Annuler"
style="cursor: pointer;"
style="cursor: pointer"
@click="cancelEdit"
></i>
</div>
</div>
@ -51,10 +51,17 @@
</li>
<li class="text-sm border-0 list-group-item ps-0">
<strong class="text-dark">Catégorie:</strong> &nbsp;
<span v-if="!isEditing" :class="`badge badge-${getCategoryColor(categorie)}`">
<span
v-if="!isEditing"
:class="`badge badge-${getCategoryColor(categorie)}`"
>
{{ getCategoryLabel(categorie) }}
</span>
<select v-else v-model="editForm.categorie" class="form-select form-select-sm d-inline-block w-auto">
<select
v-else
v-model="editForm.categorie"
class="form-select form-select-sm d-inline-block w-auto"
>
<option value="entreprise">Entreprise</option>
<option value="particulier">Particulier</option>
<option value="association">Association</option>
@ -126,7 +133,10 @@
placeholder="Ville"
class="form-control form-control-sm mb-1"
/>
<select v-model="editForm.billing_country_code" class="form-select form-select-sm">
<select
v-model="editForm.billing_country_code"
class="form-select form-select-sm"
>
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="CH">Suisse</option>
@ -141,17 +151,24 @@
</li>
<li class="text-sm border-0 list-group-item ps-0">
<strong class="text-dark">Statut:</strong> &nbsp;
<span v-if="!isEditing" :class="`badge badge-${is_active ? 'success' : 'danger'}`">
<span
v-if="!isEditing"
:class="`badge badge-${is_active ? 'success' : 'danger'}`"
>
{{ is_active ? "Actif" : "Inactif" }}
</span>
<select v-else v-model="editForm.is_active" class="form-select form-select-sm d-inline-block w-auto">
<select
v-else
v-model="editForm.is_active"
class="form-select form-select-sm d-inline-block w-auto"
>
<option :value="true">Actif</option>
<option :value="false">Inactif</option>
</select>
</li>
<li class="text-sm border-0 list-group-item ps-0">
<strong class="text-dark">Notes:</strong> &nbsp;
<span v-if="!isEditing">{{ notes || 'Aucune note' }}</span>
<span v-if="!isEditing">{{ notes || "Aucune note" }}</span>
<textarea
v-else
v-model="editForm.notes"
@ -295,7 +312,7 @@ const saveChanges = () => {
notes: editForm.notes,
is_active: editForm.is_active,
};
emit("update:client", updateData);
isEditing.value = false;
};

View File

@ -6,8 +6,8 @@
<h6 class="mb-0">Contacts</h6>
<button
class="btn btn-primary btn-sm"
@click="addContact"
title="Add contact"
@click="addContact"
>
<i class="fas fa-plus"></i>
</button>
@ -45,8 +45,8 @@
</div>
<button
class="btn btn-outline-danger btn-sm delete-btn ms-2"
@click="deleteContact(contact.id)"
title="Delete contact"
@click="deleteContact(contact.id)"
>
<i class="fas fa-trash"></i>
</button>

View File

@ -12,14 +12,18 @@
<!-- Header -->
<div class="modal-header">
<h5 class="modal-title">
<i :class="isModification ? 'fas fa-edit me-2' : 'fas fa-user-plus me-2'"></i>
{{ isModification ? 'Modifier le contact' : 'Ajouter un contact' }}
<i
:class="
isModification ? 'fas fa-edit me-2' : 'fas fa-user-plus me-2'
"
></i>
{{ isModification ? "Modifier le contact" : "Ajouter un contact" }}
</h5>
<button
type="button"
class="btn-close"
@click="closeModal"
aria-label="Close"
@click="closeModal"
></button>
</div>
@ -27,7 +31,7 @@
<div class="modal-body">
<form @submit.prevent="submitForm">
<!-- Client ID (hidden) -->
<input type="hidden" v-model="formData.client_id" />
<input v-model="formData.client_id" type="hidden" />
<!-- First Name -->
<div class="mb-3">
@ -122,8 +126,8 @@
<button
type="button"
class="btn btn-outline-secondary"
@click="closeModal"
:disabled="contactIsLoading"
@click="closeModal"
>
<i class="fas fa-times me-1"></i>
Annuler
@ -131,11 +135,19 @@
<button
type="button"
class="btn btn-primary"
@click="submitForm"
:disabled="contactIsLoading"
@click="submitForm"
>
<i class="fas fa-save me-1"></i>
{{ contactIsLoading ? (isModification ? "Modification..." : "Création...") : (isModification ? "Modifier le contact" : "Créer le contact") }}
{{
contactIsLoading
? isModification
? "Modification..."
: "Création..."
: isModification
? "Modifier le contact"
: "Créer le contact"
}}
</button>
</div>
</div>

View File

@ -37,13 +37,13 @@
<div class="search-container position-relative">
<soft-input
:value="searchQuery"
@input="handleSearchInput($event.target.value)"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.client_id }"
type="text"
placeholder="Tapez pour rechercher un client..."
icon="ni ni-zoom-split-in"
iconDir="left"
icon-dir="left"
@input="handleSearchInput($event.target.value)"
@focus="showDropdown = true"
@blur="onInputBlur"
/>
@ -73,7 +73,7 @@
>
<div>
<strong>{{ client.name }}</strong>
<div class="text-muted small" v-if="client.email">
<div v-if="client.email" class="text-muted small">
{{ client.email }}
</div>
</div>
@ -142,12 +142,12 @@
<label class="form-label">Prénom</label>
<soft-input
:value="form.first_name"
@input="form.first_name = $event.target.value"
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 }}
@ -157,12 +157,12 @@
<label class="form-label">Nom</label>
<soft-input
:value="form.last_name"
@input="form.last_name = $event.target.value"
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 }}
@ -176,12 +176,12 @@
<label class="form-label">Email</label>
<soft-input
:value="form.email"
@input="form.email = $event.target.value"
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 }}
@ -195,12 +195,12 @@
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
@input="form.phone = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +33 1 23 45 67 89"
maxlength="50"
@input="form.phone = $event.target.value"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
@ -210,12 +210,12 @@
<label class="form-label">Mobile</label>
<soft-input
:value="form.mobile"
@input="form.mobile = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.mobile }"
type="text"
placeholder="ex. +33 6 12 34 56 78"
maxlength="50"
@input="form.mobile = $event.target.value"
/>
<div v-if="fieldErrors.mobile" class="invalid-feedback">
{{ fieldErrors.mobile }}
@ -229,12 +229,12 @@
<label class="form-label">Rôle</label>
<soft-input
:value="form.role"
@input="form.role = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.role }"
type="text"
placeholder="ex. Directeur Commercial"
maxlength="191"
@input="form.role = $event.target.value"
/>
<div v-if="fieldErrors.role" class="invalid-feedback">
{{ fieldErrors.role }}
@ -248,11 +248,11 @@
<label class="form-label">Notes</label>
<textarea
:value="form.notes"
@input="form.notes = $event.target.value"
class="form-control multisteps-form__input"
rows="3"
placeholder="Notes supplémentaires sur le contact..."
maxlength="1000"
@input="form.notes = $event.target.value"
></textarea>
</div>
</div>

View File

@ -10,8 +10,8 @@
<label class="form-label">Catégorie de client</label>
<select
:value="form.client_category_id"
@input="form.client_category_id = $event.target.value"
class="form-control multisteps-form__input"
@input="form.client_category_id = $event.target.value"
>
<option value="">Sélectionner une catégorie</option>
<option
@ -33,11 +33,11 @@
>
<soft-input
:value="form.name"
@input="form.name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.name }"
type="text"
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
@input="form.name = $event.target.value"
/>
<div v-if="fieldErrors.name" class="invalid-feedback">
{{ fieldErrors.name }}
@ -51,12 +51,12 @@
<label class="form-label">Numéro de TVA</label>
<soft-input
:value="form.vat_number"
@input="form.vat_number = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.vat_number }"
type="text"
placeholder="ex. FR12345678901"
maxlength="32"
@input="form.vat_number = $event.target.value"
/>
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
{{ fieldErrors.vat_number }}
@ -66,12 +66,12 @@
<label class="form-label">SIRET</label>
<soft-input
:value="form.siret"
@input="form.siret = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.siret }"
type="text"
placeholder="ex. 12345678901234"
maxlength="20"
@input="form.siret = $event.target.value"
/>
<div v-if="fieldErrors.siret" class="invalid-feedback">
{{ fieldErrors.siret }}
@ -85,11 +85,11 @@
<label class="form-label">Email</label>
<soft-input
:value="form.email"
@input="form.email = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.email }"
type="email"
placeholder="ex. contact@entreprise.com"
@input="form.email = $event.target.value"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
@ -99,12 +99,12 @@
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
@input="form.phone = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +33 1 23 45 67 89"
maxlength="50"
@input="form.phone = $event.target.value"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
@ -118,12 +118,12 @@
<label class="form-label">Adresse ligne 1</label>
<soft-input
:value="form.billing_address_line1"
@input="form.billing_address_line1 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
type="text"
placeholder="ex. 123 Rue Principale"
maxlength="255"
@input="form.billing_address_line1 = $event.target.value"
/>
<div
v-if="fieldErrors.billing_address_line1"
@ -139,12 +139,12 @@
<label class="form-label">Adresse ligne 2</label>
<soft-input
:value="form.billing_address_line2"
@input="form.billing_address_line2 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
type="text"
placeholder="ex. Appartement, Suite, etc."
maxlength="255"
@input="form.billing_address_line2 = $event.target.value"
/>
<div
v-if="fieldErrors.billing_address_line2"
@ -160,12 +160,12 @@
<label class="form-label">Code postal</label>
<soft-input
:value="form.billing_postal_code"
@input="form.billing_postal_code = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
type="text"
placeholder="ex. 75001"
maxlength="20"
@input="form.billing_postal_code = $event.target.value"
/>
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
{{ fieldErrors.billing_postal_code }}
@ -175,12 +175,12 @@
<label class="form-label">Ville</label>
<soft-input
:value="form.billing_city"
@input="form.billing_city = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_city }"
type="text"
placeholder="ex. Paris"
maxlength="191"
@input="form.billing_city = $event.target.value"
/>
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
{{ fieldErrors.billing_city }}
@ -190,9 +190,9 @@
<label class="form-label">Code pays</label>
<select
:value="form.billing_country_code"
@input="form.billing_country_code = $event.target.value"
class="form-control multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
@input="form.billing_country_code = $event.target.value"
>
<option value="">Sélectionner un pays</option>
<option value="FR">France</option>
@ -216,11 +216,11 @@
<label class="form-label">Notes</label>
<textarea
:value="form.notes"
@input="form.notes = $event.target.value"
class="form-control multisteps-form__input"
rows="3"
placeholder="Notes supplémentaires sur le client..."
maxlength="1000"
@input="form.notes = $event.target.value"
></textarea>
</div>
</div>
@ -230,9 +230,9 @@
<div class="col-12">
<div class="form-check form-switch">
<input
id="isActive"
class="form-check-input"
type="checkbox"
id="isActive"
:checked="form.is_active"
@change="form.is_active = $event.target.checked"
/>

View File

@ -12,11 +12,11 @@
>
<soft-input
:value="form.name"
@input="form.name = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.name }"
type="text"
placeholder="ex. Nom de l'entreprise"
@input="form.name = $event.target.value"
/>
<div v-if="fieldErrors.name" class="invalid-feedback">
{{ fieldErrors.name }}
@ -30,12 +30,12 @@
<label class="form-label">Numéro de TVA</label>
<soft-input
:value="form.vat_number"
@input="form.vat_number = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.vat_number }"
type="text"
placeholder="ex. FR12345678901"
maxlength="32"
@input="form.vat_number = $event.target.value"
/>
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
{{ fieldErrors.vat_number }}
@ -45,12 +45,12 @@
<label class="form-label">SIRET</label>
<soft-input
:value="form.siret"
@input="form.siret = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.siret }"
type="text"
placeholder="ex. 12345678901234"
maxlength="20"
@input="form.siret = $event.target.value"
/>
<div v-if="fieldErrors.siret" class="invalid-feedback">
{{ fieldErrors.siret }}
@ -64,11 +64,11 @@
<label class="form-label">Email</label>
<soft-input
:value="form.email"
@input="form.email = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.email }"
type="email"
placeholder="ex. contact@fournisseur.com"
@input="form.email = $event.target.value"
/>
<div v-if="fieldErrors.email" class="invalid-feedback">
{{ fieldErrors.email }}
@ -78,12 +78,12 @@
<label class="form-label">Téléphone</label>
<soft-input
:value="form.phone"
@input="form.phone = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.phone }"
type="text"
placeholder="ex. +33 1 23 45 67 89"
maxlength="50"
@input="form.phone = $event.target.value"
/>
<div v-if="fieldErrors.phone" class="invalid-feedback">
{{ fieldErrors.phone }}
@ -97,12 +97,12 @@
<label class="form-label">Adresse ligne 1</label>
<soft-input
:value="form.billing_address_line1"
@input="form.billing_address_line1 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
type="text"
placeholder="ex. 123 Rue Principale"
maxlength="255"
@input="form.billing_address_line1 = $event.target.value"
/>
<div
v-if="fieldErrors.billing_address_line1"
@ -118,12 +118,12 @@
<label class="form-label">Adresse ligne 2</label>
<soft-input
:value="form.billing_address_line2"
@input="form.billing_address_line2 = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
type="text"
placeholder="ex. Bâtiment, Étage, etc."
maxlength="255"
@input="form.billing_address_line2 = $event.target.value"
/>
<div
v-if="fieldErrors.billing_address_line2"
@ -139,12 +139,12 @@
<label class="form-label">Code postal</label>
<soft-input
:value="form.billing_postal_code"
@input="form.billing_postal_code = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
type="text"
placeholder="ex. 75001"
maxlength="20"
@input="form.billing_postal_code = $event.target.value"
/>
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
{{ fieldErrors.billing_postal_code }}
@ -154,12 +154,12 @@
<label class="form-label">Ville</label>
<soft-input
:value="form.billing_city"
@input="form.billing_city = $event.target.value"
class="multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_city }"
type="text"
placeholder="ex. Paris"
maxlength="191"
@input="form.billing_city = $event.target.value"
/>
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
{{ fieldErrors.billing_city }}
@ -169,9 +169,9 @@
<label class="form-label">Code pays</label>
<select
:value="form.billing_country_code"
@input="form.billing_country_code = $event.target.value"
class="form-control multisteps-form__input"
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
@input="form.billing_country_code = $event.target.value"
>
<option value="">Sélectionner un pays</option>
<option value="FR">France</option>
@ -196,11 +196,11 @@
<label class="form-label">Notes</label>
<textarea
:value="form.notes"
@input="form.notes = $event.target.value"
class="form-control multisteps-form__input"
rows="3"
placeholder="Notes supplémentaires sur le fournisseur..."
maxlength="1000"
@input="form.notes = $event.target.value"
></textarea>
</div>
</div>
@ -210,9 +210,9 @@
<div class="col-12">
<div class="form-check form-switch">
<input
id="isActive"
class="form-check-input"
type="checkbox"
id="isActive"
:checked="form.is_active"
@change="form.is_active = $event.target.checked"
/>

View File

@ -22,8 +22,8 @@
<button
type="button"
class="btn-close"
@click="closeModal"
aria-label="Close"
@click="closeModal"
></button>
</div>
@ -31,7 +31,7 @@
<div class="modal-body">
<form @submit.prevent="submitForm">
<!-- Fournisseur ID (hidden) -->
<input type="hidden" v-model="formData.fournisseur_id" />
<input v-model="formData.fournisseur_id" type="hidden" />
<!-- First Name -->
<div class="mb-3">
@ -126,8 +126,8 @@
<button
type="button"
class="btn btn-outline-secondary"
@click="closeModal"
:disabled="contactIsLoading"
@click="closeModal"
>
<i class="fas fa-times me-1"></i>
Annuler
@ -135,8 +135,8 @@
<button
type="button"
class="btn btn-primary"
@click="submitForm"
:disabled="contactIsLoading"
@click="submitForm"
>
<i class="fas fa-save me-1"></i>
{{

View File

@ -5,8 +5,8 @@
<h6 class="mb-0">Liste des contacts</h6>
<SoftButton
class="btn btn-primary btn-sm ms-auto"
@click="contactModalIsVisible = true"
:disabled="isLoading"
@click="contactModalIsVisible = true"
>
<i class="fas fa-plus me-1"></i>Ajouter un contact
</SoftButton>
@ -106,8 +106,8 @@
class="btn btn-link text-warning p-0 mb-0"
type="button"
title="Modifier"
@click="handleModifyContact(contact)"
:disabled="isLoading"
@click="handleModifyContact(contact)"
>
<i class="fas fa-edit text-sm"></i>
</button>
@ -115,8 +115,8 @@
class="btn btn-link text-danger p-0 mb-0"
type="button"
title="Supprimer"
@click="handleRemoveContact(contact.id)"
:disabled="isLoading"
@click="handleRemoveContact(contact.id)"
>
<i class="fas fa-trash text-sm"></i>
</button>

View File

@ -19,8 +19,8 @@
</button>
<button
class="btn btn-success btn-sm"
@click="saveChanges"
:disabled="isSaving"
@click="saveChanges"
>
<i class="fas fa-save me-1"></i>
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}

View File

@ -26,7 +26,6 @@
:badge="contactsCount > 0 ? contactsCount : null"
@click="$emit('change-tab', 'contacts')"
/>
<TabNavigationItem
icon="fas fa-sticky-note"
label="Notes"

View File

@ -28,9 +28,9 @@
<button
type="button"
class="btn-close"
@click="closeModal"
aria-label="Close"
:disabled="locationIsLoading"
@click="closeModal"
></button>
</div>
@ -242,10 +242,10 @@
<div class="form-check mb-3">
<input
id="isDefaultCheckbox"
v-model="formData.is_default"
class="form-check-input"
type="checkbox"
id="isDefaultCheckbox"
:class="{ 'is-invalid': errors.is_default }"
/>
<label class="form-check-label" for="isDefaultCheckbox">
@ -263,8 +263,8 @@
<button
type="button"
class="btn btn-outline-secondary"
@click="closeModal"
:disabled="locationIsLoading"
@click="closeModal"
>
<i class="fas fa-times me-1"></i>
Annuler
@ -272,8 +272,8 @@
<button
type="button"
class="btn btn-primary"
@click="handleSubmit"
:disabled="locationIsLoading || !isFormValid"
@click="handleSubmit"
>
<i class="fas fa-save me-1"></i>
<span

View File

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

View File

@ -2,16 +2,16 @@ import { useNotificationStore } from "@/stores/notification";
/**
* Composable pour gérer les notifications dans les composants Vue
*
*
* Exemple d'utilisation :
*
*
* const notification = useNotification();
*
*
* // Notifications CRUD simples
* notification.created("Le client");
* notification.updated("La catégorie");
* notification.deleted("Le produit");
*
*
* // Notifications personnalisées
* notification.success("Succès", "Opération réussie");
* notification.error("Erreur", "Une erreur s'est produite");
@ -25,23 +25,23 @@ export function useNotification() {
// Méthodes de base
success: (title: string, message: string, duration?: number) =>
store.success(title, message, duration),
error: (title: string, message: string, duration?: number) =>
store.error(title, message, duration),
warning: (title: string, message: string, duration?: number) =>
store.warning(title, message, duration),
info: (title: string, message: string, duration?: number) =>
store.info(title, message, duration),
// Méthodes CRUD
created: (entity?: string, duration?: number) =>
store.created(entity, duration),
updated: (entity?: string, duration?: number) =>
store.updated(entity, duration),
deleted: (entity?: string, duration?: number) =>
store.deleted(entity, duration),
};

View File

@ -61,7 +61,10 @@
:class="textWhite ? textWhite : 'text-body'"
@click.prevent="handleLogout"
>
<i class="fa fa-sign-out-alt" :class="isRTL ? 'ms-sm-2' : 'me-sm-1'"></i>
<i
class="fa fa-sign-out-alt"
:class="isRTL ? 'ms-sm-2' : 'me-sm-1'"
></i>
<span v-if="isRTL" class="d-sm-inline d-none">تسجيل خروج</span>
<span v-else class="d-sm-inline d-none">Logout</span>
</a>
@ -266,7 +269,7 @@ export default {
this.toggleSidebarColor("bg-white");
this.navbarMinimize();
},
async handleLogout() {
try {
const authStore = useAuthStore();

View File

@ -217,6 +217,11 @@
</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"

View File

@ -503,6 +503,16 @@ const routes = [
name: "Gestion stock",
component: () => import("@/views/pages/Stock/Stock.vue"),
},
{
path: "/stock/produits",
name: "Gestion de produits",
component: () => import("@/views/pages/Stock/Products.vue"),
},
{
path: "/stock/produits/new",
name: "Creation produit",
component: () => import("@/views/pages/Stock/AddProduct.vue"),
},
// Employés
{
path: "/employes",

View File

@ -40,7 +40,7 @@ http.interceptors.request.use(async (config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Only ensure CSRF for unsafe methods (POST, PUT, PATCH, DELETE)
// Skip CSRF if using token-based auth
const method = (config.method || "get").toLowerCase();

View File

@ -1,2 +1,2 @@
export * from './http'
export { default as AuthService } from './auth'
export * from "./http";
export { default as AuthService } from "./auth";

View File

@ -0,0 +1,262 @@
import { request } from "./http";
// Type definitions
export interface Product {
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 ProductFormData {
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 ProductListResponse {
data: Product[];
pagination: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
};
summary: {
total_products: number;
low_stock_products: number;
total_value: number;
};
}
export interface ProductStatistics {
total_products: number;
low_stock_products: number;
expiring_products: number;
total_value: number;
}
class ProductService {
// Get all products with pagination and filters
async getAllProducts(
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<ProductListResponse> {
const response = await request<ProductListResponse>({
url: "/api/products",
method: "get",
params,
});
return response;
}
// Get a single product by ID
async getProduct(id: number): Promise<{ data: Product }> {
const response = await request<{ data: Product }>({
url: `/api/products/${id}`,
method: "get",
});
return response;
}
// Create a new product
async createProduct(
productData: ProductFormData
): Promise<{ data: Product }> {
const formattedPayload = this.transformProductPayload(productData);
const response = await request<{ data: Product }>({
url: "/api/products",
method: "post",
data: formattedPayload,
});
return response;
}
// Update an existing product
async updateProduct(
id: number,
productData: ProductFormData
): Promise<{ data: Product }> {
const formattedPayload = this.transformProductPayload(productData);
const response = await request<{ data: Product }>({
url: `/api/products/${id}`,
method: "put",
data: formattedPayload,
});
return response;
}
// Delete a product
async deleteProduct(id: number): Promise<{ message: string }> {
const response = await request<{ message: string }>({
url: `/api/products/${id}`,
method: "delete",
});
return response;
}
// Search products by name
async searchProducts(
searchTerm: string,
exactMatch: boolean = false
): Promise<{ data: Product[]; count: number; message: string }> {
const response = await request<{
data: Product[];
count: number;
message: string;
}>({
url: "/api/products/searchBy",
method: "get",
params: {
name: searchTerm,
exact: exactMatch,
},
});
return response;
}
// Get products with low stock
async getLowStockProducts(
params: { per_page?: number } = {}
): Promise<ProductListResponse> {
const response = await request<ProductListResponse>({
url: "/api/products/low-stock",
method: "get",
params,
});
return response;
}
// Get products by category
async getProductsByCategory(
category: string,
params: { per_page?: number } = {}
): Promise<ProductListResponse> {
const response = await request<ProductListResponse>({
url: "/api/products/by-category",
method: "get",
params: {
category,
...params,
},
});
return response;
}
// Get product statistics
async getProductStatistics(): Promise<{
data: ProductStatistics;
message: string;
}> {
const response = await request<{
data: ProductStatistics;
message: string;
}>({
url: "/api/products/statistics",
method: "get",
});
return response;
}
// Update stock quantity for a product
async updateStock(
productId: number,
newStock: number
): Promise<{ data: Product; message: string }> {
const response = await request<{ data: Product; message: string }>({
url: `/api/products/${productId}/stock`,
method: "patch",
data: {
stock_actuel: newStock,
},
});
return response;
}
/**
* Transform product payload to match Laravel form request structure
*/
transformProductPayload(payload: Partial<ProductFormData>): 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 product expiration checks
static isExpired(product: Product): boolean {
if (!product.date_expiration) return false;
return new Date(product.date_expiration) < new Date();
}
static isExpiringSoon(product: Product, days: number = 30): boolean {
if (!product.date_expiration) return false;
const expirationDate = new Date(product.date_expiration);
const soonDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return expirationDate <= soonDate && expirationDate >= new Date();
}
}
export default ProductService;

View File

@ -40,13 +40,23 @@ export const useNotificationStore = defineStore("notification", {
},
// Méthodes pratiques pour différents types de notifications
success(title: string, message: string, duration?: number) {
return this.addNotification({ type: "success", title, message, duration });
return this.addNotification({
type: "success",
title,
message,
duration,
});
},
error(title: string, message: string, duration?: number) {
return this.addNotification({ type: "error", title, message, duration });
},
warning(title: string, message: string, duration?: number) {
return this.addNotification({ type: "warning", title, message, duration });
return this.addNotification({
type: "warning",
title,
message,
duration,
});
},
info(title: string, message: string, duration?: number) {
return this.addNotification({ type: "info", title, message, duration });

View File

@ -0,0 +1,290 @@
import { defineStore } from "pinia";
import ProductService from "@/services/product";
import type { Product } from "@/services/product";
interface Meta {
current_page: number;
last_page: number;
per_page: number;
total: number;
from: number;
to: number;
}
// Create an instance of ProductService
const productService = new ProductService();
export const useProductStore = defineStore("product", {
state: () => ({
products: [] as Product[],
currentProduct: null as Product | null,
loading: false,
isLoading: false,
error: null as string | null,
meta: {
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
from: 1,
to: 0,
} as Meta,
}),
getters: {
lowStockProducts: (state) =>
state.products.filter((product) => product.is_low_stock),
expiringProducts: (state) =>
state.products.filter(
(product) =>
product.date_expiration &&
new Date(product.date_expiration) <=
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
),
categories: (state) => {
const categorySet = new Set(
state.products.map((product) => product.categorie).filter(Boolean)
);
return Array.from(categorySet).sort();
},
totalProducts: (state) => state.meta.total,
totalValue: (state) =>
state.products.reduce(
(total, product) =>
total + product.stock_actuel * product.prix_unitaire,
0
),
},
actions: {
async fetchProducts(params = {}) {
this.loading = true;
this.error = null;
try {
const response = await productService.getAllProducts(params);
this.products = response.data;
this.meta = {
current_page: response.pagination.current_page,
last_page: response.pagination.last_page,
per_page: response.pagination.per_page,
total: response.pagination.total,
from: response.pagination.from,
to: response.pagination.to,
};
return response;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement des produits";
throw error;
} finally {
this.loading = false;
}
},
async createProduct(productData: any) {
this.isLoading = true;
this.error = null;
try {
const product = await productService.createProduct(productData);
// Add the new product to the beginning of the list
this.products.unshift(product.data);
this.meta.total += 1;
return product.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la création du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async updateProduct(id: number, productData: any) {
this.isLoading = true;
this.error = null;
try {
const updatedProduct = await productService.updateProduct(
id,
productData
);
// Update the product in the list
const index = this.products.findIndex((p) => p.id === id);
if (index !== -1) {
this.products[index] = updatedProduct.data;
}
// Update current product if it matches
if (this.currentProduct?.id === id) {
this.currentProduct = updatedProduct.data;
}
return updatedProduct.data;
} catch (error: any) {
this.error =
error.message || "Erreur lors de la mise à jour du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async deleteProduct(id: number) {
this.isLoading = true;
this.error = null;
try {
await productService.deleteProduct(id);
// Remove the product from the list
this.products = this.products.filter((p) => p.id !== id);
this.meta.total -= 1;
// Clear current product if it was deleted
if (this.currentProduct?.id === id) {
this.currentProduct = null;
}
return true;
} catch (error: any) {
this.error =
error.message || "Erreur lors de la suppression du produit";
throw error;
} finally {
this.isLoading = false;
}
},
async fetchProduct(id: number) {
this.loading = true;
this.error = null;
try {
const product = await productService.getProduct(id);
this.currentProduct = product.data;
return product.data;
} catch (error: any) {
this.error = error.message || "Erreur lors du chargement du produit";
throw error;
} finally {
this.loading = false;
}
},
async searchProducts(searchTerm: string, exact = false) {
this.loading = true;
this.error = null;
try {
const products = await productService.searchProducts(searchTerm, exact);
// Update current products list with search results
this.products = products.data;
return products.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la recherche";
throw error;
} finally {
this.loading = false;
}
},
async fetchLowStockProducts() {
this.loading = true;
this.error = null;
try {
const products = await productService.getLowStockProducts();
return products.data;
} catch (error: any) {
this.error =
error.message ||
"Erreur lors du chargement des produits à stock faible";
throw error;
} finally {
this.loading = false;
}
},
async fetchProductsByCategory(category: string) {
this.loading = true;
this.error = null;
try {
const products = await productService.getProductsByCategory(category);
return products.data;
} catch (error: any) {
this.error =
error.message ||
"Erreur lors du chargement des produits par catégorie";
throw error;
} finally {
this.loading = false;
}
},
async getProductStatistics() {
try {
const stats = await productService.getProductStatistics();
return stats.data;
} catch (error: any) {
this.error =
error.message || "Erreur lors du chargement des statistiques";
throw error;
}
},
async updateStock(productId: number, newStock: number) {
try {
const updatedProduct = await productService.updateStock(
productId,
newStock
);
// Update the product in the list
const index = this.products.findIndex((p) => p.id === productId);
if (index !== -1) {
this.products[index] = updatedProduct.data;
}
return updatedProduct.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la mise à jour du stock";
throw error;
}
},
resetState() {
this.products = [];
this.currentProduct = null;
this.error = null;
this.loading = false;
this.isLoading = false;
},
// Local filtering functions
filterByCategory(category: string) {
if (!category) return this.products;
return this.products.filter((product) => product.categorie === category);
},
filterByLowStock() {
return this.products.filter((product) => product.is_low_stock);
},
filterByExpiration(days = 30) {
const cutoffDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return this.products.filter(
(product) =>
product.date_expiration &&
new Date(product.date_expiration) <= cutoffDate
);
},
},
});

View File

@ -113,6 +113,13 @@ export default {
SoftSwitch,
SoftButton,
},
data() {
return {
email: "",
password: "",
remember: true,
};
},
created() {
this.toggleEveryDisplay();
this.toggleHideConfig();
@ -123,13 +130,6 @@ export default {
this.toggleHideConfig();
body.classList.add("bg-gray-100");
},
data() {
return {
email: "",
password: "",
remember: true,
};
},
methods: {
...mapMutations(["toggleEveryDisplay", "toggleHideConfig"]),
onEmailInput(e) {

View File

@ -0,0 +1,69 @@
<template>
<add-product-presentation
:fournisseurs="fournisseurStore.fournisseurs"
:loading="productStore.isLoading"
:validation-errors="validationErrors"
:success="showSuccess"
@create-product="handleCreateProduct"
/>
</template>
<script setup>
import AddProductPresentation from "@/components/Organism/Stock/AddProductPresentation.vue";
import { useFournisseurStore } from "@/stores/fournisseurStore";
import { useProductStore } from "@/stores/productStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const fournisseurStore = useFournisseurStore();
const productStore = useProductStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
onMounted(async () => {
// Load fournisseurs for the supplier dropdown
await fournisseurStore.fetchFournisseurs();
});
const handleCreateProduct = async (form) => {
try {
console.log(form);
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create product
const product = await productStore.createProduct(form);
// Show success notification
notificationStore.created("Produit");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Gestion de produits" });
}, 2000);
} catch (error) {
console.error("Error creating product:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
"Erreur de validation",
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
} else {
notificationStore.error("Erreur", "Une erreur inattendue s'est produite");
}
}
};
</script>

View File

@ -0,0 +1,46 @@
<template>
<product-presentation
:product-data="productStore.products"
:loading-data="productStore.loading"
@push-details="goDetails"
@delete-product="handleDeleteProduct"
/>
</template>
<script setup>
import ProductPresentation from "@/components/Organism/Stock/ProductPresentation.vue";
import { useProductStore } from "@/stores/productStore";
import { onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
const productStore = useProductStore();
const router = useRouter();
onMounted(async () => {
await productStore.fetchProducts();
});
onUnmounted(() => {
// Clean up store state when leaving the page
productStore.resetState();
});
const goDetails = (product) => {
router.push({
name: "Product details",
params: {
id: product.id,
},
});
};
const handleDeleteProduct = async (product) => {
try {
await productStore.deleteProduct(product.id);
// Optional: Show success notification
} catch (error) {
// Error is already handled in the store
console.error("Error deleting product:", error);
}
};
</script>

View File

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