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\Requests\StoreInterventionRequest;
|
||||
use App\HttpRequests\StoreInterventionWithAllDataRequest;
|
||||
use App\Http\Requests\StoreInterventionWithAllDataRequest;
|
||||
use App\Http\Requests\UpdateInterventionRequest;
|
||||
use App\Http\Resources\Intervention\InterventionResource;
|
||||
use App\Http\Resources\Intervention\InterventionCollection;
|
||||
|
||||
@ -28,7 +28,7 @@ class ProductCategoryController extends Controller
|
||||
public function index(Request $request): ProductCategoryCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
$filters = [
|
||||
'search' => $request->get('search'),
|
||||
'active' => $request->get('active'),
|
||||
@ -249,7 +249,7 @@ class ProductCategoryController extends Controller
|
||||
{
|
||||
try {
|
||||
$term = $request->get('term', '');
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
|
||||
if (empty($term)) {
|
||||
return response()->json([
|
||||
|
||||
@ -28,13 +28,14 @@ class ProductController extends Controller
|
||||
public function index(Request $request): ProductCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
$filters = [
|
||||
'search' => $request->get('search'),
|
||||
'categorie' => $request->get('categorie_id'),
|
||||
'fournisseur_id' => $request->get('fournisseur_id'),
|
||||
'low_stock' => $request->get('low_stock'),
|
||||
'expiring_soon' => $request->get('expiring_soon'),
|
||||
'is_intervention' => $request->get('is_intervention'),
|
||||
'sort_by' => $request->get('sort_by', 'created_at'),
|
||||
'sort_direction' => $request->get('sort_direction', 'desc'),
|
||||
];
|
||||
@ -172,7 +173,7 @@ class ProductController extends Controller
|
||||
public function lowStock(Request $request): ProductCollection|JsonResponse
|
||||
{
|
||||
try {
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
$products = $this->productRepository->getLowStockProducts($perPage);
|
||||
|
||||
return new ProductCollection($products);
|
||||
@ -197,7 +198,7 @@ class ProductController extends Controller
|
||||
{
|
||||
try {
|
||||
$categoryId = $request->get('category_id');
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$perPage = (int) $request->get('per_page', 15);
|
||||
|
||||
if (empty($categoryId)) {
|
||||
return response()->json([
|
||||
|
||||
@ -28,6 +28,7 @@ class StoreInterventionRequest extends FormRequest
|
||||
'deceased_id' => ['nullable', 'exists:deceased,id'],
|
||||
'order_giver' => ['nullable', 'string', 'max:255'],
|
||||
'location_id' => ['nullable', 'exists:client_locations,id'],
|
||||
'product_id' => ['nullable', 'exists:products,id'],
|
||||
'type' => ['required', Rule::in([
|
||||
'thanatopraxie',
|
||||
'toilette_mortuaire',
|
||||
|
||||
@ -22,27 +22,12 @@ class StoreProductCategoryRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'code' => 'required|string|max:64|unique:product_categories,code',
|
||||
'name' => 'required|string|max:191',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'intervention' => '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'],
|
||||
'order_giver' => ['nullable', 'string', 'max:255'],
|
||||
'location_id' => ['nullable', 'exists:client_locations,id'],
|
||||
'product_id' => ['nullable', 'exists:products,id'],
|
||||
'type' => ['sometimes', 'required', Rule::in([
|
||||
'thanatopraxie',
|
||||
'toilette_mortuaire',
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProductCategoryRequest extends FormRequest
|
||||
{
|
||||
@ -21,28 +22,27 @@ class UpdateProductCategoryRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$categoryId = $this->route('id');
|
||||
|
||||
return [
|
||||
'parent_id' => 'nullable|exists:product_categories,id',
|
||||
'code' => "nullable|string|max:64|unique:product_categories,code,{$categoryId}",
|
||||
'name' => 'nullable|string|max:191',
|
||||
'code' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:64',
|
||||
Rule::unique('product_categories')->ignore($this->route('product_category')),
|
||||
],
|
||||
'name' => 'required|string|max:191',
|
||||
'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
|
||||
{
|
||||
return [
|
||||
'parent_id.exists' => 'La catégorie parente sélectionnée n\'existe pas.',
|
||||
'code.string' => 'Le code de la catégorie doit être une chaîne de caractères.',
|
||||
'code.max' => 'Le code de la catégorie ne peut pas dépasser 64 caractères.',
|
||||
'code.unique' => 'Ce code de catégorie existe déjà.',
|
||||
'name.string' => 'Le nom de la catégorie doit être une chaîne de caractères.',
|
||||
'name.max' => 'Le nom de la catégorie ne peut pas dépasser 191 caractères.',
|
||||
'description.string' => 'La description doit être une chaîne de caractères.',
|
||||
'active.boolean' => 'Le statut actif doit être un booléen.',
|
||||
},
|
||||
],
|
||||
'intervention' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ class ProductCategoryResource extends JsonResource
|
||||
'description' => $this->description,
|
||||
'active' => $this->active,
|
||||
'path' => $this->path,
|
||||
'intervention'=> $this->intervention,
|
||||
'has_children' => $this->hasChildren(),
|
||||
'has_products' => $this->hasProducts(),
|
||||
'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',
|
||||
'location_id',
|
||||
'type',
|
||||
'product_id',
|
||||
'scheduled_at',
|
||||
'duration_min',
|
||||
'status',
|
||||
@ -41,6 +42,14 @@ class Intervention extends Model
|
||||
'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.
|
||||
*/
|
||||
|
||||
@ -48,7 +48,7 @@ class Product extends Model
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class);
|
||||
return $this->belongsTo(ProductCategory::class, 'categorie_id');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,26 +2,31 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'intervention',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'intervention' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the parent category
|
||||
* Get the parent category.
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
@ -29,7 +34,7 @@ class ProductCategory extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the child categories
|
||||
* Get the child categories.
|
||||
*/
|
||||
public function children(): HasMany
|
||||
{
|
||||
@ -37,39 +42,7 @@ class ProductCategory extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all products in this category
|
||||
*/
|
||||
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
|
||||
* Scope a query to only include active categories.
|
||||
*/
|
||||
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)
|
||||
{
|
||||
@ -85,7 +58,7 @@ class ProductCategory extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category has children
|
||||
* Check if the category has children.
|
||||
*/
|
||||
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
|
||||
{
|
||||
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\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 Illuminate\Database\Eloquent\Collection;
|
||||
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)
|
||||
{
|
||||
$this->model = $model;
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories with pagination
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['search']) && !empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['active']) && $filters['active'] !== null) {
|
||||
$query->where('active', $filters['active']);
|
||||
}
|
||||
|
||||
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
|
||||
$query->where('parent_id', $filters['parent_id']);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortBy = $filters['sort_by'] ?? 'name';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product categories
|
||||
*/
|
||||
public function all(array $filters = []): Collection
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['active']) && $filters['active'] !== null) {
|
||||
$query->where('active', $filters['active']);
|
||||
}
|
||||
|
||||
if (isset($filters['parent_id']) && $filters['parent_id'] !== null) {
|
||||
$query->where('parent_id', $filters['parent_id']);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'name';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a product category by ID
|
||||
*/
|
||||
public function find(string $id): ?ProductCategory
|
||||
{
|
||||
return $this->model->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new product category
|
||||
*/
|
||||
public function create(array $data): ProductCategory
|
||||
{
|
||||
return $this->model->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product category
|
||||
*/
|
||||
public function update(string $id, array $data): bool
|
||||
{
|
||||
$category = $this->find($id);
|
||||
if ($category) {
|
||||
return $category->update($data);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product category
|
||||
*/
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$category = $this->find($id);
|
||||
if ($category) {
|
||||
return $category->delete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active categories only
|
||||
* Get active categories only.
|
||||
* Overriding or adding custom logic if needed, but BaseRepository might not have 'active' scope standard.
|
||||
* Keeping it as a specific method for this repository.
|
||||
*/
|
||||
public function getActive(): Collection
|
||||
{
|
||||
@ -120,7 +30,7 @@ class ProductCategoryRepository implements ProductCategoryRepositoryInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root categories (no parent)
|
||||
* Get root categories (no parent).
|
||||
*/
|
||||
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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
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('description', 'like', "%{$term}%")
|
||||
->orWhere('description', 'like', "%{$term}%");
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category statistics
|
||||
* Get category statistics.
|
||||
*/
|
||||
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);
|
||||
|
||||
if (!$category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -3,41 +3,16 @@
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface ProductCategoryRepositoryInterface
|
||||
interface ProductCategoryRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get all product categories with pagination
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all product categories
|
||||
*/
|
||||
public function all(array $filters = []): Collection;
|
||||
|
||||
/**
|
||||
* Find a product category by ID
|
||||
*/
|
||||
public function find(string $id): ?ProductCategory;
|
||||
|
||||
/**
|
||||
* Create a new product category
|
||||
*/
|
||||
public function create(array $data): ProductCategory;
|
||||
|
||||
/**
|
||||
* Update a product category
|
||||
*/
|
||||
public function update(string $id, array $data): bool;
|
||||
|
||||
/**
|
||||
* Delete a product category
|
||||
*/
|
||||
public function delete(string $id): bool;
|
||||
|
||||
/**
|
||||
* Get active categories only
|
||||
*/
|
||||
|
||||
@ -47,6 +47,12 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
|
||||
->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
|
||||
$sortField = $filters['sort_by'] ?? 'created_at';
|
||||
$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 getByCategory(string $category, int $perPage = 15): LengthAwarePaginator;
|
||||
public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator;
|
||||
|
||||
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ return [
|
||||
|
||||
// IMPORTANT: Do NOT use '*' when sending credentials. List explicit origins.
|
||||
// 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)
|
||||
'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 class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="reference" class="form-label"
|
||||
>Référence *</label
|
||||
>
|
||||
@ -78,33 +78,31 @@
|
||||
>
|
||||
<select
|
||||
id="categorie"
|
||||
v-model="form.categorie"
|
||||
v-model="form.categorie_id"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': validationErrors.categorie }"
|
||||
:class="{ 'is-invalid': validationErrors.categorie_id }"
|
||||
required
|
||||
>
|
||||
<option value="">Sélectionnez une catégorie</option>
|
||||
<option value="Alimentaire">Alimentaire</option>
|
||||
<option value="Médical">Médical</option>
|
||||
<option value="Cosmétique">Cosmétique</option>
|
||||
<option value="Ménage">Ménage</option>
|
||||
<option value="Électronique">Électronique</option>
|
||||
<option value="Vêtements">Vêtements</option>
|
||||
<option value="Jouets">Jouets</option>
|
||||
<option value="Livre">Livre</option>
|
||||
<option value="Autre">Autre</option>
|
||||
<option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:value="category.id"
|
||||
>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="validationErrors.categorie"
|
||||
v-if="validationErrors.categorie_id"
|
||||
class="invalid-feedback"
|
||||
>
|
||||
{{ validationErrors.categorie[0] }}
|
||||
{{ validationErrors.categorie_id[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="fabricant" class="form-label">Fabricant</label>
|
||||
<soft-input
|
||||
id="fabricant"
|
||||
@ -126,7 +124,7 @@
|
||||
<!-- Stock Information -->
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="stock_actuel" class="form-label"
|
||||
>Stock Actuel *</label
|
||||
>
|
||||
@ -150,7 +148,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="stock_minimum" class="form-label"
|
||||
>Stock Minimum *</label
|
||||
>
|
||||
@ -173,7 +171,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-4" v-if="!isIntervention">
|
||||
<div class="form-group">
|
||||
<label for="unite" class="form-label">Unité *</label>
|
||||
<select
|
||||
@ -234,7 +232,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="date_expiration" class="form-label"
|
||||
>Date d'Expiration</label
|
||||
>
|
||||
@ -260,7 +258,7 @@
|
||||
<!-- Lot Number -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="numero_lot" class="form-label"
|
||||
>Numéro de Lot</label
|
||||
>
|
||||
@ -281,7 +279,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<div class="form-group" v-if="!isIntervention">
|
||||
<label for="fournisseur_id" class="form-label"
|
||||
>Fournisseur</label
|
||||
>
|
||||
@ -311,7 +309,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Packaging Information -->
|
||||
<div class="row">
|
||||
<div class="row" v-if="!isIntervention">
|
||||
<div class="col-md-4">
|
||||
<div class="form-group">
|
||||
<label for="conditionnement_nom" class="form-label"
|
||||
@ -385,7 +383,7 @@
|
||||
</div>
|
||||
|
||||
<!-- URLs -->
|
||||
<div class="row">
|
||||
<div class="row" v-if="!isIntervention">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="photo_url" class="form-label"
|
||||
@ -482,6 +480,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
success: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -495,7 +497,7 @@ const emit = defineEmits(["create-product"]);
|
||||
const form = reactive({
|
||||
nom: "",
|
||||
reference: "",
|
||||
categorie: "",
|
||||
categorie_id: "",
|
||||
fabricant: "",
|
||||
stock_actuel: 0,
|
||||
stock_minimum: 0,
|
||||
@ -511,6 +513,56 @@ const form = reactive({
|
||||
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
|
||||
const handleSubmit = () => {
|
||||
// Clean up the form data
|
||||
|
||||
@ -374,6 +374,12 @@ export default {
|
||||
miniIcon: "M",
|
||||
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",
|
||||
component: () => import("@/views/pages/Parametrage/Modeles.vue"),
|
||||
},
|
||||
{
|
||||
path: "/parametrage/categories-produits",
|
||||
name: "Product Categories",
|
||||
component: () =>
|
||||
import("@/views/pages/Parametrage/ProductCategories.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@ -1,510 +1,144 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import ProductCategoryService from "@/services/productCategory";
|
||||
|
||||
import type {
|
||||
import ProductCategoryService, {
|
||||
ProductCategory,
|
||||
ProductCategoryFormData,
|
||||
ProductCategoryListResponse,
|
||||
} from "@/services/productCategory";
|
||||
|
||||
// Create an instance of productCategoryService
|
||||
// Create an instance of the service
|
||||
const productCategoryService = new ProductCategoryService();
|
||||
|
||||
export const useProductCategoryStore = defineStore("productCategory", () => {
|
||||
// State
|
||||
const productCategories = ref<ProductCategory[]>([]);
|
||||
const currentProductCategory = ref<ProductCategory | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
interface Meta {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
// Pagination state
|
||||
const pagination = ref({
|
||||
export const useProductCategoryStore = defineStore("productCategory", {
|
||||
state: () => ({
|
||||
categories: [] as ProductCategory[],
|
||||
currentCategory: null as ProductCategory | null,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
error: null as string | null,
|
||||
meta: {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 10,
|
||||
per_page: 15,
|
||||
total: 0,
|
||||
});
|
||||
from: 1,
|
||||
to: 0,
|
||||
} as Meta,
|
||||
}),
|
||||
|
||||
// Statistics state
|
||||
const statistics = ref({
|
||||
total_categories: 0,
|
||||
active_categories: 0,
|
||||
inactive_categories: 0,
|
||||
categories_with_children: 0,
|
||||
root_categories: 0,
|
||||
});
|
||||
|
||||
// Getters
|
||||
const allProductCategories = computed(() => productCategories.value);
|
||||
|
||||
const activeProductCategories = computed(() =>
|
||||
productCategories.value.filter((category) => category.active)
|
||||
);
|
||||
|
||||
const inactiveProductCategories = computed(() =>
|
||||
productCategories.value.filter((category) => !category.active)
|
||||
);
|
||||
|
||||
const rootProductCategories = computed(() =>
|
||||
productCategories.value.filter((category) => !category.parent_id)
|
||||
);
|
||||
|
||||
const parentProductCategories = computed(() =>
|
||||
productCategories.value.filter((category) => category.has_children)
|
||||
);
|
||||
|
||||
const isLoading = computed(() => loading.value);
|
||||
const hasError = computed(() => error.value !== null);
|
||||
const getError = computed(() => error.value);
|
||||
|
||||
const getProductCategoryById = computed(() => (id: number) =>
|
||||
productCategories.value.find((category) => category.id === id)
|
||||
);
|
||||
|
||||
const getPagination = computed(() => pagination.value);
|
||||
const getStatistics = computed(() => statistics.value);
|
||||
|
||||
// Actions
|
||||
const setLoading = (isLoading: boolean) => {
|
||||
loading.value = isLoading;
|
||||
};
|
||||
|
||||
const setError = (err: string | null) => {
|
||||
error.value = err;
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const setProductCategories = (newProductCategories: ProductCategory[]) => {
|
||||
productCategories.value = newProductCategories;
|
||||
};
|
||||
|
||||
const setCurrentProductCategory = (category: ProductCategory | null) => {
|
||||
currentProductCategory.value = category;
|
||||
};
|
||||
|
||||
const setPagination = (meta: any) => {
|
||||
if (meta) {
|
||||
pagination.value = {
|
||||
current_page: meta.current_page || 1,
|
||||
last_page: meta.last_page || 1,
|
||||
per_page: meta.per_page || 10,
|
||||
total: meta.total || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const setStatistics = (stats: any) => {
|
||||
if (stats) {
|
||||
statistics.value = {
|
||||
total_categories: stats.total_categories || 0,
|
||||
active_categories: stats.active_categories || 0,
|
||||
inactive_categories: stats.inactive_categories || 0,
|
||||
categories_with_children: stats.categories_with_children || 0,
|
||||
root_categories: stats.root_categories || 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer toutes les catégories de produits avec pagination et filtres optionnels
|
||||
*/
|
||||
const fetchProductCategories = async (params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
active?: boolean;
|
||||
parent_id?: number;
|
||||
sort_by?: string;
|
||||
sort_direction?: "asc" | "desc";
|
||||
}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getters: {
|
||||
activeCategories: (state) => state.categories.filter((c) => c.active),
|
||||
rootCategories: (state) => state.categories.filter((c) => !c.parent_id),
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchCategories(params = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await productCategoryService.getAllProductCategories(
|
||||
params
|
||||
);
|
||||
setProductCategories(response.data);
|
||||
const response: ProductCategoryListResponse = await productCategoryService.getAllProductCategories(params);
|
||||
this.categories = response.data;
|
||||
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;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement des catégories de produits";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || "Erreur lors du chargement des catégories";
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupérer une seule catégorie de produit par ID
|
||||
*/
|
||||
const fetchProductCategory = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
async fetchAllCategories() {
|
||||
// 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
|
||||
return this.fetchCategories({ per_page: 1000 });
|
||||
},
|
||||
|
||||
async fetchCategory(id: number) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await productCategoryService.getProductCategory(id);
|
||||
setCurrentProductCategory(response.data);
|
||||
this.currentCategory = response.data;
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement de la catégorie de produit";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || "Erreur lors du chargement de la catégorie";
|
||||
throw error;
|
||||
} 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 {
|
||||
const response = await productCategoryService.createProductCategory(
|
||||
payload
|
||||
);
|
||||
// Ajouter la nouvelle catégorie à la liste
|
||||
productCategories.value.push(response.data);
|
||||
setCurrentProductCategory(response.data);
|
||||
const response = await productCategoryService.createProductCategory(data);
|
||||
this.categories.unshift(response.data);
|
||||
this.meta.total++;
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec de la création de la catégorie de produit";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || "Erreur lors de la création de la catégorie";
|
||||
throw error;
|
||||
} 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 {
|
||||
const response = await productCategoryService.updateProductCategory(
|
||||
payload.id,
|
||||
payload
|
||||
);
|
||||
const updatedCategory = response.data;
|
||||
|
||||
// Mettre à jour dans la liste des catégories
|
||||
const index = productCategories.value.findIndex(
|
||||
(category) => category.id === updatedCategory.id
|
||||
);
|
||||
const response = await productCategoryService.updateProductCategory(id, data);
|
||||
const index = this.categories.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
productCategories.value[index] = updatedCategory;
|
||||
this.categories[index] = response.data;
|
||||
}
|
||||
|
||||
// Mettre à jour la catégorie actuelle si c'est celle en cours d'édition
|
||||
if (
|
||||
currentProductCategory.value &&
|
||||
currentProductCategory.value.id === updatedCategory.id
|
||||
) {
|
||||
setCurrentProductCategory(updatedCategory);
|
||||
if (this.currentCategory?.id === id) {
|
||||
this.currentCategory = response.data;
|
||||
}
|
||||
|
||||
return updatedCategory;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec de la mise à jour de la catégorie de produit";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Supprimer une catégorie de produit
|
||||
*/
|
||||
const deleteProductCategory = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.deleteProductCategory(id);
|
||||
|
||||
// Retirer de la liste des catégories
|
||||
productCategories.value = productCategories.value.filter(
|
||||
(category) => category.id !== id
|
||||
);
|
||||
|
||||
// Effacer la catégorie actuelle si c'est celle en cours de suppression
|
||||
if (
|
||||
currentProductCategory.value &&
|
||||
currentProductCategory.value.id === id
|
||||
) {
|
||||
setCurrentProductCategory(null);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec de la suppression de la catégorie de produit";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Rechercher des catégories de produits
|
||||
*/
|
||||
const searchProductCategories = async (
|
||||
term: string,
|
||||
params?: {
|
||||
per_page?: number;
|
||||
}
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.searchProductCategories(
|
||||
term,
|
||||
params
|
||||
);
|
||||
setProductCategories(response.data);
|
||||
if (response.pagination) {
|
||||
setPagination(response.pagination);
|
||||
}
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec de la recherche de catégories de produits";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer uniquement les catégories actives
|
||||
*/
|
||||
const fetchActiveProductCategories = async (params?: {
|
||||
per_page?: number;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.getActiveProductCategories();
|
||||
setProductCategories(response.data);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement des catégories actives";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer les catégories racine
|
||||
*/
|
||||
const fetchRootProductCategories = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.getRootProductCategories();
|
||||
setProductCategories(response.data);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement des catégories racine";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer la structure hiérarchique
|
||||
*/
|
||||
const fetchHierarchicalProductCategories = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.getHierarchicalProductCategories();
|
||||
setProductCategories(response.data);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement de la structure hiérarchique";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupérer les statistiques des catégories
|
||||
*/
|
||||
const fetchProductCategoryStatistics = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await productCategoryService.getProductCategoryStatistics();
|
||||
setStatistics(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec du chargement des statistiques";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || "Erreur lors de la mise à jour de la catégorie";
|
||||
throw error;
|
||||
} 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 {
|
||||
const response = await productCategoryService.toggleProductCategoryStatus(
|
||||
id,
|
||||
active
|
||||
);
|
||||
const updatedCategory = response.data;
|
||||
|
||||
// Mettre à jour dans la liste
|
||||
const index = productCategories.value.findIndex(
|
||||
(category) => category.id === updatedCategory.id
|
||||
);
|
||||
if (index !== -1) {
|
||||
productCategories.value[index] = updatedCategory;
|
||||
await productCategoryService.deleteProductCategory(id);
|
||||
this.categories = this.categories.filter((c) => c.id !== id);
|
||||
this.meta.total--;
|
||||
if (this.currentCategory?.id === id) {
|
||||
this.currentCategory = null;
|
||||
}
|
||||
|
||||
return updatedCategory;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Échec de la mise à jour du statut";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.error = error.message || "Erreur lors de la suppression de la catégorie";
|
||||
throw error;
|
||||
} 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>
|
||||
<add-product-presentation
|
||||
:fournisseurs="fournisseurStore.fournisseurs"
|
||||
:categories="productCategoryStore.categories"
|
||||
:loading="productStore.isLoading"
|
||||
:validation-errors="validationErrors"
|
||||
:success="showSuccess"
|
||||
:categories="productCategoryStore.productCategories"
|
||||
@create-product="handleCreateProduct"
|
||||
/>
|
||||
</template>
|
||||
@ -28,7 +28,7 @@ const showSuccess = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
await fournisseurStore.fetchFournisseurs();
|
||||
await productCategoryStore.fetchProductCategories();
|
||||
await productCategoryStore.fetchAllCategories();
|
||||
});
|
||||
|
||||
const handleCreateProduct = async (form) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user