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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Fournisseur extends Model
|
class Fournisseur extends Model
|
||||||
{
|
{
|
||||||
@ -32,6 +33,11 @@ class Fournisseur extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function products(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function commercial(): ?string
|
public function commercial(): ?string
|
||||||
{
|
{
|
||||||
return $this->user ? $this->user->name : 'Système';
|
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) {
|
$this->app->bind(\App\Repositories\FournisseurRepositoryInterface::class, function ($app) {
|
||||||
return new \App\Repositories\FournisseurRepository($app->make(\App\Models\Fournisseur::class));
|
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\ContactController;
|
||||||
use App\Http\Controllers\Api\ClientCategoryController;
|
use App\Http\Controllers\Api\ClientCategoryController;
|
||||||
use App\Http\Controllers\Api\FournisseurController;
|
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::get('/fournisseurs/searchBy', [FournisseurController::class, 'searchBy']);
|
||||||
Route::apiResource('fournisseurs', FournisseurController::class);
|
Route::apiResource('fournisseurs', FournisseurController::class);
|
||||||
Route::get('fournisseurs/{fournisseurId}/contacts', [ContactController::class, 'getContactsByFournisseur']);
|
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
|
// Relax console/debugger in development
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ignorePatterns: [
|
ignorePatterns: [
|
||||||
@ -43,5 +42,4 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,9 +10,13 @@
|
|||||||
<table-action />
|
<table-action />
|
||||||
</template>
|
</template>
|
||||||
<template #contact-table>
|
<template #contact-table>
|
||||||
<contact-table :data="contacts" :loading="loading" @delete="handleDelete" />
|
<contact-table
|
||||||
</template>
|
:data="contacts"
|
||||||
</contact-template>s
|
:loading="loading"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</template> </contact-template
|
||||||
|
>s
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ContactTemplate from "@/components/templates/CRM/ContactTemplate.vue";
|
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="form-control"
|
||||||
:class="getClasses(size, success, error)"
|
:class="getClasses(size, success, error)"
|
||||||
:name="name"
|
:name="name"
|
||||||
:value="value"
|
:value="modelValue"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:isRequired="isRequired"
|
:isRequired="isRequired"
|
||||||
|
:min="min"
|
||||||
|
:step="step"
|
||||||
|
@input="handleInput"
|
||||||
|
@blur="handleBlur"
|
||||||
/>
|
/>
|
||||||
<span v-if="iconDir === 'right'" class="input-group-text">
|
<span v-if="iconDir === 'right'" class="input-group-text">
|
||||||
<i :class="getIcon(icon)"></i>
|
<i :class="getIcon(icon)"></i>
|
||||||
@ -53,8 +57,8 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
value: {
|
modelValue: {
|
||||||
type: String,
|
type: [String, Number],
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
@ -69,7 +73,16 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
min: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
emits: ["update:modelValue", "blur"],
|
||||||
methods: {
|
methods: {
|
||||||
getClasses: (size, success, error) => {
|
getClasses: (size, success, error) => {
|
||||||
let sizeValue, isValidValue;
|
let sizeValue, isValidValue;
|
||||||
@ -88,6 +101,14 @@ export default {
|
|||||||
},
|
},
|
||||||
getIcon: (icon) => (icon ? icon : null),
|
getIcon: (icon) => (icon ? icon : null),
|
||||||
hasIcon: (icon) => (icon ? "input-group" : 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>
|
</script>
|
||||||
|
|||||||
@ -101,7 +101,7 @@
|
|||||||
<!-- Email Column -->
|
<!-- Email Column -->
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<div class="text-secondary">
|
<div class="text-secondary">
|
||||||
{{ contact.email || '-' }}
|
{{ contact.email || "-" }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -114,13 +114,17 @@
|
|||||||
<div v-if="contact.mobile">
|
<div v-if="contact.mobile">
|
||||||
<i class="fas fa-mobile-alt me-1"></i>{{ contact.mobile }}
|
<i class="fas fa-mobile-alt me-1"></i>{{ contact.mobile }}
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!contact.phone && !contact.mobile" class="text-muted">-</span>
|
<span
|
||||||
|
v-if="!contact.phone && !contact.mobile"
|
||||||
|
class="text-muted"
|
||||||
|
>-</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Position Column -->
|
<!-- Position Column -->
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
{{ contact.position || '-' }}
|
{{ contact.position || "-" }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Status Column (Primary Contact Badge) -->
|
<!-- Status Column (Primary Contact Badge) -->
|
||||||
@ -259,15 +263,21 @@ const initializeDataTable = () => {
|
|||||||
const handleTableClick = (event) => {
|
const handleTableClick = (event) => {
|
||||||
const button = event.target.closest("button");
|
const button = event.target.closest("button");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const contactId = button.getAttribute("data-contact-id");
|
const contactId = button.getAttribute("data-contact-id");
|
||||||
if (!contactId) return;
|
if (!contactId) return;
|
||||||
|
|
||||||
if (button.title === "Delete Contact" || button.querySelector(".fa-trash")) {
|
if (button.title === "Delete Contact" || button.querySelector(".fa-trash")) {
|
||||||
emit("delete", contactId);
|
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);
|
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);
|
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
|
Souvenez de moi
|
||||||
</SoftSwitch>
|
</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 }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
@ -145,7 +145,11 @@ const contactModalIsVisible = ref(false);
|
|||||||
const isModification = ref(false);
|
const isModification = ref(false);
|
||||||
const selectedContact = ref(null);
|
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) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return "?";
|
if (!name) return "?";
|
||||||
|
|||||||
@ -19,8 +19,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-success btn-sm"
|
class="btn btn-success btn-sm"
|
||||||
@click="saveChanges"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
|
@click="saveChanges"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-1"></i>
|
<i class="fas fa-save me-1"></i>
|
||||||
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
|
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
|
||||||
|
|||||||
@ -8,29 +8,29 @@
|
|||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
<i
|
<i
|
||||||
v-if="!isEditing"
|
v-if="!isEditing"
|
||||||
@click="toggleEditMode"
|
|
||||||
class="text-sm fas fa-user-edit text-secondary cursor-pointer"
|
class="text-sm fas fa-user-edit text-secondary cursor-pointer"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top"
|
data-bs-placement="top"
|
||||||
:title="action.tooltip"
|
:title="action.tooltip"
|
||||||
style="cursor: pointer;"
|
style="cursor: pointer"
|
||||||
|
@click="toggleEditMode"
|
||||||
></i>
|
></i>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<i
|
<i
|
||||||
@click="saveChanges"
|
|
||||||
class="text-sm fas fa-save text-success cursor-pointer me-2"
|
class="text-sm fas fa-save text-success cursor-pointer me-2"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top"
|
data-bs-placement="top"
|
||||||
title="Sauvegarder"
|
title="Sauvegarder"
|
||||||
style="cursor: pointer;"
|
style="cursor: pointer"
|
||||||
|
@click="saveChanges"
|
||||||
></i>
|
></i>
|
||||||
<i
|
<i
|
||||||
@click="cancelEdit"
|
|
||||||
class="text-sm fas fa-times text-danger cursor-pointer"
|
class="text-sm fas fa-times text-danger cursor-pointer"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-placement="top"
|
data-bs-placement="top"
|
||||||
title="Annuler"
|
title="Annuler"
|
||||||
style="cursor: pointer;"
|
style="cursor: pointer"
|
||||||
|
@click="cancelEdit"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,10 +51,17 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="text-sm border-0 list-group-item ps-0">
|
<li class="text-sm border-0 list-group-item ps-0">
|
||||||
<strong class="text-dark">Catégorie:</strong>
|
<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) }}
|
{{ getCategoryLabel(categorie) }}
|
||||||
</span>
|
</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="entreprise">Entreprise</option>
|
||||||
<option value="particulier">Particulier</option>
|
<option value="particulier">Particulier</option>
|
||||||
<option value="association">Association</option>
|
<option value="association">Association</option>
|
||||||
@ -126,7 +133,10 @@
|
|||||||
placeholder="Ville"
|
placeholder="Ville"
|
||||||
class="form-control form-control-sm mb-1"
|
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="FR">France</option>
|
||||||
<option value="BE">Belgique</option>
|
<option value="BE">Belgique</option>
|
||||||
<option value="CH">Suisse</option>
|
<option value="CH">Suisse</option>
|
||||||
@ -141,17 +151,24 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="text-sm border-0 list-group-item ps-0">
|
<li class="text-sm border-0 list-group-item ps-0">
|
||||||
<strong class="text-dark">Statut:</strong>
|
<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" }}
|
{{ is_active ? "Actif" : "Inactif" }}
|
||||||
</span>
|
</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="true">Actif</option>
|
||||||
<option :value="false">Inactif</option>
|
<option :value="false">Inactif</option>
|
||||||
</select>
|
</select>
|
||||||
</li>
|
</li>
|
||||||
<li class="text-sm border-0 list-group-item ps-0">
|
<li class="text-sm border-0 list-group-item ps-0">
|
||||||
<strong class="text-dark">Notes:</strong>
|
<strong class="text-dark">Notes:</strong>
|
||||||
<span v-if="!isEditing">{{ notes || 'Aucune note' }}</span>
|
<span v-if="!isEditing">{{ notes || "Aucune note" }}</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-else
|
v-else
|
||||||
v-model="editForm.notes"
|
v-model="editForm.notes"
|
||||||
@ -295,7 +312,7 @@ const saveChanges = () => {
|
|||||||
notes: editForm.notes,
|
notes: editForm.notes,
|
||||||
is_active: editForm.is_active,
|
is_active: editForm.is_active,
|
||||||
};
|
};
|
||||||
|
|
||||||
emit("update:client", updateData);
|
emit("update:client", updateData);
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<h6 class="mb-0">Contacts</h6>
|
<h6 class="mb-0">Contacts</h6>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
@click="addContact"
|
|
||||||
title="Add contact"
|
title="Add contact"
|
||||||
|
@click="addContact"
|
||||||
>
|
>
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -45,8 +45,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-danger btn-sm delete-btn ms-2"
|
class="btn btn-outline-danger btn-sm delete-btn ms-2"
|
||||||
@click="deleteContact(contact.id)"
|
|
||||||
title="Delete contact"
|
title="Delete contact"
|
||||||
|
@click="deleteContact(contact.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -12,14 +12,18 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">
|
<h5 class="modal-title">
|
||||||
<i :class="isModification ? 'fas fa-edit me-2' : 'fas fa-user-plus me-2'"></i>
|
<i
|
||||||
{{ isModification ? 'Modifier le contact' : 'Ajouter un contact' }}
|
:class="
|
||||||
|
isModification ? 'fas fa-edit me-2' : 'fas fa-user-plus me-2'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
{{ isModification ? "Modifier le contact" : "Ajouter un contact" }}
|
||||||
</h5>
|
</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="closeModal"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
@click="closeModal"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -27,7 +31,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<!-- Client ID (hidden) -->
|
<!-- Client ID (hidden) -->
|
||||||
<input type="hidden" v-model="formData.client_id" />
|
<input v-model="formData.client_id" type="hidden" />
|
||||||
|
|
||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -122,8 +126,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
@click="closeModal"
|
|
||||||
:disabled="contactIsLoading"
|
:disabled="contactIsLoading"
|
||||||
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times me-1"></i>
|
<i class="fas fa-times me-1"></i>
|
||||||
Annuler
|
Annuler
|
||||||
@ -131,11 +135,19 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submitForm"
|
|
||||||
:disabled="contactIsLoading"
|
:disabled="contactIsLoading"
|
||||||
|
@click="submitForm"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-1"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,13 +37,13 @@
|
|||||||
<div class="search-container position-relative">
|
<div class="search-container position-relative">
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
@input="handleSearchInput($event.target.value)"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.client_id }"
|
:class="{ 'is-invalid': fieldErrors.client_id }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Tapez pour rechercher un client..."
|
placeholder="Tapez pour rechercher un client..."
|
||||||
icon="ni ni-zoom-split-in"
|
icon="ni ni-zoom-split-in"
|
||||||
iconDir="left"
|
icon-dir="left"
|
||||||
|
@input="handleSearchInput($event.target.value)"
|
||||||
@focus="showDropdown = true"
|
@focus="showDropdown = true"
|
||||||
@blur="onInputBlur"
|
@blur="onInputBlur"
|
||||||
/>
|
/>
|
||||||
@ -73,7 +73,7 @@
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ client.name }}</strong>
|
<strong>{{ client.name }}</strong>
|
||||||
<div class="text-muted small" v-if="client.email">
|
<div v-if="client.email" class="text-muted small">
|
||||||
{{ client.email }}
|
{{ client.email }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -142,12 +142,12 @@
|
|||||||
<label class="form-label">Prénom</label>
|
<label class="form-label">Prénom</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.first_name"
|
:value="form.first_name"
|
||||||
@input="form.first_name = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.first_name }"
|
:class="{ 'is-invalid': fieldErrors.first_name }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Jean"
|
placeholder="ex. Jean"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.first_name = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.first_name" class="invalid-feedback">
|
<div v-if="fieldErrors.first_name" class="invalid-feedback">
|
||||||
{{ fieldErrors.first_name }}
|
{{ fieldErrors.first_name }}
|
||||||
@ -157,12 +157,12 @@
|
|||||||
<label class="form-label">Nom</label>
|
<label class="form-label">Nom</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.last_name"
|
:value="form.last_name"
|
||||||
@input="form.last_name = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.last_name }"
|
:class="{ 'is-invalid': fieldErrors.last_name }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Dupont"
|
placeholder="ex. Dupont"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.last_name = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.last_name" class="invalid-feedback">
|
<div v-if="fieldErrors.last_name" class="invalid-feedback">
|
||||||
{{ fieldErrors.last_name }}
|
{{ fieldErrors.last_name }}
|
||||||
@ -176,12 +176,12 @@
|
|||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.email"
|
:value="form.email"
|
||||||
@input="form.email = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.email }"
|
:class="{ 'is-invalid': fieldErrors.email }"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ex. jean.dupont@entreprise.com"
|
placeholder="ex. jean.dupont@entreprise.com"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.email = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.email" class="invalid-feedback">
|
<div v-if="fieldErrors.email" class="invalid-feedback">
|
||||||
{{ fieldErrors.email }}
|
{{ fieldErrors.email }}
|
||||||
@ -195,12 +195,12 @@
|
|||||||
<label class="form-label">Téléphone</label>
|
<label class="form-label">Téléphone</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.phone"
|
:value="form.phone"
|
||||||
@input="form.phone = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
:class="{ 'is-invalid': fieldErrors.phone }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. +33 1 23 45 67 89"
|
placeholder="ex. +33 1 23 45 67 89"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
|
@input="form.phone = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
||||||
{{ fieldErrors.phone }}
|
{{ fieldErrors.phone }}
|
||||||
@ -210,12 +210,12 @@
|
|||||||
<label class="form-label">Mobile</label>
|
<label class="form-label">Mobile</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.mobile"
|
:value="form.mobile"
|
||||||
@input="form.mobile = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.mobile }"
|
:class="{ 'is-invalid': fieldErrors.mobile }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. +33 6 12 34 56 78"
|
placeholder="ex. +33 6 12 34 56 78"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
|
@input="form.mobile = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.mobile" class="invalid-feedback">
|
<div v-if="fieldErrors.mobile" class="invalid-feedback">
|
||||||
{{ fieldErrors.mobile }}
|
{{ fieldErrors.mobile }}
|
||||||
@ -229,12 +229,12 @@
|
|||||||
<label class="form-label">Rôle</label>
|
<label class="form-label">Rôle</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.role"
|
:value="form.role"
|
||||||
@input="form.role = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.role }"
|
:class="{ 'is-invalid': fieldErrors.role }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Directeur Commercial"
|
placeholder="ex. Directeur Commercial"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.role = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.role" class="invalid-feedback">
|
<div v-if="fieldErrors.role" class="invalid-feedback">
|
||||||
{{ fieldErrors.role }}
|
{{ fieldErrors.role }}
|
||||||
@ -248,11 +248,11 @@
|
|||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="form.notes"
|
:value="form.notes"
|
||||||
@input="form.notes = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Notes supplémentaires sur le contact..."
|
placeholder="Notes supplémentaires sur le contact..."
|
||||||
maxlength="1000"
|
maxlength="1000"
|
||||||
|
@input="form.notes = $event.target.value"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
<label class="form-label">Catégorie de client</label>
|
<label class="form-label">Catégorie de client</label>
|
||||||
<select
|
<select
|
||||||
:value="form.client_category_id"
|
:value="form.client_category_id"
|
||||||
@input="form.client_category_id = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
|
@input="form.client_category_id = $event.target.value"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner une catégorie</option>
|
<option value="">Sélectionner une catégorie</option>
|
||||||
<option
|
<option
|
||||||
@ -33,11 +33,11 @@
|
|||||||
>
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.name"
|
:value="form.name"
|
||||||
@input="form.name = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.name }"
|
:class="{ 'is-invalid': fieldErrors.name }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
|
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
|
||||||
|
@input="form.name = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.name" class="invalid-feedback">
|
<div v-if="fieldErrors.name" class="invalid-feedback">
|
||||||
{{ fieldErrors.name }}
|
{{ fieldErrors.name }}
|
||||||
@ -51,12 +51,12 @@
|
|||||||
<label class="form-label">Numéro de TVA</label>
|
<label class="form-label">Numéro de TVA</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.vat_number"
|
:value="form.vat_number"
|
||||||
@input="form.vat_number = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. FR12345678901"
|
placeholder="ex. FR12345678901"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
|
@input="form.vat_number = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
||||||
{{ fieldErrors.vat_number }}
|
{{ fieldErrors.vat_number }}
|
||||||
@ -66,12 +66,12 @@
|
|||||||
<label class="form-label">SIRET</label>
|
<label class="form-label">SIRET</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.siret"
|
:value="form.siret"
|
||||||
@input="form.siret = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.siret }"
|
:class="{ 'is-invalid': fieldErrors.siret }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 12345678901234"
|
placeholder="ex. 12345678901234"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
|
@input="form.siret = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
||||||
{{ fieldErrors.siret }}
|
{{ fieldErrors.siret }}
|
||||||
@ -85,11 +85,11 @@
|
|||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.email"
|
:value="form.email"
|
||||||
@input="form.email = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.email }"
|
:class="{ 'is-invalid': fieldErrors.email }"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ex. contact@entreprise.com"
|
placeholder="ex. contact@entreprise.com"
|
||||||
|
@input="form.email = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.email" class="invalid-feedback">
|
<div v-if="fieldErrors.email" class="invalid-feedback">
|
||||||
{{ fieldErrors.email }}
|
{{ fieldErrors.email }}
|
||||||
@ -99,12 +99,12 @@
|
|||||||
<label class="form-label">Téléphone</label>
|
<label class="form-label">Téléphone</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.phone"
|
:value="form.phone"
|
||||||
@input="form.phone = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
:class="{ 'is-invalid': fieldErrors.phone }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. +33 1 23 45 67 89"
|
placeholder="ex. +33 1 23 45 67 89"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
|
@input="form.phone = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
||||||
{{ fieldErrors.phone }}
|
{{ fieldErrors.phone }}
|
||||||
@ -118,12 +118,12 @@
|
|||||||
<label class="form-label">Adresse ligne 1</label>
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line1"
|
:value="form.billing_address_line1"
|
||||||
@input="form.billing_address_line1 = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 123 Rue Principale"
|
placeholder="ex. 123 Rue Principale"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
|
@input="form.billing_address_line1 = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line1"
|
v-if="fieldErrors.billing_address_line1"
|
||||||
@ -139,12 +139,12 @@
|
|||||||
<label class="form-label">Adresse ligne 2</label>
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line2"
|
:value="form.billing_address_line2"
|
||||||
@input="form.billing_address_line2 = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Appartement, Suite, etc."
|
placeholder="ex. Appartement, Suite, etc."
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
|
@input="form.billing_address_line2 = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line2"
|
v-if="fieldErrors.billing_address_line2"
|
||||||
@ -160,12 +160,12 @@
|
|||||||
<label class="form-label">Code postal</label>
|
<label class="form-label">Code postal</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_postal_code"
|
:value="form.billing_postal_code"
|
||||||
@input="form.billing_postal_code = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 75001"
|
placeholder="ex. 75001"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
|
@input="form.billing_postal_code = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_postal_code }}
|
{{ fieldErrors.billing_postal_code }}
|
||||||
@ -175,12 +175,12 @@
|
|||||||
<label class="form-label">Ville</label>
|
<label class="form-label">Ville</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_city"
|
:value="form.billing_city"
|
||||||
@input="form.billing_city = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Paris"
|
placeholder="ex. Paris"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.billing_city = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_city }}
|
{{ fieldErrors.billing_city }}
|
||||||
@ -190,9 +190,9 @@
|
|||||||
<label class="form-label">Code pays</label>
|
<label class="form-label">Code pays</label>
|
||||||
<select
|
<select
|
||||||
:value="form.billing_country_code"
|
:value="form.billing_country_code"
|
||||||
@input="form.billing_country_code = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
||||||
|
@input="form.billing_country_code = $event.target.value"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner un pays</option>
|
<option value="">Sélectionner un pays</option>
|
||||||
<option value="FR">France</option>
|
<option value="FR">France</option>
|
||||||
@ -216,11 +216,11 @@
|
|||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="form.notes"
|
:value="form.notes"
|
||||||
@input="form.notes = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Notes supplémentaires sur le client..."
|
placeholder="Notes supplémentaires sur le client..."
|
||||||
maxlength="1000"
|
maxlength="1000"
|
||||||
|
@input="form.notes = $event.target.value"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -230,9 +230,9 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input
|
<input
|
||||||
|
id="isActive"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="isActive"
|
|
||||||
:checked="form.is_active"
|
:checked="form.is_active"
|
||||||
@change="form.is_active = $event.target.checked"
|
@change="form.is_active = $event.target.checked"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,11 +12,11 @@
|
|||||||
>
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.name"
|
:value="form.name"
|
||||||
@input="form.name = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.name }"
|
:class="{ 'is-invalid': fieldErrors.name }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Nom de l'entreprise"
|
placeholder="ex. Nom de l'entreprise"
|
||||||
|
@input="form.name = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.name" class="invalid-feedback">
|
<div v-if="fieldErrors.name" class="invalid-feedback">
|
||||||
{{ fieldErrors.name }}
|
{{ fieldErrors.name }}
|
||||||
@ -30,12 +30,12 @@
|
|||||||
<label class="form-label">Numéro de TVA</label>
|
<label class="form-label">Numéro de TVA</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.vat_number"
|
:value="form.vat_number"
|
||||||
@input="form.vat_number = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. FR12345678901"
|
placeholder="ex. FR12345678901"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
|
@input="form.vat_number = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
||||||
{{ fieldErrors.vat_number }}
|
{{ fieldErrors.vat_number }}
|
||||||
@ -45,12 +45,12 @@
|
|||||||
<label class="form-label">SIRET</label>
|
<label class="form-label">SIRET</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.siret"
|
:value="form.siret"
|
||||||
@input="form.siret = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.siret }"
|
:class="{ 'is-invalid': fieldErrors.siret }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 12345678901234"
|
placeholder="ex. 12345678901234"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
|
@input="form.siret = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
||||||
{{ fieldErrors.siret }}
|
{{ fieldErrors.siret }}
|
||||||
@ -64,11 +64,11 @@
|
|||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.email"
|
:value="form.email"
|
||||||
@input="form.email = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.email }"
|
:class="{ 'is-invalid': fieldErrors.email }"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ex. contact@fournisseur.com"
|
placeholder="ex. contact@fournisseur.com"
|
||||||
|
@input="form.email = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.email" class="invalid-feedback">
|
<div v-if="fieldErrors.email" class="invalid-feedback">
|
||||||
{{ fieldErrors.email }}
|
{{ fieldErrors.email }}
|
||||||
@ -78,12 +78,12 @@
|
|||||||
<label class="form-label">Téléphone</label>
|
<label class="form-label">Téléphone</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.phone"
|
:value="form.phone"
|
||||||
@input="form.phone = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
:class="{ 'is-invalid': fieldErrors.phone }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. +33 1 23 45 67 89"
|
placeholder="ex. +33 1 23 45 67 89"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
|
@input="form.phone = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
||||||
{{ fieldErrors.phone }}
|
{{ fieldErrors.phone }}
|
||||||
@ -97,12 +97,12 @@
|
|||||||
<label class="form-label">Adresse ligne 1</label>
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line1"
|
:value="form.billing_address_line1"
|
||||||
@input="form.billing_address_line1 = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 123 Rue Principale"
|
placeholder="ex. 123 Rue Principale"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
|
@input="form.billing_address_line1 = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line1"
|
v-if="fieldErrors.billing_address_line1"
|
||||||
@ -118,12 +118,12 @@
|
|||||||
<label class="form-label">Adresse ligne 2</label>
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line2"
|
:value="form.billing_address_line2"
|
||||||
@input="form.billing_address_line2 = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Bâtiment, Étage, etc."
|
placeholder="ex. Bâtiment, Étage, etc."
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
|
@input="form.billing_address_line2 = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line2"
|
v-if="fieldErrors.billing_address_line2"
|
||||||
@ -139,12 +139,12 @@
|
|||||||
<label class="form-label">Code postal</label>
|
<label class="form-label">Code postal</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_postal_code"
|
:value="form.billing_postal_code"
|
||||||
@input="form.billing_postal_code = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 75001"
|
placeholder="ex. 75001"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
|
@input="form.billing_postal_code = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_postal_code }}
|
{{ fieldErrors.billing_postal_code }}
|
||||||
@ -154,12 +154,12 @@
|
|||||||
<label class="form-label">Ville</label>
|
<label class="form-label">Ville</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_city"
|
:value="form.billing_city"
|
||||||
@input="form.billing_city = $event.target.value"
|
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Paris"
|
placeholder="ex. Paris"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
|
@input="form.billing_city = $event.target.value"
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_city }}
|
{{ fieldErrors.billing_city }}
|
||||||
@ -169,9 +169,9 @@
|
|||||||
<label class="form-label">Code pays</label>
|
<label class="form-label">Code pays</label>
|
||||||
<select
|
<select
|
||||||
:value="form.billing_country_code"
|
:value="form.billing_country_code"
|
||||||
@input="form.billing_country_code = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
||||||
|
@input="form.billing_country_code = $event.target.value"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner un pays</option>
|
<option value="">Sélectionner un pays</option>
|
||||||
<option value="FR">France</option>
|
<option value="FR">France</option>
|
||||||
@ -196,11 +196,11 @@
|
|||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="form.notes"
|
:value="form.notes"
|
||||||
@input="form.notes = $event.target.value"
|
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Notes supplémentaires sur le fournisseur..."
|
placeholder="Notes supplémentaires sur le fournisseur..."
|
||||||
maxlength="1000"
|
maxlength="1000"
|
||||||
|
@input="form.notes = $event.target.value"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -210,9 +210,9 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input
|
<input
|
||||||
|
id="isActive"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="isActive"
|
|
||||||
:checked="form.is_active"
|
:checked="form.is_active"
|
||||||
@change="form.is_active = $event.target.checked"
|
@change="form.is_active = $event.target.checked"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -22,8 +22,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="closeModal"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
@click="closeModal"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -31,7 +31,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<!-- Fournisseur ID (hidden) -->
|
<!-- Fournisseur ID (hidden) -->
|
||||||
<input type="hidden" v-model="formData.fournisseur_id" />
|
<input v-model="formData.fournisseur_id" type="hidden" />
|
||||||
|
|
||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -126,8 +126,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
@click="closeModal"
|
|
||||||
:disabled="contactIsLoading"
|
:disabled="contactIsLoading"
|
||||||
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times me-1"></i>
|
<i class="fas fa-times me-1"></i>
|
||||||
Annuler
|
Annuler
|
||||||
@ -135,8 +135,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="submitForm"
|
|
||||||
:disabled="contactIsLoading"
|
:disabled="contactIsLoading"
|
||||||
|
@click="submitForm"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-1"></i>
|
<i class="fas fa-save me-1"></i>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
<h6 class="mb-0">Liste des contacts</h6>
|
<h6 class="mb-0">Liste des contacts</h6>
|
||||||
<SoftButton
|
<SoftButton
|
||||||
class="btn btn-primary btn-sm ms-auto"
|
class="btn btn-primary btn-sm ms-auto"
|
||||||
@click="contactModalIsVisible = true"
|
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@click="contactModalIsVisible = true"
|
||||||
>
|
>
|
||||||
<i class="fas fa-plus me-1"></i>Ajouter un contact
|
<i class="fas fa-plus me-1"></i>Ajouter un contact
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
@ -106,8 +106,8 @@
|
|||||||
class="btn btn-link text-warning p-0 mb-0"
|
class="btn btn-link text-warning p-0 mb-0"
|
||||||
type="button"
|
type="button"
|
||||||
title="Modifier"
|
title="Modifier"
|
||||||
@click="handleModifyContact(contact)"
|
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@click="handleModifyContact(contact)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-edit text-sm"></i>
|
<i class="fas fa-edit text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -115,8 +115,8 @@
|
|||||||
class="btn btn-link text-danger p-0 mb-0"
|
class="btn btn-link text-danger p-0 mb-0"
|
||||||
type="button"
|
type="button"
|
||||||
title="Supprimer"
|
title="Supprimer"
|
||||||
@click="handleRemoveContact(contact.id)"
|
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@click="handleRemoveContact(contact.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash text-sm"></i>
|
<i class="fas fa-trash text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -19,8 +19,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-success btn-sm"
|
class="btn btn-success btn-sm"
|
||||||
@click="saveChanges"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
|
@click="saveChanges"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-1"></i>
|
<i class="fas fa-save me-1"></i>
|
||||||
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
|
{{ isSaving ? "Enregistrement..." : "Enregistrer" }}
|
||||||
|
|||||||
@ -26,7 +26,6 @@
|
|||||||
:badge="contactsCount > 0 ? contactsCount : null"
|
:badge="contactsCount > 0 ? contactsCount : null"
|
||||||
@click="$emit('change-tab', 'contacts')"
|
@click="$emit('change-tab', 'contacts')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabNavigationItem
|
<TabNavigationItem
|
||||||
icon="fas fa-sticky-note"
|
icon="fas fa-sticky-note"
|
||||||
label="Notes"
|
label="Notes"
|
||||||
|
|||||||
@ -28,9 +28,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
@click="closeModal"
|
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
:disabled="locationIsLoading"
|
:disabled="locationIsLoading"
|
||||||
|
@click="closeModal"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,10 +242,10 @@
|
|||||||
|
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
<input
|
<input
|
||||||
|
id="isDefaultCheckbox"
|
||||||
v-model="formData.is_default"
|
v-model="formData.is_default"
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="isDefaultCheckbox"
|
|
||||||
:class="{ 'is-invalid': errors.is_default }"
|
:class="{ 'is-invalid': errors.is_default }"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="isDefaultCheckbox">
|
<label class="form-check-label" for="isDefaultCheckbox">
|
||||||
@ -263,8 +263,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
@click="closeModal"
|
|
||||||
:disabled="locationIsLoading"
|
:disabled="locationIsLoading"
|
||||||
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times me-1"></i>
|
<i class="fas fa-times me-1"></i>
|
||||||
Annuler
|
Annuler
|
||||||
@ -272,8 +272,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="handleSubmit"
|
|
||||||
:disabled="locationIsLoading || !isFormValid"
|
:disabled="locationIsLoading || !isFormValid"
|
||||||
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-1"></i>
|
<i class="fas fa-save me-1"></i>
|
||||||
<span
|
<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
|
* Composable pour gérer les notifications dans les composants Vue
|
||||||
*
|
*
|
||||||
* Exemple d'utilisation :
|
* Exemple d'utilisation :
|
||||||
*
|
*
|
||||||
* const notification = useNotification();
|
* const notification = useNotification();
|
||||||
*
|
*
|
||||||
* // Notifications CRUD simples
|
* // Notifications CRUD simples
|
||||||
* notification.created("Le client");
|
* notification.created("Le client");
|
||||||
* notification.updated("La catégorie");
|
* notification.updated("La catégorie");
|
||||||
* notification.deleted("Le produit");
|
* notification.deleted("Le produit");
|
||||||
*
|
*
|
||||||
* // Notifications personnalisées
|
* // Notifications personnalisées
|
||||||
* notification.success("Succès", "Opération réussie");
|
* notification.success("Succès", "Opération réussie");
|
||||||
* notification.error("Erreur", "Une erreur s'est produite");
|
* notification.error("Erreur", "Une erreur s'est produite");
|
||||||
@ -25,23 +25,23 @@ export function useNotification() {
|
|||||||
// Méthodes de base
|
// Méthodes de base
|
||||||
success: (title: string, message: string, duration?: number) =>
|
success: (title: string, message: string, duration?: number) =>
|
||||||
store.success(title, message, duration),
|
store.success(title, message, duration),
|
||||||
|
|
||||||
error: (title: string, message: string, duration?: number) =>
|
error: (title: string, message: string, duration?: number) =>
|
||||||
store.error(title, message, duration),
|
store.error(title, message, duration),
|
||||||
|
|
||||||
warning: (title: string, message: string, duration?: number) =>
|
warning: (title: string, message: string, duration?: number) =>
|
||||||
store.warning(title, message, duration),
|
store.warning(title, message, duration),
|
||||||
|
|
||||||
info: (title: string, message: string, duration?: number) =>
|
info: (title: string, message: string, duration?: number) =>
|
||||||
store.info(title, message, duration),
|
store.info(title, message, duration),
|
||||||
|
|
||||||
// Méthodes CRUD
|
// Méthodes CRUD
|
||||||
created: (entity?: string, duration?: number) =>
|
created: (entity?: string, duration?: number) =>
|
||||||
store.created(entity, duration),
|
store.created(entity, duration),
|
||||||
|
|
||||||
updated: (entity?: string, duration?: number) =>
|
updated: (entity?: string, duration?: number) =>
|
||||||
store.updated(entity, duration),
|
store.updated(entity, duration),
|
||||||
|
|
||||||
deleted: (entity?: string, duration?: number) =>
|
deleted: (entity?: string, duration?: number) =>
|
||||||
store.deleted(entity, duration),
|
store.deleted(entity, duration),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -61,7 +61,10 @@
|
|||||||
:class="textWhite ? textWhite : 'text-body'"
|
:class="textWhite ? textWhite : 'text-body'"
|
||||||
@click.prevent="handleLogout"
|
@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-if="isRTL" class="d-sm-inline d-none">تسجيل خروج</span>
|
||||||
<span v-else class="d-sm-inline d-none">Logout</span>
|
<span v-else class="d-sm-inline d-none">Logout</span>
|
||||||
</a>
|
</a>
|
||||||
@ -266,7 +269,7 @@ export default {
|
|||||||
this.toggleSidebarColor("bg-white");
|
this.toggleSidebarColor("bg-white");
|
||||||
this.navbarMinimize();
|
this.navbarMinimize();
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
try {
|
try {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|||||||
@ -217,6 +217,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #list>
|
<template #list>
|
||||||
<ul class="nav ms-4 ps-3">
|
<ul class="nav ms-4 ps-3">
|
||||||
|
<sidenav-item
|
||||||
|
:to="{ name: 'Gestion de produits' }"
|
||||||
|
mini-icon="R"
|
||||||
|
text="Produits"
|
||||||
|
/>
|
||||||
<sidenav-item
|
<sidenav-item
|
||||||
:to="{ name: 'Reception stock' }"
|
:to="{ name: 'Reception stock' }"
|
||||||
mini-icon="R"
|
mini-icon="R"
|
||||||
|
|||||||
@ -503,6 +503,16 @@ const routes = [
|
|||||||
name: "Gestion stock",
|
name: "Gestion stock",
|
||||||
component: () => import("@/views/pages/Stock/Stock.vue"),
|
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
|
// Employés
|
||||||
{
|
{
|
||||||
path: "/employes",
|
path: "/employes",
|
||||||
|
|||||||
@ -40,7 +40,7 @@ http.interceptors.request.use(async (config) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only ensure CSRF for unsafe methods (POST, PUT, PATCH, DELETE)
|
// Only ensure CSRF for unsafe methods (POST, PUT, PATCH, DELETE)
|
||||||
// Skip CSRF if using token-based auth
|
// Skip CSRF if using token-based auth
|
||||||
const method = (config.method || "get").toLowerCase();
|
const method = (config.method || "get").toLowerCase();
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from './http'
|
export * from "./http";
|
||||||
export { default as AuthService } from './auth'
|
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
|
// Méthodes pratiques pour différents types de notifications
|
||||||
success(title: string, message: string, duration?: number) {
|
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) {
|
error(title: string, message: string, duration?: number) {
|
||||||
return this.addNotification({ type: "error", title, message, duration });
|
return this.addNotification({ type: "error", title, message, duration });
|
||||||
},
|
},
|
||||||
warning(title: string, message: string, duration?: number) {
|
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) {
|
info(title: string, message: string, duration?: number) {
|
||||||
return this.addNotification({ type: "info", title, message, duration });
|
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,
|
SoftSwitch,
|
||||||
SoftButton,
|
SoftButton,
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
remember: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
created() {
|
created() {
|
||||||
this.toggleEveryDisplay();
|
this.toggleEveryDisplay();
|
||||||
this.toggleHideConfig();
|
this.toggleHideConfig();
|
||||||
@ -123,13 +130,6 @@ export default {
|
|||||||
this.toggleHideConfig();
|
this.toggleHideConfig();
|
||||||
body.classList.add("bg-gray-100");
|
body.classList.add("bg-gray-100");
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
remember: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
...mapMutations(["toggleEveryDisplay", "toggleHideConfig"]),
|
...mapMutations(["toggleEveryDisplay", "toggleHideConfig"]),
|
||||||
onEmailInput(e) {
|
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