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:
parent
dd6fc4665c
commit
9cbc1bcbdb
@ -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.',
|
||||
|
||||
154
thanasoft-back/app/Http/Controllers/Api/PriceListController.php
Normal file
154
thanasoft-back/app/Http/Controllers/Api/PriceListController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
43
thanasoft-back/app/Http/Requests/StorePriceListRequest.php
Normal file
43
thanasoft-back/app/Http/Requests/StorePriceListRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
54
thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php
Normal file
54
thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
|
||||
25
thanasoft-back/app/Http/Resources/PriceListResource.php
Normal file
25
thanasoft-back/app/Http/Resources/PriceListResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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));
|
||||
});
|
||||
|
||||
15
thanasoft-back/app/Repositories/PriceListRepository.php
Normal file
15
thanasoft-back/app/Repositories/PriceListRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
interface PriceListRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
268
thanasoft-front/refont_interview/AssignPractitionerModal.vue
Normal file
268
thanasoft-front/refont_interview/AssignPractitionerModal.vue
Normal 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>
|
||||
1056
thanasoft-front/refont_interview/InterventionDetailPresentation.vue
Normal file
1056
thanasoft-front/refont_interview/InterventionDetailPresentation.vue
Normal file
File diff suppressed because it is too large
Load Diff
230
thanasoft-front/refont_interview/InterventionDetailSidebar.vue
Normal file
230
thanasoft-front/refont_interview/InterventionDetailSidebar.vue
Normal 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>
|
||||
803
thanasoft-front/refont_interview/InterventionDetails.vue
Normal file
803
thanasoft-front/refont_interview/InterventionDetails.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -210,7 +210,7 @@
|
||||
<SoftButton
|
||||
type="button"
|
||||
color="warning"
|
||||
size="sm"
|
||||
size="sm"
|
||||
:variant="
|
||||
formData.status === 'draft' ? 'gradient' : 'outline'
|
||||
"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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" },
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
78
thanasoft-front/src/services/priceList.ts
Normal file
78
thanasoft-front/src/services/priceList.ts
Normal 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;
|
||||
@ -115,6 +115,7 @@ class ProductService {
|
||||
url: `/api/products/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
console.log(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -283,6 +283,7 @@ export const useFournisseurStore = defineStore("fournisseur", () => {
|
||||
currentFournisseur,
|
||||
loading,
|
||||
error,
|
||||
searchResults,
|
||||
|
||||
// Getters
|
||||
allFournisseurs,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
12
thanasoft-front/src/views/pages/Ventes/PriceLists.vue
Normal file
12
thanasoft-front/src/views/pages/Ventes/PriceLists.vue
Normal 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>
|
||||
2030
thanasoft-front/tsconfig.tsbuildinfo
Normal file
2030
thanasoft-front/tsconfig.tsbuildinfo
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user