add api produit categorie, et page categori produit
This commit is contained in:
parent
638af78e1f
commit
aa306f5d19
@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreProductCategoryRequest;
|
||||
use App\Http\Requests\UpdateProductCategoryRequest;
|
||||
use App\Http\Resources\ProductCategory\ProductCategoryResource;
|
||||
use App\Http\Resources\ProductCategory\ProductCategoryCollection;
|
||||
use App\Repositories\ProductCategoryRepositoryInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductCategoryRepositoryInterface $productCategoryRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of product categories.
|
||||
*/
|
||||
public function index(Request $request): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$filters = [
|
||||
'search' => $request->get('search'),
|
||||
'active' => $request->get('active'),
|
||||
'parent_id' => $request->get('parent_id'),
|
||||
'sort_by' => $request->get('sort_by', 'name'),
|
||||
'sort_direction' => $request->get('sort_direction', 'asc'),
|
||||
];
|
||||
|
||||
// Remove null filters
|
||||
$filters = array_filter($filters, function ($value) {
|
||||
return $value !== null && $value !== '';
|
||||
});
|
||||
|
||||
$categories = $this->productCategoryRepository->paginate($perPage, $filters);
|
||||
|
||||
return new ProductCategoryCollection($categories);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching product categories: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération des catégories de produits.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created product category.
|
||||
*/
|
||||
public function store(StoreProductCategoryRequest $request): ProductCategoryResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$category = $this->productCategoryRepository->create($request->validated());
|
||||
return new ProductCategoryResource($category);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating product category: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'data' => $request->validated(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la création de la catégorie.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified product category.
|
||||
*/
|
||||
public function show(string $id): ProductCategoryResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$category = $this->productCategoryRepository->find($id);
|
||||
|
||||
if (!$category) {
|
||||
return response()->json([
|
||||
'message' => 'Catégorie non trouvée.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return new ProductCategoryResource($category);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching product category: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'category_id' => $id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération de la catégorie.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified product category.
|
||||
*/
|
||||
public function update(UpdateProductCategoryRequest $request, string $id): ProductCategoryResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$updated = $this->productCategoryRepository->update($id, $request->validated());
|
||||
|
||||
if (!$updated) {
|
||||
return response()->json([
|
||||
'message' => 'Catégorie non trouvée ou échec de la mise à jour.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$category = $this->productCategoryRepository->find($id);
|
||||
return new ProductCategoryResource($category);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating product category: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'category_id' => $id,
|
||||
'data' => $request->validated(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la mise à jour de la catégorie.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified product category.
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Check if category can be deleted
|
||||
if (!$this->productCategoryRepository->canDelete($id)) {
|
||||
return response()->json([
|
||||
'message' => 'Impossible de supprimer cette catégorie. Elle peut avoir des sous-catégories ou des produits associés.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$deleted = $this->productCategoryRepository->delete($id);
|
||||
|
||||
if (!$deleted) {
|
||||
return response()->json([
|
||||
'message' => 'Catégorie non trouvée ou échec de la suppression.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Catégorie supprimée avec succès.',
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error deleting product category: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'category_id' => $id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la suppression de la catégorie.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active categories only
|
||||
*/
|
||||
public function active(): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$categories = $this->productCategoryRepository->getActive();
|
||||
return new ProductCategoryCollection(collect(['data' => $categories]));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching active product categories: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération des catégories actives.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root categories (no parent)
|
||||
*/
|
||||
public function roots(): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$categories = $this->productCategoryRepository->getRoots();
|
||||
return new ProductCategoryCollection(collect(['data' => $categories]));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching root product categories: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération des catégories racine.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories with their children (hierarchical structure)
|
||||
*/
|
||||
public function hierarchical(): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$categories = $this->productCategoryRepository->getWithChildren();
|
||||
return new ProductCategoryCollection(collect(['data' => $categories]));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching hierarchical product categories: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la récupération de la structure hiérarchique.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search categories by name, code or description
|
||||
*/
|
||||
public function search(Request $request): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$term = $request->get('term', '');
|
||||
$perPage = $request->get('per_page', 15);
|
||||
|
||||
if (empty($term)) {
|
||||
return response()->json([
|
||||
'message' => 'Le paramètre "term" est requis pour la recherche.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$categories = $this->productCategoryRepository->search($term, $perPage);
|
||||
|
||||
return new ProductCategoryCollection($categories);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error searching product categories: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'search_term' => $term,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la recherche des catégories.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category statistics
|
||||
*/
|
||||
public function statistics(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$stats = $this->productCategoryRepository->getStatistics();
|
||||
|
||||
return response()->json([
|
||||
'data' => $stats,
|
||||
'message' => 'Statistiques des catégories récupérées avec succès.',
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching product category 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle category active status
|
||||
*/
|
||||
public function toggleActive(Request $request, string $id): ProductCategoryResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'active' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$category = $this->productCategoryRepository->find($id);
|
||||
|
||||
if (!$category) {
|
||||
return response()->json([
|
||||
'message' => 'Catégorie non trouvée.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$updated = $this->productCategoryRepository->update($id, [
|
||||
'active' => $request->boolean('active')
|
||||
]);
|
||||
|
||||
if (!$updated) {
|
||||
return response()->json([
|
||||
'message' => 'Échec de la mise à jour du statut.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$category = $this->productCategoryRepository->find($id);
|
||||
return new ProductCategoryResource($category);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error toggling product category status: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'category_id' => $id,
|
||||
'data' => $request->all(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Une erreur est survenue lors de la mise à jour du statut.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,7 +67,22 @@ class ProductController extends Controller
|
||||
public function store(StoreProductRequest $request): ProductResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$product = $this->productRepository->create($request->validated());
|
||||
$validatedData = $request->validated();
|
||||
|
||||
// Handle image upload
|
||||
if ($request->hasFile('image')) {
|
||||
// Create product without image first
|
||||
$product = $this->productRepository->create($validatedData);
|
||||
|
||||
// Upload and attach image
|
||||
$imagePath = $product->uploadImage($request->file('image'));
|
||||
|
||||
// Refresh product to get updated data
|
||||
$product = $this->productRepository->find($product->id);
|
||||
} else {
|
||||
$product = $this->productRepository->create($validatedData);
|
||||
}
|
||||
|
||||
return new ProductResource($product);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error creating product: ' . $e->getMessage(), [
|
||||
@ -240,7 +255,29 @@ class ProductController extends Controller
|
||||
public function update(UpdateProductRequest $request, string $id): ProductResource|JsonResponse
|
||||
{
|
||||
try {
|
||||
$updated = $this->productRepository->update($id, $request->validated());
|
||||
$validatedData = $request->validated();
|
||||
$product = $this->productRepository->find($id);
|
||||
|
||||
if (!$product) {
|
||||
return response()->json([
|
||||
'message' => 'Produit non trouvé.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Handle image upload/removal
|
||||
if ($request->boolean('remove_image')) {
|
||||
// Remove existing image
|
||||
$product->deleteImage();
|
||||
} elseif ($request->hasFile('image')) {
|
||||
// Upload new image
|
||||
$product->uploadImage($request->file('image'));
|
||||
}
|
||||
|
||||
// Remove image-related fields from validated data before updating other fields
|
||||
unset($validatedData['image'], $validatedData['remove_image']);
|
||||
|
||||
// Update other product fields
|
||||
$updated = $this->productRepository->update($id, $validatedData);
|
||||
|
||||
if (!$updated) {
|
||||
return response()->json([
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreProductCategoryRequest 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 [
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'code' => 'required|string|max:64|unique:product_categories,code',
|
||||
'name' => 'required|string|max:191',
|
||||
'description' => 'nullable|string',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'parent_id.exists' => 'La catégorie parente sélectionnée n\'existe pas.',
|
||||
'code.required' => 'Le code de la catégorie est obligatoire.',
|
||||
'code.string' => 'Le code de la catégorie doit être une chaîne de caractères.',
|
||||
'code.max' => 'Le code de la catégorie ne peut pas dépasser 64 caractères.',
|
||||
'code.unique' => 'Ce code de catégorie existe déjà.',
|
||||
'name.required' => 'Le nom de la catégorie est obligatoire.',
|
||||
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.',
|
||||
'name.max' => 'Le nom de la catégorie ne peut pas dépasser 191 caractères.',
|
||||
'description.string' => 'La description doit être une chaîne de caractères.',
|
||||
'active.boolean' => 'Le statut actif doit être un booléen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -35,7 +35,8 @@ class StoreProductRequest extends FormRequest
|
||||
'conditionnement_nom' => 'nullable|string|max:191',
|
||||
'conditionnement_quantite' => 'nullable|numeric|min:0',
|
||||
'conditionnement_unite' => 'nullable|string|max:50',
|
||||
'photo_url' => 'nullable|url|max:500',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
||||
'remove_image' => 'nullable|boolean',
|
||||
'fiche_technique_url' => 'nullable|url|max:500',
|
||||
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
|
||||
];
|
||||
@ -78,8 +79,9 @@ class StoreProductRequest extends FormRequest
|
||||
'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.',
|
||||
'image.image' => 'Le fichier doit être une image valide.',
|
||||
'image.mimes' => 'L\'image doit être de type: jpeg, png, jpg, gif ou svg.',
|
||||
'image.max' => 'L\'image ne peut pas dépasser 2MB.',
|
||||
'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,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateProductCategoryRequest 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
|
||||
{
|
||||
$categoryId = $this->route('id');
|
||||
|
||||
return [
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'code' => "nullable|string|max:64|unique:product_categories,code,{$categoryId}",
|
||||
'name' => 'nullable|string|max:191',
|
||||
'description' => 'nullable|string',
|
||||
'active' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'parent_id.exists' => 'La catégorie parente sélectionnée n\'existe pas.',
|
||||
'code.string' => 'Le code de la catégorie doit être une chaîne de caractères.',
|
||||
'code.max' => 'Le code de la catégorie ne peut pas dépasser 64 caractères.',
|
||||
'code.unique' => 'Ce code de catégorie existe déjà.',
|
||||
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.',
|
||||
'name.max' => 'Le nom de la catégorie ne peut pas dépasser 191 caractères.',
|
||||
'description.string' => 'La description doit être une chaîne de caractères.',
|
||||
'active.boolean' => 'Le statut actif doit être un booléen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -21,9 +21,11 @@ class UpdateProductRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$productId = $this->route('id');
|
||||
|
||||
return [
|
||||
'nom' => 'required|string|max:255',
|
||||
'reference' => 'required|string|max:100|unique:products,reference,' . $this->product?->id,
|
||||
'reference' => "nullable",
|
||||
'categorie' => 'required|string|max:191',
|
||||
'fabricant' => 'nullable|string|max:191',
|
||||
'stock_actuel' => 'required|numeric|min:0',
|
||||
@ -35,7 +37,8 @@ class UpdateProductRequest extends FormRequest
|
||||
'conditionnement_nom' => 'nullable|string|max:191',
|
||||
'conditionnement_quantite' => 'nullable|numeric|min:0',
|
||||
'conditionnement_unite' => 'nullable|string|max:50',
|
||||
'photo_url' => 'nullable|url|max:500',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
||||
'remove_image' => 'nullable|boolean',
|
||||
'fiche_technique_url' => 'nullable|url|max:500',
|
||||
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
|
||||
];
|
||||
@ -78,8 +81,9 @@ class UpdateProductRequest extends FormRequest
|
||||
'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.',
|
||||
'image.image' => 'Le fichier doit être une image valide.',
|
||||
'image.mimes' => 'L\'image doit être de type: jpeg, png, jpg, gif ou svg.',
|
||||
'image.max' => 'L\'image ne peut pas dépasser 2MB.',
|
||||
'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,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\ProductCategory;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ProductCategoryCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($request)
|
||||
{
|
||||
if ($this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
||||
return [
|
||||
'data' => ProductCategoryResource::collection($this->collection),
|
||||
'pagination' => [
|
||||
'current_page' => $this->currentPage(),
|
||||
'from' => $this->firstItem(),
|
||||
'last_page' => $this->lastPage(),
|
||||
'per_page' => $this->perPage(),
|
||||
'to' => $this->lastItem(),
|
||||
'total' => $this->total(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => ProductCategoryResource::collection($this->collection),
|
||||
'count' => $this->collection->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\ProductCategory;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProductCategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'parent_id' => $this->parent_id,
|
||||
'code' => $this->code,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'active' => $this->active,
|
||||
'path' => $this->path,
|
||||
'has_children' => $this->hasChildren(),
|
||||
'has_products' => $this->hasProducts(),
|
||||
'children_count' => $this->children()->count(),
|
||||
'products_count' => $this->products()->count(),
|
||||
|
||||
// Parent information
|
||||
'parent' => $this->whenLoaded('parent', function () {
|
||||
return new ProductCategoryResource($this->parent);
|
||||
}),
|
||||
|
||||
// Children information
|
||||
'children' => $this->whenLoaded('children', function () {
|
||||
return ProductCategoryResource::collection($this->children);
|
||||
}),
|
||||
|
||||
// Relationships
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
@ -21,7 +22,7 @@ class Product extends Model
|
||||
'conditionnement_nom',
|
||||
'conditionnement_quantite',
|
||||
'conditionnement_unite',
|
||||
'photo_url',
|
||||
'image',
|
||||
'fiche_technique_url',
|
||||
'fournisseur_id',
|
||||
];
|
||||
@ -41,4 +42,73 @@ class Product extends Model
|
||||
{
|
||||
return $this->belongsTo(Fournisseur::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category that owns the product.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'categorie', 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle image upload
|
||||
*/
|
||||
public function uploadImage($image)
|
||||
{
|
||||
if ($image) {
|
||||
// Delete old image if exists
|
||||
if ($this->image) {
|
||||
$this->deleteImage();
|
||||
}
|
||||
|
||||
// Store the new image
|
||||
$imageName = time() . '_' . uniqid() . '.' . $image->getClientOriginalExtension();
|
||||
$imagePath = $image->storeAs('products', $imageName, 'public');
|
||||
|
||||
$this->image = $imagePath;
|
||||
$this->save();
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the product image
|
||||
*/
|
||||
public function deleteImage()
|
||||
{
|
||||
if ($this->image) {
|
||||
Storage::disk('public')->delete($this->image);
|
||||
$this->image = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full URL of the image
|
||||
*/
|
||||
public function getImageUrlAttribute()
|
||||
{
|
||||
if ($this->image) {
|
||||
return Storage::url($this->image);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::deleting(function ($product) {
|
||||
// Delete the image when the product is deleted
|
||||
$product->deleteImage();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
102
thanasoft-back/app/Models/ProductCategory.php
Normal file
102
thanasoft-back/app/Models/ProductCategory.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the parent category
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the child categories
|
||||
*/
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products in this category
|
||||
*/
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'categorie', 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant categories (recursive)
|
||||
*/
|
||||
public function descendants(): HasMany
|
||||
{
|
||||
return $this->children()->with('descendants');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hierarchical path of the category
|
||||
*/
|
||||
public function getPathAttribute(): string
|
||||
{
|
||||
$path = $this->name;
|
||||
$parent = $this->parent;
|
||||
|
||||
while ($parent) {
|
||||
$path = $parent->name . ' > ' . $path;
|
||||
$parent = $parent->parent;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get only active categories
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to get root categories (no parent)
|
||||
*/
|
||||
public function scopeRoots($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category has children
|
||||
*/
|
||||
public function hasChildren(): bool
|
||||
{
|
||||
return $this->children()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category has products
|
||||
*/
|
||||
public function hasProducts(): bool
|
||||
{
|
||||
return $this->products()->exists();
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->bind(\App\Repositories\ProductRepositoryInterface::class, function ($app) {
|
||||
return new \App\Repositories\ProductRepository($app->make(\App\Models\Product::class));
|
||||
});
|
||||
|
||||
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, function ($app) {
|
||||
return new \App\Repositories\ProductCategoryRepository($app->make(\App\Models\ProductCategory::class));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
195
thanasoft-back/app/Repositories/ProductCategoryRepository.php
Normal file
195
thanasoft-back/app/Repositories/ProductCategoryRepository.php
Normal file
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ProductCategoryRepository implements ProductCategoryRepositoryInterface
|
||||
{
|
||||
protected $model;
|
||||
|
||||
public function __construct(ProductCategory $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories with pagination
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['search']) && !empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['active']) && $filters['active'] !== null) {
|
||||
$query->where('active', $filters['active']);
|
||||
}
|
||||
|
||||
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
|
||||
$query->where('parent_id', $filters['parent_id']);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortBy = $filters['sort_by'] ?? 'name';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories
|
||||
*/
|
||||
public function all(array $filters = []): Collection
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['active']) && $filters['active'] !== null) {
|
||||
$query->where('active', $filters['active']);
|
||||
}
|
||||
|
||||
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
|
||||
$query->where('parent_id', $filters['parent_id']);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'name';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a product category by ID
|
||||
*/
|
||||
public function find(string $id): ?ProductCategory
|
||||
{
|
||||
return $this->model->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new product category
|
||||
*/
|
||||
public function create(array $data): ProductCategory
|
||||
{
|
||||
return $this->model->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product category
|
||||
*/
|
||||
public function update(string $id, array $data): bool
|
||||
{
|
||||
$category = $this->find($id);
|
||||
if ($category) {
|
||||
return $category->update($data);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product category
|
||||
*/
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$category = $this->find($id);
|
||||
if ($category) {
|
||||
return $category->delete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active categories only
|
||||
*/
|
||||
public function getActive(): Collection
|
||||
{
|
||||
return $this->model->active()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root categories (no parent)
|
||||
*/
|
||||
public function getRoots(): Collection
|
||||
{
|
||||
return $this->model->roots()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories with their children
|
||||
*/
|
||||
public function getWithChildren(): Collection
|
||||
{
|
||||
return $this->model->with('children')
|
||||
->roots()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search categories by name or code
|
||||
*/
|
||||
public function search(string $term, int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
return $this->model->where('name', 'like', "%{$term}%")
|
||||
->orWhere('code', 'like', "%{$term}%")
|
||||
->orWhere('description', 'like', "%{$term}%")
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$total = $this->model->count();
|
||||
$active = $this->model->active()->count();
|
||||
$inactive = $total - $active;
|
||||
$withChildren = $this->model->has('children')->count();
|
||||
$rootCategories = $this->model->roots()->count();
|
||||
|
||||
return [
|
||||
'total_categories' => $total,
|
||||
'active_categories' => $active,
|
||||
'inactive_categories' => $inactive,
|
||||
'categories_with_children' => $withChildren,
|
||||
'root_categories' => $rootCategories,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category can be deleted
|
||||
*/
|
||||
public function canDelete(string $id): bool
|
||||
{
|
||||
$category = $this->find($id);
|
||||
if (!$category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot delete if has children
|
||||
if ($category->hasChildren()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot delete if has products
|
||||
if ($category->hasProducts()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface ProductCategoryRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get all product categories with pagination
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all product categories
|
||||
*/
|
||||
public function all(array $filters = []): Collection;
|
||||
|
||||
/**
|
||||
* Find a product category by ID
|
||||
*/
|
||||
public function find(string $id): ?ProductCategory;
|
||||
|
||||
/**
|
||||
* Create a new product category
|
||||
*/
|
||||
public function create(array $data): ProductCategory;
|
||||
|
||||
/**
|
||||
* Update a product category
|
||||
*/
|
||||
public function update(string $id, array $data): bool;
|
||||
|
||||
/**
|
||||
* Delete a product category
|
||||
*/
|
||||
public function delete(string $id): bool;
|
||||
|
||||
/**
|
||||
* Get active categories only
|
||||
*/
|
||||
public function getActive(): Collection;
|
||||
|
||||
/**
|
||||
* Get root categories (no parent)
|
||||
*/
|
||||
public function getRoots(): Collection;
|
||||
|
||||
/**
|
||||
* Get categories with their children
|
||||
*/
|
||||
public function getWithChildren(): Collection;
|
||||
|
||||
/**
|
||||
* Search categories by name or code
|
||||
*/
|
||||
public function search(string $term, int $perPage = 15): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get category statistics
|
||||
*/
|
||||
public function getStatistics(): array;
|
||||
|
||||
/**
|
||||
* Check if category can be deleted
|
||||
*/
|
||||
public function canDelete(string $id): bool;
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?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::table('products', function (Blueprint $table) {
|
||||
// Remove the old photo_url column
|
||||
$table->dropColumn('photo_url');
|
||||
|
||||
// Add new image column for file uploads
|
||||
$table->string('image')->nullable()->after('conditionnement_unite');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Remove the image column
|
||||
$table->dropColumn('image');
|
||||
|
||||
// Restore the old photo_url column
|
||||
$table->string('photo_url')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
<?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('product_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('parent_id')->nullable();
|
||||
$table->string('code', 64);
|
||||
$table->string('name', 191);
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('parent_id')
|
||||
->references('id')
|
||||
->on('product_categories')
|
||||
->onDelete('set null');
|
||||
|
||||
$table->index('code');
|
||||
$table->index('active');
|
||||
$table->index('parent_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_categories');
|
||||
}
|
||||
};
|
||||
@ -19,5 +19,7 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->call(ProductCategorySeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
101
thanasoft-back/database/seeders/ProductCategorySeeder.php
Normal file
101
thanasoft-back/database/seeders/ProductCategorySeeder.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\ProductCategory;
|
||||
|
||||
class ProductCategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create root categories
|
||||
$electronique = ProductCategory::create([
|
||||
'code' => 'ELECTRONIQUE',
|
||||
'name' => 'Électronique',
|
||||
'description' => 'Produits électroniques et technologiques',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$alimentaire = ProductCategory::create([
|
||||
'code' => 'ALIMENTAIRE',
|
||||
'name' => 'Alimentaire',
|
||||
'description' => 'Produits alimentaires et boissons',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$medical = ProductCategory::create([
|
||||
'code' => 'MEDICAL',
|
||||
'name' => 'Médical',
|
||||
'description' => 'Produits médicaux et pharmaceutiques',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Create subcategories
|
||||
ProductCategory::create([
|
||||
'parent_id' => $electronique->id,
|
||||
'code' => 'TELEPHONE',
|
||||
'name' => 'Téléphones',
|
||||
'description' => 'Smartphones et téléphones mobiles',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'parent_id' => $electronique->id,
|
||||
'code' => 'ORDINATEUR',
|
||||
'name' => 'Ordinateurs',
|
||||
'description' => 'Ordinateurs portables et de bureau',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'parent_id' => $alimentaire->id,
|
||||
'code' => 'FRUITS',
|
||||
'name' => 'Fruits',
|
||||
'description' => 'Fruits frais et transformés',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'parent_id' => $alimentaire->id,
|
||||
'code' => 'LEGUMES',
|
||||
'name' => 'Légumes',
|
||||
'description' => 'Légumes frais et transformés',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'parent_id' => $medical->id,
|
||||
'code' => 'MEDICAMENT',
|
||||
'name' => 'Médicaments',
|
||||
'description' => 'Médicaments et traitements',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'parent_id' => $medical->id,
|
||||
'code' => 'MATERIEL',
|
||||
'name' => 'Matériel Médical',
|
||||
'description' => 'Équipements et instruments médicaux',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Create some inactive categories for testing
|
||||
ProductCategory::create([
|
||||
'code' => 'COSMETIQUE',
|
||||
'name' => 'Cosmétique',
|
||||
'description' => 'Produits cosmétiques et de beauté',
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
ProductCategory::create([
|
||||
'code' => 'MENAGE',
|
||||
'name' => 'Ménage',
|
||||
'description' => 'Produits d\'entretien et de nettoyage',
|
||||
'active' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ use App\Http\Controllers\Api\ContactController;
|
||||
use App\Http\Controllers\Api\ClientCategoryController;
|
||||
use App\Http\Controllers\Api\FournisseurController;
|
||||
use App\Http\Controllers\Api\ProductController;
|
||||
use App\Http\Controllers\Api\ProductCategoryController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -63,4 +64,13 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/products/statistics', [ProductController::class, 'statistics']);
|
||||
Route::apiResource('products', ProductController::class);
|
||||
Route::patch('/products/{id}/stock', [ProductController::class, 'updateStock']);
|
||||
|
||||
// Product Category management
|
||||
Route::get('/product-categories/search', [ProductCategoryController::class, 'search']);
|
||||
Route::get('/product-categories/active', [ProductCategoryController::class, 'active']);
|
||||
Route::get('/product-categories/roots', [ProductCategoryController::class, 'roots']);
|
||||
Route::get('/product-categories/hierarchical', [ProductCategoryController::class, 'hierarchical']);
|
||||
Route::get('/product-categories/statistics', [ProductCategoryController::class, 'statistics']);
|
||||
Route::apiResource('product-categories', ProductCategoryController::class);
|
||||
Route::patch('/product-categories/{id}/toggle-active', [ProductCategoryController::class, 'toggleActive']);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user