add product
This commit is contained in:
parent
4b056038d6
commit
edb9c87c1e
259
thanas
Normal file
259
thanas
Normal 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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
338
thanasoft-back/app/Http/Controllers/Api/ProductController.php
Normal file
338
thanasoft-back/app/Http/Controllers/Api/ProductController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
thanasoft-back/app/Http/Requests/StoreProductRequest.php
Normal file
88
thanasoft-back/app/Http/Requests/StoreProductRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
88
thanasoft-back/app/Http/Requests/UpdateProductRequest.php
Normal file
88
thanasoft-back/app/Http/Requests/UpdateProductRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
44
thanasoft-back/app/Models/Product.php
Normal file
44
thanasoft-back/app/Models/Product.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
138
thanasoft-back/app/Repositories/ProductRepository.php
Normal file
138
thanasoft-back/app/Repositories/ProductRepository.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
138
thanasoft-back/app/Repositories/ProductRepository.php</path
Normal file
138
thanasoft-back/app/Repositories/ProductRepository.php</path
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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 = {
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
@ -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 "?";
|
||||
|
||||
@ -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" }}
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
{{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" }}
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
:badge="contactsCount > 0 ? contactsCount : null"
|
||||
@click="$emit('change-tab', 'contacts')"
|
||||
/>
|
||||
|
||||
<TabNavigationItem
|
||||
icon="fas fa-sticky-note"
|
||||
label="Notes"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export * from './http'
|
||||
export { default as AuthService } from './auth'
|
||||
export * from "./http";
|
||||
export { default as AuthService } from "./auth";
|
||||
|
||||
262
thanasoft-front/src/services/product.ts
Normal file
262
thanasoft-front/src/services/product.ts
Normal 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;
|
||||
@ -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 });
|
||||
|
||||
290
thanasoft-front/src/stores/productStore.ts
Normal file
290
thanasoft-front/src/stores/productStore.ts
Normal 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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
69
thanasoft-front/src/views/pages/Stock/AddProduct.vue
Normal file
69
thanasoft-front/src/views/pages/Stock/AddProduct.vue
Normal 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>
|
||||
46
thanasoft-front/src/views/pages/Stock/Products.vue
Normal file
46
thanasoft-front/src/views/pages/Stock/Products.vue
Normal 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>
|
||||
11
thanasoft-front/src/views/pages/Stock/Produit.vue
Normal file
11
thanasoft-front/src/views/pages/Stock/Produit.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Gestion de produit</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ReceptionStock",
|
||||
};
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user