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:
parent
f9b6e5e0f6
commit
5d93f9d39a
@ -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;
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
function ($attribute, $value, $fail) {
|
||||||
|
if ($this->route('product_category') && $value == $this->route('product_category')->id) {
|
||||||
|
$fail('The parent category cannot be the category itself.');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
public function messages(): array
|
],
|
||||||
{
|
'intervention' => 'boolean',
|
||||||
return [
|
'active' => 'boolean',
|
||||||
'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.',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,114 +5,24 @@ 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 all product categories with pagination
|
* Get active categories only.
|
||||||
*/
|
* Overriding or adding custom logic if needed, but BaseRepository might not have 'active' scope standard.
|
||||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
|
* Keeping it as a specific method for this repository.
|
||||||
{
|
|
||||||
$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
|
public function getActive(): Collection
|
||||||
{
|
{
|
||||||
@ -120,7 +30,7 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get root categories (no parent)
|
* Get root categories (no parent).
|
||||||
*/
|
*/
|
||||||
public function getRoots(): Collection
|
public function getRoots(): Collection
|
||||||
{
|
{
|
||||||
@ -128,7 +38,7 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get categories with their children
|
* Get categories with their children.
|
||||||
*/
|
*/
|
||||||
public function getWithChildren(): Collection
|
public function getWithChildren(): Collection
|
||||||
{
|
{
|
||||||
@ -139,19 +49,57 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search categories by name or code
|
* Get all product categories with pagination
|
||||||
|
*/
|
||||||
|
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = $this->model->newQuery();
|
||||||
|
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where(function (Builder $q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('code', 'like', "%{$search}%")
|
||||||
|
->orWhere('description', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($filters['active']) && $filters['active'] !== '') {
|
||||||
|
$query->where('active', (bool) $filters['active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($filters['parent_id']) && $filters['parent_id'] !== '') {
|
||||||
|
if ($filters['parent_id'] === 'null') {
|
||||||
|
$query->whereNull('parent_id');
|
||||||
|
} else {
|
||||||
|
$query->where('parent_id', $filters['parent_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortBy = $filters['sort_by'] ?? 'name';
|
||||||
|
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||||
|
$query->orderBy($sortBy, $sortDirection);
|
||||||
|
|
||||||
|
return $query->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search categories by name or code.
|
||||||
|
* BaseRepository usually has a general search, but this one is specific with 'code' and 'description'.
|
||||||
*/
|
*/
|
||||||
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) {
|
||||||
|
$query->where('name', 'like', "%{$term}%")
|
||||||
->orWhere('code', 'like', "%{$term}%")
|
->orWhere('code', 'like', "%{$term}%")
|
||||||
->orWhere('description', 'like', "%{$term}%")
|
->orWhere('description', 'like', "%{$term}%");
|
||||||
|
})
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->paginate($perPage);
|
->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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' => [],
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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">×</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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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: () => ({
|
||||||
|
categories: [] as ProductCategory[],
|
||||||
|
currentCategory: null as ProductCategory | null,
|
||||||
|
loading: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
meta: {
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
last_page: 1,
|
last_page: 1,
|
||||||
per_page: 10,
|
per_page: 15,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
from: 1,
|
||||||
|
to: 0,
|
||||||
|
} as Meta,
|
||||||
|
}),
|
||||||
|
|
||||||
// Statistics state
|
getters: {
|
||||||
const statistics = ref({
|
activeCategories: (state) => state.categories.filter((c) => c.active),
|
||||||
total_categories: 0,
|
rootCategories: (state) => state.categories.filter((c) => !c.parent_id),
|
||||||
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);
|
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchCategories(params = {}) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.getAllProductCategories(
|
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(params);
|
||||||
params
|
this.categories = response.data;
|
||||||
);
|
|
||||||
setProductCategories(response.data);
|
|
||||||
if (response.pagination) {
|
if (response.pagination) {
|
||||||
setPagination(response.pagination);
|
this.meta = {
|
||||||
|
current_page: response.pagination.current_page,
|
||||||
|
last_page: response.pagination.last_page,
|
||||||
|
per_page: response.pagination.per_page,
|
||||||
|
total: response.pagination.total,
|
||||||
|
from: response.pagination.from,
|
||||||
|
to: response.pagination.to,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
const errorMessage =
|
this.error = error.message || "Erreur lors du chargement des catégories";
|
||||||
err.response?.data?.message ||
|
throw error;
|
||||||
err.message ||
|
|
||||||
"Échec du chargement des catégories de produits";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.loading = false;
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
|
||||||
/**
|
async fetchAllCategories() {
|
||||||
* Récupérer une seule catégorie de produit par ID
|
// Helper to get all categories for dropdowns without pagination constraints
|
||||||
*/
|
// The service might need a way to request all, or we use a large per_page
|
||||||
const fetchProductCategory = async (id: number) => {
|
return this.fetchCategories({ per_page: 1000 });
|
||||||
setLoading(true);
|
},
|
||||||
setError(null);
|
|
||||||
|
|
||||||
|
async fetchCategory(id: number) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.getProductCategory(id);
|
const response = await productCategoryService.getProductCategory(id);
|
||||||
setCurrentProductCategory(response.data);
|
this.currentCategory = response.data;
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
const errorMessage =
|
this.error = error.message || "Erreur lors du chargement de la catégorie";
|
||||||
err.response?.data?.message ||
|
throw error;
|
||||||
err.message ||
|
|
||||||
"Échec du chargement de la catégorie de produit";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.loading = false;
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Créer une nouvelle catégorie de produit
|
|
||||||
*/
|
|
||||||
const createProductCategory = async (payload: ProductCategoryFormData) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
|
async createCategory(data: any) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.createProductCategory(
|
const response = await productCategoryService.createProductCategory(data);
|
||||||
payload
|
this.categories.unshift(response.data);
|
||||||
);
|
this.meta.total++;
|
||||||
// Ajouter la nouvelle catégorie à la liste
|
|
||||||
productCategories.value.push(response.data);
|
|
||||||
setCurrentProductCategory(response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
const errorMessage =
|
this.error = error.message || "Erreur lors de la création de la catégorie";
|
||||||
err.response?.data?.message ||
|
throw error;
|
||||||
err.message ||
|
|
||||||
"Échec de la création de la catégorie de produit";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Mettre à jour une catégorie de produit existante
|
|
||||||
*/
|
|
||||||
const updateProductCategory = async (
|
|
||||||
payload: ProductCategoryFormData & { id: number }
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
|
async updateCategory(id: number, data: any) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.updateProductCategory(
|
const response = await productCategoryService.updateProductCategory(id, data);
|
||||||
payload.id,
|
const index = this.categories.findIndex((c) => c.id === 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) {
|
if (index !== -1) {
|
||||||
productCategories.value[index] = updatedCategory;
|
this.categories[index] = response.data;
|
||||||
}
|
}
|
||||||
|
if (this.currentCategory?.id === id) {
|
||||||
// Mettre à jour la catégorie actuelle si c'est celle en cours d'édition
|
this.currentCategory = response.data;
|
||||||
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;
|
return response.data;
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
const errorMessage =
|
this.error = error.message || "Erreur lors de la mise à jour de la catégorie";
|
||||||
err.response?.data?.message ||
|
throw error;
|
||||||
err.message ||
|
|
||||||
"Échec du chargement des statistiques";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Basculer le statut actif/inactif d'une catégorie
|
|
||||||
*/
|
|
||||||
const toggleProductCategoryStatus = async (id: number, active: boolean) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
|
async deleteCategory(id: number) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const response = await productCategoryService.toggleProductCategoryStatus(
|
await productCategoryService.deleteProductCategory(id);
|
||||||
id,
|
this.categories = this.categories.filter((c) => c.id !== id);
|
||||||
active
|
this.meta.total--;
|
||||||
);
|
if (this.currentCategory?.id === id) {
|
||||||
const updatedCategory = response.data;
|
this.currentCategory = null;
|
||||||
|
|
||||||
// Mettre à jour dans la liste
|
|
||||||
const index = productCategories.value.findIndex(
|
|
||||||
(category) => category.id === updatedCategory.id
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
|
||||||
productCategories.value[index] = updatedCategory;
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
return updatedCategory;
|
} catch (error: any) {
|
||||||
} catch (err: any) {
|
this.error = error.message || "Erreur lors de la suppression de la catégorie";
|
||||||
const errorMessage =
|
throw error;
|
||||||
err.response?.data?.message ||
|
|
||||||
err.message ||
|
|
||||||
"Échec de la mise à jour du statut";
|
|
||||||
setError(errorMessage);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
},
|
||||||
/**
|
|
||||||
* Réinitialiser l'état
|
|
||||||
*/
|
|
||||||
const resetState = () => {
|
|
||||||
productCategories.value = [];
|
|
||||||
currentProductCategory.value = null;
|
|
||||||
loading.value = false;
|
|
||||||
error.value = null;
|
|
||||||
pagination.value = {
|
|
||||||
current_page: 1,
|
|
||||||
last_page: 1,
|
|
||||||
per_page: 10,
|
|
||||||
total: 0,
|
|
||||||
};
|
|
||||||
statistics.value = {
|
|
||||||
total_categories: 0,
|
|
||||||
active_categories: 0,
|
|
||||||
inactive_categories: 0,
|
|
||||||
categories_with_children: 0,
|
|
||||||
root_categories: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
productCategories,
|
|
||||||
currentProductCategory,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
pagination,
|
|
||||||
statistics,
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
allProductCategories,
|
|
||||||
activeProductCategories,
|
|
||||||
inactiveProductCategories,
|
|
||||||
rootProductCategories,
|
|
||||||
parentProductCategories,
|
|
||||||
isLoading,
|
|
||||||
hasError,
|
|
||||||
getError,
|
|
||||||
getProductCategoryById,
|
|
||||||
getPagination,
|
|
||||||
getStatistics,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setLoading,
|
|
||||||
setError,
|
|
||||||
clearError,
|
|
||||||
setProductCategories,
|
|
||||||
setCurrentProductCategory,
|
|
||||||
setPagination,
|
|
||||||
setStatistics,
|
|
||||||
fetchProductCategories,
|
|
||||||
fetchProductCategory,
|
|
||||||
createProductCategory,
|
|
||||||
updateProductCategory,
|
|
||||||
deleteProductCategory,
|
|
||||||
searchProductCategories,
|
|
||||||
fetchActiveProductCategories,
|
|
||||||
fetchRootProductCategories,
|
|
||||||
fetchHierarchicalProductCategories,
|
|
||||||
fetchProductCategoryStatistics,
|
|
||||||
toggleProductCategoryStatus,
|
|
||||||
resetState,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default useProductCategoryStore;
|
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user