feat(ui): add price lists and group-based quote flows

Add price list management across the API, store, services, routes,
navigation, and sales views.

Support quotes for either a client or a client group, including PDF
download and nullable client validation for group-based recipients.

Extend client groups to manage assigned clients directly from the form
and detail views, and refresh supplier, intervention, stock, and order
screens with updated interactions and layouts.
This commit is contained in:
nyavokevin 2026-04-02 12:07:11 +03:00
parent dd6fc4665c
commit 9cbc1bcbdb
65 changed files with 9902 additions and 1971 deletions

View File

@ -13,7 +13,9 @@ use App\Models\Client;
use App\Repositories\ClientGroupRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
class ClientGroupController extends Controller
{
@ -49,7 +51,22 @@ class ClientGroupController extends Controller
public function store(StoreClientGroupRequest $request): ClientGroupResource|JsonResponse
{
try {
$clientGroup = $this->clientGroupRepository->create($request->validated());
$validated = $request->validated();
$clientGroup = DB::transaction(function () use ($validated) {
$clientIds = Arr::get($validated, 'client_ids', []);
$clientGroup = $this->clientGroupRepository->create(Arr::except($validated, ['client_ids']));
if (!empty($clientIds)) {
Client::query()
->whereIn('id', $clientIds)
->update(['group_id' => $clientGroup->id]);
}
return $clientGroup->load(['clients'])->loadCount('clients');
});
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error creating client group: ' . $e->getMessage(), [
@ -79,6 +96,8 @@ class ClientGroupController extends Controller
], 404);
}
$clientGroup->load(['clients'])->loadCount('clients');
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error fetching client group: ' . $e->getMessage(), [
@ -100,7 +119,32 @@ class ClientGroupController extends Controller
public function update(UpdateClientGroupRequest $request, string $id): ClientGroupResource|JsonResponse
{
try {
$updated = $this->clientGroupRepository->update($id, $request->validated());
$validated = $request->validated();
$updated = DB::transaction(function () use ($id, $validated) {
$updated = $this->clientGroupRepository->update($id, Arr::except($validated, ['client_ids']));
if (!$updated) {
return false;
}
if (array_key_exists('client_ids', $validated)) {
$clientIds = $validated['client_ids'] ?? [];
Client::query()
->where('group_id', (int) $id)
->whereNotIn('id', $clientIds)
->update(['group_id' => null]);
if (!empty($clientIds)) {
Client::query()
->whereIn('id', $clientIds)
->update(['group_id' => (int) $id]);
}
}
return true;
});
if (!$updated) {
return response()->json([
@ -109,6 +153,8 @@ class ClientGroupController extends Controller
}
$clientGroup = $this->clientGroupRepository->find($id);
$clientGroup?->load(['clients'])->loadCount('clients');
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error updating client group: ' . $e->getMessage(), [
@ -172,9 +218,13 @@ class ClientGroupController extends Controller
$clientIds = $request->validated('client_ids');
$updatedCount = Client::query()
->whereIn('id', $clientIds)
->update(['group_id' => $clientGroup->id]);
$updatedCount = DB::transaction(function () use ($clientIds, $clientGroup) {
return Client::query()
->whereIn('id', $clientIds)
->update(['group_id' => $clientGroup->id]);
});
$clientGroup->load(['clients'])->loadCount('clients');
return response()->json([
'message' => 'Clients assignés au groupe avec succès.',

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePriceListRequest;
use App\Http\Requests\UpdatePriceListRequest;
use App\Http\Resources\PriceListResource;
use App\Repositories\PriceListRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Log;
class PriceListController extends Controller
{
public function __construct(
private readonly PriceListRepositoryInterface $priceListRepository
) {
}
/**
* Display a listing of price lists.
*/
public function index(): AnonymousResourceCollection|JsonResponse
{
try {
$priceLists = $this->priceListRepository->all()->sortBy('name')->values();
return PriceListResource::collection($priceLists);
} catch (\Exception $e) {
Log::error('Error fetching price lists: ' . $e->getMessage(), [
'exception' => $e,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération des listes de prix.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Store a newly created price list.
*/
public function store(StorePriceListRequest $request): PriceListResource|JsonResponse
{
try {
$priceList = $this->priceListRepository->create($request->validated());
return new PriceListResource($priceList);
} catch (\Exception $e) {
Log::error('Error creating price list: ' . $e->getMessage(), [
'exception' => $e,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la création de la liste de prix.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Display the specified price list.
*/
public function show(string $id): PriceListResource|JsonResponse
{
try {
$priceList = $this->priceListRepository->find($id);
if (! $priceList) {
return response()->json([
'message' => 'Liste de prix non trouvée.',
], 404);
}
return new PriceListResource($priceList);
} catch (\Exception $e) {
Log::error('Error fetching price list: ' . $e->getMessage(), [
'exception' => $e,
'price_list_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la récupération de la liste de prix.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Update the specified price list.
*/
public function update(UpdatePriceListRequest $request, string $id): PriceListResource|JsonResponse
{
try {
$updated = $this->priceListRepository->update($id, $request->validated());
if (! $updated) {
return response()->json([
'message' => 'Liste de prix non trouvée ou échec de la mise à jour.',
], 404);
}
$priceList = $this->priceListRepository->find($id);
return new PriceListResource($priceList);
} catch (\Exception $e) {
Log::error('Error updating price list: ' . $e->getMessage(), [
'exception' => $e,
'price_list_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise à jour de la liste de prix.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* Remove the specified price list.
*/
public function destroy(string $id): JsonResponse
{
try {
$deleted = $this->priceListRepository->delete($id);
if (! $deleted) {
return response()->json([
'message' => 'Liste de prix non trouvée ou échec de la suppression.',
], 404);
}
return response()->json([
'message' => 'Liste de prix supprimée avec succès.',
]);
} catch (\Exception $e) {
Log::error('Error deleting price list: ' . $e->getMessage(), [
'exception' => $e,
'price_list_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression de la liste de prix.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -21,7 +21,8 @@ class PurchaseOrderController extends Controller
public function __construct(
protected PurchaseOrderRepositoryInterface $purchaseOrderRepository,
protected GoodsReceiptRepositoryInterface $goodsReceiptRepository
) {
)
{
}
/**
@ -32,7 +33,8 @@ class PurchaseOrderController extends Controller
try {
$purchaseOrders = $this->purchaseOrderRepository->all();
return PurchaseOrderResource::collection($purchaseOrders);
} catch (\Exception $e) {
}
catch (\Exception $e) {
Log::error('Error fetching purchase orders: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@ -52,8 +54,15 @@ class PurchaseOrderController extends Controller
{
try {
$purchaseOrder = $this->purchaseOrderRepository->create($request->validated());
// If PO is created directly as validated/delivered, ensure a draft goods receipt exists.
if ($purchaseOrder && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)) {
$this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
}
return new PurchaseOrderResource($purchaseOrder);
} catch (\Exception $e) {
}
catch (\Exception $e) {
Log::error('Error creating purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@ -82,7 +91,8 @@ class PurchaseOrderController extends Controller
}
return new PurchaseOrderResource($purchaseOrder);
} catch (\Exception $e) {
}
catch (\Exception $e) {
Log::error('Error fetching purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@ -102,9 +112,6 @@ class PurchaseOrderController extends Controller
public function update(UpdatePurchaseOrderRequest $request, string $id): PurchaseOrderResource|JsonResponse
{
try {
$existingPurchaseOrder = $this->purchaseOrderRepository->find($id);
$previousStatus = $existingPurchaseOrder?->status;
$updated = $this->purchaseOrderRepository->update($id, $request->validated());
if (!$updated) {
@ -115,17 +122,15 @@ class PurchaseOrderController extends Controller
$purchaseOrder = $this->purchaseOrderRepository->find($id);
// On validation/delivery (status => confirmee|livree), create a draft goods receipt automatically.
if (
$purchaseOrder
&& in_array($purchaseOrder->status, ['confirmee', 'livree'], true)
&& !in_array($previousStatus, ['confirmee', 'livree'], true)
) {
// Ensure draft goods receipt exists when PO is validated/delivered.
// Idempotent: guarded by purchase_order_id existence check in helper.
if ($purchaseOrder && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)) {
$this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
}
return new PurchaseOrderResource($purchaseOrder);
} catch (\Exception $e) {
}
catch (\Exception $e) {
Log::error('Error updating purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@ -158,28 +163,28 @@ class PurchaseOrderController extends Controller
throw new \RuntimeException('Aucun entrepôt disponible pour créer la réception de marchandise.');
}
$receiptNumber = 'GR-' . now()->format('Ym') . '-' . str_pad((string) $purchaseOrder->id, 4, '0', STR_PAD_LEFT);
$receiptNumber = 'GR-' . now()->format('Ym') . '-' . str_pad((string)$purchaseOrder->id, 4, '0', STR_PAD_LEFT);
$lines = collect($purchaseOrder->lines ?? [])
->filter(fn($line) => !empty($line->product_id))
->map(function ($line) {
return [
'product_id' => (int) $line->product_id,
'packaging_id' => null,
'packages_qty_received' => null,
'units_qty_received' => (float) $line->quantity,
'qty_received_base' => (float) $line->quantity,
'unit_price' => (float) $line->unit_price,
'unit_price_per_package' => null,
'tva_rate_id' => null,
];
})
return [
'product_id' => (int)$line->product_id,
'packaging_id' => null,
'packages_qty_received' => null,
'units_qty_received' => (float)$line->quantity,
'qty_received_base' => (float)$line->quantity,
'unit_price' => (float)$line->unit_price,
'unit_price_per_package' => null,
'tva_rate_id' => null,
];
})
->values()
->all();
$this->goodsReceiptRepository->create([
'purchase_order_id' => $purchaseOrder->id,
'warehouse_id' => (int) $warehouseId,
'warehouse_id' => (int)$warehouseId,
'receipt_number' => $receiptNumber,
'receipt_date' => now()->toDateString(),
'status' => 'draft',
@ -204,7 +209,8 @@ class PurchaseOrderController extends Controller
return response()->json([
'message' => 'Commande fournisseur supprimée avec succès.',
], 200);
} catch (\Exception $e) {
}
catch (\Exception $e) {
Log::error('Error deleting purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@ -217,4 +223,4 @@ class PurchaseOrderController extends Controller
], 500);
}
}
}
}

View File

@ -17,6 +17,7 @@ use Illuminate\Http\Request;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Mail;
use App\Mail\DocumentMail;
use Symfony\Component\HttpFoundation\Response;
class QuoteController extends Controller
{
@ -199,4 +200,34 @@ class QuoteController extends Controller
], 500);
}
}
/**
* Download the quote as a PDF.
*/
public function downloadPdf(string $id): Response|JsonResponse
{
try {
$quote = Quote::with(['client', 'group', 'lines'])->find($id);
if (! $quote) {
return response()->json([
'message' => 'Devis non trouvé.',
], 404);
}
$pdf = Pdf::loadView('pdf.quote_pdf', ['quote' => $quote]);
return $pdf->download('Devis_' . $quote->reference . '.pdf');
} catch (\Exception $e) {
Log::error('Error downloading quote PDF: ' . $e->getMessage(), [
'exception' => $e,
'quote_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la generation du PDF.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -24,6 +24,8 @@ class StoreClientGroupRequest extends FormRequest
return [
'name' => 'required|string|max:191|unique:client_groups,name',
'description' => 'nullable|string',
'client_ids' => 'sometimes|array',
'client_ids.*' => 'integer|distinct|exists:clients,id',
];
}
@ -35,6 +37,10 @@ class StoreClientGroupRequest extends FormRequest
'name.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
'name.unique' => 'Un groupe avec ce nom existe déjà.',
'description.string' => 'La description doit être une chaîne de caractères.',
'client_ids.array' => 'La liste des clients doit être un tableau.',
'client_ids.*.integer' => 'Chaque ID client doit être un entier.',
'client_ids.*.distinct' => 'Un client ne peut pas être sélectionné plusieurs fois.',
'client_ids.*.exists' => 'Un ou plusieurs clients sélectionnés sont introuvables.',
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePriceListRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:191|unique:price_lists,name',
'valid_from' => 'nullable|date',
'valid_to' => 'nullable|date|after_or_equal:valid_from',
'is_default' => 'nullable|boolean',
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom de la liste de prix est obligatoire.',
'name.unique' => 'Une liste de prix avec ce nom existe déjà.',
'valid_from.date' => 'La date de début doit être une date valide.',
'valid_to.date' => 'La date de fin doit être une date valide.',
'valid_to.after_or_equal' => 'La date de fin doit être postérieure ou égale à la date de début.',
'is_default.boolean' => 'Le statut par défaut doit être vrai ou faux.',
];
}
}

View File

@ -22,7 +22,7 @@ class StoreQuoteRequest extends FormRequest
public function rules(): array
{
return [
'client_id' => 'required|exists:clients,id',
'client_id' => 'nullable|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id',
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'required|date',
@ -46,10 +46,32 @@ class StoreQuoteRequest extends FormRequest
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$hasClient = filled($this->input('client_id'));
$hasGroup = filled($this->input('group_id'));
if (! $hasClient && ! $hasGroup) {
$message = 'Un client ou un groupe de clients est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('group_id', $message);
}
if ($hasClient && $hasGroup) {
$message = 'Sélectionnez soit un client, soit un groupe de clients.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('group_id', $message);
}
});
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.nullable' => 'Le client sélectionné est invalide.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'status.required' => 'Le statut est obligatoire.',

View File

@ -22,7 +22,10 @@ class UpdateClientGroupRequest extends FormRequest
*/
public function rules(): array
{
$clientGroupId = $this->route('client_group') ? $this->route('client_group')->id : $this->route('id');
$routeClientGroup = $this->route('client_group');
$clientGroupId = is_object($routeClientGroup)
? $routeClientGroup->id
: ($routeClientGroup ?? $this->route('id'));
return [
'name' => [
@ -32,6 +35,8 @@ class UpdateClientGroupRequest extends FormRequest
Rule::unique('client_groups', 'name')->ignore($clientGroupId)
],
'description' => 'nullable|string',
'client_ids' => 'sometimes|array',
'client_ids.*' => 'integer|distinct|exists:clients,id',
];
}
@ -43,6 +48,10 @@ class UpdateClientGroupRequest extends FormRequest
'name.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
'name.unique' => 'Un groupe avec ce nom existe déjà.',
'description.string' => 'La description doit être une chaîne de caractères.',
'client_ids.array' => 'La liste des clients doit être un tableau.',
'client_ids.*.integer' => 'Chaque ID client doit être un entier.',
'client_ids.*.distinct' => 'Un client ne peut pas être sélectionné plusieurs fois.',
'client_ids.*.exists' => 'Un ou plusieurs clients sélectionnés sont introuvables.',
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePriceListRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$routePriceList = $this->route('price_list');
$priceListId = is_object($routePriceList)
? $routePriceList->id
: ($routePriceList ?? $this->route('id'));
return [
'name' => [
'required',
'string',
'max:191',
Rule::unique('price_lists', 'name')->ignore($priceListId),
],
'valid_from' => 'nullable|date',
'valid_to' => 'nullable|date|after_or_equal:valid_from',
'is_default' => 'nullable|boolean',
];
}
public function messages(): array
{
return [
'name.required' => 'Le nom de la liste de prix est obligatoire.',
'name.unique' => 'Une liste de prix avec ce nom existe déjà.',
'valid_from.date' => 'La date de début doit être une date valide.',
'valid_to.date' => 'La date de fin doit être une date valide.',
'valid_to.after_or_equal' => 'La date de fin doit être postérieure ou égale à la date de début.',
'is_default.boolean' => 'Le statut par défaut doit être vrai ou faux.',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Resources\Client;
use App\Http\Resources\Client\ClientResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@ -18,6 +19,9 @@ class ClientGroupResource extends JsonResource
'id' => $this->id,
'name' => $this->name,
'description' => $this->description ?? null,
'clients_count' => $this->whenCounted('clients'),
'client_ids' => $this->whenLoaded('clients', fn () => $this->clients->pluck('id')->values()),
'clients' => ClientResource::collection($this->whenLoaded('clients')),
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
];

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PriceListResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'valid_from' => $this->valid_from?->format('Y-m-d'),
'valid_to' => $this->valid_to?->format('Y-m-d'),
'is_default' => (bool) $this->is_default,
];
}
}

View File

@ -61,6 +61,11 @@ class Client extends Model
return $this->belongsTo(ClientCategory::class, 'client_category_id');
}
public function group(): BelongsTo
{
return $this->belongsTo(ClientGroup::class, 'group_id');
}
/**
* Get the human-readable label for the client type.
*/

View File

@ -24,6 +24,10 @@ class AppServiceProvider extends ServiceProvider
return new \App\Repositories\ClientGroupRepository($app->make(\App\Models\ClientGroup::class));
});
$this->app->bind(\App\Repositories\PriceListRepositoryInterface::class, function ($app) {
return new \App\Repositories\PriceListRepository($app->make(\App\Models\PriceList::class));
});
$this->app->bind(\App\Repositories\ClientContactRepositoryInterface::class, function ($app) {
return new \App\Repositories\ClientContactRepository($app->make(\App\Models\ClientContact::class));
});

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\PriceList;
class PriceListRepository extends BaseRepository implements PriceListRepositoryInterface
{
public function __construct(PriceList $model)
{
parent::__construct($model);
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface PriceListRepositoryInterface extends BaseRepositoryInterface
{
}

View File

@ -25,8 +25,8 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('nom', 'like', '%' . $filters['search'] . '%')
->orWhere('reference', 'like', '%' . $filters['search'] . '%')
->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
->orWhere('reference', 'like', '%' . $filters['search'] . '%')
->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
});
}
@ -44,7 +44,7 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
if (isset($filters['expiring_soon'])) {
$query->where('date_expiration', '<=', now()->addDays(30)->toDateString())
->where('date_expiration', '>=', now()->toDateString());
->where('date_expiration', '>=', now()->toDateString());
}
if (isset($filters['is_intervention'])) {
@ -82,11 +82,12 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
if ($exactMatch) {
$query->where('nom', $name);
} else {
}
else {
$query->where('nom', 'like', '%' . $name . '%');
}
return $query->paginate($perPage);
return $query->get();
}
/**
@ -147,8 +148,8 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
{
return $this->model->newQuery()
->whereHas('category', function ($query) {
$query->where('intervention', true);
})
$query->where('intervention', true);
})
->first();
}
}
}

View File

@ -24,7 +24,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
return $this->model->with(['client', 'lines.product'])->get($columns);
return $this->model->with(['client', 'group', 'lines.product'])->get($columns);
}
public function create(array $data): Quote
@ -123,7 +123,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function find(int|string $id, array $columns = ['*']): ?Quote
{
return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns);
return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $quoteId, ?string $oldStatus, string $newStatus, ?string $comment = null): void

View File

@ -0,0 +1,28 @@
<?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('quotes', function (Blueprint $table) {
$table->unsignedBigInteger('client_id')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('quotes', function (Blueprint $table) {
$table->unsignedBigInteger('client_id')->nullable(false)->change();
});
}
};

View File

@ -26,17 +26,19 @@
<p>Solutions de gestion funéraire</p>
</div>
<div class="client-info">
<h3>CLIENT</h3>
<p><strong>{{ $quote->client->name }}</strong></p>
<p>{{ $quote->client->billing_address_line1 }}</p>
<p>{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}</p>
<h3>{{ $quote->client ? 'CLIENT' : 'GROUPE CLIENT' }}</h3>
<p><strong>{{ $quote->client?->name ?? $quote->group?->name ?? 'Destinataire inconnu' }}</strong></p>
@if($quote->client)
<p>{{ $quote->client->billing_address_line1 }}</p>
<p>{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}</p>
@endif
</div>
<div class="clear"></div>
</div>
<div class="title">Devis : {{ $quote->reference }}</div>
<p>Date : {{ $quote->quote_date->format('d/m/Y') }}<br>
Valable jusqu'au : {{ $quote->valid_until->format('d/m/Y') }}</p>
Valable jusqu'au : {{ $quote->valid_until?->format('d/m/Y') ?? 'Non definie' }}</p>
<table class="details-table">
<thead>
@ -51,7 +53,7 @@
@foreach($quote->lines as $line)
<tr>
<td>{{ $line->description }}</td>
<td style="text-align: right;">{{ $line->quantity }}</td>
<td style="text-align: right;">{{ $line->quantity ?? $line->units_qty ?? $line->qty_base ?? 0 }}</td>
<td style="text-align: right;">{{ number_format($line->unit_price, 2, ',', ' ') }} {{ $quote->currency }}</td>
<td style="text-align: right;">{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $quote->currency }}</td>
</tr>

View File

@ -21,6 +21,7 @@ use App\Http\Controllers\Api\FileAttachmentController;
use App\Http\Controllers\Api\QuoteController;
use App\Http\Controllers\Api\ClientActivityTimelineController;
use App\Http\Controllers\Api\PurchaseOrderController;
use App\Http\Controllers\Api\PriceListController;
use App\Http\Controllers\Api\TvaRateController;
use App\Http\Controllers\Api\GoodsReceiptController;
@ -57,6 +58,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('clients', ClientController::class);
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
Route::apiResource('client-groups', ClientGroupController::class);
Route::apiResource('price-lists', PriceListController::class);
Route::apiResource('client-locations', ClientLocationController::class);
Route::apiResource('client-locations', ClientLocationController::class);
@ -77,6 +79,7 @@ Route::middleware('auth:sanctum')->group(function () {
// Quote management
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
Route::get('/quotes/{id}/download-pdf', [QuoteController::class, 'downloadPdf']);
Route::apiResource('quotes', QuoteController::class);
// Invoice management

View File

@ -0,0 +1,268 @@
<template>
<!-- Backdrop -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="isOpen" class="modal-backdrop" @mousedown.self="$emit('close')">
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<!-- Header -->
<div class="modal-header">
<div class="modal-title-wrap">
<div class="modal-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</div>
<div>
<h2 id="modal-title" class="modal-title">Assigner un praticien</h2>
<p class="modal-sub">Sélectionnez un praticien et son rôle</p>
</div>
</div>
<button class="close-btn" @click="$emit('close')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Practitioner ID -->
<div class="form-group">
<label class="form-label">Identifiant du praticien</label>
<input
v-model="form.practitionerId"
type="number"
class="form-input"
placeholder="ex: 42"
min="1"
/>
<p class="form-hint">Entrez l'ID du praticien à assigner à cette intervention.</p>
</div>
<!-- Role -->
<div class="form-group">
<label class="form-label">Rôle</label>
<div class="role-grid">
<button
class="role-option"
:class="{ selected: form.role === 'principal' }"
@click="form.role = 'principal'"
type="button"
>
<div class="role-radio">
<div v-if="form.role === 'principal'" class="role-radio-dot"></div>
</div>
<div class="role-info">
<div class="role-name">Principal</div>
<div class="role-desc">Responsable de l'intervention</div>
</div>
<span class="role-chip chip-principal">Principal</span>
</button>
<button
class="role-option"
:class="{ selected: form.role === 'assistant' }"
@click="form.role = 'assistant'"
type="button"
>
<div class="role-radio">
<div v-if="form.role === 'assistant'" class="role-radio-dot"></div>
</div>
<div class="role-info">
<div class="role-name">Assistant</div>
<div class="role-desc">Rôle de soutien et assistance</div>
</div>
<span class="role-chip chip-assistant">Assistant</span>
</button>
</div>
</div>
<!-- Validation error -->
<Transition name="slide-error">
<div v-if="error" class="error-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ error }}
</div>
</Transition>
</div>
<!-- Footer -->
<div class="modal-footer">
<button class="btn-ghost" @click="$emit('close')">Annuler</button>
<button class="btn-primary" @click="handleSubmit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Confirmer l'assignation
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch, defineProps, defineEmits } from 'vue';
const props = defineProps({
isOpen: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'assign']);
const form = ref({ practitionerId: '', role: 'principal' });
const error = ref('');
// Reset form when modal opens
watch(() => props.isOpen, open => {
if (open) { form.value = { practitionerId: '', role: 'principal' }; error.value = ''; }
});
const handleSubmit = () => {
error.value = '';
if (!form.value.practitionerId) {
error.value = 'Veuillez entrer un identifiant de praticien.';
return;
}
if (parseInt(form.value.practitionerId) <= 0) {
error.value = 'L\'identifiant doit être un nombre positif.';
return;
}
emit('assign', {
practitionerId: parseInt(form.value.practitionerId),
role: form.value.role,
});
};
</script>
<style scoped>
/* ── Tokens ── */
.modal-backdrop {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface: #ffffff;
--surface-2:#f8fafc;
--border: #e2e8f0;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
--r-md: 12px;
position: fixed; inset: 0;
background: rgba(15,23,42,.45);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
z-index: 1000; padding: 20px;
font-family: 'Inter', system-ui, sans-serif;
}
/* ── Modal box ── */
.modal-box {
background: var(--surface);
border-radius: 16px;
width: 100%; max-width: 460px;
box-shadow: 0 24px 64px rgba(0,0,0,.18), 0 0 0 1px rgba(0,0,0,.05);
overflow: hidden;
}
/* Header */
.modal-header {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 22px 24px 18px; border-bottom: 1px solid var(--border);
}
.modal-title-wrap { display: flex; align-items: center; gap: 12px; }
.modal-icon {
width: 38px; height: 38px; border-radius: 10px;
background: var(--brand-lt); color: var(--brand);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.modal-title { font-size: 16px; font-weight: 700; color: var(--text-1); margin: 0; }
.modal-sub { font-size: 12.5px; color: var(--text-3); margin: 2px 0 0; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border);
background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: var(--text-3); transition: all .15s; flex-shrink: 0;
}
.close-btn:hover { background: var(--surface-2); color: var(--text-1); }
/* Body */
.modal-body { padding: 20px 24px; display: flex; flex-direction: column; gap: 18px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 12px; font-weight: 700; color: var(--text-2); text-transform: uppercase; letter-spacing: .5px; }
.form-hint { font-size: 11.5px; color: var(--text-3); margin: 2px 0 0; }
.form-input {
padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13.5px; color: var(--text-1); background: var(--surface); outline: none;
transition: border-color .15s, box-shadow .15s; font-family: inherit; width: 100%;
}
.form-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.1); }
/* Role grid */
.role-grid { display: flex; flex-direction: column; gap: 8px; }
.role-option {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
border: 1.5px solid var(--border); border-radius: var(--r-sm);
background: transparent; cursor: pointer; text-align: left; width: 100%;
transition: all .15s;
}
.role-option:hover { border-color: #a5b4fc; background: var(--brand-lt); }
.role-option.selected { border-color: var(--brand); background: var(--brand-lt); }
.role-radio {
width: 17px; height: 17px; border-radius: 50%; border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: border-color .15s;
}
.role-option.selected .role-radio { border-color: var(--brand); }
.role-radio-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brand); }
.role-info { flex: 1; }
.role-name { font-size: 13.5px; font-weight: 600; color: var(--text-1); }
.role-desc { font-size: 11.5px; color: var(--text-3); margin-top: 1px; }
.role-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; }
.chip-principal { background: #eef2ff; color: #4f46e5; }
.chip-assistant { background: #f0fdf4; color: #16a34a; }
/* Error */
.error-banner {
display: flex; align-items: center; gap: 8px;
padding: 10px 13px; background: #fef2f2; border: 1px solid #fecaca;
border-radius: var(--r-sm); font-size: 13px; color: #dc2626; font-weight: 500;
}
/* Footer */
.modal-footer {
display: flex; gap: 10px; justify-content: flex-end;
padding: 16px 24px; background: var(--surface-2); border-top: 1px solid var(--border);
}
.btn-primary {
display: inline-flex; align-items: center; gap: 7px;
padding: 9px 18px; border-radius: var(--r-sm); border: none; cursor: pointer;
font-size: 13px; font-weight: 600; color: white; background: var(--brand);
transition: all .15s;
}
.btn-primary:hover { background: var(--brand-dk); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
.btn-ghost {
display: inline-flex; align-items: center; gap: 7px;
padding: 9px 16px; border-radius: var(--r-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-2); background: transparent;
transition: all .15s;
}
.btn-ghost:hover { background: var(--border); color: var(--text-1); }
/* ── Transitions ── */
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity .2s ease; }
.modal-fade-enter-active .modal-box, .modal-fade-leave-active .modal-box { transition: transform .2s ease, opacity .2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
.modal-fade-enter-from .modal-box, .modal-fade-leave-to .modal-box { transform: scale(.96) translateY(8px); opacity: 0; }
.slide-error-enter-active, .slide-error-leave-active { transition: all .2s ease; }
.slide-error-enter-from, .slide-error-leave-to { opacity: 0; transform: translateY(-6px); }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,230 @@
<template>
<div class="sidebar-wrap">
<!-- Hero Card -->
<div class="hero-card">
<div class="hero-avatar">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h2 class="hero-name">{{ intervention.defuntName || 'Personne inconnue' }}</h2>
<p class="hero-type">{{ intervention.title || 'Type non défini' }}</p>
<div class="status-badge" :class="'sb-' + (intervention.status?.color || 'secondary')">
{{ intervention.status?.label || 'En attente' }}
</div>
</div>
<div class="divider"></div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="qs-row">
<div class="qs-icon" style="background:#eef2ff;color:#4f46e5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<div class="qs-text">
<div class="qs-label">Date</div>
<div class="qs-value">{{ intervention.date || '—' }}</div>
</div>
</div>
<div class="qs-row">
<div class="qs-icon" style="background:#ecfdf5;color:#059669">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<div class="qs-text">
<div class="qs-label">Lieu</div>
<div class="qs-value">{{ intervention.lieux || '—' }}</div>
</div>
</div>
<div class="qs-row">
<div class="qs-icon" style="background:#fff7ed;color:#d97706">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="qs-text">
<div class="qs-label">Durée</div>
<div class="qs-value">{{ intervention.duree || '—' }}</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Team preview -->
<div v-if="intervention.members?.length" class="team-preview">
<div class="tp-label">Équipe</div>
<div class="tp-avatars">
<div
v-for="(m, i) in intervention.members.slice(0, 5)"
:key="i"
class="tp-avatar"
:title="m.name"
:style="{ zIndex: 10 - i }"
>
{{ getInitials(m.name) }}
</div>
<div v-if="intervention.members.length > 5" class="tp-avatar tp-more">
+{{ intervention.members.length - 5 }}
</div>
</div>
</div>
<div class="divider"></div>
<!-- Tab Navigation -->
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<span class="tab-icon" v-html="tab.icon"></span>
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{ teamCount }}</span>
<span v-if="tab.id === 'documents' && documentsCount > 0" class="tab-badge">{{ documentsCount }}</span>
</button>
</nav>
<!-- Assign Button -->
<div class="assign-wrap">
<button class="assign-btn" @click="$emit('assign-practitioner')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Assigner un praticien
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
intervention: { type: Object, required: true },
activeTab: { type: String, default: 'overview' },
practitioners:{ type: Array, default: () => [] },
teamCount: { type: Number, default: 0 },
documentsCount:{ type: Number, default: 0 },
});
defineEmits(['change-tab', 'assign-practitioner']);
const getInitials = n => n ? n.split(' ').map(w => w[0]).join('').toUpperCase().substring(0,2) : '?';
const tabs = [
{ id:'overview', label:"Vue d'ensemble", icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
{ id:'details', label:'Détails', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
{ id:'team', label:'Équipe', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>` },
{ id:'documents', label:'Documents', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
{ id:'quote', label:'Devis', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
{ id:'history', label:'Historique', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
];
</script>
<style scoped>
.sidebar-wrap {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface: #ffffff;
--surface-2:#f8fafc;
--border: #e2e8f0;
--border-lt:#f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
font-family: 'Inter', system-ui, sans-serif;
}
/* Hero */
.hero-card {
padding: 24px 20px 18px;
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
}
.hero-avatar {
width: 58px; height: 58px; border-radius: 50%;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: flex; align-items: center; justify-content: center;
color: white; margin-bottom: 2px;
box-shadow: 0 4px 14px rgba(79,70,229,.28);
}
.hero-name { font-size: 15px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.3; }
.hero-type { font-size: 12px; color: var(--text-2); margin: 0; font-weight: 500; }
/* Status badge */
.status-badge { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
.sb-success { background:#dcfce7; color:#16a34a; }
.sb-warning { background:#fef9c3; color:#ca8a04; }
.sb-danger { background:#fee2e2; color:#dc2626; }
.sb-info { background:#dbeafe; color:#2563eb; }
.sb-primary { background:#eef2ff; color:#4f46e5; }
.sb-secondary{ background:#f1f5f9; color:#64748b; }
.divider { height: 1px; background: var(--border-lt); }
/* Quick stats */
.quick-stats { padding: 14px 18px; display: flex; flex-direction: column; gap: 10px; }
.qs-row { display: flex; align-items: flex-start; gap: 10px; }
.qs-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.qs-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); font-weight: 600; }
.qs-value { font-size: 12.5px; color: var(--text-1); font-weight: 500; margin-top: 1px; }
/* Team preview */
.team-preview { padding: 12px 18px; display: flex; align-items: center; gap: 12px; }
.tp-label { font-size: 11.5px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .4px; }
.tp-avatars { display: flex; }
.tp-avatar {
width: 30px; height: 30px; border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: 2px solid var(--surface);
color: white; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
margin-left: -6px; cursor: default;
transition: transform .15s;
}
.tp-avatar:first-child { margin-left: 0; }
.tp-avatar:hover { transform: translateY(-3px); }
.tp-more { background: var(--surface-2); color: var(--text-2); font-size: 9px; }
/* Tab nav */
.tab-nav { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
.tab-item {
display: flex; align-items: center; gap: 9px; padding: 8px 11px;
border-radius: var(--r-sm); border: none; background: transparent; cursor: pointer;
width: 100%; text-align: left; font-size: 13px; font-weight: 500; color: var(--text-2);
transition: all .12s;
}
.tab-item:hover { background: var(--surface-2); color: var(--text-1); }
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
.tab-item.active .tab-icon { color: var(--brand); }
.tab-label { flex: 1; }
.tab-badge {
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px;
background: var(--brand); color: white; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
/* Assign */
.assign-wrap { padding: 0 10px 12px; }
.assign-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px; border: 1.5px dashed var(--border); border-radius: var(--r-sm);
background: transparent; cursor: pointer; font-size: 12.5px; font-weight: 500; color: var(--text-2);
transition: all .15s;
}
.assign-btn:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-lt); }
</style>

View File

@ -0,0 +1,803 @@
<template>
<div class="intervention-page">
<!-- Top Navigation Bar -->
<div class="page-topbar">
<router-link to="/interventions" class="back-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Interventions
</router-link>
<div v-if="intervention" class="topbar-center">
<span class="topbar-id">#{{ intervention.id }}</span>
<span class="topbar-divider">·</span>
<span class="topbar-name">{{ getDeceasedName(intervention) }}</span>
</div>
<div class="topbar-right">
<div v-if="interventionStore.isLoading" class="topbar-loading">
<div class="mini-spinner"></div>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="interventionStore.isLoading && !intervention" class="fullpage-center">
<div class="loading-orb"></div>
<p class="loading-text">Chargement de l'intervention</p>
</div>
<!-- Error -->
<div v-else-if="interventionStore.getError && !intervention" class="fullpage-center">
<div class="state-icon error-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<h3 class="state-title">Erreur de chargement</h3>
<p class="state-desc">{{ interventionStore.getError }}</p>
<router-link to="/interventions" class="btn-primary">Retour à la liste</router-link>
</div>
<!-- Main Layout -->
<div v-else-if="intervention" class="page-layout">
<!-- LEFT SIDEBAR -->
<aside class="sidebar">
<!-- Hero -->
<div class="hero-card">
<div class="hero-avatar">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h2 class="hero-name">{{ getDeceasedName(intervention) }}</h2>
<p class="hero-type">{{ getTypeLabel(intervention.type) }}</p>
<StatusBadge :status="intervention.status" />
</div>
<!-- Quick Stats -->
<div class="quick-stats">
<QuickStat color="#4f46e5" :label="'Date'" :value="formatDate(intervention.scheduled_at)">
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></template>
</QuickStat>
<QuickStat color="#059669" :label="'Lieu'" :value="intervention.location?.name || 'Non défini'">
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></template>
</QuickStat>
<QuickStat color="#d97706" :label="'Durée'" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'">
<template #icon><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></template>
</QuickStat>
</div>
<!-- Tab Nav -->
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<span class="tab-icon" v-html="tab.icon"></span>
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
</button>
</nav>
<!-- Assign Button -->
<button class="assign-cta" @click="openAssignModal">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
Assigner un praticien
</button>
</aside>
<!-- MAIN CONTENT -->
<main class="main-content">
<!-- OVERVIEW -->
<section v-if="activeTab === 'overview'" class="tab-section">
<SectionHeader title="Vue d'ensemble" />
<div class="info-grid">
<InfoCard title="Informations générales" accent="#4f46e5">
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M6 20v-2a6 6 0 0 1 12 0v2"/></svg></template>
<DataRow label="Nom du défunt" :value="getDeceasedName(intervention)" />
<DataRow label="Date prévue" :value="formatDate(intervention.scheduled_at)" />
<DataRow label="Lieu" :value="intervention.location?.name || 'Non défini'" />
<DataRow label="Durée" :value="intervention.duration_min ? intervention.duration_min + ' min' : 'Non définie'" />
</InfoCard>
<InfoCard title="Contact & Communication" accent="#10b981">
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 9.91a16 16 0 0 0 6.16 6.16l.91-.91a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 17z"/></svg></template>
<DataRow label="Contact familial" :value="intervention.order_giver || 'Non renseigné'" />
<DataRow label="Email / Tél." :value="intervention.client ? (intervention.client.email || intervention.client.phone || '-') : '-'" />
<DataRow label="Type intervention" :value="getTypeLabel(intervention.type)" />
</InfoCard>
<InfoCard title="Notes & Description" accent="#8b5cf6" class="full-col">
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></template>
<p class="notes-text">{{ intervention.notes || 'Aucune description disponible.' }}</p>
</InfoCard>
</div>
</section>
<!-- DETAILS -->
<section v-if="activeTab === 'details'" class="tab-section">
<SectionHeader title="Détails de l'intervention" />
<div class="card-wrap">
<!-- Editable form fields -->
<div class="edit-form">
<div class="form-row">
<div class="form-group">
<label>Type d'intervention</label>
<select v-model="editForm.type" class="form-select">
<option value="thanatopraxie">Thanatopraxie</option>
<option value="toilette_mortuaire">Toilette mortuaire</option>
<option value="exhumation">Exhumation</option>
<option value="retrait_pacemaker">Retrait pacemaker</option>
<option value="retrait_bijoux">Retrait bijoux</option>
<option value="autre">Autre</option>
</select>
</div>
<div class="form-group">
<label>Statut</label>
<select v-model="editForm.status" class="form-select">
<option value="demande">Demande</option>
<option value="planifie">Planifié</option>
<option value="en_cours">En cours</option>
<option value="termine">Terminé</option>
<option value="annule">Annulé</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Date prévue</label>
<input type="datetime-local" v-model="editForm.scheduled_at" class="form-input" />
</div>
<div class="form-group">
<label>Durée (minutes)</label>
<input type="number" v-model="editForm.duration_min" class="form-input" min="0" />
</div>
</div>
<div class="form-group">
<label>Contact familial (donneur d'ordre)</label>
<input type="text" v-model="editForm.order_giver" class="form-input" />
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="editForm.notes" class="form-textarea" rows="4" placeholder="Ajouter des notes…"></textarea>
</div>
<div class="form-actions">
<button class="btn-ghost" @click="resetForm">Annuler</button>
<button class="btn-primary" :disabled="interventionStore.isLoading" @click="submitUpdate">
<span v-if="interventionStore.isLoading" class="mini-spinner white"></span>
Enregistrer les modifications
</button>
</div>
</div>
</div>
</section>
<!-- TEAM -->
<section v-if="activeTab === 'team'" class="tab-section">
<SectionHeader title="Équipe assignée">
<button class="btn-primary sm" @click="openAssignModal">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Ajouter
</button>
</SectionHeader>
<div v-if="intervention.practitioners?.length" class="practitioner-grid">
<div v-for="(p, i) in intervention.practitioners" :key="i" class="practitioner-card">
<div class="pract-avatar">{{ getInitials(getPractName(p)) }}</div>
<div class="pract-info">
<div class="pract-name">{{ getPractName(p) }}</div>
<span class="role-chip" :class="p.pivot?.role === 'principal' ? 'chip-principal' : 'chip-assistant'">
{{ p.pivot?.role === 'principal' ? 'Principal' : 'Assistant' }}
</span>
</div>
<button class="unassign-btn" title="Désassigner" @click="handleUnassign(p)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<EmptyState v-else icon="team" message="Aucun praticien assigné">
<button class="btn-primary sm" @click="openAssignModal">Assigner maintenant</button>
</EmptyState>
</section>
<!-- DOCUMENTS -->
<section v-if="activeTab === 'documents'" class="tab-section">
<SectionHeader title="Documents" />
<DocumentManagement
:documents="documentAttachments"
:loading="documentStore.isLoading"
:error="documentStore.getError"
@files-selected="() => {}"
@upload-files="handleUploadFiles"
@delete-document="handleDeleteDocument"
@delete-documents="handleDeleteDocuments"
@update-document-label="handleUpdateDocumentLabel"
@retry="loadDocuments"
/>
</section>
<!-- QUOTE -->
<section v-if="activeTab === 'quote'" class="tab-section">
<SectionHeader title="Devis associé">
<router-link v-if="intervention.quote?.id" :to="`/ventes/devis/${intervention.quote.id}`" class="btn-primary sm">
Ouvrir le devis
</router-link>
</SectionHeader>
<div v-if="intervention.quote">
<div class="info-grid">
<InfoCard title="Informations" accent="#3b82f6">
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></template>
<DataRow label="Référence" :value="intervention.quote.reference" />
<DataRow label="Date" :value="intervention.quote.quote_date" />
<DataRow label="Validité" :value="intervention.quote.valid_until" />
<div class="data-row">
<span class="data-label">Statut</span>
<span class="status-chip" :class="'sc-' + getQuoteColor(intervention.quote.status)">{{ getQuoteLabel(intervention.quote.status) }}</span>
</div>
</InfoCard>
<InfoCard title="Montants" accent="#10b981">
<template #icon><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></template>
<DataRow label="Total HT" :value="fmtCurrency(intervention.quote.total_ht)" />
<DataRow label="Total TVA" :value="fmtCurrency(intervention.quote.total_tva)" />
<DataRow label="Total TTC" :value="fmtCurrency(intervention.quote.total_ttc)" :bold="true" />
</InfoCard>
</div>
<div v-if="intervention.quote.lines?.length" class="quote-lines">
<div class="lines-title">Lignes du devis</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>Description</th><th class="tc">Qté</th><th class="tr">PU HT</th><th class="tr">Total HT</th></tr></thead>
<tbody>
<tr v-for="l in intervention.quote.lines" :key="l.id">
<td>{{ l.description || '-' }}</td>
<td class="tc">{{ l.units_qty || 0 }}</td>
<td class="tr">{{ fmtCurrency(l.unit_price) }}</td>
<td class="tr fw6">{{ fmtCurrency(l.total_ht) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<EmptyState v-else icon="quote" message="Aucun devis associé à cette intervention" />
</section>
<!-- HISTORY -->
<section v-if="activeTab === 'history'" class="tab-section">
<SectionHeader title="Historique" />
<EmptyState icon="history" message="Historique des modifications">
<span class="coming-soon-chip">Fonctionnalité à venir</span>
</EmptyState>
</section>
</main>
</div>
<!-- Assign Modal -->
<AssignPractitionerModal
:is-open="isModalOpen"
@close="closeAssignModal"
@assign="handleAssignPractitioner"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, defineComponent, h } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useInterventionStore } from '@/stores/interventionStore';
import { useNotificationStore } from '@/stores/notification';
import { useDocumentAttachmentStore } from '@/stores/documentAttachmentStore';
import DocumentManagement from '@/components/molecules/Interventions/DocumentManagement.vue';
import AssignPractitionerModal from '@/components/molecules/intervention/AssignPractitionerModal.vue';
// Inline sub-components
const StatusBadge = {
props: { status: String },
setup(props) {
const map = { demande: ['warning','Demande'], planifie: ['info','Planifié'], en_cours: ['primary','En cours'], termine: ['success','Terminé'], annule: ['danger','Annulé'] };
const [color, label] = map[props.status] || ['secondary', props.status || 'En attente'];
return () => h('span', { class: `status-badge sb-${color}` }, label);
}
};
const QuickStat = {
props: { label: String, value: String, color: String },
template: `
<div class="qs-item">
<div class="qs-icon" :style="{ background: color + '18', color }"><slot name="icon"/></div>
<div><div class="qs-label">{{ label }}</div><div class="qs-value">{{ value || '-' }}</div></div>
</div>
`
};
const InfoCard = {
props: { title: String, accent: String },
template: `
<div class="info-card">
<div class="info-card-header" :style="{ '--a': accent }">
<span class="ic-icon"><slot name="icon"/></span>
<span class="ic-title">{{ title }}</span>
</div>
<div class="info-card-body"><slot/></div>
</div>
`
};
const DataRow = {
props: { label: String, value: String, bold: Boolean },
template: `
<div class="data-row">
<span class="data-label">{{ label }}</span>
<span class="data-value" :class="bold ? 'fw6' : ''">{{ value || '-' }}</span>
</div>
`
};
const SectionHeader = {
props: { title: String },
template: `<div class="section-header"><h3 class="section-title">{{ title }}</h3><slot/></div>`
};
const EmptyState = {
props: { icon: String, message: String },
setup(props, { slots }) {
const icons = {
team: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="23" y1="11" x2="17" y2="11"/><line x1="20" y1="8" x2="20" y2="14"/></svg>`,
quote: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
history: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
};
return () => h('div', { class: 'empty-state' }, [
h('div', { class: 'empty-icon', innerHTML: icons[props.icon] || icons.team }),
h('p', { class: 'empty-msg' }, props.message),
slots.default?.(),
]);
}
};
// Setup
const route = useRoute();
const interventionStore = useInterventionStore();
const notifStore = useNotificationStore();
const documentStore = useDocumentAttachmentStore();
const intervention = ref(null);
const activeTab = ref('overview');
const isModalOpen = ref(false);
const editForm = ref({});
const documentAttachments = computed(() =>
documentStore.getInterventionAttachments(intervention.value?.id || 0)
);
const tabs = computed(() => [
{ id: 'overview', label: "Vue d'ensemble", icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
{ id: 'details', label: 'Détails', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
{ id: 'team', label: 'Équipe', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`, badge: intervention.value?.practitioners?.length || null },
{ id: 'documents', label: 'Documents', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
{ id: 'quote', label: 'Devis', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
{ id: 'history', label: 'Historique', icon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
]);
// Helpers
const getDeceasedName = i => i?.deceased
? `${i.deceased.last_name || ''} ${i.deceased.first_name || ''}`.trim()
: `Personne ${i?.deceased_id || 'inconnue'}`;
const formatDate = v => v ? new Date(v).toLocaleString('fr-FR') : 'Non définie';
const getTypeLabel = t => ({ thanatopraxie:'Thanatopraxie', toilette_mortuaire:'Toilette mortuaire', exhumation:'Exhumation', retrait_pacemaker:'Retrait pacemaker', retrait_bijoux:'Retrait bijoux', autre:'Autre' }[t] || t || 'Type non défini');
const getQuoteLabel = s => ({ brouillon:'Brouillon', envoye:'Envoyé', accepte:'Accepté', refuse:'Refusé', expire:'Expiré' }[s] || s || 'Inconnu');
const getQuoteColor = s => ({ brouillon:'secondary', envoye:'info', accepte:'success', refuse:'danger', expire:'warning' }[s] || 'secondary');
const fmtCurrency = v => new Intl.NumberFormat('fr-FR', { style:'currency', currency:'EUR' }).format(Number(v || 0));
const getPractName = p => p.employee ? `${p.employee.first_name || ''} ${p.employee.last_name || ''}`.trim() : `${p.first_name || ''} ${p.last_name || ''}`.trim();
const getInitials = n => n ? n.split(' ').map(w => w[0]).join('').toUpperCase().substring(0,2) : '?';
// Edit form
const resetForm = () => {
if (!intervention.value) return;
editForm.value = {
type: intervention.value.type || '',
status: intervention.value.status || '',
scheduled_at: intervention.value.scheduled_at ? intervention.value.scheduled_at.substring(0,16) : '',
duration_min: intervention.value.duration_min || '',
order_giver: intervention.value.order_giver || '',
notes: intervention.value.notes || '',
};
};
const submitUpdate = async () => {
try {
const result = await interventionStore.updateIntervention({ id: intervention.value.id, ...editForm.value });
intervention.value = result;
notifStore.updated('Intervention');
} catch (e) {
notifStore.error('Erreur', 'Impossible de mettre à jour');
}
};
// Data fetch
const fetchIntervention = async () => {
try {
const id = parseInt(route.params.id);
if (id) {
intervention.value = await interventionStore.fetchInterventionById(id);
resetForm();
}
} catch (e) {
notifStore.error('Erreur', 'Impossible de charger l\'intervention');
}
};
// Modal & assignment
const openAssignModal = () => { isModalOpen.value = true; };
const closeAssignModal = () => { isModalOpen.value = false; };
const handleAssignPractitioner = async (data) => {
try {
const payload = data.role === 'principal'
? { principal_practitioner_id: data.practitionerId }
: { assistant_practitioner_ids: [data.practitionerId] };
await interventionStore.assignPractitioner(intervention.value.id, payload);
await fetchIntervention();
notifStore.created('Praticien assigné');
closeAssignModal();
} catch (e) {
notifStore.error('Erreur', 'Impossible d\'assigner');
}
};
const handleUnassign = async (p) => {
try {
await interventionStore.unassignPractitioner(intervention.value.id, p.id);
await fetchIntervention();
notifStore.updated('Praticien désassigné');
} catch (e) {
notifStore.error('Erreur', 'Impossible de désassigner');
}
};
// Documents
const loadDocuments = async () => {
if (!intervention.value?.id) return;
try { await documentStore.fetchInterventionFiles(intervention.value.id); }
catch (e) { documentStore.clearError(); }
};
const handleUploadFiles = async files => {
if (!intervention.value?.id || !files.length) return;
try { await documentStore.uploadAndAttachFiles(files, 'App\\Models\\Intervention', intervention.value.id); }
catch { documentStore.clearError(); }
};
const handleDeleteDocument = async id => { try { await documentStore.detachFile(id); } catch { documentStore.clearError(); } };
const handleDeleteDocuments = async ids => { try { await documentStore.detachMultipleFiles({ attachment_ids: ids }); } catch { documentStore.clearError(); } };
const handleUpdateDocumentLabel = async ({ id, label }) => { try { await documentStore.updateAttachmentMetadata(id, { label }); } catch { documentStore.clearError(); } };
// Watchers & lifecycle
watch(() => interventionStore.currentIntervention, v => { if (v) intervention.value = v; }, { deep: true });
watch(activeTab, tab => { if (tab === 'documents' && intervention.value?.id) loadDocuments(); });
onMounted(fetchIntervention);
</script>
<style scoped>
/* ── Design tokens ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
.intervention-page {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface: #ffffff;
--surface-2: #f8fafc;
--surface-3: #f1f5f9;
--border: #e2e8f0;
--border-lt: #f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
--r-md: 12px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
--shadow-md: 0 4px 16px rgba(0,0,0,.08);
min-height: 100vh;
color: var(--text-1);
display: flex;
flex-direction: column;
}
/* ── Top bar ───────────────────────────────────────────────────────────── */
.page-topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 24px;
height: 56px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-sm);
}
.back-btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 500; color: var(--text-2);
text-decoration: none; padding: 5px 10px; border-radius: var(--r-sm);
transition: background .15s, color .15s;
}
.back-btn:hover { background: var(--surface-3); color: var(--text-1); }
.topbar-center { display: flex; align-items: center; gap: 8px; margin: 0 auto; }
.topbar-id { font-size: 13px; font-weight: 600; color: var(--text-2); }
.topbar-divider { color: var(--text-3); }
.topbar-name { font-size: 14px; font-weight: 600; color: var(--text-1); }
.topbar-right { margin-left: auto; }
.topbar-loading { display: flex; align-items: center; }
/* ── Layout ────────────────────────────────────────────────────────────── */
.page-layout {
display: grid;
grid-template-columns: 272px 1fr;
flex: 1;
}
/* ── Sidebar ───────────────────────────────────────────────────────────── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 56px;
height: calc(100vh - 56px);
overflow-y: auto;
}
/* Hero */
.hero-card {
padding: 24px 20px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
border-bottom: 1px solid var(--border-lt);
}
.hero-avatar {
width: 60px; height: 60px; border-radius: 50%;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: flex; align-items: center; justify-content: center;
color: white; margin-bottom: 4px;
box-shadow: 0 4px 14px rgba(79,70,229,.3);
}
.hero-name { font-size: 15px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.3; }
.hero-type { font-size: 12px; color: var(--text-2); margin: 0; font-weight: 500; }
/* Quick stats */
.quick-stats {
padding: 14px 18px;
display: flex; flex-direction: column; gap: 10px;
border-bottom: 1px solid var(--border-lt);
}
.qs-item { display: flex; align-items: flex-start; gap: 10px; }
.qs-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.qs-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); font-weight: 600; }
.qs-value { font-size: 12.5px; color: var(--text-1); font-weight: 500; margin-top: 1px; }
/* Tab nav */
.tab-nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.tab-item {
display: flex; align-items: center; gap: 9px; padding: 8px 11px;
border-radius: var(--r-sm); border: none; background: transparent; cursor: pointer;
width: 100%; text-align: left; font-size: 13.5px; font-weight: 500; color: var(--text-2);
transition: all .12s;
}
.tab-item:hover { background: var(--surface-3); color: var(--text-1); }
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
.tab-item.active .tab-icon { color: var(--brand); }
.tab-label { flex: 1; }
.tab-badge {
min-width: 19px; height: 19px; padding: 0 5px; border-radius: 10px;
background: var(--brand); color: white; font-size: 10.5px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.tab-item.active .tab-badge { background: var(--brand-dk); }
/* Assign CTA */
.assign-cta {
margin: 0 10px 14px; display: flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px; border: 1.5px dashed var(--border); border-radius: var(--r-sm);
background: transparent; cursor: pointer; font-size: 12.5px; font-weight: 500; color: var(--text-2);
transition: all .15s;
}
.assign-cta:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-lt); }
/* ── Main content ──────────────────────────────────────────────────────── */
.main-content { padding: 24px 28px; overflow-y: auto; }
.tab-section { animation: fadeUp .2s ease; }
@keyframes fadeUp { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:translateY(0); } }
.section-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px;
}
.section-title { font-size: 17px; font-weight: 700; color: var(--text-1); margin: 0; }
/* ── Info grid ─────────────────────────────────────────────────────────── */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.full-col { grid-column: 1 / -1; }
/* Info card */
.info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.info-card-header {
display: flex; align-items: center; gap: 9px;
padding: 12px 16px; border-bottom: 1px solid var(--border-lt);
background: var(--surface-2);
}
.ic-icon {
width: 26px; height: 26px; border-radius: 7px; display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--a, #4f46e5) 14%, transparent);
color: var(--a, #4f46e5);
}
.ic-title {
font-size: 11.5px; font-weight: 700; color: var(--text-1);
text-transform: uppercase; letter-spacing: .6px;
}
.info-card-body { padding: 4px 16px 12px; }
/* Data row */
.data-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border-lt); gap: 12px; }
.data-row:last-child { border-bottom: none; }
.data-label { font-size: 12px; color: var(--text-3); font-weight: 500; flex-shrink: 0; }
.data-value { font-size: 13px; color: var(--text-1); text-align: right; }
.fw6 { font-weight: 600; }
.notes-text { font-size: 13.5px; color: var(--text-2); line-height: 1.7; margin: 8px 0 0; }
/* ── Edit form ─────────────────────────────────────────────────────────── */
.card-wrap {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-md); padding: 24px; box-shadow: var(--shadow-sm);
}
.edit-form { display: flex; flex-direction: column; gap: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .4px; }
.form-input, .form-select, .form-textarea {
padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13.5px; color: var(--text-1); background: var(--surface); outline: none;
transition: border-color .15s, box-shadow .15s; font-family: inherit;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.1);
}
.form-textarea { resize: vertical; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 4px; }
/* ── Team ──────────────────────────────────────────────────────────────── */
.practitioner-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.practitioner-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
box-shadow: var(--shadow-sm); transition: box-shadow .15s;
}
.practitioner-card:hover { box-shadow: var(--shadow-md); }
.pract-avatar {
width: 42px; height: 42px; border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.pract-info { flex: 1; }
.pract-name { font-size: 13.5px; font-weight: 600; color: var(--text-1); }
.role-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10.5px; font-weight: 600; margin-top: 3px; }
.chip-principal { background: #eef2ff; color: #4f46e5; }
.chip-assistant { background: #f0fdf4; color: #16a34a; }
.unassign-btn {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border);
background: var(--surface); cursor: pointer; display: flex; align-items: center; justify-content: center;
color: var(--text-3); transition: all .15s; flex-shrink: 0;
}
.unassign-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
/* ── Status badge ──────────────────────────────────────────────────────── */
.status-badge {
display: inline-flex; align-items: center; padding: 3px 10px;
border-radius: 20px; font-size: 11.5px; font-weight: 600; letter-spacing: .2px;
}
.sb-success { background:#dcfce7; color:#16a34a; }
.sb-warning { background:#fef9c3; color:#ca8a04; }
.sb-danger { background:#fee2e2; color:#dc2626; }
.sb-info { background:#dbeafe; color:#2563eb; }
.sb-primary { background:#eef2ff; color:#4f46e5; }
.sb-secondary{ background:#f1f5f9; color:#64748b; }
/* Status chip (quote) */
.status-chip { display:inline-block; padding:2px 9px; border-radius:10px; font-size:11.5px; font-weight:600; }
.sc-success { background:#dcfce7; color:#16a34a; }
.sc-info { background:#dbeafe; color:#2563eb; }
.sc-warning { background:#fef9c3; color:#ca8a04; }
.sc-danger { background:#fee2e2; color:#dc2626; }
.sc-secondary{ background:#f1f5f9; color:#64748b; }
/* ── Buttons ───────────────────────────────────────────────────────────── */
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 18px; border-radius: var(--r-sm); border: none; cursor: pointer;
font-size: 13px; font-weight: 600; color: white; background: var(--brand);
text-decoration: none; transition: all .15s;
}
.btn-primary:hover { background: var(--brand-dk); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79,70,229,.3); }
.btn-primary.sm { padding: 6px 13px; font-size: 12px; }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 18px; border-radius: var(--r-sm); border: 1px solid var(--border);
cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-2); background: transparent;
transition: all .15s;
}
.btn-ghost:hover { background: var(--surface-3); color: var(--text-1); }
/* ── Quote ─────────────────────────────────────────────────────────────── */
.quote-lines { margin-top: 18px; }
.lines-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); margin-bottom: 10px; }
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; box-shadow: var(--shadow-sm); }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table thead { background: var(--surface-2); }
.data-table th { padding: 10px 16px; font-size: 11px; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); text-align: left; }
.data-table td { padding: 11px 16px; border-bottom: 1px solid var(--border-lt); color: var(--text-1); }
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover td { background: var(--surface-2); }
.tc { text-align: center; }
.tr { text-align: right; }
/* ── Empty state ───────────────────────────────────────────────────────── */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 24px; text-align: center; gap: 10px; }
.empty-icon { width: 60px; height: 60px; border-radius: 50%; background: var(--surface-3); display: flex; align-items: center; justify-content: center; color: var(--text-3); margin-bottom: 4px; }
.empty-msg { font-size: 14px; color: var(--text-2); margin: 0; font-weight: 500; }
.coming-soon-chip { font-size: 11px; color: var(--text-3); background: var(--surface-3); padding: 3px 10px; border-radius: 20px; }
/* ── Loading / error ───────────────────────────────────────────────────── */
.fullpage-center { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; padding: 80px; text-align: center; }
.loading-orb { width: 40px; height: 40px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--brand); animation: spin .75s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 14px; color: var(--text-2); margin: 0; }
.state-icon { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.error-icon { background: #fee2e2; color: #dc2626; }
.state-title { font-size: 18px; font-weight: 700; margin: 0; }
.state-desc { font-size: 14px; color: var(--text-2); margin: 0; }
.mini-spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(79,70,229,.3); border-top-color: var(--brand); animation: spin .6s linear infinite; display: inline-block; }
.mini-spinner.white { border-color: rgba(255,255,255,.3); border-top-color: white; }
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 860px) {
.page-layout { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
.tab-nav { flex-direction: row; flex-wrap: wrap; }
.tab-item { flex: none; }
.info-grid { grid-template-columns: 1fr; }
.full-col { grid-column: 1; }
.form-row { grid-template-columns: 1fr; }
.main-content { padding: 16px; }
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<span class="tab-icon" v-html="tab.icon"></span>
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{ teamCount }}</span>
<span v-if="tab.id === 'documents' && documentsCount > 0" class="tab-badge">{{ documentsCount }}</span>
</button>
</nav>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
activeTab: { type: String, required: true },
teamCount: { type: Number, default: 0 },
documentsCount:{ type: Number, default: 0 },
});
defineEmits(['change-tab']);
const tabs = [
{ id:'overview', label:"Vue d'ensemble", icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
{ id:'details', label:'Détails', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>` },
{ id:'team', label:'Équipe', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>` },
{ id:'documents', label:'Documents', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>` },
{ id:'quote', label:'Devis', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>` },
{ id:'history', label:'Historique', icon:`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>` },
];
</script>
<style scoped>
.tab-nav {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface-2:#f8fafc;
--border-lt:#f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
display: flex;
flex-direction: column;
gap: 2px;
font-family: 'Inter', system-ui, sans-serif;
}
.tab-item {
display: flex; align-items: center; gap: 9px;
padding: 8px 11px; border-radius: var(--r-sm);
border: none; background: transparent; cursor: pointer;
width: 100%; text-align: left;
font-size: 13.5px; font-weight: 500; color: var(--text-2);
transition: background .12s, color .12s;
}
.tab-item:hover { background: var(--surface-2); color: var(--text-1); }
.tab-item.active { background: var(--brand-lt); color: var(--brand); font-weight: 600; }
.tab-icon { flex-shrink: 0; display: flex; color: var(--text-3); }
.tab-item.active .tab-icon { color: var(--brand); }
.tab-label { flex: 1; }
.tab-badge {
min-width: 19px; height: 19px; padding: 0 5px; border-radius: 10px;
background: var(--brand); color: white;
font-size: 10.5px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.tab-item.active .tab-badge { background: var(--brand-dk); }
</style>

View File

@ -57,6 +57,38 @@
</h6>
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
</div>
<div class="col-md-12 mb-3">
<h6 class="text-sm text-uppercase text-muted">Clients du groupe</h6>
<p class="text-sm mb-2">
{{ clientGroup.clients_count || clientGroup.clients?.length || 0 }}
client(s)
</p>
<div
v-if="clientGroup.clients && clientGroup.clients.length"
class="d-flex flex-column gap-2"
>
<div
v-for="client in clientGroup.clients"
:key="client.id"
class="d-flex align-items-center justify-content-between p-3 border rounded bg-light"
>
<div class="d-flex flex-column">
<span class="font-weight-bold text-sm">{{ client.name }}</span>
<span class="text-xs text-muted">
{{ client.email || "Pas d'email" }}
</span>
<span v-if="client.phone" class="text-xs text-muted">
{{ client.phone }}
</span>
</div>
</div>
</div>
<p v-else class="text-sm text-muted mb-0">
Aucun client assigné à ce groupe.
</p>
</div>
</div>
</div>
</div>

View File

@ -32,25 +32,38 @@ const router = useRouter();
const clientGroupStore = useClientGroupStore();
const notificationStore = useNotificationStore();
const formData = ref({ name: "", description: "" });
const formData = ref({
name: "",
description: "",
clients: [],
});
const loading = ref(false);
const isEdit = ref(!!props.groupId);
onMounted(async () => {
if (props.groupId) {
try {
const group = await clientGroupStore.fetchClientGroup(props.groupId);
try {
const group = props.groupId
? await clientGroupStore.fetchClientGroup(props.groupId)
: null;
if (group) {
formData.value = {
name: group.name,
description: group.description || "",
clients: group.clients || [],
};
} catch (error) {
notificationStore.error(
"Erreur",
"Impossible de charger le groupe",
3000
);
}
} catch (error) {
notificationStore.error(
"Erreur",
"Impossible de charger le groupe",
3000
);
try {
router.push("/clients/groups");
} catch (navigationError) {
console.error(navigationError);
}
}
});

View File

@ -46,6 +46,26 @@
{{ secondaryActionLabel }}
</soft-button>
<soft-button
color="warning"
variant="outline"
class="btn-toolbar btn-sm"
@click="goToModifyCommande"
>
<i class="fas fa-pen me-2"></i>
Modifier
</soft-button>
<soft-button
color="primary"
variant="outline"
class="btn-toolbar btn-sm"
@click="goToAddWarehouse"
>
<i class="fas fa-warehouse me-2"></i>
Ajouter un entrepôt
</soft-button>
<soft-button
color="dark"
variant="outline"
@ -209,6 +229,7 @@
<script setup>
import { ref, defineProps, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import SoftButton from "@/components/SoftButton.vue";
import { PurchaseOrderService } from "@/services/purchaseOrder";
import { useNotificationStore } from "@/stores/notification";
@ -221,6 +242,7 @@ const props = defineProps({
});
const notificationStore = useNotificationStore();
const router = useRouter();
const commande = ref(null);
const loading = ref(true);
const error = ref(null);
@ -415,7 +437,12 @@ const changeStatus = async (newStatus) => {
);
} catch (err) {
console.error("Error updating status:", err);
notificationStore.error("Erreur", "Échec de la mise à jour du statut.");
const backendMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
"Échec de la mise à jour du statut.";
notificationStore.error("Erreur", backendMessage);
} finally {
if (requestId === statusUpdateRequestId.value) {
isUpdatingStatus.value = false;
@ -444,6 +471,15 @@ const downloadPdf = () => {
window.print();
};
const goToModifyCommande = () => {
if (!commande.value?.id) return;
router.push(`/fournisseurs/commandes/new?edit=${commande.value.id}`);
};
const goToAddWarehouse = () => {
router.push("/stock/warehouses/new");
};
onMounted(() => {
fetchCommande();
});

View File

@ -5,7 +5,7 @@
<div class="card">
<div class="card-header pb-0 p-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Créer une nouvelle commande</h6>
<h6 class="mb-0"></h6>
<soft-button
color="secondary"
variant="outline"

View File

@ -1,71 +1,477 @@
<template>
<div class="card position-sticky top-1">
<!-- Intervention Profile Card -->
<InterventionProfileCard :intervention="intervention" />
<hr class="horizontal dark my-3 mx-3" />
<!-- Tab Navigation -->
<div class="card-body pt-0">
<InterventionTabNavigation
:active-tab="activeTab"
:team-count="practitioners.length"
:documents-count="0"
@change-tab="changeTab"
/>
<div class="sidebar-wrap">
<!-- Hero Card -->
<div class="hero-card">
<div class="hero-avatar">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h2 class="hero-name">
{{ intervention.defuntName || "Personne inconnue" }}
</h2>
<p class="hero-type">{{ intervention.title || "Type non défini" }}</p>
<div
class="status-badge"
:class="'sb-' + (intervention.status?.color || 'secondary')"
>
{{ intervention.status?.label || "En attente" }}
</div>
</div>
<!-- Assign Practitioner Button -->
<div class="mx-3 mb-3">
<div class="divider"></div>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="qs-row">
<div class="qs-icon" style="background: #eef2ff; color: #4f46e5">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div class="qs-text">
<div class="qs-label">Date</div>
<div class="qs-value">{{ intervention.date || "—" }}</div>
</div>
</div>
<div class="qs-row">
<div class="qs-icon" style="background: #ecfdf5; color: #059669">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<div class="qs-text">
<div class="qs-label">Lieu</div>
<div class="qs-value">{{ intervention.lieux || "—" }}</div>
</div>
</div>
<div class="qs-row">
<div class="qs-icon" style="background: #fff7ed; color: #d97706">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<div class="qs-text">
<div class="qs-label">Durée</div>
<div class="qs-value">{{ intervention.duree || "—" }}</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Team preview -->
<div v-if="intervention.members?.length" class="team-preview">
<div class="tp-label">Équipe</div>
<div class="tp-avatars">
<div
v-for="(m, i) in intervention.members.slice(0, 5)"
:key="i"
class="tp-avatar"
:title="m.name"
:style="{ zIndex: 10 - i }"
>
{{ getInitials(m.name) }}
</div>
<div v-if="intervention.members.length > 5" class="tp-avatar tp-more">
+{{ intervention.members.length - 5 }}
</div>
</div>
</div>
<div class="divider"></div>
<!-- Tab Navigation -->
<nav class="tab-nav">
<button
type="button"
class="btn btn-sm btn-outline-primary w-100"
@click="assignPractitioner"
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<i class="fas fa-user-plus me-2"></i>Assigner un praticien
<span class="tab-icon" v-html="tab.icon"></span>
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{
teamCount
}}</span>
<span
v-if="tab.id === 'documents' && documentsCount > 0"
class="tab-badge"
>{{ documentsCount }}</span
>
</button>
</nav>
<!-- Assign Button -->
<div class="assign-wrap">
<button class="assign-btn" @click="$emit('assign-practitioner')">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="8.5" cy="7" r="4" />
<line x1="20" y1="8" x2="20" y2="14" />
<line x1="23" y1="11" x2="17" y2="11" />
</svg>
Assigner un praticien
</button>
</div>
</div>
</template>
<script setup>
import InterventionProfileCard from "@/components/molecules/intervention/InterventionProfileCard.vue";
import InterventionTabNavigation from "@/components/molecules/intervention/InterventionTabNavigation.vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
intervention: {
type: Object,
required: true,
},
activeTab: {
type: String,
default: "overview",
},
practitioners: {
type: Array,
default: () => [],
},
defineProps({
intervention: { type: Object, required: true },
activeTab: { type: String, default: "overview" },
practitioners: { type: Array, default: () => [] },
teamCount: { type: Number, default: 0 },
documentsCount: { type: Number, default: 0 },
});
defineEmits(["change-tab", "assign-practitioner"]);
const emit = defineEmits(["change-tab", "assign-practitioner"]);
const getInitials = (n) =>
n
? n
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.substring(0, 2)
: "?";
const changeTab = (tab) => {
emit("change-tab", tab);
};
const assignPractitioner = () => {
emit("assign-practitioner");
};
const tabs = [
{
id: "overview",
label: "Vue d'ensemble",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
},
{
id: "details",
label: "Détails",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
},
{
id: "team",
label: "Équipe",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
},
{
id: "documents",
label: "Documents",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
},
{
id: "quote",
label: "Devis",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
},
{
id: "history",
label: "Historique",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
},
];
</script>
<style scoped>
.position-sticky {
top: 1rem;
.sidebar-wrap {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface: #ffffff;
--surface-2: #f8fafc;
--border: #e2e8f0;
--border-lt: #f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
font-family: inherit;
}
.card {
border: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
/* Hero */
.hero-card {
padding: 24px 20px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.hero-avatar {
width: 58px;
height: 58px;
border-radius: 50%;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-bottom: 2px;
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.28);
}
.hero-name {
font-size: 15px;
font-weight: 700;
color: var(--text-1);
margin: 0;
line-height: 1.3;
}
.hero-type {
font-size: 12px;
color: var(--text-2);
margin: 0;
font-weight: 500;
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
}
.sb-success {
background: #dcfce7;
color: #16a34a;
}
.sb-warning {
background: #fef9c3;
color: #ca8a04;
}
.sb-danger {
background: #fee2e2;
color: #dc2626;
}
.sb-info {
background: #dbeafe;
color: #2563eb;
}
.sb-primary {
background: #eef2ff;
color: #4f46e5;
}
.sb-secondary {
background: #f1f5f9;
color: #64748b;
}
.divider {
height: 1px;
background: var(--border-lt);
}
/* Quick stats */
.quick-stats {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.qs-row {
display: flex;
align-items: flex-start;
gap: 10px;
}
.qs-icon {
width: 28px;
height: 28px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.qs-label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-3);
font-weight: 600;
}
.qs-value {
font-size: 12.5px;
color: var(--text-1);
font-weight: 500;
margin-top: 1px;
}
/* Team preview */
.team-preview {
padding: 12px 18px;
display: flex;
align-items: center;
gap: 12px;
}
.tp-label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.tp-avatars {
display: flex;
}
.tp-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: 2px solid var(--surface);
color: white;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-left: -6px;
cursor: default;
transition: transform 0.15s;
}
.tp-avatar:first-child {
margin-left: 0;
}
.tp-avatar:hover {
transform: translateY(-3px);
}
.tp-more {
background: var(--surface-2);
color: var(--text-2);
font-size: 9px;
}
/* Tab nav */
.tab-nav {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.tab-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
border-radius: var(--r-sm);
border: none;
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
transition: all 0.12s;
}
.tab-item:hover {
background: var(--surface-2);
color: var(--text-1);
}
.tab-item.active {
background: var(--brand-lt);
color: var(--brand);
font-weight: 600;
}
.tab-icon {
flex-shrink: 0;
display: flex;
color: var(--text-3);
}
.tab-item.active .tab-icon {
color: var(--brand);
}
.tab-label {
flex: 1;
}
.tab-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--brand);
color: white;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
/* Assign */
.assign-wrap {
padding: 0 10px 12px;
}
.assign-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px;
border: 1.5px dashed var(--border);
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
font-size: 12.5px;
font-weight: 500;
color: var(--text-2);
transition: all 0.15s;
}
.assign-btn:hover {
border-color: var(--brand);
color: var(--brand);
background: var(--brand-lt);
}
</style>

View File

@ -183,6 +183,7 @@ const loading = ref(true);
const updating = ref(false);
const error = ref(null);
const selectedStatus = ref("brouillon");
let invoiceStatusRequestId = 0;
const load = async () => {
loading.value = true;
@ -280,10 +281,12 @@ const onStatusSelect = (event) => {
/* ── Status Update ── */
const changeStatus = (id, newStatus) => {
if (!id || updating.value) return;
const requestId = ++invoiceStatusRequestId;
updating.value = true;
invoiceStore
.updateInvoice({ id, status: newStatus })
.then((updated) => {
if (requestId !== invoiceStatusRequestId) return;
if (`${props.invoiceId}` !== `${id}`) return;
invoice.value = updated;
selectedStatus.value = updated?.status || newStatus;
@ -302,7 +305,9 @@ const changeStatus = (id, newStatus) => {
);
})
.finally(() => {
updating.value = false;
if (requestId === invoiceStatusRequestId) {
updating.value = false;
}
});
};
</script>

View File

@ -21,20 +21,59 @@
</soft-button>
</template>
<!-- Client Selection -->
<!-- Recipient Selection -->
<template #client-selection>
<div class="field-group">
<label class="field-label"
>Client <span class="text-danger">*</span></label
>Destinataire <span class="text-danger">*</span></label
>
<select v-model="form.client_id" class="form-select field-select">
<option value="" disabled> Sélectionner un client </option>
<div class="recipient-toggle">
<button
type="button"
class="recipient-toggle__btn"
:class="{ 'recipient-toggle__btn--active': form.recipient_type === 'client' }"
@click="setRecipientType('client')"
>
Client
</button>
<button
type="button"
class="recipient-toggle__btn"
:class="{ 'recipient-toggle__btn--active': form.recipient_type === 'group' }"
@click="setRecipientType('group')"
>
Groupe client
</button>
</div>
<select
v-if="form.recipient_type === 'client'"
v-model="form.client_id"
class="form-select field-select"
>
<option :value="null" disabled> Sélectionner un client </option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
<p v-if="!form.client_id && attempted" class="field-error">
<i class="fas fa-exclamation-circle me-1"></i>Client requis
<select
v-else
v-model="form.group_id"
class="form-select field-select"
>
<option :value="null" disabled> Sélectionner un groupe </option>
<option
v-for="group in clientGroups"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</option>
</select>
<p v-if="recipientError" class="field-error">
<i class="fas fa-exclamation-circle me-1"></i>{{ recipientError }}
</p>
</div>
</template>
@ -133,12 +172,15 @@ import SoftButton from "@/components/SoftButton.vue";
import SoftInput from "@/components/SoftInput.vue";
import { useQuoteStore } from "@/stores/quoteStore";
import { useClientStore } from "@/stores/clientStore";
import { useClientGroupStore } from "@/stores/clientGroupStore";
import { storeToRefs } from "pinia";
const router = useRouter();
const quoteStore = useQuoteStore();
const clientStore = useClientStore();
const clientGroupStore = useClientGroupStore();
const { clients } = storeToRefs(clientStore);
const { clientGroups } = storeToRefs(clientGroupStore);
const loading = ref(false);
const attempted = ref(false);
@ -174,7 +216,9 @@ const defaultLine = () => ({
});
const form = ref({
client_id: "",
recipient_type: "client",
client_id: null,
group_id: null,
quote_date: new Date().toISOString().split("T")[0],
valid_until: "",
status: "brouillon",
@ -182,6 +226,20 @@ const form = ref({
lines: [defaultLine()],
});
const recipientError = computed(() => {
if (!attempted.value) return "";
if (form.value.recipient_type === "client" && !form.value.client_id) {
return "Client requis";
}
if (form.value.recipient_type === "group" && !form.value.group_id) {
return "Groupe client requis";
}
return "";
});
const totals = computed(() => {
let ht = 0;
let tva = 0;
@ -197,6 +255,16 @@ const totals = computed(() => {
const addLine = () => form.value.lines.push(defaultLine());
const removeLine = (index) => form.value.lines.splice(index, 1);
const setRecipientType = (type) => {
form.value.recipient_type = type;
if (type === "client") {
form.value.group_id = null;
} else {
form.value.client_id = null;
}
};
const formatCurrency = (value) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(
value
@ -204,12 +272,15 @@ const formatCurrency = (value) =>
const saveQuote = async () => {
attempted.value = true;
if (!form.value.client_id) return;
if (recipientError.value) return;
loading.value = true;
try {
await quoteStore.createQuote({
client_id: form.value.client_id,
client_id:
form.value.recipient_type === "client" ? form.value.client_id : null,
group_id:
form.value.recipient_type === "group" ? form.value.group_id : null,
status: form.value.status,
quote_date: form.value.quote_date,
valid_until: form.value.valid_until,
@ -238,10 +309,40 @@ const saveQuote = async () => {
const cancel = () => router.back();
onMounted(() => clientStore.fetchClients());
onMounted(() => {
clientStore.fetchClients();
clientGroupStore.fetchClientGroups({ per_page: 100 });
});
</script>
<style scoped>
.recipient-toggle {
display: inline-flex;
gap: 0.35rem;
padding: 0.25rem;
margin-bottom: 0.75rem;
border: 1px solid #e9ecef;
border-radius: 10px;
background: #f8f9fc;
}
.recipient-toggle__btn {
border: 0;
background: transparent;
color: #6b7280;
font-size: 0.82rem;
font-weight: 700;
padding: 0.5rem 0.9rem;
border-radius: 8px;
transition: all 0.2s ease;
}
.recipient-toggle__btn--active {
background: #fff;
color: #344767;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
/* ── Field Groups ── */
.field-label {
display: block;

View File

@ -42,7 +42,13 @@
<i :class="statusIcon(quote.status) + ' me-1'"></i>
{{ getStatusLabel(quote.status) }}
</soft-badge>
<soft-button color="secondary" variant="gradient" class="mb-0">
<soft-button
color="secondary"
variant="gradient"
class="mb-0"
:disabled="false"
@click="exportPdf"
>
Export PDF
</soft-button>
</div>
@ -56,7 +62,7 @@
</div>
<div>
<h6 class="text-lg mb-0 mt-1">
{{ quote.client?.name || "Client inconnu" }}
{{ recipientName }}
</h6>
<p class="text-sm mb-3">
{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.
@ -109,18 +115,18 @@
>
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">
{{ quote.client?.name || "Client inconnu" }}
{{ recipientName }}
</h6>
<span class="mb-2 text-xs">
Email Address:
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.email || "—"
quote.client?.email || groupDetailsFallback
}}</span>
</span>
<span class="mb-2 text-xs">
Phone:
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.phone || "—"
quote.client?.phone || groupDetailsFallback
}}</span>
</span>
<span class="text-xs">
@ -159,7 +165,7 @@
</template>
<script setup>
import { ref, onMounted, defineProps } from "vue";
import { computed, ref, onMounted, defineProps } from "vue";
import { useQuoteStore } from "@/stores/quoteStore";
import { useNotificationStore } from "@/stores/notification";
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
@ -168,6 +174,8 @@ import QuoteLinesTable from "@/components/molecules/Quote/QuoteLinesTable.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const activePdfExports = new Set();
const props = defineProps({
quoteId: { type: [String, Number], required: true },
});
@ -181,6 +189,16 @@ const updating = ref(false);
const error = ref(null);
const selectedStatus = ref("brouillon");
const recipientName = computed(() => {
if (quote.value?.client?.name) return quote.value.client.name;
if (quote.value?.group?.name) return quote.value.group.name;
return "Client inconnu";
});
const groupDetailsFallback = computed(() => {
return quote.value?.group?.name ? `Groupe: ${quote.value.group.name}` : "—";
});
const load = async () => {
loading.value = true;
error.value = null;
@ -266,6 +284,39 @@ const onStatusSelect = (event) => {
changeStatus(quote.value.id, newStatus);
};
const exportPdf = async () => {
if (!quote.value?.id) return;
const exportKey = String(quote.value.id);
if (activePdfExports.has(exportKey)) return;
activePdfExports.add(exportKey);
try {
const blob = await quoteStore.downloadQuotePdf(quote.value.id);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `devis-${quote.value.reference || quote.value.id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
notificationStore.success("Export PDF", "Le devis a ete telecharge.", 3000);
} catch (e) {
console.error(e);
notificationStore.error(
"Erreur",
"Impossible d'exporter le devis en PDF",
3000
);
} finally {
activePdfExports.delete(exportKey);
}
};
/* ── Status Update ── */
const changeStatus = (id, newStatus) => {
if (!id || updating.value) return;

View File

@ -1,6 +1,5 @@
<template>
<div class="pdp">
<!-- Loading -->
<div v-if="loading" class="pdp__state">
<div class="pdp__spinner"></div>
@ -9,17 +8,41 @@
<!-- Error -->
<div v-else-if="error" class="pdp__state">
<svg class="pdp__state-icon pdp__state-icon--danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
<svg
class="pdp__state-icon pdp__state-icon--danger"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4M12 16h.01" />
</svg>
<h5>Erreur de chargement</h5>
<p>{{ error }}</p>
<SoftButton color="primary" variant="outline" size="sm" @click="loadProduct">
<SoftButton
color="primary"
variant="outline"
size="sm"
@click="loadProduct"
>
Réessayer
</SoftButton>
</div>
<!-- Empty -->
<div v-else-if="!productData" class="pdp__state">
<svg class="pdp__state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M20 7H4a2 2 0 00-2 2v9a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 3H8l-2 4h12l-2-4z"/></svg>
<svg
class="pdp__state-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path
d="M20 7H4a2 2 0 00-2 2v9a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 3H8l-2 4h12l-2-4z"
/>
</svg>
<h5>Produit introuvable</h5>
<p>Ce produit n'existe pas ou a été supprimé.</p>
<SoftButton color="primary" variant="outline" size="sm" @click="goBack">
@ -29,12 +52,25 @@
<!-- Main Content -->
<template v-else>
<!-- Top bar -->
<div class="pdp__topbar">
<div class="pdp__topbar-left">
<SoftButton color="secondary" variant="outline" size="sm" @click="goBack">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 3L5 8l5 5"/></svg>
<SoftButton
color="secondary"
variant="outline"
size="sm"
@click="goBack"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M10 3L5 8l5 5" />
</svg>
Retour
</SoftButton>
<div class="pdp__breadcrumb">
@ -47,22 +83,84 @@
</div>
<div class="pdp__topbar-actions">
<template v-if="!isEditMode">
<SoftButton color="primary" variant="outline" size="sm" @click="toggleEditMode">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z"/></svg>
<SoftButton
color="primary"
variant="outline"
size="sm"
@click="toggleEditMode"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z" />
</svg>
Modifier
</SoftButton>
<SoftButton color="danger" variant="gradient" size="sm" :disabled="loading" @click="deleteProduct">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 4h10M6 4V2h4v2M5 4v9a1 1 0 001 1h4a1 1 0 001-1V4"/></svg>
<SoftButton
color="danger"
variant="gradient"
size="sm"
:disabled="loading"
@click="deleteProduct"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3 4h10M6 4V2h4v2M5 4v9a1 1 0 001 1h4a1 1 0 001-1V4" />
</svg>
Supprimer
</SoftButton>
</template>
<template v-else>
<SoftButton color="secondary" variant="outline" size="sm" :disabled="saving" @click="cancelEdit">
<SoftButton
color="secondary"
variant="outline"
size="sm"
:disabled="saving"
@click="cancelEdit"
>
Annuler
</SoftButton>
<SoftButton color="primary" variant="gradient" size="sm" :disabled="saving" @click="saveProduct">
<svg v-if="saving" class="pdp-btn__spin" width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 1v3M8 12v3M1 8h3M12 8h3"/></svg>
<svg v-else width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8l3 3 7-7"/></svg>
<SoftButton
color="primary"
variant="gradient"
size="sm"
:disabled="saving"
@click="saveProduct"
>
<svg
v-if="saving"
class="pdp-btn__spin"
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M8 1v3M8 12v3M1 8h3M12 8h3" />
</svg>
<svg
v-else
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3 8l3 3 7-7" />
</svg>
{{ saving ? "Sauvegarde…" : "Sauvegarder" }}
</SoftButton>
</template>
@ -71,7 +169,17 @@
<!-- Validation errors -->
<div v-if="hasValidationErrors" class="pdp__errors" role="alert">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v3M8 10.5v.5"/></svg>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="8" cy="8" r="6" />
<path d="M8 5v3M8 10.5v.5" />
</svg>
<div>
<strong>Erreurs de validation</strong>
<ul>
@ -84,7 +192,6 @@
<!-- Body -->
<div class="pdp__body">
<!-- Sidebar -->
<product-sidebar
v-model="activeTab"
@ -99,11 +206,19 @@
<!-- Panel -->
<div class="pdp__panel">
<!-- Edit mode banner -->
<transition name="pdp-fade">
<div v-if="isEditMode" class="pdp__edit-banner">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z"/></svg>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z" />
</svg>
Mode édition modifiez les champs puis cliquez sur Sauvegarder
</div>
</transition>
@ -121,13 +236,25 @@
</div>
<div class="pdp-hero__info">
<h1 class="pdp-hero__name">{{ productData.nom }}</h1>
<p v-if="productData.reference" class="pdp-hero__ref">{{ productData.reference }}</p>
<p v-if="productData.reference" class="pdp-hero__ref">
{{ productData.reference }}
</p>
<div class="pdp-hero__badges">
<product-badge :variant="productData.is_low_stock ? 'warning' : 'success'">
{{ productData.is_low_stock ? "Stock faible" : "Stock OK" }}
<product-badge
:variant="
productData.is_low_stock ? 'warning' : 'success'
"
>
{{
productData.is_low_stock ? "Stock faible" : "Stock OK"
}}
</product-badge>
<product-badge v-if="isExpired" variant="danger">Expiré</product-badge>
<product-badge v-else-if="isExpiringSoon" variant="info">Expire bientôt</product-badge>
<product-badge v-if="isExpired" variant="danger"
>Expiré</product-badge
>
<product-badge v-else-if="isExpiringSoon" variant="info"
>Expire bientôt</product-badge
>
</div>
</div>
</div>
@ -175,7 +302,19 @@
target="_blank"
class="pdp-doc"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6M12 18v-6M9 15l3 3 3-3"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
/>
<path d="M14 2v6h6M12 18v-6M9 15l3 3 3-3" />
</svg>
<span class="pdp-doc__name">Fiche technique</span>
<span class="pdp-doc__action">Télécharger</span>
</a>
@ -215,7 +354,6 @@
</div>
</div>
</div>
</div>
</div>
</template>
@ -225,109 +363,173 @@
<script setup>
import { ref, onMounted, computed, defineEmits, defineProps } from "vue";
import { useRoute, useRouter } from "vue-router";
import ProductService from "@/services/product";
import { useProductStore } from "@/stores/productStore";
import ProductService from "@/services/product";
import { useProductStore } from "@/stores/productStore";
import { useProductCategoryStore } from "@/stores/productCategoryStore";
/* atoms */
import ProductImage from "@/components/atoms/Product/ProductImage.vue";
import ProductBadge from "@/components/atoms/Product/ProductBadge.vue";
import SoftButton from "@/components/SoftButton.vue";
import ProductImage from "@/components/atoms/Product/ProductImage.vue";
import ProductBadge from "@/components/atoms/Product/ProductBadge.vue";
import SoftButton from "@/components/SoftButton.vue";
/* molecules */
import ProductSidebar from "@/components/molecules/Product/ProductSidebar.vue";
import ProductInfoSection from "@/components/molecules/Product/ProductInfoSection.vue";
import ProductStockSection from "@/components/molecules/Product/ProductStockSection.vue";
import ProductSidebar from "@/components/molecules/Product/ProductSidebar.vue";
import ProductInfoSection from "@/components/molecules/Product/ProductInfoSection.vue";
import ProductStockSection from "@/components/molecules/Product/ProductStockSection.vue";
import ProductSupplierSection from "@/components/molecules/Product/ProductSupplierSection.vue";
import ProductMovementsSection from "@/components/molecules/Product/ProductMovementsSection.vue";
const route = useRoute();
const router = useRouter();
const route = useRoute();
const router = useRouter();
const productStore = useProductStore();
const productCategoryStore = useProductCategoryStore();
const emit = defineEmits(["view-supplier"]);
/* ── state ──────────────────────────────────────────────── */
const loading = ref(true);
const saving = ref(false);
const error = ref(null);
const loading = ref(true);
const saving = ref(false);
const error = ref(null);
const validationErrors = ref({});
const productData = ref(null);
const isEditMode = ref(false);
const originalData = ref(null);
const categories = ref([]);
const activeTab = ref("details");
const stockMovements = ref([]);
const productData = ref(null);
const isEditMode = ref(false);
const originalData = ref(null);
const categories = ref([]);
const activeTab = ref("details");
const stockMovements = ref([]);
const formData = ref({
nom: "", reference: "", categorie_id: "", fabricant: "",
numero_lot: "", date_expiration: "", unite: "", description: "",
stock_actuel: "", stock_minimum: "", prix_unitaire: "",
conditionnement_nom: "", conditionnement_quantite: "",
nom: "",
reference: "",
categorie_id: "",
fabricant: "",
numero_lot: "",
date_expiration: "",
unite: "",
description: "",
stock_actuel: "",
stock_minimum: "",
prix_unitaire: "",
conditionnement_nom: "",
conditionnement_quantite: "",
});
/* ── tabs config ─────────────────────────────────────────── */
// Using inline SVG render functions to keep it self-contained
// Replace with your icon library's components as needed
import { defineComponent, h } from "vue";
const IconInfo = defineComponent({ render: () => h("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", "stroke-width": "1.5" }, [h("circle", { cx: "8", cy: "8", r: "6" }), h("path", { d: "M8 7v5M8 5.5v.5" })]) });
const IconBox = defineComponent({ render: () => h("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", "stroke-width": "1.5" }, [h("path", { d: "M2 5l6-3 6 3v6l-6 3-6-3V5z" }), h("path", { d: "M8 2v12M2 5l6 3 6-3" })]) });
const IconMove = defineComponent({ render: () => h("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", "stroke-width": "1.5" }, [h("path", { d: "M3 8h10M9 4l4 4-4 4" })]) });
const IconInfo = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[
h("circle", { cx: "8", cy: "8", r: "6" }),
h("path", { d: "M8 7v5M8 5.5v.5" }),
]
),
});
const IconBox = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[
h("path", { d: "M2 5l6-3 6 3v6l-6 3-6-3V5z" }),
h("path", { d: "M8 2v12M2 5l6 3 6-3" }),
]
),
});
const IconMove = defineComponent({
render: () =>
h(
"svg",
{
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
},
[h("path", { d: "M3 8h10M9 4l4 4-4 4" })]
),
});
const tabs = [
{ id: "details", label: "Détails", icon: IconInfo },
{ id: "stock", label: "Stock", icon: IconBox },
{ id: "movements", label: "Mouvements", icon: IconMove },
{ id: "details", label: "Détails", icon: IconInfo },
{ id: "stock", label: "Stock", icon: IconBox },
{ id: "movements", label: "Mouvements", icon: IconMove },
];
/* ── computed ────────────────────────────────────────────── */
const productId = computed(() => parseInt(route.params.id));
const hasValidationErrors = computed(() => Object.keys(validationErrors.value).length > 0);
const hasValidationErrors = computed(
() => Object.keys(validationErrors.value).length > 0
);
const isExpired = computed(() => ProductService.isExpired(productData.value));
const isExpiringSoon = computed(() => ProductService.isExpiringSoon(productData.value, 30));
const hasDocuments = computed(() => !!productData.value?.media?.fiche_technique_url);
const isExpiringSoon = computed(() =>
ProductService.isExpiringSoon(productData.value, 30)
);
const hasDocuments = computed(
() => !!productData.value?.media?.fiche_technique_url
);
const displayCategoryName = computed(() => {
if (productData.value?.category?.name) return productData.value.category.name;
const id = productData.value?.categorie_id;
if (!id) return "Non catégorisé";
return categories.value.find(c => c.id === Number(id))?.name || "Non catégorisé";
return (
categories.value.find((c) => c.id === Number(id))?.name || "Non catégorisé"
);
});
/* ── methods ─────────────────────────────────────────────── */
const initializeFormData = (data) => {
formData.value = {
nom: data.nom || "",
reference: data.reference || "",
categorie_id: (data.categorie_id || data.category?.id)?.toString() || "",
fabricant: data.fabricant || "",
numero_lot: data.numero_lot || "",
date_expiration: data.date_expiration || "",
unite: data.unite || "",
description: data.description || "",
stock_actuel: data.stock_actuel?.toString() || "",
stock_minimum: data.stock_minimum?.toString() || "",
prix_unitaire: data.prix_unitaire?.toString() || "",
conditionnement_nom: data.conditionnement?.nom || "",
nom: data.nom || "",
reference: data.reference || "",
categorie_id: (data.categorie_id || data.category?.id)?.toString() || "",
fabricant: data.fabricant || "",
numero_lot: data.numero_lot || "",
date_expiration: data.date_expiration || "",
unite: data.unite || "",
description: data.description || "",
stock_actuel: data.stock_actuel?.toString() || "",
stock_minimum: data.stock_minimum?.toString() || "",
prix_unitaire: data.prix_unitaire?.toString() || "",
conditionnement_nom: data.conditionnement?.nom || "",
conditionnement_quantite: data.conditionnement?.quantite?.toString() || "",
};
validationErrors.value = {};
};
const loadProduct = async () => {
if (!productId.value) { error.value = "ID de produit invalide"; loading.value = false; return; }
if (!productId.value) {
error.value = "ID de produit invalide";
loading.value = false;
return;
}
loading.value = true;
error.value = null;
error.value = null;
validationErrors.value = {};
try {
const res = await productStore.fetchProduct(productId.value);
productData.value = res;
productData.value = res;
stockMovements.value = res.stock_moves || res.stockMovements || [];
initializeFormData(res);
originalData.value = { ...res };
originalData.value = { ...res };
} catch (err) {
error.value = err.response?.data?.message || "Erreur lors du chargement du produit";
error.value =
err.response?.data?.message || "Erreur lors du chargement du produit";
} finally {
loading.value = false;
}
@ -337,32 +539,44 @@ const loadCategories = async () => {
try {
const res = await productCategoryStore.fetchAllCategories();
categories.value = res?.data || [];
} catch { categories.value = []; }
} catch {
categories.value = [];
}
};
const goBack = () => router.push("/stock/produits");
const toggleEditMode = () => { isEditMode.value = true; initializeFormData(productData.value); };
const cancelEdit = () => { if (originalData.value) initializeFormData(originalData.value); isEditMode.value = false; };
const goBack = () => router.push("/stock/produits");
const toggleEditMode = () => {
isEditMode.value = true;
initializeFormData(productData.value);
};
const cancelEdit = () => {
if (originalData.value) initializeFormData(originalData.value);
isEditMode.value = false;
};
const saveProduct = async () => {
saving.value = true;
validationErrors.value = {};
try {
await productStore.updateProduct(productId.value, {
nom: formData.value.nom,
reference: formData.value.reference,
categorie_id: parseInt(formData.value.categorie_id) || productData.value?.categorie_id || null,
fabricant: formData.value.fabricant,
numero_lot: formData.value.numero_lot,
date_expiration: formData.value.date_expiration || null,
unite: formData.value.unite,
description: formData.value.description,
stock_actuel: parseInt(formData.value.stock_actuel) || 0,
stock_minimum: parseInt(formData.value.stock_minimum) || 0,
prix_unitaire: parseFloat(formData.value.prix_unitaire) || 0,
conditionnement_nom: formData.value.conditionnement_nom || null,
conditionnement_quantite: parseInt(formData.value.conditionnement_quantite) || null,
conditionnement_unite: formData.value.unite || null,
nom: formData.value.nom,
reference: formData.value.reference,
categorie_id:
parseInt(formData.value.categorie_id) ||
productData.value?.categorie_id ||
null,
fabricant: formData.value.fabricant,
numero_lot: formData.value.numero_lot,
date_expiration: formData.value.date_expiration || null,
unite: formData.value.unite,
description: formData.value.description,
stock_actuel: parseInt(formData.value.stock_actuel) || 0,
stock_minimum: parseInt(formData.value.stock_minimum) || 0,
prix_unitaire: parseFloat(formData.value.prix_unitaire) || 0,
conditionnement_nom: formData.value.conditionnement_nom || null,
conditionnement_quantite:
parseInt(formData.value.conditionnement_quantite) || null,
conditionnement_unite: formData.value.unite || null,
});
await loadProduct();
isEditMode.value = false;
@ -370,7 +584,8 @@ const saveProduct = async () => {
if (err.response?.status === 422 && err.response?.data?.errors) {
validationErrors.value = err.response.data.errors;
} else {
error.value = err.response?.data?.message || "Erreur lors de la sauvegarde";
error.value =
err.response?.data?.message || "Erreur lors de la sauvegarde";
}
} finally {
saving.value = false;
@ -389,16 +604,26 @@ const deleteProduct = async () => {
const handleViewSupplier = (supplier) => emit("view-supplier", supplier);
const getFieldLabel = (field) => ({
nom: "Nom", reference: "Référence", categorie_id: "Catégorie",
fabricant: "Fabricant", numero_lot: "N° de lot",
date_expiration: "Date d'expiration", unite: "Unité",
stock_actuel: "Stock actuel", stock_minimum: "Stock minimum",
prix_unitaire: "Prix unitaire", conditionnement_nom: "Conditionnement",
conditionnement_quantite: "Qté conditionnement",
})[field] || field;
const getFieldLabel = (field) =>
({
nom: "Nom",
reference: "Référence",
categorie_id: "Catégorie",
fabricant: "Fabricant",
numero_lot: "N° de lot",
date_expiration: "Date d'expiration",
unite: "Unité",
stock_actuel: "Stock actuel",
stock_minimum: "Stock minimum",
prix_unitaire: "Prix unitaire",
conditionnement_nom: "Conditionnement",
conditionnement_quantite: "Qté conditionnement",
}[field] || field);
onMounted(() => { loadCategories(); loadProduct(); });
onMounted(() => {
loadCategories();
loadProduct();
});
</script>
<style scoped>
@ -427,18 +652,37 @@ onMounted(() => { loadCategories(); loadProduct(); });
text-align: center;
color: #6b7280;
}
.pdp__state h5 { font-size: 16px; font-weight: 600; color: #111827; margin: 0; }
.pdp__state p { font-size: 14px; margin: 0; }
.pdp__state-icon { width: 36px; height: 36px; color: #d1d5db; }
.pdp__state-icon--danger { color: #fca5a5; }
.pdp__state h5 {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.pdp__state p {
font-size: 14px;
margin: 0;
}
.pdp__state-icon {
width: 36px;
height: 36px;
color: #d1d5db;
}
.pdp__state-icon--danger {
color: #fca5a5;
}
.pdp__spinner {
width: 24px; height: 24px;
width: 24px;
height: 24px;
border: 2px solid #e5e7eb;
border-top-color: #111827;
border-radius: 50%;
animation: pdp-spin 0.75s linear infinite;
}
@keyframes pdp-spin { to { transform: rotate(360deg); } }
@keyframes pdp-spin {
to {
transform: rotate(360deg);
}
}
/* ── top bar ─────────────────────────────────────────────── */
.pdp__topbar {
@ -448,8 +692,16 @@ onMounted(() => { loadCategories(); loadProduct(); });
gap: 1rem;
flex-wrap: wrap;
}
.pdp__topbar-left { display: flex; align-items: center; gap: 0.75rem; }
.pdp__topbar-actions { display: flex; align-items: center; gap: 0.5rem; }
.pdp__topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pdp__topbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pdp__breadcrumb {
display: flex;
@ -458,8 +710,13 @@ onMounted(() => { loadCategories(); loadProduct(); });
font-size: 13px;
color: #9ca3af;
}
.pdp__breadcrumb-sep { color: #d1d5db; }
.pdp__breadcrumb-current { color: #374151; font-weight: 500; }
.pdp__breadcrumb-sep {
color: #d1d5db;
}
.pdp__breadcrumb-current {
color: #374151;
font-weight: 500;
}
/* ── errors ──────────────────────────────────────────────── */
.pdp__errors {
@ -474,10 +731,22 @@ onMounted(() => { loadCategories(); loadProduct(); });
font-size: 13px;
color: #991b1b;
}
.pdp__errors svg { flex-shrink: 0; margin-top: 1px; }
.pdp__errors strong { display: block; font-weight: 600; margin-bottom: 4px; }
.pdp__errors ul { margin: 0; padding-left: 1.25rem; }
.pdp__errors li { margin-bottom: 2px; }
.pdp__errors svg {
flex-shrink: 0;
margin-top: 1px;
}
.pdp__errors strong {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.pdp__errors ul {
margin: 0;
padding-left: 1.25rem;
}
.pdp__errors li {
margin-bottom: 2px;
}
/* ── edit banner ─────────────────────────────────────────── */
.pdp__edit-banner {
@ -529,8 +798,12 @@ onMounted(() => { loadCategories(); loadProduct(); });
padding: 2px 7px;
border-radius: 20px;
}
.pdp-card__body { padding: 1.25rem; }
.pdp-card__body--flush { padding: 0; }
.pdp-card__body {
padding: 1.25rem;
}
.pdp-card__body--flush {
padding: 0;
}
/* ── hero ────────────────────────────────────────────────── */
.pdp-hero {
@ -559,9 +832,13 @@ onMounted(() => { loadCategories(); loadProduct(); });
font-size: 12px;
color: #8392ab;
margin: 0 0 0.625rem;
font-family: 'SF Mono', 'Fira Code', monospace;
font-family: "SF Mono", "Fira Code", monospace;
}
.pdp-hero__badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pdp-hero__badges { display: flex; flex-wrap: wrap; gap: 5px; }
/* ── doc item ────────────────────────────────────────────── */
.pdp-doc {
@ -576,9 +853,18 @@ onMounted(() => { loadCategories(); loadProduct(); });
transition: background 0.12s, border-color 0.12s;
color: #374151;
}
.pdp-doc:hover { background: #f3f4f6; border-color: #d1d5db; }
.pdp-doc svg { color: #dc2626; flex-shrink: 0; }
.pdp-doc__name { font-size: 14px; font-weight: 500; }
.pdp-doc:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.pdp-doc svg {
color: #dc2626;
flex-shrink: 0;
}
.pdp-doc__name {
font-size: 14px;
font-weight: 500;
}
.pdp-doc__action {
font-size: 12px;
color: #6b7280;
@ -600,10 +886,19 @@ onMounted(() => { loadCategories(); loadProduct(); });
transition: background 0.12s, border-color 0.12s, color 0.12s, transform 0.1s;
white-space: nowrap;
}
.pdp-btn:active:not(:disabled) { transform: scale(0.97); }
.pdp-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.pdp-btn:active:not(:disabled) {
transform: scale(0.97);
}
.pdp-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pdp-btn--dark { background: #111827; color: #fff; border-color: #111827; }
.pdp-btn--dark {
background: #111827;
color: #fff;
border-color: #111827;
}
.pdp-btn--dark {
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
color: #fff;
@ -623,26 +918,58 @@ onMounted(() => { loadCategories(); loadProduct(); });
border-color: #5e72e4;
}
.pdp-btn--ghost { background: transparent; color: #6b7280; border-color: transparent; }
.pdp-btn--ghost:hover:not(:disabled) { background: #f3f4f6; color: #344767; }
.pdp-btn--ghost {
background: transparent;
color: #6b7280;
border-color: transparent;
}
.pdp-btn--ghost:hover:not(:disabled) {
background: #f3f4f6;
color: #344767;
}
.pdp-btn--danger { background: #fef2f2; color: #991b1b; border-color: #fecaca; }
.pdp-btn--danger:hover:not(:disabled) { background: #fee2e2; border-color: #fca5a5; }
.pdp-btn--danger {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
}
.pdp-btn--danger:hover:not(:disabled) {
background: #fee2e2;
border-color: #fca5a5;
}
.pdp-btn--sm { padding: 5px 10px; font-size: 12px; }
.pdp-btn--sm {
padding: 5px 10px;
font-size: 12px;
}
.pdp-btn__spin { animation: pdp-spin 0.75s linear infinite; }
.pdp-btn__spin {
animation: pdp-spin 0.75s linear infinite;
}
/* ── panel ───────────────────────────────────────────────── */
.pdp__panel { min-width: 0; }
.pdp__panel {
min-width: 0;
}
/* ── transitions ─────────────────────────────────────────── */
.pdp-fade-enter-active, .pdp-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
.pdp-fade-enter-from, .pdp-fade-leave-to { opacity: 0; transform: translateY(-4px); }
.pdp-fade-enter-active,
.pdp-fade-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.pdp-fade-enter-from,
.pdp-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* ── responsive ──────────────────────────────────────────── */
@media (max-width: 768px) {
.pdp__body { grid-template-columns: 1fr; }
.pdp { padding: 1rem; }
.pdp__body {
grid-template-columns: 1fr;
}
.pdp {
padding: 1rem;
}
}
</style>

View File

@ -203,6 +203,7 @@ const { currentGoodsReceipt: goodsReceipt, loading, error } = storeToRefs(
);
const isUpdatingStatus = ref(false);
let receiptStatusRequestId = 0;
const availableStatuses = ["draft", "posted"];
@ -262,6 +263,8 @@ const getStatusClass = (status) => {
const changeStatus = async (newStatus) => {
if (!goodsReceipt.value || goodsReceipt.value.status === newStatus) return;
const requestId = ++receiptStatusRequestId;
try {
isUpdatingStatus.value = true;
await goodsReceiptStore.updateGoodsReceipt({
@ -272,7 +275,9 @@ const changeStatus = async (newStatus) => {
} catch (e) {
console.error("Failed to update receipt status", e);
} finally {
isUpdatingStatus.value = false;
if (requestId === receiptStatusRequestId) {
isUpdatingStatus.value = false;
}
}
};

View File

@ -11,9 +11,9 @@
import { defineProps } from "vue";
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], default: "" },
fallback: { type: String, default: "—" },
label: { type: String, required: true },
value: { type: [String, Number], default: "" },
fallback: { type: String, default: "—" },
valueClass: { type: String, default: "" },
});
</script>
@ -27,7 +27,9 @@ defineProps({
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.field-row:last-child { border-bottom: none; }
.field-row:last-child {
border-bottom: none;
}
.field-row__label {
font-size: 12px;
@ -43,6 +45,12 @@ defineProps({
color: #111827;
line-height: 1.5;
}
.field-row__value.expired { color: #dc2626; font-weight: 500; }
.field-row__value.expiring-soon { color: #d97706; font-weight: 500; }
.field-row__value.expired {
color: #dc2626;
font-weight: 500;
}
.field-row__value.expiring-soon {
color: #d97706;
font-weight: 500;
}
</style>

View File

@ -41,20 +41,24 @@ import { defineProps, defineEmits } from "vue";
defineProps({
modelValue: { type: [String, Number], default: "" },
label: { type: String, required: true },
type: { type: String, default: "text" },
placeholder:{ type: String, default: "" },
required: { type: Boolean, default: false },
error: { type: String, default: "" },
rows: { type: Number, default: 3 },
min: { type: [String, Number], default: undefined },
step: { type: [String, Number], default: undefined },
label: { type: String, required: true },
type: { type: String, default: "text" },
placeholder: { type: String, default: "" },
required: { type: Boolean, default: false },
error: { type: String, default: "" },
rows: { type: Number, default: 3 },
min: { type: [String, Number], default: undefined },
step: { type: [String, Number], default: undefined },
});
defineEmits(["update:modelValue"]);
</script>
<style scoped>
.field-input-group { display: flex; flex-direction: column; gap: 5px; }
.field-input-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.field-input-group__label {
font-size: 11px;
@ -63,7 +67,10 @@ defineEmits(["update:modelValue"]);
text-transform: uppercase;
color: #6b7280;
}
.field-input-group__req { color: #dc2626; margin-left: 2px; }
.field-input-group__req {
color: #dc2626;
margin-left: 2px;
}
.field-input-group__ctrl {
font-size: 14px;
@ -82,8 +89,12 @@ defineEmits(["update:modelValue"]);
border-color: #5e72e4;
box-shadow: 0 0 0 3px rgba(94, 114, 228, 0.18);
}
.field-input-group__ctrl.is-invalid { border-color: #dc2626; }
.field-input-group__ctrl.is-invalid:focus { box-shadow: 0 0 0 3px rgba(220,38,38,0.08); }
.field-input-group__ctrl.is-invalid {
border-color: #dc2626;
}
.field-input-group__ctrl.is-invalid:focus {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08);
}
.field-input-group__error {
font-size: 12px;

View File

@ -39,18 +39,43 @@ defineProps({
flex-shrink: 0;
}
.product-badge--success { background: rgba(45, 206, 137, 0.16); color: #2dce89; }
.product-badge--success .product-badge__dot { background: #2dce89; }
.product-badge--success {
background: rgba(45, 206, 137, 0.16);
color: #2dce89;
}
.product-badge--success .product-badge__dot {
background: #2dce89;
}
.product-badge--warning { background: rgba(251, 99, 64, 0.16); color: #fb6340; }
.product-badge--warning .product-badge__dot { background: #fb6340; }
.product-badge--warning {
background: rgba(251, 99, 64, 0.16);
color: #fb6340;
}
.product-badge--warning .product-badge__dot {
background: #fb6340;
}
.product-badge--danger { background: rgba(245, 54, 92, 0.16); color: #f5365c; }
.product-badge--danger .product-badge__dot { background: #f5365c; }
.product-badge--danger {
background: rgba(245, 54, 92, 0.16);
color: #f5365c;
}
.product-badge--danger .product-badge__dot {
background: #f5365c;
}
.product-badge--info { background: rgba(17, 205, 239, 0.16); color: #11cdef; }
.product-badge--info .product-badge__dot { background: #11cdef; }
.product-badge--info {
background: rgba(17, 205, 239, 0.16);
color: #11cdef;
}
.product-badge--info .product-badge__dot {
background: #11cdef;
}
.product-badge--neutral { background: rgba(131, 146, 171, 0.16); color: #8392ab; }
.product-badge--neutral .product-badge__dot { background: #8392ab; }
.product-badge--neutral {
background: rgba(131, 146, 171, 0.16);
color: #8392ab;
}
.product-badge--neutral .product-badge__dot {
background: #8392ab;
}
</style>

View File

@ -13,10 +13,10 @@
import { defineProps } from "vue";
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
unit: { type: String, default: "" },
sub: { type: String, default: "" },
label: { type: String, required: true },
value: { type: [String, Number], required: true },
unit: { type: String, default: "" },
sub: { type: String, default: "" },
valueClass: { type: String, default: "" },
});
</script>
@ -49,8 +49,12 @@ defineProps({
color: #111827;
line-height: 1.1;
}
.stat-card__number.low { color: #dc2626; }
.stat-card__number.ok { color: #059669; }
.stat-card__number.low {
color: #dc2626;
}
.stat-card__number.ok {
color: #059669;
}
.stat-card__unit {
font-size: 13px;
font-weight: 500;

View File

@ -30,6 +30,41 @@
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label">Clients du groupe</label>
<ClientSearchInput
:exclude-ids="selectedClientIds"
@select="handleClientSelect"
/>
<div v-if="selectedClients.length" class="mt-3">
<span class="text-sm fw-bold">Clients sélectionnés :</span>
<div class="mt-2 d-flex flex-column gap-2">
<div
v-for="client in selectedClients"
:key="client.id"
class="d-flex align-items-center justify-content-between p-2 border rounded bg-light"
>
<div class="d-flex flex-column">
<span class="font-weight-bold text-sm">{{ client.name }}</span>
<span class="text-xs text-muted">
{{ client.email || "Pas d'email" }}
</span>
</div>
<button
type="button"
class="btn btn-link text-danger mb-0 p-0"
@click="removeClient(client.id)"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12 d-flex justify-content-end">
<soft-button
@ -63,13 +98,18 @@
</template>
<script setup>
import { ref, watch, defineProps, defineEmits } from "vue";
import { computed, ref, watch, defineProps, defineEmits } from "vue";
import SoftButton from "@/components/SoftButton.vue";
import ClientSearchInput from "@/components/molecules/client/ClientSearchInput.vue";
const props = defineProps({
initialData: {
type: Object,
default: () => ({ name: "", description: "" }),
default: () => ({
name: "",
description: "",
clients: [],
}),
},
isEdit: {
type: Boolean,
@ -86,8 +126,15 @@ const emit = defineEmits(["submit", "cancel"]);
const formData = ref({
name: props.initialData.name || "",
description: props.initialData.description || "",
clients: props.initialData.clients || [],
});
const selectedClients = computed(() => formData.value.clients || []);
const selectedClientIds = computed(() =>
selectedClients.value.map((client) => Number(client.id))
);
watch(
() => props.initialData,
(newData) => {
@ -95,13 +142,34 @@ watch(
formData.value = {
name: newData.name || "",
description: newData.description || "",
clients: newData.clients || [],
};
}
},
{ deep: true }
);
const handleClientSelect = (client) => {
const alreadySelected = selectedClientIds.value.includes(Number(client.id));
if (alreadySelected) {
return;
}
formData.value.clients = [...selectedClients.value, client];
};
const removeClient = (clientId) => {
formData.value.clients = selectedClients.value.filter(
(client) => Number(client.id) !== Number(clientId)
);
};
const handleSubmit = () => {
emit("submit", { ...formData.value });
emit("submit", {
name: formData.value.name,
description: formData.value.description,
client_ids: selectedClientIds.value,
});
};
</script>

View File

@ -31,20 +31,24 @@
<!-- En-tête avec titre et badge de statut -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h5 class="mb-0">Détails de l'Intervention</h5>
<SoftBadge :color="statusObject.color" :variant="statusObject.variant" size="sm">
{{ statusObject.label }}
</SoftBadge>
</div>
<!-- Informations Client -->
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="mb-0">Informations Client</h6>
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
<SoftButton
color="secondary"
variant="gradient"
size="sm"
:disabled="loading"
@click="toggleEditMode"
>
{{ editMode ? "Sauvegarder" : "Modifier" }}
</button>
</SoftButton>
</div>
<div class="row">
@ -116,22 +120,25 @@
<!-- Actions -->
<div class="d-flex justify-content-between">
<div v-if="editMode">
<button
type="button"
class="btn btn-sm bg-gradient-danger me-2"
<SoftButton
color="danger"
variant="gradient"
size="sm"
class="me-2"
:disabled="loading"
@click="resetChanges"
>
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-success"
</SoftButton>
<SoftButton
color="success"
variant="gradient"
size="sm"
:disabled="loading || !hasChanges"
@click="saveChanges"
>
<i class="fas fa-save me-2"></i>Sauvegarder
</button>
</SoftButton>
</div>
</div>
</template>
@ -193,20 +200,22 @@
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm bg-gradient-secondary"
<SoftButton
color="secondary"
variant="gradient"
size="sm"
@click="showTeamModal = false"
>
Annuler
</button>
<button
type="button"
class="btn btn-sm bg-gradient-primary"
</SoftButton>
<SoftButton
color="primary"
variant="gradient"
size="sm"
@click="saveTeamSelection"
>
Valider la sélection
</button>
</SoftButton>
</div>
</div>
</div>
@ -217,6 +226,7 @@
import { ref, computed, watch, defineProps, defineEmits } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import SoftButton from "@/components/SoftButton.vue";
import LocationManager from "@/components/Organism/Location/LocationManager.vue";
const props = defineProps({

View File

@ -2,19 +2,33 @@
<div class="info-section">
<!-- Read mode -->
<template v-if="!editMode">
<field-display label="Nom du produit" :value="product.nom" />
<field-display label="Référence" :value="product.reference" fallback="Sans référence" />
<field-display label="Catégorie" :value="categoryName" />
<field-display label="Fabricant" :value="product.fabricant" fallback="Non renseigné" />
<field-display label="Numéro de lot" :value="product.numero_lot" fallback="Non renseigné" />
<field-display label="Unité" :value="product.unite" />
<field-display label="Nom du produit" :value="product.nom" />
<field-display
label="Référence"
:value="product.reference"
fallback="Sans référence"
/>
<field-display label="Catégorie" :value="categoryName" />
<field-display
label="Fabricant"
:value="product.fabricant"
fallback="Non renseigné"
/>
<field-display
label="Numéro de lot"
:value="product.numero_lot"
fallback="Non renseigné"
/>
<field-display label="Unité" :value="product.unite" />
<field-display
label="Date d'expiration"
:value="formattedExpiration"
:value-class="expirationClass"
/>
<field-display label="Description">
<span style="white-space: pre-line">{{ product.description || "Aucune description" }}</span>
<span style="white-space: pre-line">{{
product.description || "Aucune description"
}}</span>
</field-display>
</template>
@ -35,7 +49,9 @@
required
:error="errors.reference?.[0]"
placeholder="Référence produit"
@update:model-value="$emit('update:form', { ...form, reference: $event })"
@update:model-value="
$emit('update:form', { ...form, reference: $event })
"
/>
<field-input
label="Catégorie"
@ -43,10 +59,16 @@
:model-value="form.categorie_id"
required
:error="errors.categorie_id?.[0]"
@update:model-value="$emit('update:form', { ...form, categorie_id: $event })"
@update:model-value="
$emit('update:form', { ...form, categorie_id: $event })
"
>
<option value="">Sélectionner une catégorie</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id.toString()">
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id.toString()"
>
{{ cat.name }}
</option>
</field-input>
@ -55,21 +77,27 @@
:model-value="form.fabricant"
:error="errors.fabricant?.[0]"
placeholder="Fabricant"
@update:model-value="$emit('update:form', { ...form, fabricant: $event })"
@update:model-value="
$emit('update:form', { ...form, fabricant: $event })
"
/>
<field-input
label="Numéro de lot"
:model-value="form.numero_lot"
:error="errors.numero_lot?.[0]"
placeholder="Numéro de lot"
@update:model-value="$emit('update:form', { ...form, numero_lot: $event })"
@update:model-value="
$emit('update:form', { ...form, numero_lot: $event })
"
/>
<field-input
label="Date d'expiration"
type="date"
:model-value="form.date_expiration"
:error="errors.date_expiration?.[0]"
@update:model-value="$emit('update:form', { ...form, date_expiration: $event })"
@update:model-value="
$emit('update:form', { ...form, date_expiration: $event })
"
/>
<field-input
label="Unité"
@ -85,7 +113,9 @@
:model-value="form.description"
placeholder="Description du produit..."
style="grid-column: 1 / -1"
@update:model-value="$emit('update:form', { ...form, description: $event })"
@update:model-value="
$emit('update:form', { ...form, description: $event })
"
/>
</div>
</template>
@ -95,22 +125,24 @@
<script setup>
import { computed, defineProps, defineEmits } from "vue";
import FieldDisplay from "@/components/atoms/Product/FieldDisplay.vue";
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
const props = defineProps({
product: { type: Object, required: true },
form: { type: Object, required: true },
categories: { type: Array, default: () => [] },
errors: { type: Object, default: () => ({}) },
editMode: { type: Boolean, default: false },
categoryName:{ type: String, default: "" },
product: { type: Object, required: true },
form: { type: Object, required: true },
categories: { type: Array, default: () => [] },
errors: { type: Object, default: () => ({}) },
editMode: { type: Boolean, default: false },
categoryName: { type: String, default: "" },
});
defineEmits(["update:form"]);
const formattedExpiration = computed(() => {
if (!props.product?.date_expiration) return "Non renseignée";
return new Date(props.product.date_expiration).toLocaleDateString("fr-FR", {
day: "numeric", month: "long", year: "numeric",
day: "numeric",
month: "long",
year: "numeric",
});
});
@ -126,13 +158,18 @@ const expirationClass = computed(() => {
</script>
<style scoped>
.info-section { display: flex; flex-direction: column; }
.info-section {
display: flex;
flex-direction: column;
}
.info-section__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
@media (max-width: 640px) {
.info-section__grid { grid-template-columns: 1fr; }
.info-section__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,7 +1,16 @@
<template>
<div>
<div v-if="!movements.length" class="movements-empty">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M7 16l-4-4 4-4M17 8l4 4-4 4M14 4l-4 16"/></svg>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path d="M7 16l-4-4 4-4M17 8l4 4-4 4M14 4l-4 16" />
</svg>
<p>Aucun mouvement de stock enregistré</p>
</div>
<div v-else class="movements-table-wrap">
@ -16,7 +25,9 @@
</thead>
<tbody>
<tr v-for="m in movements" :key="m.id">
<td class="movements-table__date">{{ formatDate(m.date || m.created_at) }}</td>
<td class="movements-table__date">
{{ formatDate(m.date || m.created_at) }}
</td>
<td>
<span class="movements-table__type" :class="typeClass(m.type)">
{{ m.type || "—" }}
@ -25,7 +36,9 @@
<td class="movements-table__qty" :class="qtyClass(m)">
{{ formatQty(m) }}
</td>
<td class="movements-table__ref">{{ m.reference || m.reason || "—" }}</td>
<td class="movements-table__ref">
{{ m.reference || m.reason || "—" }}
</td>
</tr>
</tbody>
</table>
@ -43,7 +56,9 @@ defineProps({
const formatDate = (d) => {
if (!d) return "—";
return new Date(d).toLocaleDateString("fr-FR", {
day: "2-digit", month: "short", year: "numeric",
day: "2-digit",
month: "short",
year: "numeric",
});
};
@ -57,7 +72,8 @@ const formatQty = (m) => {
const typeClass = (type) => {
if (!type) return "";
const t = type.toLowerCase();
if (t.includes("entree") || t.includes("entrée") || t.includes("achat")) return "type-in";
if (t.includes("entree") || t.includes("entrée") || t.includes("achat"))
return "type-in";
if (t.includes("sortie") || t.includes("utilisation")) return "type-out";
return "type-neutral";
};
@ -78,9 +94,14 @@ const qtyClass = (m) => {
color: #9ca3af;
text-align: center;
}
.movements-empty p { font-size: 14px; margin: 0; }
.movements-empty p {
font-size: 14px;
margin: 0;
}
.movements-table-wrap { overflow-x: auto; }
.movements-table-wrap {
overflow-x: auto;
}
.movements-table {
width: 100%;
@ -106,11 +127,21 @@ const qtyClass = (m) => {
border-bottom: 1px solid #f9fafb;
vertical-align: middle;
}
.movements-table tr:last-child td { border-bottom: none; }
.movements-table tr:hover td { background: #f9fafb; }
.movements-table tr:last-child td {
border-bottom: none;
}
.movements-table tr:hover td {
background: #f9fafb;
}
.movements-table__date { color: #6b7280; font-variant-numeric: tabular-nums; }
.movements-table__ref { color: #9ca3af; font-size: 12px; }
.movements-table__date {
color: #6b7280;
font-variant-numeric: tabular-nums;
}
.movements-table__ref {
color: #9ca3af;
font-size: 12px;
}
.movements-table__type {
display: inline-block;
@ -121,11 +152,27 @@ const qtyClass = (m) => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.type-in { background: #ecfdf5; color: #065f46; }
.type-out { background: #fef2f2; color: #991b1b; }
.type-neutral { background: #f3f4f6; color: #374151; }
.type-in {
background: #ecfdf5;
color: #065f46;
}
.type-out {
background: #fef2f2;
color: #991b1b;
}
.type-neutral {
background: #f3f4f6;
color: #374151;
}
.movements-table__qty { font-weight: 600; font-variant-numeric: tabular-nums; }
.qty-positive { color: #059669; }
.qty-negative { color: #dc2626; }
.movements-table__qty {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.qty-positive {
color: #059669;
}
.qty-negative {
color: #dc2626;
}
</style>

View File

@ -1,10 +1,7 @@
<template>
<aside class="product-sidebar">
<div class="product-sidebar__img-wrap">
<product-image
:image-url="imageUrl"
:alt-text="`Image de ${name}`"
/>
<product-image :image-url="imageUrl" :alt-text="`Image de ${name}`" />
</div>
<div class="product-sidebar__meta">
@ -15,7 +12,9 @@
{{ isLowStock ? "Stock faible" : "Stock OK" }}
</product-badge>
<product-badge v-if="isExpired" variant="danger">Expiré</product-badge>
<product-badge v-else-if="isExpiringSoon" variant="info">Expire bientôt</product-badge>
<product-badge v-else-if="isExpiringSoon" variant="info"
>Expire bientôt</product-badge
>
</div>
</div>
@ -40,14 +39,14 @@ import ProductImage from "@/components/atoms/Product/ProductImage.vue";
import ProductBadge from "@/components/atoms/Product/ProductBadge.vue";
defineProps({
modelValue: { type: String, default: "details" },
name: { type: String, default: "" },
reference: { type: String, default: "" },
imageUrl: { type: String, default: "" },
isLowStock: { type: Boolean, default: false },
isExpired: { type: Boolean, default: false },
isExpiringSoon:{ type: Boolean, default: false },
tabs: { type: Array, default: () => [] },
modelValue: { type: String, default: "details" },
name: { type: String, default: "" },
reference: { type: String, default: "" },
imageUrl: { type: String, default: "" },
isLowStock: { type: Boolean, default: false },
isExpired: { type: Boolean, default: false },
isExpiringSoon: { type: Boolean, default: false },
tabs: { type: Array, default: () => [] },
});
defineEmits(["update:modelValue"]);
</script>
@ -94,7 +93,7 @@ defineEmits(["update:modelValue"]);
font-size: 12px;
color: #9ca3af;
margin: 0;
font-family: 'SF Mono', 'Fira Code', monospace;
font-family: "SF Mono", "Fira Code", monospace;
}
.product-sidebar__badges {
display: flex;
@ -126,10 +125,17 @@ defineEmits(["update:modelValue"]);
text-align: left;
transition: background 0.12s, color 0.12s;
}
.product-sidebar__nav-item:hover { background: #f9fafb; color: #111827; }
.product-sidebar__nav-item:hover {
background: #f9fafb;
color: #111827;
}
.product-sidebar__nav-item.is-active {
background: #111827;
color: #fff;
}
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
.nav-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
</style>

View File

@ -30,7 +30,9 @@
min="0"
step="1"
placeholder="0"
@update:model-value="$emit('update:form', { ...form, stock_actuel: $event })"
@update:model-value="
$emit('update:form', { ...form, stock_actuel: $event })
"
/>
<field-input
label="Stock minimum"
@ -41,7 +43,9 @@
min="0"
step="1"
placeholder="0"
@update:model-value="$emit('update:form', { ...form, stock_minimum: $event })"
@update:model-value="
$emit('update:form', { ...form, stock_minimum: $event })
"
/>
<field-input
label="Prix unitaire (€)"
@ -52,14 +56,18 @@
min="0"
step="0.01"
placeholder="0.00"
@update:model-value="$emit('update:form', { ...form, prix_unitaire: $event })"
@update:model-value="
$emit('update:form', { ...form, prix_unitaire: $event })
"
/>
<field-input
label="Conditionnement"
:model-value="form.conditionnement_nom"
:error="errors.conditionnement_nom?.[0]"
placeholder="ex: Carton 12 unités"
@update:model-value="$emit('update:form', { ...form, conditionnement_nom: $event })"
@update:model-value="
$emit('update:form', { ...form, conditionnement_nom: $event })
"
/>
<field-input
label="Qté par conditionnement"
@ -69,7 +77,9 @@
min="0"
step="1"
placeholder="0"
@update:model-value="$emit('update:form', { ...form, conditionnement_quantite: $event })"
@update:model-value="
$emit('update:form', { ...form, conditionnement_quantite: $event })
"
/>
</div>
</template>
@ -80,7 +90,8 @@
<span class="cond-value">
{{ product.conditionnement?.nom }}
<span v-if="product.conditionnement?.quantite">
· {{ product.conditionnement.quantite }} {{ product.conditionnement.unite }}
· {{ product.conditionnement.quantite }}
{{ product.conditionnement.unite }}
</span>
</span>
</div>
@ -111,27 +122,32 @@
<script setup>
import { computed, defineProps, defineEmits } from "vue";
import ProductStatCard from "@/components/atoms/Product/ProductStatCard.vue";
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
import FieldInput from "@/components/atoms/Product/FieldInput.vue";
import SoftBadge from "@/components/SoftBadge.vue";
const props = defineProps({
product: { type: Object, required: true },
form: { type: Object, required: true },
errors: { type: Object, default: () => ({}) },
editMode: { type: Boolean, default: false },
isExpired: { type: Boolean, default: false },
isExpiringSoon:{ type: Boolean, default: false },
product: { type: Object, required: true },
form: { type: Object, required: true },
errors: { type: Object, default: () => ({}) },
editMode: { type: Boolean, default: false },
isExpired: { type: Boolean, default: false },
isExpiringSoon: { type: Boolean, default: false },
});
defineEmits(["update:form"]);
const formattedPrice = computed(() => {
const p = props.product.prix_unitaire;
if (!p && p !== 0) return "—";
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(p);
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(p);
});
const hasConditioning = computed(() =>
props.product.conditionnement?.nom || props.product.conditionnement?.quantite
const hasConditioning = computed(
() =>
props.product.conditionnement?.nom ||
props.product.conditionnement?.quantite
);
</script>
@ -146,7 +162,11 @@ const hasConditioning = computed(() =>
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.stock-indicators { display: flex; flex-wrap: wrap; gap: 6px; }
.stock-indicators {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cond-row {
display: flex;
align-items: center;
@ -164,9 +184,16 @@ const hasConditioning = computed(() =>
color: #9ca3af;
min-width: 120px;
}
.cond-value { font-size: 14px; color: #111827; }
.cond-value {
font-size: 14px;
color: #111827;
}
@media (max-width: 640px) {
.stock-stats { grid-template-columns: 1fr 1fr; }
.stock-grid { grid-template-columns: 1fr; }
.stock-stats {
grid-template-columns: 1fr 1fr;
}
.stock-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -6,18 +6,39 @@
</div>
<div class="supplier-card__body">
<p class="supplier-card__name">{{ supplier.name || supplier.nom }}</p>
<p v-if="supplier.email" class="supplier-card__email">{{ supplier.email }}</p>
<p v-if="supplier.phone" class="supplier-card__phone">{{ supplier.phone }}</p>
<p v-if="supplier.email" class="supplier-card__email">
{{ supplier.email }}
</p>
<p v-if="supplier.phone" class="supplier-card__phone">
{{ supplier.phone }}
</p>
</div>
<button class="supplier-card__cta" @click="$emit('view', supplier)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 13L13 3M13 3H7M13 3v6"/>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3 13L13 3M13 3H7M13 3v6" />
</svg>
Voir le fournisseur
</button>
</div>
<div v-else class="supplier-empty">
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1"><rect x="2" y="4" width="12" height="9" rx="1.5"/><path d="M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1"/></svg>
<svg
width="20"
height="20"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<rect x="2" y="4" width="12" height="9" rx="1.5" />
<path d="M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1" />
</svg>
Aucun fournisseur associé
</div>
</div>
@ -33,7 +54,12 @@ defineEmits(["view"]);
const initials = computed(() => {
const name = props.supplier?.name || props.supplier?.nom || "";
return name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase();
return name
.split(" ")
.map((w) => w[0] || "")
.join("")
.slice(0, 2)
.toUpperCase();
});
</script>
@ -60,7 +86,10 @@ const initials = computed(() => {
font-weight: 700;
flex-shrink: 0;
}
.supplier-card__body { flex: 1; min-width: 0; }
.supplier-card__body {
flex: 1;
min-width: 0;
}
.supplier-card__name {
font-size: 15px;
font-weight: 600;
@ -90,7 +119,10 @@ const initials = computed(() => {
transition: background 0.12s, border-color 0.12s;
flex-shrink: 0;
}
.supplier-card__cta:hover { background: #f3f4f6; border-color: #9ca3af; }
.supplier-card__cta:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.supplier-empty {
display: flex;

View File

@ -210,7 +210,7 @@
<SoftButton
type="button"
color="warning"
size="sm"
size="sm"
:variant="
formData.status === 'draft' ? 'gradient' : 'outline'
"

View File

@ -3,7 +3,7 @@
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<div class="spinner-border text-success loading-spinner-circle" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
@ -108,13 +108,7 @@
<!-- Product Column -->
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="product.media?.photo_url || getRandomAvatar()"
size="xs"
class="me-2"
alt="product image"
circular
/>
<soft-checkbox />
<span>{{ product.nom }}</span>
</div>
</td>
@ -208,15 +202,16 @@
<i class="fas fa-clock me-1"></i>
Expire bientôt
</soft-button>
<!-- Normal Status -->
<span
<soft-button
v-if="!product.is_low_stock && !isExpiringSoon(product)"
class="badge badge-success"
color="success"
variant="outline"
class="btn-sm"
>
<i class="fas fa-check me-1"></i>
Normal
</span>
<i class="fas fa-check me-1"></i>
Stock Normal
</soft-button>
</div>
</td>
@ -466,22 +461,30 @@ onMounted(() => {
.loading-container {
position: relative;
min-height: 260px;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.loading-spinner-circle {
width: 2.25rem;
height: 2.25rem;
border-width: 0.28em;
}
.loading-content {
opacity: 0.7;
opacity: 0.55;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
animation: none;
}
.skeleton-checkbox {
@ -585,11 +588,6 @@ onMounted(() => {
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long {
width: 80px;
}

View File

@ -1,241 +1,502 @@
<template>
<div
class="modal fade"
:class="{ show: isOpen }"
:style="{ display: isOpen ? 'block' : 'none' }"
tabindex="-1"
role="dialog"
@click.self="close"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Assigner un thanatopracteur</h5>
<button
type="button"
class="btn-close"
aria-label="Fermer"
@click="close"
></button>
</div>
<div class="modal-body">
<!-- Search Input -->
<div class="mb-3">
<label class="form-label">Rechercher par nom</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Tapez le nom du thanatopracteur..."
@input="handleSearch"
/>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-3">
<div
class="spinner-border spinner-border-sm text-primary"
role="status"
>
<span class="visually-hidden">Chargement...</span>
</div>
<span class="ms-2">Recherche en cours...</span>
</div>
<!-- Results List -->
<div v-else-if="searchResults.length > 0" class="list-group">
<button
v-for="practitioner in searchResults"
:key="practitioner.id"
type="button"
class="list-group-item list-group-item-action d-flex align-items-center"
:class="{ active: selectedPractitioner?.id === practitioner.id }"
@click="selectPractitioner(practitioner)"
>
<div class="avatar avatar-sm me-3">
<img
:src="practitioner.avatar || '/images/avatar-default.png'"
alt="Avatar"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover"
/>
<!-- Backdrop -->
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="isOpen"
class="modal-backdrop"
@mousedown.self="$emit('close')"
>
<div
class="modal-box"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<!-- Header -->
<div class="modal-header">
<div class="modal-title-wrap">
<div class="modal-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="8.5" cy="7" r="4" />
<line x1="20" y1="8" x2="20" y2="14" />
<line x1="23" y1="11" x2="17" y2="11" />
</svg>
</div>
<div class="flex-grow-1">
<h6 class="mb-0">
{{
practitioner.employee.full_name ||
`${practitioner.employee.first_name} ${practitioner.employee.last_name}`
}}
</h6>
<small class="text-muted">{{
practitioner.employee.email || "Email non disponible"
}}</small>
<div>
<h2 id="modal-title" class="modal-title">
Assigner un praticien
</h2>
<p class="modal-sub">Sélectionnez un praticien et son rôle</p>
</div>
<i
v-if="selectedPractitioner?.id === practitioner.id"
class="fas fa-check text-success"
></i>
</div>
<button class="close-btn" @click="$emit('close')">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- No Results -->
<div
v-else-if="searchQuery.length >= 2 && !loading"
class="text-center text-muted py-3"
>
<i class="fas fa-user-slash fa-2x mb-2"></i>
<p class="mb-0">Aucun thanatopracteur trouvé</p>
<!-- Body -->
<div class="modal-body">
<!-- Practitioner ID -->
<div class="form-group">
<label class="form-label">Identifiant du praticien</label>
<input
v-model="form.practitionerId"
type="number"
class="form-input"
placeholder="ex: 42"
min="1"
/>
<p class="form-hint">
Entrez l'ID du praticien à assigner à cette intervention.
</p>
</div>
<!-- Role -->
<div class="form-group">
<label class="form-label">Rôle</label>
<div class="role-grid">
<button
class="role-option"
:class="{ selected: form.role === 'principal' }"
type="button"
@click="form.role = 'principal'"
>
<div class="role-radio">
<div
v-if="form.role === 'principal'"
class="role-radio-dot"
></div>
</div>
<div class="role-info">
<div class="role-name">Principal</div>
<div class="role-desc">Responsable de l'intervention</div>
</div>
<span class="role-chip chip-principal">Principal</span>
</button>
<button
class="role-option"
:class="{ selected: form.role === 'assistant' }"
type="button"
@click="form.role = 'assistant'"
>
<div class="role-radio">
<div
v-if="form.role === 'assistant'"
class="role-radio-dot"
></div>
</div>
<div class="role-info">
<div class="role-name">Assistant</div>
<div class="role-desc">Rôle de soutien et assistance</div>
</div>
<span class="role-chip chip-assistant">Assistant</span>
</button>
</div>
</div>
<!-- Validation error -->
<Transition name="slide-error">
<div v-if="error" class="error-banner">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{{ error }}
</div>
</Transition>
</div>
<!-- Initial State -->
<div v-else class="text-center text-muted py-3">
<i class="fas fa-search fa-2x mb-2"></i>
<p class="mb-0">Tapez au moins 2 caractères pour rechercher</p>
<!-- Footer -->
<div class="modal-footer">
<button class="btn-ghost" @click="$emit('close')">Annuler</button>
<button class="btn-primary" @click="handleSubmit">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Confirmer l'assignation
</button>
</div>
<!-- Role Selection -->
<div v-if="selectedPractitioner" class="mt-3">
<label class="form-label">Rôle dans l'intervention</label>
<select v-model="selectedRole" class="form-select">
<option value="principal">Principal</option>
<option value="assistant">Assistant</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedPractitioner"
@click="confirmAssignment"
>
<i class="fas fa-user-plus me-2"></i>Assigner
</button>
</div>
</div>
</div>
</div>
<!-- Backdrop -->
<div v-if="isOpen" class="modal-backdrop fade show"></div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch } from "vue";
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
import { defineProps, defineEmits } from "vue";
import { ref, watch, defineProps, defineEmits } from "vue";
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
isOpen: { type: Boolean, default: false },
});
const emit = defineEmits(["close", "assign"]);
const thanatopractitionerStore = useThanatopractitionerStore();
const form = ref({ practitionerId: "", role: "principal" });
const error = ref("");
const searchQuery = ref("");
const searchResults = ref([]);
const selectedPractitioner = ref(null);
const selectedRole = ref("principal");
const loading = ref(false);
let searchTimeout = null;
const handleSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (searchQuery.value.length < 2) {
searchResults.value = [];
return;
}
searchTimeout = setTimeout(async () => {
loading.value = true;
try {
const response = await thanatopractitionerStore.searchThanatopractitioners(
searchQuery.value
);
// The service returns the data directly, not wrapped in response.data
searchResults.value = Array.isArray(response) ? response : [];
} catch (error) {
console.error("Error searching practitioners:", error);
searchResults.value = [];
} finally {
loading.value = false;
}
}, 300);
};
const selectPractitioner = (practitioner) => {
selectedPractitioner.value = practitioner;
};
const confirmAssignment = () => {
if (selectedPractitioner.value) {
emit("assign", {
practitionerId: selectedPractitioner.value.id,
role: selectedRole.value,
});
resetForm();
}
};
const close = () => {
resetForm();
emit("close");
};
const resetForm = () => {
searchQuery.value = "";
searchResults.value = [];
selectedPractitioner.value = null;
selectedRole.value = "principal";
};
// Reset form when modal closes
// Reset form when modal opens
watch(
() => props.isOpen,
(newValue) => {
if (!newValue) {
resetForm();
(open) => {
if (open) {
form.value = { practitionerId: "", role: "principal" };
error.value = "";
}
}
);
const handleSubmit = () => {
error.value = "";
if (!form.value.practitionerId) {
error.value = "Veuillez entrer un identifiant de praticien.";
return;
}
if (parseInt(form.value.practitionerId) <= 0) {
error.value = "L'identifiant doit être un nombre positif.";
return;
}
emit("assign", {
practitionerId: parseInt(form.value.practitionerId),
role: form.value.role,
});
};
</script>
<style scoped>
.modal.show {
background-color: rgba(0, 0, 0, 0.5);
/* ── Tokens ── */
.modal-backdrop {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface: #ffffff;
--surface-2: #f8fafc;
--border: #e2e8f0;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
--r-md: 12px;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.list-group-item {
/* ── Modal box ── */
.modal-box {
background: var(--surface);
border-radius: 16px;
width: 100%;
max-width: 460px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Header */
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 22px 24px 18px;
border-bottom: 1px solid var(--border);
}
.modal-title-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.modal-icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--brand-lt);
color: var(--brand);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modal-title {
font-size: 16px;
font-weight: 700;
color: var(--text-1);
margin: 0;
}
.modal-sub {
font-size: 12.5px;
color: var(--text-3);
margin: 2px 0 0;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
transition: all 0.15s;
flex-shrink: 0;
}
.close-btn:hover {
background: var(--surface-2);
color: var(--text-1);
}
.list-group-item:hover {
background-color: #f8f9fa;
/* Body */
.modal-body {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 18px;
}
.list-group-item.active {
background-color: #e3f2fd;
border-color: #2196f3;
color: inherit;
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 12px;
font-weight: 700;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-hint {
font-size: 11.5px;
color: var(--text-3);
margin: 2px 0 0;
}
.list-group-item.active:hover {
background-color: #bbdefb;
.form-input {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 13.5px;
color: var(--text-1);
background: var(--surface);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-family: inherit;
width: 100%;
}
.form-input:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* Role grid */
.role-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.role-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1.5px solid var(--border);
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
}
.role-option:hover {
border-color: #a5b4fc;
background: var(--brand-lt);
}
.role-option.selected {
border-color: var(--brand);
background: var(--brand-lt);
}
.role-radio {
width: 17px;
height: 17px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.15s;
}
.role-option.selected .role-radio {
border-color: var(--brand);
}
.role-radio-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--brand);
}
.role-info {
flex: 1;
}
.role-name {
font-size: 13.5px;
font-weight: 600;
color: var(--text-1);
}
.role-desc {
font-size: 11.5px;
color: var(--text-3);
margin-top: 1px;
}
.role-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10.5px;
font-weight: 600;
}
.chip-principal {
background: #eef2ff;
color: #4f46e5;
}
.chip-assistant {
background: #f0fdf4;
color: #16a34a;
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 13px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--r-sm);
font-size: 13px;
color: #dc2626;
font-weight: 500;
}
/* Footer */
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 16px 24px;
background: var(--surface-2);
border-top: 1px solid var(--border);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: var(--r-sm);
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: white;
background: var(--brand);
transition: all 0.15s;
}
.btn-primary:hover {
background: var(--brand-dk);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 16px;
border-radius: var(--r-sm);
border: 1px solid var(--border);
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
background: transparent;
transition: all 0.15s;
}
.btn-ghost:hover {
background: var(--border);
color: var(--text-1);
}
/* ── Transitions ── */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .modal-box,
.modal-fade-leave-active .modal-box {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-box,
.modal-fade-leave-to .modal-box {
transform: scale(0.96) translateY(8px);
opacity: 0;
}
.slide-error-enter-active,
.slide-error-leave-active {
transition: all 0.2s ease;
}
.slide-error-enter-from,
.slide-error-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div class="data-row">
<span class="data-label">{{ label }}</span>
<span class="data-value" :class="{ 'fw-semibold': bold }">{{ value || "-" }}</span>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
label: {
type: String,
default: "",
},
value: {
type: [String, Number],
default: "",
},
bold: {
type: Boolean,
default: false,
},
});
</script>

View File

@ -0,0 +1,25 @@
<template>
<div class="info-section">
<div class="info-section-header" :style="{ '--accent': iconColor }">
<span class="info-section-icon"><slot name="icon" /></span>
<span class="info-section-title">{{ title }}</span>
</div>
<div class="info-section-body"><slot /></div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
title: {
type: String,
default: "",
},
iconColor: {
type: String,
default: "#6366f1",
},
});
</script>

View File

@ -1,66 +1,139 @@
<template>
<ul class="nav nav-pills flex-column">
<TabNavigationItem
icon="fas fa-eye"
label="Vue d'ensemble"
:is-active="activeTab === 'overview'"
spacing=""
@click="$emit('change-tab', 'overview')"
/>
<TabNavigationItem
icon="fas fa-list"
label="Détails"
:is-active="activeTab === 'details'"
@click="$emit('change-tab', 'details')"
/>
<TabNavigationItem
icon="fas fa-users"
label="Équipe"
:is-active="activeTab === 'team'"
:badge="teamCount > 0 ? teamCount : null"
@click="$emit('change-tab', 'team')"
/>
<TabNavigationItem
icon="fas fa-file-alt"
label="Documents"
:is-active="activeTab === 'documents'"
:badge="documentsCount > 0 ? documentsCount : null"
@click="$emit('change-tab', 'documents')"
/>
<TabNavigationItem
icon="fas fa-file-invoice"
label="Devis"
:is-active="activeTab === 'quote'"
@click="$emit('change-tab', 'quote')"
/>
<TabNavigationItem
icon="fas fa-history"
label="Historique"
:is-active="activeTab === 'history'"
@click="$emit('change-tab', 'history')"
/>
</ul>
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: activeTab === tab.id }"
@click="$emit('change-tab', tab.id)"
>
<span class="tab-icon" v-html="tab.icon"></span>
<span class="tab-label">{{ tab.label }}</span>
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{
teamCount
}}</span>
<span
v-if="tab.id === 'documents' && documentsCount > 0"
class="tab-badge"
>{{ documentsCount }}</span
>
</button>
</nav>
</template>
<script setup>
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
import { defineProps, defineEmits, computed } from "vue";
import { defineProps, defineEmits } from "vue";
defineProps({
activeTab: {
type: String,
required: true,
},
teamCount: {
type: Number,
default: 0,
},
documentsCount: {
type: Number,
default: 0,
},
activeTab: { type: String, required: true },
teamCount: { type: Number, default: 0 },
documentsCount: { type: Number, default: 0 },
});
defineEmits(["change-tab"]);
const tabs = [
{
id: "overview",
label: "Vue d'ensemble",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
},
{
id: "details",
label: "Détails",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
},
{
id: "team",
label: "Équipe",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
},
{
id: "documents",
label: "Documents",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
},
{
id: "quote",
label: "Devis",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
},
{
id: "history",
label: "Historique",
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.5"/></svg>`,
},
];
</script>
<style scoped>
.tab-nav {
--brand: #4f46e5;
--brand-lt: #eef2ff;
--brand-dk: #3730a3;
--surface-2: #f8fafc;
--border-lt: #f1f5f9;
--text-1: #0f172a;
--text-2: #64748b;
--text-3: #94a3b8;
--r-sm: 8px;
display: flex;
flex-direction: column;
gap: 2px;
font-family: inherit;
}
.tab-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
border-radius: var(--r-sm);
border: none;
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 13.5px;
font-weight: 500;
color: var(--text-2);
transition: background 0.12s, color 0.12s;
}
.tab-item:hover {
background: var(--surface-2);
color: var(--text-1);
}
.tab-item.active {
background: var(--brand-lt);
color: var(--brand);
font-weight: 600;
}
.tab-icon {
flex-shrink: 0;
display: flex;
color: var(--text-3);
}
.tab-item.active .tab-icon {
color: var(--brand);
}
.tab-label {
flex: 1;
}
.tab-badge {
min-width: 19px;
height: 19px;
padding: 0 5px;
border-radius: 10px;
background: var(--brand);
color: white;
font-size: 10.5px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.tab-item.active .tab-badge {
background: var(--brand-dk);
}
</style>

View File

@ -0,0 +1,21 @@
<template>
<span class="status-pill" :class="['sp-' + (status?.color || 'secondary'), large ? 'sp-lg' : '']">
{{ status?.label || "En attente" }}
</span>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
status: {
type: Object,
default: () => ({ label: "En attente", color: "secondary" }),
},
large: {
type: Boolean,
default: false,
},
});
</script>

View File

@ -293,6 +293,12 @@ export default {
miniIcon: "F",
text: "Factures",
},
{
id: "price-lists",
route: { name: "Liste Price Lists" },
miniIcon: "P",
text: "Listes prix",
},
{
id: "ventes-stats",
route: { name: "Statistiques ventes" },

View File

@ -555,6 +555,11 @@ const routes = [
name: "Invoice Details",
component: () => import("@/views/pages/Ventes/InvoiceDetail.vue"),
},
{
path: "/ventes/listes-prix",
name: "Liste Price Lists",
component: () => import("@/views/pages/Ventes/PriceLists.vue"),
},
// Avoirs
{
path: "/avoirs",

View File

@ -1,9 +1,13 @@
import { request } from "./http";
import type { Client } from "./client";
export interface ClientGroup {
id: number;
name: string;
description: string | null;
clients_count?: number;
client_ids?: number[];
clients?: Client[];
created_at: string;
updated_at: string;
}
@ -25,6 +29,7 @@ export interface ClientGroupResponse {
export interface CreateClientGroupPayload {
name: string;
description?: string | null;
client_ids?: number[];
}
export interface UpdateClientGroupPayload

View File

@ -0,0 +1,78 @@
import { request } from "./http";
export interface PriceList {
id: number;
name: string;
valid_from: string | null;
valid_to: string | null;
is_default: boolean;
}
export interface PriceListListResponse {
data: PriceList[];
}
export interface PriceListResponse {
data: PriceList;
}
export interface CreatePriceListPayload {
name: string;
valid_from?: string | null;
valid_to?: string | null;
is_default?: boolean;
}
export interface UpdatePriceListPayload
extends Partial<CreatePriceListPayload> {
id: number;
}
export const PriceListService = {
async getAllPriceLists(): Promise<PriceListListResponse> {
return await request<PriceListListResponse>({
url: "/api/price-lists",
method: "get",
});
},
async getPriceList(id: number): Promise<PriceListResponse> {
return await request<PriceListResponse>({
url: `/api/price-lists/${id}`,
method: "get",
});
},
async createPriceList(
payload: CreatePriceListPayload
): Promise<PriceListResponse> {
return await request<PriceListResponse>({
url: "/api/price-lists",
method: "post",
data: payload,
});
},
async updatePriceList(
payload: UpdatePriceListPayload
): Promise<PriceListResponse> {
const { id, ...updateData } = payload;
return await request<PriceListResponse>({
url: `/api/price-lists/${id}`,
method: "put",
data: updateData,
});
},
async deletePriceList(
id: number
): Promise<{ success?: boolean; message: string }> {
return await request<{ success?: boolean; message: string }>({
url: `/api/price-lists/${id}`,
method: "delete",
});
},
};
export default PriceListService;

View File

@ -115,6 +115,7 @@ class ProductService {
url: `/api/products/${id}`,
method: "get",
});
console.log(response);
return response;
}

View File

@ -1,9 +1,9 @@
import { request } from "./http";
import { http, request } from "./http";
import { Client } from "./client";
export interface Quote {
id: number;
client_id: number;
client_id: number | null;
group_id: number | null;
reference: string;
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
@ -50,7 +50,7 @@ export interface QuoteLine {
}
export interface CreateQuotePayload {
client_id: number;
client_id: number | null;
group_id?: number | null;
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
quote_date: string;
@ -111,6 +111,17 @@ export const QuoteService = {
return response;
},
async downloadQuotePdf(id: number): Promise<Blob> {
const response = await http.get(`/api/quotes/${id}/download-pdf`, {
responseType: "blob",
headers: {
Accept: "application/pdf",
},
});
return response.data;
},
/**
* Update an existing quote
*/

View File

@ -283,6 +283,7 @@ export const useFournisseurStore = defineStore("fournisseur", () => {
currentFournisseur,
loading,
error,
searchResults,
// Getters
allFournisseurs,

View File

@ -133,6 +133,17 @@ export const useQuoteStore = defineStore("quote", () => {
}
};
const downloadQuotePdf = async (id: number) => {
try {
return await QuoteService.downloadQuotePdf(id);
} catch (err: any) {
const errorMessage =
err.response?.data?.message || err.message || "Failed to download quote PDF";
setError(errorMessage);
throw err;
}
};
/**
* Update an existing quote
*/
@ -217,6 +228,7 @@ export const useQuoteStore = defineStore("quote", () => {
fetchQuotes,
fetchQuote,
createQuote,
downloadQuotePdf,
updateQuote,
deleteQuote,
};

View File

@ -0,0 +1,12 @@
<template>
<div class="container-fluid py-4">
<div class="card">
<div class="card-body py-5 text-center">
<h4 class="mb-3">Listes de prix</h4>
<p class="text-muted mb-0">
Cette page est prête pour la gestion des listes de prix.
</p>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff