feat: Implement product category management and a multi-step intervention wizard with product selection, including new database migrations and CORS configuration updates.

This commit is contained in:
kevin 2026-01-05 17:21:02 +03:00
parent f9b6e5e0f6
commit 5d93f9d39a
37 changed files with 2314 additions and 2271 deletions

View File

@ -4,7 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInterventionRequest; use App\Http\Requests\StoreInterventionRequest;
use App\HttpRequests\StoreInterventionWithAllDataRequest; use App\Http\Requests\StoreInterventionWithAllDataRequest;
use App\Http\Requests\UpdateInterventionRequest; use App\Http\Requests\UpdateInterventionRequest;
use App\Http\Resources\Intervention\InterventionResource; use App\Http\Resources\Intervention\InterventionResource;
use App\Http\Resources\Intervention\InterventionCollection; use App\Http\Resources\Intervention\InterventionCollection;

View File

@ -28,7 +28,7 @@ class ProductCategoryController extends Controller
public function index(Request $request): ProductCategoryCollection|JsonResponse public function index(Request $request): ProductCategoryCollection|JsonResponse
{ {
try { try {
$perPage = $request->get('per_page', 15); $perPage = (int) $request->get('per_page', 15);
$filters = [ $filters = [
'search' => $request->get('search'), 'search' => $request->get('search'),
'active' => $request->get('active'), 'active' => $request->get('active'),
@ -249,7 +249,7 @@ class ProductCategoryController extends Controller
{ {
try { try {
$term = $request->get('term', ''); $term = $request->get('term', '');
$perPage = $request->get('per_page', 15); $perPage = (int) $request->get('per_page', 15);
if (empty($term)) { if (empty($term)) {
return response()->json([ return response()->json([

View File

@ -28,13 +28,14 @@ class ProductController extends Controller
public function index(Request $request): ProductCollection|JsonResponse public function index(Request $request): ProductCollection|JsonResponse
{ {
try { try {
$perPage = $request->get('per_page', 15); $perPage = (int) $request->get('per_page', 15);
$filters = [ $filters = [
'search' => $request->get('search'), 'search' => $request->get('search'),
'categorie' => $request->get('categorie_id'), 'categorie' => $request->get('categorie_id'),
'fournisseur_id' => $request->get('fournisseur_id'), 'fournisseur_id' => $request->get('fournisseur_id'),
'low_stock' => $request->get('low_stock'), 'low_stock' => $request->get('low_stock'),
'expiring_soon' => $request->get('expiring_soon'), 'expiring_soon' => $request->get('expiring_soon'),
'is_intervention' => $request->get('is_intervention'),
'sort_by' => $request->get('sort_by', 'created_at'), 'sort_by' => $request->get('sort_by', 'created_at'),
'sort_direction' => $request->get('sort_direction', 'desc'), 'sort_direction' => $request->get('sort_direction', 'desc'),
]; ];
@ -172,7 +173,7 @@ class ProductController extends Controller
public function lowStock(Request $request): ProductCollection|JsonResponse public function lowStock(Request $request): ProductCollection|JsonResponse
{ {
try { try {
$perPage = $request->get('per_page', 15); $perPage = (int) $request->get('per_page', 15);
$products = $this->productRepository->getLowStockProducts($perPage); $products = $this->productRepository->getLowStockProducts($perPage);
return new ProductCollection($products); return new ProductCollection($products);
@ -197,7 +198,7 @@ class ProductController extends Controller
{ {
try { try {
$categoryId = $request->get('category_id'); $categoryId = $request->get('category_id');
$perPage = $request->get('per_page', 15); $perPage = (int) $request->get('per_page', 15);
if (empty($categoryId)) { if (empty($categoryId)) {
return response()->json([ return response()->json([

View File

@ -28,6 +28,7 @@ class StoreInterventionRequest extends FormRequest
'deceased_id' => ['nullable', 'exists:deceased,id'], 'deceased_id' => ['nullable', 'exists:deceased,id'],
'order_giver' => ['nullable', 'string', 'max:255'], 'order_giver' => ['nullable', 'string', 'max:255'],
'location_id' => ['nullable', 'exists:client_locations,id'], 'location_id' => ['nullable', 'exists:client_locations,id'],
'product_id' => ['nullable', 'exists:products,id'],
'type' => ['required', Rule::in([ 'type' => ['required', Rule::in([
'thanatopraxie', 'thanatopraxie',
'toilette_mortuaire', 'toilette_mortuaire',

View File

@ -22,27 +22,12 @@ class StoreProductCategoryRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'parent_id' => 'nullable|exists:product_categories,id',
'code' => 'required|string|max:64|unique:product_categories,code', 'code' => 'required|string|max:64|unique:product_categories,code',
'name' => 'required|string|max:191', 'name' => 'required|string|max:191',
'description' => 'nullable|string', 'description' => 'nullable|string',
'parent_id' => 'nullable|exists:product_categories,id',
'intervention' => 'boolean',
'active' => 'boolean', '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.',
];
}
} }

View File

@ -28,6 +28,7 @@ class UpdateInterventionRequest extends FormRequest
'deceased_id' => ['nullable', 'exists:deceased,id'], 'deceased_id' => ['nullable', 'exists:deceased,id'],
'order_giver' => ['nullable', 'string', 'max:255'], 'order_giver' => ['nullable', 'string', 'max:255'],
'location_id' => ['nullable', 'exists:client_locations,id'], 'location_id' => ['nullable', 'exists:client_locations,id'],
'product_id' => ['nullable', 'exists:products,id'],
'type' => ['sometimes', 'required', Rule::in([ 'type' => ['sometimes', 'required', Rule::in([
'thanatopraxie', 'thanatopraxie',
'toilette_mortuaire', 'toilette_mortuaire',

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductCategoryRequest extends FormRequest class UpdateProductCategoryRequest extends FormRequest
{ {
@ -21,28 +22,27 @@ class UpdateProductCategoryRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$categoryId = $this->route('id');
return [ return [
'parent_id' => 'nullable|exists:product_categories,id', 'code' => [
'code' => "nullable|string|max:64|unique:product_categories,code,{$categoryId}", 'required',
'name' => 'nullable|string|max:191', 'string',
'max:64',
Rule::unique('product_categories')->ignore($this->route('product_category')),
],
'name' => 'required|string|max:191',
'description' => 'nullable|string', 'description' => 'nullable|string',
'active' => 'nullable|boolean', 'parent_id' => [
]; 'nullable',
} 'exists:product_categories,id',
// Prevent setting parent to itself
public function messages(): array function ($attribute, $value, $fail) {
{ if ($this->route('product_category') && $value == $this->route('product_category')->id) {
return [ $fail('The parent category cannot be the category itself.');
'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à.', 'intervention' => 'boolean',
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.', 'active' => 'boolean',
'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.',
]; ];
} }
} }

View File

@ -22,6 +22,7 @@ class ProductCategoryResource extends JsonResource
'description' => $this->description, 'description' => $this->description,
'active' => $this->active, 'active' => $this->active,
'path' => $this->path, 'path' => $this->path,
'intervention'=> $this->intervention,
'has_children' => $this->hasChildren(), 'has_children' => $this->hasChildren(),
'has_products' => $this->hasProducts(), 'has_products' => $this->hasProducts(),
'children_count' => $this->children()->count(), 'children_count' => $this->children()->count(),

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductCategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'parent_id' => $this->parent_id,
'code' => $this->code,
'name' => $this->name,
'description' => $this->description,
'intervention' => $this->intervention,
'active' => $this->active,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'parent' => new ProductCategoryResource($this->whenLoaded('parent')),
'children' => ProductCategoryResource::collection($this->whenLoaded('children')),
];
}
}

View File

@ -23,6 +23,7 @@ class Intervention extends Model
'order_giver', 'order_giver',
'location_id', 'location_id',
'type', 'type',
'product_id',
'scheduled_at', 'scheduled_at',
'duration_min', 'duration_min',
'status', 'status',
@ -41,6 +42,14 @@ class Intervention extends Model
'attachments_count' => 'integer' 'attachments_count' => 'integer'
]; ];
/**
* Get the product associated with the intervention.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/** /**
* Get the client associated with the intervention. * Get the client associated with the intervention.
*/ */

View File

@ -48,7 +48,7 @@ class Product extends Model
*/ */
public function category(): BelongsTo public function category(): BelongsTo
{ {
return $this->belongsTo(ProductCategory::class); return $this->belongsTo(ProductCategory::class, 'categorie_id');
} }
/** /**

View File

@ -2,26 +2,31 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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; use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductCategory extends Model class ProductCategory extends Model
{ {
use HasFactory;
protected $fillable = [ protected $fillable = [
'parent_id', 'parent_id',
'code', 'code',
'name', 'name',
'description', 'description',
'intervention',
'active', 'active',
]; ];
protected $casts = [ protected $casts = [
'active' => 'boolean', 'active' => 'boolean',
'intervention' => 'boolean',
]; ];
/** /**
* Get the parent category * Get the parent category.
*/ */
public function parent(): BelongsTo public function parent(): BelongsTo
{ {
@ -29,7 +34,7 @@ class ProductCategory extends Model
} }
/** /**
* Get the child categories * Get the child categories.
*/ */
public function children(): HasMany public function children(): HasMany
{ {
@ -37,39 +42,7 @@ class ProductCategory extends Model
} }
/** /**
* Get all products in this category * Scope a query to only include active categories.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class, 'categorie_id');
}
/**
* 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) public function scopeActive($query)
{ {
@ -77,7 +50,7 @@ class ProductCategory extends Model
} }
/** /**
* Scope to get root categories (no parent) * Scope a query to only include root categories.
*/ */
public function scopeRoots($query) public function scopeRoots($query)
{ {
@ -85,7 +58,7 @@ class ProductCategory extends Model
} }
/** /**
* Check if category has children * Check if the category has children.
*/ */
public function hasChildren(): bool public function hasChildren(): bool
{ {
@ -93,8 +66,16 @@ class ProductCategory extends Model
} }
/** /**
* Check if category has products * Check if the category has products.
* Note: Assuming a Product model exists with a category_id or similar relationship.
* Since the migration for products might not be linked yet, this is a placeholder or checks a relation if defined.
* For now, I will assume a products relationship exists or will be added.
*/ */
public function products(): HasMany
{
return $this->hasMany(Product::class, 'categorie_id');
}
public function hasProducts(): bool public function hasProducts(): bool
{ {
return $this->products()->exists(); return $this->products()->exists();

View File

@ -64,6 +64,8 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(\App\Repositories\InterventionPractitionerRepositoryInterface::class, \App\Repositories\InterventionPractitionerRepository::class); $this->app->bind(\App\Repositories\InterventionPractitionerRepositoryInterface::class, \App\Repositories\InterventionPractitionerRepository::class);
$this->app->bind(\App\Repositories\FileRepositoryInterface::class, \App\Repositories\FileRepository::class); $this->app->bind(\App\Repositories\FileRepositoryInterface::class, \App\Repositories\FileRepository::class);
$this->app->bind(\App\Repositories\ProductCategoryRepositoryInterface::class, \App\Repositories\ProductCategoryRepository::class);
} }
/** /**

View File

@ -5,14 +5,47 @@ namespace App\Repositories;
use App\Models\ProductCategory; use App\Models\ProductCategory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
class ProductCategoryRepository implements ProductCategoryRepositoryInterface class ProductCategoryRepository extends BaseRepository implements ProductCategoryRepositoryInterface
{ {
protected $model; /**
* ProductCategoryRepository constructor.
*
* @param ProductCategory $model
*/
public function __construct(ProductCategory $model) public function __construct(ProductCategory $model)
{ {
$this->model = $model; parent::__construct($model);
}
/**
* Get active categories only.
* Overriding or adding custom logic if needed, but BaseRepository might not have 'active' scope standard.
* Keeping it as a specific method for this repository.
*/
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();
} }
/** /**
@ -22,25 +55,27 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
{ {
$query = $this->model->newQuery(); $query = $this->model->newQuery();
// Apply filters if (!empty($filters['search'])) {
if (isset($filters['search']) && !empty($filters['search'])) {
$search = $filters['search']; $search = $filters['search'];
$query->where(function ($q) use ($search) { $query->where(function (Builder $q) use ($search) {
$q->where('name', 'like', "%{$search}%") $q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%") ->orWhere('code', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%"); ->orWhere('description', 'like', "%{$search}%");
}); });
} }
if (isset($filters['active']) && $filters['active'] !== null) { if (isset($filters['active']) && $filters['active'] !== '') {
$query->where('active', $filters['active']); $query->where('active', (bool) $filters['active']);
} }
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) { if (isset($filters['parent_id']) && $filters['parent_id'] !== '') {
$query->where('parent_id', $filters['parent_id']); if ($filters['parent_id'] === 'null') {
$query->whereNull('parent_id');
} else {
$query->where('parent_id', $filters['parent_id']);
}
} }
// Sorting
$sortBy = $filters['sort_by'] ?? 'name'; $sortBy = $filters['sort_by'] ?? 'name';
$sortDirection = $filters['sort_direction'] ?? 'asc'; $sortDirection = $filters['sort_direction'] ?? 'asc';
$query->orderBy($sortBy, $sortDirection); $query->orderBy($sortBy, $sortDirection);
@ -49,109 +84,22 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
} }
/** /**
* Get all product categories * Search categories by name or code.
*/ * BaseRepository usually has a general search, but this one is specific with 'code' and 'description'.
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 public function search(string $term, int $perPage = 15): LengthAwarePaginator
{ {
return $this->model->where('name', 'like', "%{$term}%") return $this->model->where(function ($query) use ($term) {
->orWhere('code', 'like', "%{$term}%") $query->where('name', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%") ->orWhere('code', 'like', "%{$term}%")
->orderBy('name') ->orWhere('description', 'like', "%{$term}%");
->paginate($perPage); })
->orderBy('name')
->paginate($perPage);
} }
/** /**
* Get category statistics * Get category statistics.
*/ */
public function getStatistics(): array public function getStatistics(): array
{ {
@ -171,11 +119,12 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
} }
/** /**
* Check if category can be deleted * Check if category can be deleted.
*/ */
public function canDelete(string $id): bool public function canDelete($id): bool
{ {
$category = $this->find($id); $category = $this->find($id);
if (!$category) { if (!$category) {
return false; return false;
} }

View File

@ -3,41 +3,16 @@
namespace App\Repositories; namespace App\Repositories;
use App\Models\ProductCategory; use App\Models\ProductCategory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface ProductCategoryRepositoryInterface interface ProductCategoryRepositoryInterface extends BaseRepositoryInterface
{ {
/** /**
* Get all product categories with pagination * Get all product categories with pagination
*/ */
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator; 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 * Get active categories only
*/ */

View File

@ -47,6 +47,12 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
->where('date_expiration', '>=', now()->toDateString()); ->where('date_expiration', '>=', now()->toDateString());
} }
if (isset($filters['is_intervention'])) {
$query->whereHas('category', function ($q) use ($filters) {
$q->where('intervention', filter_var($filters['is_intervention'], FILTER_VALIDATE_BOOLEAN));
});
}
// Apply sorting // Apply sorting
$sortField = $filters['sort_by'] ?? 'created_at'; $sortField = $filters['sort_by'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc'; $sortDirection = $filters['sort_direction'] ?? 'desc';

View File

@ -14,7 +14,7 @@ interface ProductRepositoryInterface extends BaseRepositoryInterface
public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false); public function searchByName(string $name, int $perPage = 15, bool $exactMatch = false);
public function getByCategory(string $category, int $perPage = 15): LengthAwarePaginator; public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator;
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator; public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator;
} }

View File

@ -9,7 +9,7 @@ return [
// IMPORTANT: Do NOT use '*' when sending credentials. List explicit origins. // IMPORTANT: Do NOT use '*' when sending credentials. List explicit origins.
// Set FRONTEND_URL in .env to override the default if needed. // Set FRONTEND_URL in .env to override the default if needed.
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8081')], 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:8081'), 'http://localhost:8080'],
// Alternatively, use patterns (kept empty for clarity) // Alternatively, use patterns (kept empty for clarity)
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],

View File

@ -0,0 +1,32 @@
<?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('product_categories', function (Blueprint $table) {
if (!Schema::hasColumn('product_categories', 'intervention')) {
$table->boolean('intervention')->default(false)->after('description');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('product_categories', function (Blueprint $table) {
if (Schema::hasColumn('product_categories', 'intervention')) {
$table->dropColumn('intervention');
}
});
}
};

View File

@ -0,0 +1,35 @@
<?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) {
if (Schema::hasColumn('products', 'categorie')) {
$table->dropColumn('categorie');
}
// Ensure categorie_id exists and is FK (It should be from previous migration)
// If strictly needed we could check !Schema::hasColumn('products', 'categorie_id') but we know it failed on that.
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
if (!Schema::hasColumn('products', 'categorie')) {
$table->string('categorie')->nullable();
}
});
}
};

View File

@ -0,0 +1,30 @@
<?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('interventions', function (Blueprint $table) {
$table->foreignId('product_id')->nullable()->after('location_id')
->constrained('products')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interventions', function (Blueprint $table) {
$table->dropForeign(['product_id']);
$table->dropColumn('product_id');
});
}
};

View File

@ -0,0 +1,231 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Informations du Client</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom du client *</label>
<input
v-model="formData.name"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('name') }"
placeholder="Nom du client"
required
maxlength="255"
/>
<div v-if="getFieldError('name')" class="invalid-feedback">
{{ getFieldError("name") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email</label>
<input
v-model="formData.email"
type="email"
class="form-control"
:class="{ 'is-invalid': hasError('email') }"
placeholder="email@example.com"
maxlength="191"
/>
<div v-if="getFieldError('email')" class="invalid-feedback">
{{ getFieldError("email") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone</label>
<input
v-model="formData.phone"
type="tel"
class="form-control"
placeholder="+261 XX XX XXX XX"
maxlength="50"
/>
<div v-if="getFieldError('phone')" class="invalid-feedback">
{{ getFieldError("phone") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Numéro TVA</label>
<input
v-model="formData.vat_number"
type="text"
class="form-control"
placeholder="Numéro TVA"
maxlength="32"
/>
<div v-if="getFieldError('vat_number')" class="invalid-feedback">
{{ getFieldError("vat_number") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">SIRET</label>
<input
v-model="formData.siret"
type="text"
class="form-control"
placeholder="SIRET"
maxlength="20"
/>
<div v-if="getFieldError('siret')" class="invalid-feedback">
{{ getFieldError("siret") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Adresse</label>
<input
v-model="formData.billing_address_line1"
type="text"
class="form-control"
:class="{
'is-invalid': hasError('billing_address_line1'),
}"
placeholder="Adresse complète"
maxlength="255"
/>
<div
v-if="getFieldError('billing_address_line1')"
class="invalid-feedback"
>
{{ getFieldError("billing_address_line1") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Complément d'adresse</label>
<input
v-model="formData.billing_address_line2"
type="text"
class="form-control"
:class="{
'is-invalid': hasError('billing_address_line2'),
}"
placeholder="Appartement, étage, etc."
maxlength="255"
/>
<div
v-if="getFieldError('billing_address_line2')"
class="invalid-feedback"
>
{{ getFieldError("billing_address_line2") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Ville</label>
<input
v-model="formData.billing_city"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('billing_city') }"
placeholder="Ville"
maxlength="191"
/>
<div v-if="getFieldError('billing_city')" class="invalid-feedback">
{{ getFieldError("billing_city") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Code postal</label>
<input
v-model="formData.billing_postal_code"
type="text"
class="form-control"
:class="{
'is-invalid': hasError('billing_postal_code'),
}"
placeholder="Code postal"
maxlength="20"
/>
<div v-if="getFieldError('billing_postal_code')" class="invalid-feedback">
{{ getFieldError("billing_postal_code") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Code pays</label>
<input
v-model="formData.billing_country_code"
type="text"
class="form-control"
:class="{
'is-invalid': hasError('billing_country_code'),
}"
placeholder="MG"
maxlength="2"
/>
<div
v-if="getFieldError('billing_country_code')"
class="invalid-feedback"
>
{{ getFieldError("billing_country_code") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control"
rows="3"
placeholder="Notes sur le client..."
></textarea>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" @click="$emit('prev')">
<i class="fas fa-arrow-left me-2"></i>
Précédent
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
Validation...
</span>
<span v-else>
Suivant
<i class="fas fa-arrow-right ms-2"></i>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
validating: {
type: Boolean,
default: false,
},
});
defineEmits(["next", "prev"]);
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -0,0 +1,153 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Informations du Défunt</h6>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Prénom</label>
<input
v-model="formData.first_name"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('first_name') }"
placeholder="Prénom du défunt"
maxlength="191"
/>
<div v-if="getFieldError('first_name')" class="invalid-feedback">
{{ getFieldError("first_name") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom *</label>
<input
v-model="formData.last_name"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('last_name') }"
placeholder="Nom du défunt"
required
maxlength="191"
/>
<div v-if="getFieldError('last_name')" class="invalid-feedback">
{{ getFieldError("last_name") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Date de naissance</label>
<input
v-model="formData.birth_date"
type="date"
class="form-control"
:class="{ 'is-invalid': hasError('birth_date') }"
/>
<div v-if="getFieldError('birth_date')" class="invalid-feedback">
{{ getFieldError("birth_date") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Date de décès</label>
<input
v-model="formData.death_date"
type="date"
class="form-control"
:class="{ 'is-invalid': hasError('death_date') }"
/>
<div v-if="getFieldError('death_date')" class="invalid-feedback">
{{ getFieldError("death_date") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Lieu de décès</label>
<input
v-model="formData.place_of_death"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('place_of_death') }"
placeholder="Lieu de décès"
maxlength="255"
/>
<div v-if="getFieldError('place_of_death')" class="invalid-feedback">
{{ getFieldError("place_of_death") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Genre</label>
<select v-model="formData.gender" class="form-select">
<option value="">Sélectionner</option>
<option value="male">Homme</option>
<option value="female">Femme</option>
<option value="other">Autre</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control"
:class="{ 'is-invalid': hasError('notes') }"
rows="3"
placeholder="Notes supplémentaires..."
></textarea>
<div v-if="getFieldError('notes')" class="invalid-feedback">
{{ getFieldError("notes") }}
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
Validation...
</span>
<span v-else>
Suivant
<i class="fas fa-arrow-right ms-2"></i>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, toRefs } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
validating: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["next"]);
// Error helpers using props
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -0,0 +1,161 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Documents à joindre</h6>
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Certificat de décès</label>
<input
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('death_certificate') }"
@change="handleFileUpload($event, 'death_certificate')"
accept=".pdf,.jpg,.jpeg,.png"
/>
<small class="text-muted">
Formats acceptés: PDF, JPG, PNG (Max 5MB)
</small>
<div
v-if="getFieldError('death_certificate')"
class="invalid-feedback"
>
{{ getFieldError("death_certificate") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Autorisation de soins</label>
<input
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('care_authorization') }"
@change="handleFileUpload($event, 'care_authorization')"
accept=".pdf,.jpg,.jpeg,.png"
/>
<div
v-if="getFieldError('care_authorization')"
class="invalid-feedback"
>
{{ getFieldError("care_authorization") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Pièce d'identité</label>
<input
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('identity_document') }"
@change="handleFileUpload($event, 'identity_document')"
accept=".pdf,.jpg,.jpeg,.png"
/>
<div
v-if="getFieldError('identity_document')"
class="invalid-feedback"
>
{{ getFieldError("identity_document") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Autres documents</label>
<input
type="file"
class="form-control"
:class="{ 'is-invalid': hasError('other_documents') }"
@change="handleFileUpload($event, 'other_documents')"
accept=".pdf,.jpg,.jpeg,.png"
multiple
/>
<div
v-if="getFieldError('other_documents')"
class="invalid-feedback"
>
{{ getFieldError("other_documents") }}
</div>
</div>
<div v-if="uploadedFiles.length > 0" class="col-md-12 mb-3">
<h6 class="text-sm">Documents ajoutés:</h6>
<ul class="list-group">
<li
v-for="(file, index) in uploadedFiles"
:key="index"
class="list-group-item d-flex justify-content-between align-items-center"
>
<span>
<i class="fas fa-file me-2"></i>
{{ file.name }}
</span>
<button
type="button"
class="btn btn-sm btn-danger"
@click="$emit('remove-file', index)"
>
<i class="fas fa-trash"></i>
</button>
</li>
</ul>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" @click="$emit('prev')">
<i class="fas fa-arrow-left me-2"></i>
Précédent
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
Validation...
</span>
<span v-else>
Suivant
<i class="fas fa-arrow-right ms-2"></i>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
uploadedFiles: {
type: Array,
default: () => [],
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
validating: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["next", "prev", "file-upload", "remove-file"]);
const handleFileUpload = (event, type) => {
emit("file-upload", event, type);
};
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -0,0 +1,175 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Détails de l'intervention</h6>
<div class="row">
<!--
Deprecated: Type is now handled in StepProductSelection,
but kept here hidden or read-only if needed for strict compatibility,
or we can remove it as it's redundant with the new step.
For now, I'll remove the select and perhaps display the selected product type.
-->
<div class="col-md-12 mb-3">
<label class="form-label">Type d'intervention (sélectionné)</label>
<input
type="text"
class="form-control"
:value="formData.type"
disabled
/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Date et heure *</label>
<input
v-model="formData.scheduled_at"
type="datetime-local"
class="form-control"
:class="{ 'is-invalid': hasError('scheduled_at') }"
required
/>
<div v-if="getFieldError('scheduled_at')" class="invalid-feedback">
{{ getFieldError("scheduled_at") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Durée estimée (minutes)</label>
<input
v-model.number="formData.duration_min"
type="number"
class="form-control"
:class="{ 'is-invalid': hasError('duration_min') }"
placeholder="Ex: 60"
min="0"
/>
<div v-if="getFieldError('duration_min')" class="invalid-feedback">
{{ getFieldError("duration_min") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Statut</label>
<select v-model="formData.status" class="form-select">
<option value="demande">Demande</option>
<option value="planifie">Planifiée</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminée</option>
<option value="annule">Annulée</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Thanatopracteur</label>
<select
v-model="formData.assigned_practitioner_id"
class="form-select"
>
<option value="">Sélectionner un thanatopracteur</option>
<option
v-for="practitioner in practitioners"
:key="practitioner.employee_id"
:value="practitioner.id"
>
{{ practitioner.employee?.first_name }}
{{ practitioner.employee?.last_name }}
</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Donneur d'ordre</label>
<input
v-model="formData.order_giver"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('order_giver') }"
placeholder="Nom du donneur d'ordre"
maxlength="255"
/>
<div v-if="getFieldError('order_giver')" class="invalid-feedback">
{{ getFieldError("order_giver") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control"
:class="{ 'is-invalid': hasError('notes') }"
rows="3"
placeholder="Notes et détails de l'intervention..."
></textarea>
<div v-if="getFieldError('notes')" class="invalid-feedback">
{{ getFieldError("notes") }}
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" @click="$emit('prev')">
<i class="fas fa-arrow-left me-2"></i>
Précédent
</button>
<button
type="submit"
class="btn bg-gradient-success"
:disabled="submitting"
>
<span v-if="submitting">
<i class="fas fa-spinner fa-spin me-2"></i>
Enregistrement...
</span>
<span v-else>
<i class="fas fa-check me-2"></i>
{{ isEditing ? "Mettre à jour" : "Créer l'intervention" }}
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
practitioners: {
type: Array,
default: () => [],
},
isEditing: {
type: Boolean,
default: false,
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
submitting: {
type: Boolean,
default: false,
},
});
defineEmits(["prev"]); // Submit is handled by form @submit in parent or here?
// The parent form tag wraps all panels.
// If this button is type="submit", it submits the parent form.
// But wait, the parent sends 'handleSubmit' on form submit.
// So this button just triggers the submit event of the form.
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -0,0 +1,154 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Lieu de l'intervention</h6>
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Nom du lieu</label>
<input
v-model="formData.name"
type="text"
class="form-control"
placeholder="Ex: Hôpital, Domicile, Funérarium..."
maxlength="255"
/>
<div v-if="getFieldError('name')" class="invalid-feedback">
{{ getFieldError("name") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Adresse</label>
<input
v-model="formData.address"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('address') }"
placeholder="Adresse complète"
maxlength="255"
/>
<div v-if="getFieldError('address')" class="invalid-feedback">
{{ getFieldError("address") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Ville</label>
<input
v-model="formData.city"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('city') }"
placeholder="Ville"
maxlength="191"
/>
<div v-if="getFieldError('city')" class="invalid-feedback">
{{ getFieldError("city") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Code postal</label>
<input
v-model="formData.postal_code"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('postal_code') }"
placeholder="Code postal"
maxlength="20"
/>
<div v-if="getFieldError('postal_code')" class="invalid-feedback">
{{ getFieldError("postal_code") }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Code pays</label>
<input
v-model="formData.country_code"
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('country_code') }"
placeholder="MG"
maxlength="2"
/>
<div v-if="getFieldError('country_code')" class="invalid-feedback">
{{ getFieldError("country_code") }}
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Instructions d'accès</label>
<textarea
v-model="formData.access_instructions"
class="form-control"
rows="3"
placeholder="Informations pour accéder au lieu..."
></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Notes</label>
<textarea
v-model="formData.notes"
class="form-control"
rows="3"
placeholder="Notes sur le lieu..."
></textarea>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" @click="$emit('prev')">
<i class="fas fa-arrow-left me-2"></i>
Précédent
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
Validation...
</span>
<span v-else>
Suivant
<i class="fas fa-arrow-right ms-2"></i>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
validating: {
type: Boolean,
default: false,
},
});
defineEmits(["next", "prev"]);
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
const hasError = (field) => {
return props.errors.some((err) => err.field === field);
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="multisteps-form__panel" :class="isActive ? activeClass : ''">
<h6 class="mb-3 text-dark font-weight-bold">Sélection du Produit</h6>
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Type de produit *</label>
<div class="d-flex flex-column gap-2">
<div v-if="loading" class="text-center py-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div v-else-if="interventionProducts.length === 0" class="text-center py-3 text-muted">
Aucun produit d'intervention trouvé.
</div>
<div
v-else
v-for="product in interventionProducts"
:key="product.id"
class="form-check p-3 border rounded"
:class="{ 'border-primary bg-light': formData.product_id === product.id }"
>
<input
class="form-check-input"
type="radio"
name="productSelection"
:id="'product-' + product.id"
:value="product.id"
v-model="formData.product_id"
/>
<label class="form-check-label fw-bold w-100" :for="'product-' + product.id">
{{ product.nom }}
<div class="text-muted fw-normal small">
{{ product.description || product.reference }}
</div>
</label>
</div>
</div>
<div v-if="getFieldError('product_type')" class="invalid-feedback d-block">
{{ getFieldError("product_type") }}
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" @click="$emit('prev')">
<i class="fas fa-arrow-left me-2"></i>
Précédent
</button>
<button
type="button"
class="btn bg-gradient-primary"
@click="$emit('next')"
:disabled="validating"
>
<span v-if="validating">
<i class="fas fa-spinner fa-spin me-2"></i>
Validation...
</span>
<span v-else>
Suivant
<i class="fas fa-arrow-right ms-2"></i>
</span>
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, onMounted, ref } from "vue";
import { useProductStore } from "@/stores/productStore";
const props = defineProps({
formData: {
type: Object,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
activeClass: {
type: String,
default: "js-active",
},
errors: {
type: Array,
default: () => [],
},
validating: {
type: Boolean,
default: false,
},
});
defineEmits(["next", "prev"]);
const productStore = useProductStore();
const interventionProducts = ref([]);
const loading = ref(false);
onMounted(async () => {
loading.value = true;
try {
// Fetch products that belong to intervention categories
const response = await productStore.fetchProducts({ is_intervention: true, per_page: 50 });
interventionProducts.value = response.data;
} catch (error) {
console.error("Error fetching intervention products:", error);
} finally {
loading.value = false;
}
});
const getFieldError = (field) => {
const error = props.errors.find((err) => err.field === field);
return error ? error.message : "";
};
</script>

View File

@ -0,0 +1,41 @@
<template>
<div class="row">
<div class="col-12 mx-auto mb-4">
<div class="multisteps-form__progress">
<button
v-for="(step, index) in steps"
:key="index"
class="multisteps-form__progress-btn position-relative"
type="button"
:title="step.title"
:class="activeStep >= index ? activeClass : ''"
@click="$emit('step-click', index)"
>
<span>{{ index + 1 }}. {{ step.label }}</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
defineProps({
steps: {
type: Array,
required: true,
// Expected format: [{ label: 'Défunt', title: 'Défunt' }, ...]
},
activeStep: {
type: Number,
default: 0,
},
activeClass: {
type: String,
default: "js-active",
},
});
defineEmits(["step-click"]);
</script>

View File

@ -0,0 +1,119 @@
<template>
<div class="card mb-4">
<div class="card-header pb-0">
<h6>Liste des catégories</h6>
</div>
<div class="card-body px-0 pt-0 pb-2">
<div class="table-responsive p-0">
<table class="table align-items-center mb-0">
<thead>
<tr>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7"
>
Code
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Nom
</th>
<th
class="text-uppercase text-secondary text-xxs font-weight-bolder opacity-7 ps-2"
>
Parent
</th>
<th
class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7"
>
Statut
</th>
<th
class="text-center text-uppercase text-secondary text-xxs font-weight-bolder opacity-7"
>
Intervention
</th>
<th class="text-secondary opacity-7"></th>
</tr>
</thead>
<tbody>
<tr v-for="category in categories" :key="category.id">
<td>
<div class="d-flex px-3 py-1">
<div class="d-flex flex-column justify-content-center">
<h6 class="mb-0 text-sm">{{ category.code }}</h6>
</div>
</div>
</td>
<td>
<p class="text-xs font-weight-bold mb-0">{{ category.name }}</p>
<p class="text-xs text-secondary mb-0" v-if="category.description">
{{ category.description }}
</p>
</td>
<td>
<p class="text-xs font-weight-bold mb-0">
{{ category.parent ? category.parent.name : "-" }}
</p>
</td>
<td class="align-middle text-center text-sm">
<span
class="badge badge-sm"
:class="category.active ? 'bg-gradient-success' : 'bg-gradient-secondary'"
>
{{ category.active ? "Actif" : "Inactif" }}
</span>
</td>
<td class="align-middle text-center text-sm">
<span
class="badge badge-sm"
:class="category.intervention ? 'bg-gradient-info' : 'bg-gradient-light text-dark'"
>
{{ category.intervention ? "Oui" : "Non" }}
</span>
</td>
<td class="align-middle">
<a
href="javascript:;"
class="text-secondary font-weight-bold text-xs me-3"
data-toggle="tooltip"
data-original-title="Edit user"
@click="$emit('edit', category)"
>
Edit
</a>
<a
href="javascript:;"
class="text-secondary font-weight-bold text-xs text-danger"
data-toggle="tooltip"
data-original-title="Delete user"
@click="$emit('delete', category.id)"
>
Delete
</a>
</td>
</tr>
<tr v-if="categories.length === 0">
<td colspan="6" class="text-center py-4">
<p class="text-sm mb-0">Aucune catégorie trouvée.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
defineProps({
categories: {
type: Array,
default: () => [],
},
});
defineEmits(["edit", "delete"]);
</script>

View File

@ -0,0 +1,232 @@
<template>
<div
class="modal fade"
id="productCategoryModal"
tabindex="-1"
role="dialog"
aria-labelledby="productCategoryModalLabel"
aria-hidden="true"
ref="modalRef"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="productCategoryModalLabel">
{{ isEditing ? "Modifier la catégorie" : "Nouvelle catégorie" }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSubmit">
<div class="mb-3">
<label for="categoryCode" class="form-label">Code</label>
<input
type="text"
class="form-control"
id="categoryCode"
v-model="form.code"
required
:disabled="isEditing"
placeholder="Ex: SOINS, URNE"
/>
</div>
<div class="mb-3">
<label for="categoryName" class="form-label">Nom</label>
<input
type="text"
class="form-control"
id="categoryName"
v-model="form.name"
required
placeholder="Ex: Soins de conservation"
/>
</div>
<div class="mb-3">
<label for="parentCategory" class="form-label"
>Catégorie Parente</label
>
<select
class="form-select"
id="parentCategory"
v-model="form.parent_id"
>
<option :value="null">Aucune (Racine)</option>
<option
v-for="category in availableParents"
:key="category.id"
:value="category.id"
>
{{ category.company_name || category.name }}
</option>
</select>
</div>
<div class="mb-3">
<label for="categoryDescription" class="form-label"
>Description</label
>
<textarea
class="form-control"
id="categoryDescription"
v-model="form.description"
rows="3"
></textarea>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
id="interventionSwitch"
v-model="form.intervention"
/>
<label class="form-check-label" for="interventionSwitch"
>Lié à une intervention ?</label
>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
id="activeSwitch"
v-model="form.active"
/>
<label class="form-check-label" for="activeSwitch">Actif</label>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
@click="handleSubmit"
:disabled="loading"
>
{{
loading
? "Enregistrement..."
: isEditing
? "Mettre à jour"
: "Créer"
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, defineProps, defineEmits, watch, defineExpose } from "vue";
import { Modal } from "bootstrap";
const props = defineProps({
category: {
type: Object,
default: null,
},
allCategories: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["save", "close"]);
const modalRef = ref(null);
let modalInstance = null;
const form = ref({
id: null,
code: "",
name: "",
parent_id: null,
description: "",
intervention: false,
active: true,
});
const isEditing = computed(() => !!props.category);
// Filter out self and children from parent selection to avoid loops
const availableParents = computed(() => {
if (!isEditing.value || !form.value.id) return props.allCategories;
return props.allCategories.filter(c => c.id !== form.value.id); // Simple check, ideally check recursive children
});
const resetForm = () => {
form.value = {
id: null,
code: "",
name: "",
parent_id: null,
description: "",
intervention: false,
active: true,
};
};
watch(
() => props.category,
(newVal) => {
if (newVal) {
form.value = { ...newVal };
} else {
resetForm();
}
},
{ immediate: true }
);
const show = () => {
if (modalInstance) {
modalInstance.show();
}
};
const hide = () => {
if (modalInstance) {
modalInstance.hide();
}
};
const handleSubmit = () => {
emit("save", form.value);
};
onMounted(() => {
if (modalRef.value) {
modalInstance = new Modal(modalRef.value);
modalRef.value.addEventListener("hidden.bs.modal", () => {
emit("close");
resetForm();
});
}
});
defineExpose({
show,
hide,
});
</script>
<style scoped>
/* Optional styling */
</style>

View File

@ -48,7 +48,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="reference" class="form-label" <label for="reference" class="form-label"
>Référence *</label >Référence *</label
> >
@ -78,33 +78,31 @@
> >
<select <select
id="categorie" id="categorie"
v-model="form.categorie" v-model="form.categorie_id"
class="form-control" class="form-control"
:class="{ 'is-invalid': validationErrors.categorie }" :class="{ 'is-invalid': validationErrors.categorie_id }"
required required
> >
<option value="">Sélectionnez une catégorie</option> <option value="">Sélectionnez une catégorie</option>
<option value="Alimentaire">Alimentaire</option> <option
<option value="Médical">Médical</option> v-for="category in categories"
<option value="Cosmétique">Cosmétique</option> :key="category.id"
<option value="Ménage">Ménage</option> :value="category.id"
<option value="Électronique">Électronique</option> >
<option value="Vêtements">Vêtements</option> {{ category.name }}
<option value="Jouets">Jouets</option> </option>
<option value="Livre">Livre</option>
<option value="Autre">Autre</option>
</select> </select>
<div <div
v-if="validationErrors.categorie" v-if="validationErrors.categorie_id"
class="invalid-feedback" class="invalid-feedback"
> >
{{ validationErrors.categorie[0] }} {{ validationErrors.categorie_id[0] }}
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="fabricant" class="form-label">Fabricant</label> <label for="fabricant" class="form-label">Fabricant</label>
<soft-input <soft-input
id="fabricant" id="fabricant"
@ -126,7 +124,7 @@
<!-- Stock Information --> <!-- Stock Information -->
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="stock_actuel" class="form-label" <label for="stock_actuel" class="form-label"
>Stock Actuel *</label >Stock Actuel *</label
> >
@ -150,7 +148,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="stock_minimum" class="form-label" <label for="stock_minimum" class="form-label"
>Stock Minimum *</label >Stock Minimum *</label
> >
@ -173,7 +171,7 @@
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4" v-if="!isIntervention">
<div class="form-group"> <div class="form-group">
<label for="unite" class="form-label">Unité *</label> <label for="unite" class="form-label">Unité *</label>
<select <select
@ -234,7 +232,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="date_expiration" class="form-label" <label for="date_expiration" class="form-label"
>Date d'Expiration</label >Date d'Expiration</label
> >
@ -260,7 +258,7 @@
<!-- Lot Number --> <!-- Lot Number -->
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="numero_lot" class="form-label" <label for="numero_lot" class="form-label"
>Numéro de Lot</label >Numéro de Lot</label
> >
@ -281,7 +279,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group" v-if="!isIntervention">
<label for="fournisseur_id" class="form-label" <label for="fournisseur_id" class="form-label"
>Fournisseur</label >Fournisseur</label
> >
@ -311,7 +309,7 @@
</div> </div>
<!-- Packaging Information --> <!-- Packaging Information -->
<div class="row"> <div class="row" v-if="!isIntervention">
<div class="col-md-4"> <div class="col-md-4">
<div class="form-group"> <div class="form-group">
<label for="conditionnement_nom" class="form-label" <label for="conditionnement_nom" class="form-label"
@ -385,7 +383,7 @@
</div> </div>
<!-- URLs --> <!-- URLs -->
<div class="row"> <div class="row" v-if="!isIntervention">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group">
<label for="photo_url" class="form-label" <label for="photo_url" class="form-label"
@ -482,6 +480,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
categories: {
type: Array,
default: () => [],
},
success: { success: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -495,7 +497,7 @@ const emit = defineEmits(["create-product"]);
const form = reactive({ const form = reactive({
nom: "", nom: "",
reference: "", reference: "",
categorie: "", categorie_id: "",
fabricant: "", fabricant: "",
stock_actuel: 0, stock_actuel: 0,
stock_minimum: 0, stock_minimum: 0,
@ -511,6 +513,56 @@ const form = reactive({
fournisseur_id: "", fournisseur_id: "",
}); });
// Computed property to check if selected category is an intervention
import { computed, watch } from "vue";
const isIntervention = computed(() => {
const selectedCategory = props.categories.find(
(c) => c.id === form.categorie_id
);
return selectedCategory ? selectedCategory.intervention : false;
});
// Watch logic for intervention categories
watch(
() => form.categorie_id,
(newVal) => {
if (isIntervention.value) {
form.stock_actuel = 1;
form.stock_minimum = 1;
form.unite = "pièce";
// Clear or set defaults for hidden fields if needed
form.fabricant = "";
form.date_expiration = "";
form.numero_lot = "";
form.conditionnement_nom = "";
form.conditionnement_quantite = 0;
form.conditionnement_unite = "";
form.photo_url = "";
form.fiche_technique_url = "";
form.fournisseur_id = "";
}
}
);
// Auto-generate reference for intervention products
watch(
() => form.nom,
(newVal) => {
if (isIntervention.value && newVal) {
form.reference = generateReference(newVal);
}
}
);
const generateReference = (name) => {
const prefix = name.substring(0, 3).toUpperCase();
const randomSuffix = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, "0");
return `${prefix}-${randomSuffix}`;
};
// Methods // Methods
const handleSubmit = () => { const handleSubmit = () => {
// Clean up the form data // Clean up the form data

View File

@ -374,6 +374,12 @@ export default {
miniIcon: "M", miniIcon: "M",
text: "Gestion des modèles", text: "Gestion des modèles",
}, },
{
id: "categories-produits",
route: { name: "Product Categories" },
miniIcon: "C",
text: "Catégories Produits",
},
], ],
}, },
]; ];

View File

@ -616,6 +616,12 @@ const routes = [
name: "Gestion modeles", name: "Gestion modeles",
component: () => import("@/views/pages/Parametrage/Modeles.vue"), component: () => import("@/views/pages/Parametrage/Modeles.vue"),
}, },
{
path: "/parametrage/categories-produits",
name: "Product Categories",
component: () =>
import("@/views/pages/Parametrage/ProductCategories.vue"),
},
]; ];
const router = createRouter({ const router = createRouter({

View File

@ -1,510 +1,144 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import ProductCategoryService, {
import ProductCategoryService from "@/services/productCategory";
import type {
ProductCategory, ProductCategory,
ProductCategoryFormData,
ProductCategoryListResponse, ProductCategoryListResponse,
} from "@/services/productCategory"; } from "@/services/productCategory";
// Create an instance of productCategoryService // Create an instance of the service
const productCategoryService = new ProductCategoryService(); const productCategoryService = new ProductCategoryService();
export const useProductCategoryStore = defineStore("productCategory", () => { interface Meta {
// State current_page: number;
const productCategories = ref<ProductCategory[]>([]); last_page: number;
const currentProductCategory = ref<ProductCategory | null>(null); per_page: number;
const loading = ref(false); total: number;
const error = ref<string | null>(null); from: number;
to: number;
}
// Pagination state export const useProductCategoryStore = defineStore("productCategory", {
const pagination = ref({ state: () => ({
current_page: 1, categories: [] as ProductCategory[],
last_page: 1, currentCategory: null as ProductCategory | null,
per_page: 10, loading: false,
total: 0, isLoading: false,
}); error: null as string | null,
meta: {
// Statistics state
const statistics = ref({
total_categories: 0,
active_categories: 0,
inactive_categories: 0,
categories_with_children: 0,
root_categories: 0,
});
// Getters
const allProductCategories = computed(() => productCategories.value);
const activeProductCategories = computed(() =>
productCategories.value.filter((category) => category.active)
);
const inactiveProductCategories = computed(() =>
productCategories.value.filter((category) => !category.active)
);
const rootProductCategories = computed(() =>
productCategories.value.filter((category) => !category.parent_id)
);
const parentProductCategories = computed(() =>
productCategories.value.filter((category) => category.has_children)
);
const isLoading = computed(() => loading.value);
const hasError = computed(() => error.value !== null);
const getError = computed(() => error.value);
const getProductCategoryById = computed(() => (id: number) =>
productCategories.value.find((category) => category.id === id)
);
const getPagination = computed(() => pagination.value);
const getStatistics = computed(() => statistics.value);
// Actions
const setLoading = (isLoading: boolean) => {
loading.value = isLoading;
};
const setError = (err: string | null) => {
error.value = err;
};
const clearError = () => {
error.value = null;
};
const setProductCategories = (newProductCategories: ProductCategory[]) => {
productCategories.value = newProductCategories;
};
const setCurrentProductCategory = (category: ProductCategory | null) => {
currentProductCategory.value = category;
};
const setPagination = (meta: any) => {
if (meta) {
pagination.value = {
current_page: meta.current_page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || 10,
total: meta.total || 0,
};
}
};
const setStatistics = (stats: any) => {
if (stats) {
statistics.value = {
total_categories: stats.total_categories || 0,
active_categories: stats.active_categories || 0,
inactive_categories: stats.inactive_categories || 0,
categories_with_children: stats.categories_with_children || 0,
root_categories: stats.root_categories || 0,
};
}
};
/**
* Récupérer toutes les catégories de produits avec pagination et filtres optionnels
*/
const fetchProductCategories = async (params?: {
page?: number;
per_page?: number;
search?: string;
active?: boolean;
parent_id?: number;
sort_by?: string;
sort_direction?: "asc" | "desc";
}) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getAllProductCategories(
params
);
setProductCategories(response.data);
if (response.pagination) {
setPagination(response.pagination);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories de produits";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer une seule catégorie de produit par ID
*/
const fetchProductCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getProductCategory(id);
setCurrentProductCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Créer une nouvelle catégorie de produit
*/
const createProductCategory = async (payload: ProductCategoryFormData) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.createProductCategory(
payload
);
// Ajouter la nouvelle catégorie à la liste
productCategories.value.push(response.data);
setCurrentProductCategory(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la création de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Mettre à jour une catégorie de produit existante
*/
const updateProductCategory = async (
payload: ProductCategoryFormData & { id: number }
) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.updateProductCategory(
payload.id,
payload
);
const updatedCategory = response.data;
// Mettre à jour dans la liste des catégories
const index = productCategories.value.findIndex(
(category) => category.id === updatedCategory.id
);
if (index !== -1) {
productCategories.value[index] = updatedCategory;
}
// Mettre à jour la catégorie actuelle si c'est celle en cours d'édition
if (
currentProductCategory.value &&
currentProductCategory.value.id === updatedCategory.id
) {
setCurrentProductCategory(updatedCategory);
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Supprimer une catégorie de produit
*/
const deleteProductCategory = async (id: number) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.deleteProductCategory(id);
// Retirer de la liste des catégories
productCategories.value = productCategories.value.filter(
(category) => category.id !== id
);
// Effacer la catégorie actuelle si c'est celle en cours de suppression
if (
currentProductCategory.value &&
currentProductCategory.value.id === id
) {
setCurrentProductCategory(null);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la suppression de la catégorie de produit";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Rechercher des catégories de produits
*/
const searchProductCategories = async (
term: string,
params?: {
per_page?: number;
}
) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.searchProductCategories(
term,
params
);
setProductCategories(response.data);
if (response.pagination) {
setPagination(response.pagination);
}
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la recherche de catégories de produits";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer uniquement les catégories actives
*/
const fetchActiveProductCategories = async (params?: {
per_page?: number;
}) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getActiveProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories actives";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les catégories racine
*/
const fetchRootProductCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getRootProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des catégories racine";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer la structure hiérarchique
*/
const fetchHierarchicalProductCategories = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getHierarchicalProductCategories();
setProductCategories(response.data);
return response;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement de la structure hiérarchique";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Récupérer les statistiques des catégories
*/
const fetchProductCategoryStatistics = async () => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.getProductCategoryStatistics();
setStatistics(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec du chargement des statistiques";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Basculer le statut actif/inactif d'une catégorie
*/
const toggleProductCategoryStatus = async (id: number, active: boolean) => {
setLoading(true);
setError(null);
try {
const response = await productCategoryService.toggleProductCategoryStatus(
id,
active
);
const updatedCategory = response.data;
// Mettre à jour dans la liste
const index = productCategories.value.findIndex(
(category) => category.id === updatedCategory.id
);
if (index !== -1) {
productCategories.value[index] = updatedCategory;
}
return updatedCategory;
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.message ||
"Échec de la mise à jour du statut";
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
/**
* Réinitialiser l'état
*/
const resetState = () => {
productCategories.value = [];
currentProductCategory.value = null;
loading.value = false;
error.value = null;
pagination.value = {
current_page: 1, current_page: 1,
last_page: 1, last_page: 1,
per_page: 10, per_page: 15,
total: 0, total: 0,
}; from: 1,
statistics.value = { to: 0,
total_categories: 0, } as Meta,
active_categories: 0, }),
inactive_categories: 0,
categories_with_children: 0,
root_categories: 0,
};
};
return { getters: {
// State activeCategories: (state) => state.categories.filter((c) => c.active),
productCategories, rootCategories: (state) => state.categories.filter((c) => !c.parent_id),
currentProductCategory, },
loading,
error,
pagination,
statistics,
// Getters actions: {
allProductCategories, async fetchCategories(params = {}) {
activeProductCategories, this.loading = true;
inactiveProductCategories, this.error = null;
rootProductCategories, try {
parentProductCategories, const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(params);
isLoading, this.categories = response.data;
hasError, if (response.pagination) {
getError, this.meta = {
getProductCategoryById, current_page: response.pagination.current_page,
getPagination, last_page: response.pagination.last_page,
getStatistics, 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 catégories";
throw error;
} finally {
this.loading = false;
}
},
// Actions async fetchAllCategories() {
setLoading, // Helper to get all categories for dropdowns without pagination constraints
setError, // The service might need a way to request all, or we use a large per_page
clearError, return this.fetchCategories({ per_page: 1000 });
setProductCategories, },
setCurrentProductCategory,
setPagination, async fetchCategory(id: number) {
setStatistics, this.loading = true;
fetchProductCategories, this.error = null;
fetchProductCategory, try {
createProductCategory, const response = await productCategoryService.getProductCategory(id);
updateProductCategory, this.currentCategory = response.data;
deleteProductCategory, return response.data;
searchProductCategories, } catch (error: any) {
fetchActiveProductCategories, this.error = error.message || "Erreur lors du chargement de la catégorie";
fetchRootProductCategories, throw error;
fetchHierarchicalProductCategories, } finally {
fetchProductCategoryStatistics, this.loading = false;
toggleProductCategoryStatus, }
resetState, },
};
async createCategory(data: any) {
this.isLoading = true;
this.error = null;
try {
const response = await productCategoryService.createProductCategory(data);
this.categories.unshift(response.data);
this.meta.total++;
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la création de la catégorie";
throw error;
} finally {
this.isLoading = false;
}
},
async updateCategory(id: number, data: any) {
this.isLoading = true;
this.error = null;
try {
const response = await productCategoryService.updateProductCategory(id, data);
const index = this.categories.findIndex((c) => c.id === id);
if (index !== -1) {
this.categories[index] = response.data;
}
if (this.currentCategory?.id === id) {
this.currentCategory = response.data;
}
return response.data;
} catch (error: any) {
this.error = error.message || "Erreur lors de la mise à jour de la catégorie";
throw error;
} finally {
this.isLoading = false;
}
},
async deleteCategory(id: number) {
this.isLoading = true;
this.error = null;
try {
await productCategoryService.deleteProductCategory(id);
this.categories = this.categories.filter((c) => c.id !== id);
this.meta.total--;
if (this.currentCategory?.id === id) {
this.currentCategory = null;
}
return true;
} catch (error: any) {
this.error = error.message || "Erreur lors de la suppression de la catégorie";
throw error;
} finally {
this.isLoading = false;
}
},
},
}); });
export default useProductCategoryStore;

View File

@ -0,0 +1,146 @@
<template>
<div class="py-4 container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header pb-0">
<div class="d-lg-flex">
<div>
<h5 class="mb-0">Catégories de Produits</h5>
<p class="text-sm mb-0">
Gérer les catégories de produits et leurs hiérarchies.
</p>
</div>
<div class="ms-auto my-auto mt-lg-0 mt-4">
<div class="ms-auto my-auto">
<button
class="btn bg-gradient-success btn-sm mb-0"
@click="openCreateModal"
>
+ Nouvelle Catégorie
</button>
</div>
</div>
</div>
</div>
<div class="card-body px-0 pb-0">
<div class="px-4">
<!-- You could add search here -->
</div>
<ProductCategoryList
:categories="store.categories"
@edit="openEditModal"
@delete="handleDelete"
/>
<!-- Pagination if needed -->
<!-- <div class="px-4 py-3 border-top d-flex justify-content-end">
Pagination component
</div> -->
</div>
</div>
</div>
</div>
<ProductCategoryModal
ref="modalComponent"
:category="currentCategory"
:all-categories="allCategories"
:loading="store.isLoading"
@save="handleSave"
@close="handleCloseModal"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
import ProductCategoryList from "@/components/Organism/Parametrage/ProductCategories/ProductCategoryList.vue";
import ProductCategoryModal from "@/components/Organism/Parametrage/ProductCategories/ProductCategoryModal.vue";
import Swal from "sweetalert2";
const store = useProductCategoryStore();
const modalComponent = ref(null);
const currentCategory = ref(null);
const allCategories = ref([]); // Store all for dropdown regardless of pagination
onMounted(async () => {
await fetchCategories();
// Fetch all for parents dropdown
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
});
const fetchCategories = async () => {
try {
await store.fetchCategories();
} catch (error) {
console.error(error);
}
};
const openCreateModal = () => {
currentCategory.value = null;
if (modalComponent.value) {
modalComponent.value.show();
}
};
const openEditModal = (category) => {
currentCategory.value = category;
if (modalComponent.value) {
modalComponent.value.show();
}
};
const handleCloseModal = () => {
currentCategory.value = null;
};
const handleSave = async (formData) => {
try {
if (formData.id) {
await store.updateCategory(formData.id, formData);
Swal.fire("Succès", "Catégorie mise à jour avec succès", "success");
} else {
await store.createCategory(formData);
Swal.fire("Succès", "Catégorie créée avec succès", "success");
// Update all categories list for dropdowns if new one created
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
}
if (modalComponent.value) {
modalComponent.value.hide();
}
} catch (error) {
Swal.fire("Erreur", error.message || "Une erreur est survenue", "error");
}
};
const handleDelete = async (id) => {
const result = await Swal.fire({
title: "Êtes-vous sûr ?",
text: "Cette action est irréversible !",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Oui, supprimer !",
cancelButtonText: "Annuler",
});
if (result.isConfirmed) {
try {
await store.deleteCategory(id);
Swal.fire("Supprimé !", "La catégorie a été supprimée.", "success");
// Update all categories list for dropdowns
const response = await store.fetchAllCategories();
allCategories.value = response.data || [];
} catch (error) {
Swal.fire("Erreur", error.message || "Impossible de supprimer", "error");
}
}
};
</script>

View File

@ -1,10 +1,10 @@
<template> <template>
<add-product-presentation <add-product-presentation
:fournisseurs="fournisseurStore.fournisseurs" :fournisseurs="fournisseurStore.fournisseurs"
:categories="productCategoryStore.categories"
:loading="productStore.isLoading" :loading="productStore.isLoading"
:validation-errors="validationErrors" :validation-errors="validationErrors"
:success="showSuccess" :success="showSuccess"
:categories="productCategoryStore.productCategories"
@create-product="handleCreateProduct" @create-product="handleCreateProduct"
/> />
</template> </template>
@ -28,7 +28,7 @@ const showSuccess = ref(false);
onMounted(async () => { onMounted(async () => {
await fournisseurStore.fetchFournisseurs(); await fournisseurStore.fetchFournisseurs();
await productCategoryStore.fetchProductCategories(); await productCategoryStore.fetchAllCategories();
}); });
const handleCreateProduct = async (form) => { const handleCreateProduct = async (form) => {