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 App\Repositories\ClientGroupRepositoryInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ClientGroupController extends Controller
|
class ClientGroupController extends Controller
|
||||||
{
|
{
|
||||||
@ -49,7 +51,22 @@ class ClientGroupController extends Controller
|
|||||||
public function store(StoreClientGroupRequest $request): ClientGroupResource|JsonResponse
|
public function store(StoreClientGroupRequest $request): ClientGroupResource|JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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);
|
return new ClientGroupResource($clientGroup);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error creating client group: ' . $e->getMessage(), [
|
Log::error('Error creating client group: ' . $e->getMessage(), [
|
||||||
@ -79,6 +96,8 @@ class ClientGroupController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$clientGroup->load(['clients'])->loadCount('clients');
|
||||||
|
|
||||||
return new ClientGroupResource($clientGroup);
|
return new ClientGroupResource($clientGroup);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error fetching client group: ' . $e->getMessage(), [
|
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
|
public function update(UpdateClientGroupRequest $request, string $id): ClientGroupResource|JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
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) {
|
if (!$updated) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -109,6 +153,8 @@ class ClientGroupController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$clientGroup = $this->clientGroupRepository->find($id);
|
$clientGroup = $this->clientGroupRepository->find($id);
|
||||||
|
$clientGroup?->load(['clients'])->loadCount('clients');
|
||||||
|
|
||||||
return new ClientGroupResource($clientGroup);
|
return new ClientGroupResource($clientGroup);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error updating client group: ' . $e->getMessage(), [
|
Log::error('Error updating client group: ' . $e->getMessage(), [
|
||||||
@ -172,9 +218,13 @@ class ClientGroupController extends Controller
|
|||||||
|
|
||||||
$clientIds = $request->validated('client_ids');
|
$clientIds = $request->validated('client_ids');
|
||||||
|
|
||||||
$updatedCount = Client::query()
|
$updatedCount = DB::transaction(function () use ($clientIds, $clientGroup) {
|
||||||
|
return Client::query()
|
||||||
->whereIn('id', $clientIds)
|
->whereIn('id', $clientIds)
|
||||||
->update(['group_id' => $clientGroup->id]);
|
->update(['group_id' => $clientGroup->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$clientGroup->load(['clients'])->loadCount('clients');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Clients assignés au groupe avec succès.',
|
'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(
|
public function __construct(
|
||||||
protected PurchaseOrderRepositoryInterface $purchaseOrderRepository,
|
protected PurchaseOrderRepositoryInterface $purchaseOrderRepository,
|
||||||
protected GoodsReceiptRepositoryInterface $goodsReceiptRepository
|
protected GoodsReceiptRepositoryInterface $goodsReceiptRepository
|
||||||
) {
|
)
|
||||||
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,7 +33,8 @@ class PurchaseOrderController extends Controller
|
|||||||
try {
|
try {
|
||||||
$purchaseOrders = $this->purchaseOrderRepository->all();
|
$purchaseOrders = $this->purchaseOrderRepository->all();
|
||||||
return PurchaseOrderResource::collection($purchaseOrders);
|
return PurchaseOrderResource::collection($purchaseOrders);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
Log::error('Error fetching purchase orders: ' . $e->getMessage(), [
|
Log::error('Error fetching purchase orders: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
@ -52,8 +54,15 @@ class PurchaseOrderController extends Controller
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$purchaseOrder = $this->purchaseOrderRepository->create($request->validated());
|
$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);
|
return new PurchaseOrderResource($purchaseOrder);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
Log::error('Error creating purchase order: ' . $e->getMessage(), [
|
Log::error('Error creating purchase order: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
@ -82,7 +91,8 @@ class PurchaseOrderController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new PurchaseOrderResource($purchaseOrder);
|
return new PurchaseOrderResource($purchaseOrder);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
Log::error('Error fetching purchase order: ' . $e->getMessage(), [
|
Log::error('Error fetching purchase order: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
@ -102,9 +112,6 @@ class PurchaseOrderController extends Controller
|
|||||||
public function update(UpdatePurchaseOrderRequest $request, string $id): PurchaseOrderResource|JsonResponse
|
public function update(UpdatePurchaseOrderRequest $request, string $id): PurchaseOrderResource|JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$existingPurchaseOrder = $this->purchaseOrderRepository->find($id);
|
|
||||||
$previousStatus = $existingPurchaseOrder?->status;
|
|
||||||
|
|
||||||
$updated = $this->purchaseOrderRepository->update($id, $request->validated());
|
$updated = $this->purchaseOrderRepository->update($id, $request->validated());
|
||||||
|
|
||||||
if (!$updated) {
|
if (!$updated) {
|
||||||
@ -115,17 +122,15 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
$purchaseOrder = $this->purchaseOrderRepository->find($id);
|
$purchaseOrder = $this->purchaseOrderRepository->find($id);
|
||||||
|
|
||||||
// On validation/delivery (status => confirmee|livree), create a draft goods receipt automatically.
|
// Ensure draft goods receipt exists when PO is validated/delivered.
|
||||||
if (
|
// Idempotent: guarded by purchase_order_id existence check in helper.
|
||||||
$purchaseOrder
|
if ($purchaseOrder && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)) {
|
||||||
&& in_array($purchaseOrder->status, ['confirmee', 'livree'], true)
|
|
||||||
&& !in_array($previousStatus, ['confirmee', 'livree'], true)
|
|
||||||
) {
|
|
||||||
$this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
|
$this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PurchaseOrderResource($purchaseOrder);
|
return new PurchaseOrderResource($purchaseOrder);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
Log::error('Error updating purchase order: ' . $e->getMessage(), [
|
Log::error('Error updating purchase order: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
@ -204,7 +209,8 @@ class PurchaseOrderController extends Controller
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Commande fournisseur supprimée avec succès.',
|
'message' => 'Commande fournisseur supprimée avec succès.',
|
||||||
], 200);
|
], 200);
|
||||||
} catch (\Exception $e) {
|
}
|
||||||
|
catch (\Exception $e) {
|
||||||
Log::error('Error deleting purchase order: ' . $e->getMessage(), [
|
Log::error('Error deleting purchase order: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
|
|||||||
@ -17,6 +17,7 @@ use Illuminate\Http\Request;
|
|||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use App\Mail\DocumentMail;
|
use App\Mail\DocumentMail;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class QuoteController extends Controller
|
class QuoteController extends Controller
|
||||||
{
|
{
|
||||||
@ -199,4 +200,34 @@ class QuoteController extends Controller
|
|||||||
], 500);
|
], 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 [
|
return [
|
||||||
'name' => 'required|string|max:191|unique:client_groups,name',
|
'name' => 'required|string|max:191|unique:client_groups,name',
|
||||||
'description' => 'nullable|string',
|
'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.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
|
||||||
'name.unique' => 'Un groupe avec ce nom existe déjà.',
|
'name.unique' => 'Un groupe avec ce nom existe déjà.',
|
||||||
'description.string' => 'La description doit être une chaîne de caractères.',
|
'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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'client_id' => 'required|exists:clients,id',
|
'client_id' => 'nullable|exists:clients,id',
|
||||||
'group_id' => 'nullable|exists:client_groups,id',
|
'group_id' => 'nullable|exists:client_groups,id',
|
||||||
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
|
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
|
||||||
'quote_date' => 'required|date',
|
'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
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
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.',
|
'client_id.exists' => 'Le client sélectionné est invalide.',
|
||||||
'group_id.exists' => 'Le groupe sélectionné est invalide.',
|
'group_id.exists' => 'Le groupe sélectionné est invalide.',
|
||||||
'status.required' => 'Le statut est obligatoire.',
|
'status.required' => 'Le statut est obligatoire.',
|
||||||
|
|||||||
@ -22,7 +22,10 @@ class UpdateClientGroupRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
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 [
|
return [
|
||||||
'name' => [
|
'name' => [
|
||||||
@ -32,6 +35,8 @@ class UpdateClientGroupRequest extends FormRequest
|
|||||||
Rule::unique('client_groups', 'name')->ignore($clientGroupId)
|
Rule::unique('client_groups', 'name')->ignore($clientGroupId)
|
||||||
],
|
],
|
||||||
'description' => 'nullable|string',
|
'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.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
|
||||||
'name.unique' => 'Un groupe avec ce nom existe déjà.',
|
'name.unique' => 'Un groupe avec ce nom existe déjà.',
|
||||||
'description.string' => 'La description doit être une chaîne de caractères.',
|
'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;
|
namespace App\Http\Resources\Client;
|
||||||
|
|
||||||
|
use App\Http\Resources\Client\ClientResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@ -18,6 +19,9 @@ class ClientGroupResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'description' => $this->description ?? null,
|
'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'),
|
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => $this->updated_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');
|
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.
|
* 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));
|
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) {
|
$this->app->bind(\App\Repositories\ClientContactRepositoryInterface::class, function ($app) {
|
||||||
return new \App\Repositories\ClientContactRepository($app->make(\App\Models\ClientContact::class));
|
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
|
||||||
|
{
|
||||||
|
}
|
||||||
@ -82,11 +82,12 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
|
|||||||
|
|
||||||
if ($exactMatch) {
|
if ($exactMatch) {
|
||||||
$query->where('nom', $name);
|
$query->where('nom', $name);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$query->where('nom', 'like', '%' . $name . '%');
|
$query->where('nom', 'like', '%' . $name . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->paginate($perPage);
|
return $query->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -24,7 +24,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
|
|
||||||
public function all(array $columns = ['*']): \Illuminate\Support\Collection
|
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
|
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
|
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
|
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>
|
<p>Solutions de gestion funéraire</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="client-info">
|
<div class="client-info">
|
||||||
<h3>CLIENT</h3>
|
<h3>{{ $quote->client ? 'CLIENT' : 'GROUPE CLIENT' }}</h3>
|
||||||
<p><strong>{{ $quote->client->name }}</strong></p>
|
<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_address_line1 }}</p>
|
||||||
<p>{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}</p>
|
<p>{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title">Devis : {{ $quote->reference }}</div>
|
<div class="title">Devis : {{ $quote->reference }}</div>
|
||||||
<p>Date : {{ $quote->quote_date->format('d/m/Y') }}<br>
|
<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">
|
<table class="details-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -51,7 +53,7 @@
|
|||||||
@foreach($quote->lines as $line)
|
@foreach($quote->lines as $line)
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $line->description }}</td>
|
<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->unit_price, 2, ',', ' ') }} {{ $quote->currency }}</td>
|
||||||
<td style="text-align: right;">{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $quote->currency }}</td>
|
<td style="text-align: right;">{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $quote->currency }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ use App\Http\Controllers\Api\FileAttachmentController;
|
|||||||
use App\Http\Controllers\Api\QuoteController;
|
use App\Http\Controllers\Api\QuoteController;
|
||||||
use App\Http\Controllers\Api\ClientActivityTimelineController;
|
use App\Http\Controllers\Api\ClientActivityTimelineController;
|
||||||
use App\Http\Controllers\Api\PurchaseOrderController;
|
use App\Http\Controllers\Api\PurchaseOrderController;
|
||||||
|
use App\Http\Controllers\Api\PriceListController;
|
||||||
use App\Http\Controllers\Api\TvaRateController;
|
use App\Http\Controllers\Api\TvaRateController;
|
||||||
use App\Http\Controllers\Api\GoodsReceiptController;
|
use App\Http\Controllers\Api\GoodsReceiptController;
|
||||||
|
|
||||||
@ -57,6 +58,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::apiResource('clients', ClientController::class);
|
Route::apiResource('clients', ClientController::class);
|
||||||
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
|
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
|
||||||
Route::apiResource('client-groups', ClientGroupController::class);
|
Route::apiResource('client-groups', ClientGroupController::class);
|
||||||
|
Route::apiResource('price-lists', PriceListController::class);
|
||||||
|
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::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
|
// Quote management
|
||||||
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
|
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
|
||||||
|
Route::get('/quotes/{id}/download-pdf', [QuoteController::class, 'downloadPdf']);
|
||||||
Route::apiResource('quotes', QuoteController::class);
|
Route::apiResource('quotes', QuoteController::class);
|
||||||
|
|
||||||
// Invoice management
|
// 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>
|
</h6>
|
||||||
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
|
<p class="text-sm">{{ formatDate(clientGroup.updated_at) }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -32,25 +32,38 @@ const router = useRouter();
|
|||||||
const clientGroupStore = useClientGroupStore();
|
const clientGroupStore = useClientGroupStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
const formData = ref({ name: "", description: "" });
|
const formData = ref({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
clients: [],
|
||||||
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const isEdit = ref(!!props.groupId);
|
const isEdit = ref(!!props.groupId);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.groupId) {
|
|
||||||
try {
|
try {
|
||||||
const group = await clientGroupStore.fetchClientGroup(props.groupId);
|
const group = props.groupId
|
||||||
|
? await clientGroupStore.fetchClientGroup(props.groupId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (group) {
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
description: group.description || "",
|
description: group.description || "",
|
||||||
|
clients: group.clients || [],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notificationStore.error(
|
notificationStore.error(
|
||||||
"Erreur",
|
"Erreur",
|
||||||
"Impossible de charger le groupe",
|
"Impossible de charger le groupe",
|
||||||
3000
|
3000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
router.push("/clients/groups");
|
router.push("/clients/groups");
|
||||||
|
} catch (navigationError) {
|
||||||
|
console.error(navigationError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -46,6 +46,26 @@
|
|||||||
{{ secondaryActionLabel }}
|
{{ secondaryActionLabel }}
|
||||||
</soft-button>
|
</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
|
<soft-button
|
||||||
color="dark"
|
color="dark"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -209,6 +229,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineProps, onMounted, computed } from "vue";
|
import { ref, defineProps, onMounted, computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import { PurchaseOrderService } from "@/services/purchaseOrder";
|
import { PurchaseOrderService } from "@/services/purchaseOrder";
|
||||||
import { useNotificationStore } from "@/stores/notification";
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
@ -221,6 +242,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const router = useRouter();
|
||||||
const commande = ref(null);
|
const commande = ref(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
@ -415,7 +437,12 @@ const changeStatus = async (newStatus) => {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating status:", 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 {
|
} finally {
|
||||||
if (requestId === statusUpdateRequestId.value) {
|
if (requestId === statusUpdateRequestId.value) {
|
||||||
isUpdatingStatus.value = false;
|
isUpdatingStatus.value = false;
|
||||||
@ -444,6 +471,15 @@ const downloadPdf = () => {
|
|||||||
window.print();
|
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(() => {
|
onMounted(() => {
|
||||||
fetchCommande();
|
fetchCommande();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header pb-0 p-3">
|
<div class="card-header pb-0 p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<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
|
<soft-button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,71 +1,477 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card position-sticky top-1">
|
<div class="sidebar-wrap">
|
||||||
<!-- Intervention Profile Card -->
|
<!-- Hero Card -->
|
||||||
<InterventionProfileCard :intervention="intervention" />
|
<div class="hero-card">
|
||||||
|
<div class="hero-avatar">
|
||||||
<hr class="horizontal dark my-3 mx-3" />
|
<svg
|
||||||
|
width="26"
|
||||||
<!-- Tab Navigation -->
|
height="26"
|
||||||
<div class="card-body pt-0">
|
viewBox="0 0 24 24"
|
||||||
<InterventionTabNavigation
|
fill="none"
|
||||||
:active-tab="activeTab"
|
stroke="currentColor"
|
||||||
:team-count="practitioners.length"
|
stroke-width="1.5"
|
||||||
:documents-count="0"
|
>
|
||||||
@change-tab="changeTab"
|
<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>
|
||||||
|
|
||||||
<!-- Assign Practitioner Button -->
|
<div class="divider"></div>
|
||||||
<div class="mx-3 mb-3">
|
|
||||||
<button
|
<!-- Quick Stats -->
|
||||||
type="button"
|
<div class="quick-stats">
|
||||||
class="btn btn-sm btn-outline-primary w-100"
|
<div class="qs-row">
|
||||||
@click="assignPractitioner"
|
<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"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-plus me-2"></i>Assigner un praticien
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import InterventionProfileCard from "@/components/molecules/intervention/InterventionProfileCard.vue";
|
|
||||||
import InterventionTabNavigation from "@/components/molecules/intervention/InterventionTabNavigation.vue";
|
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
intervention: {
|
intervention: { type: Object, required: true },
|
||||||
type: Object,
|
activeTab: { type: String, default: "overview" },
|
||||||
required: true,
|
practitioners: { type: Array, default: () => [] },
|
||||||
},
|
teamCount: { type: Number, default: 0 },
|
||||||
activeTab: {
|
documentsCount: { type: Number, default: 0 },
|
||||||
type: String,
|
|
||||||
default: "overview",
|
|
||||||
},
|
|
||||||
practitioners: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
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) => {
|
const tabs = [
|
||||||
emit("change-tab", tab);
|
{
|
||||||
};
|
id: "overview",
|
||||||
|
label: "Vue d'ensemble",
|
||||||
const assignPractitioner = () => {
|
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>`,
|
||||||
emit("assign-practitioner");
|
},
|
||||||
};
|
{
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.position-sticky {
|
.sidebar-wrap {
|
||||||
top: 1rem;
|
--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 {
|
/* Hero */
|
||||||
border: 0;
|
.hero-card {
|
||||||
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, 0.15);
|
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>
|
</style>
|
||||||
|
|||||||
@ -183,6 +183,7 @@ const loading = ref(true);
|
|||||||
const updating = ref(false);
|
const updating = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
const selectedStatus = ref("brouillon");
|
const selectedStatus = ref("brouillon");
|
||||||
|
let invoiceStatusRequestId = 0;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -280,10 +281,12 @@ const onStatusSelect = (event) => {
|
|||||||
/* ── Status Update ── */
|
/* ── Status Update ── */
|
||||||
const changeStatus = (id, newStatus) => {
|
const changeStatus = (id, newStatus) => {
|
||||||
if (!id || updating.value) return;
|
if (!id || updating.value) return;
|
||||||
|
const requestId = ++invoiceStatusRequestId;
|
||||||
updating.value = true;
|
updating.value = true;
|
||||||
invoiceStore
|
invoiceStore
|
||||||
.updateInvoice({ id, status: newStatus })
|
.updateInvoice({ id, status: newStatus })
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
|
if (requestId !== invoiceStatusRequestId) return;
|
||||||
if (`${props.invoiceId}` !== `${id}`) return;
|
if (`${props.invoiceId}` !== `${id}`) return;
|
||||||
invoice.value = updated;
|
invoice.value = updated;
|
||||||
selectedStatus.value = updated?.status || newStatus;
|
selectedStatus.value = updated?.status || newStatus;
|
||||||
@ -302,7 +305,9 @@ const changeStatus = (id, newStatus) => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (requestId === invoiceStatusRequestId) {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -21,20 +21,59 @@
|
|||||||
</soft-button>
|
</soft-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ── Client Selection ── -->
|
<!-- ── Recipient Selection ── -->
|
||||||
<template #client-selection>
|
<template #client-selection>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label"
|
<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">
|
<div class="recipient-toggle">
|
||||||
<option value="" disabled>— Sélectionner un client —</option>
|
<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">
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
{{ client.name }}
|
{{ client.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -133,12 +172,15 @@ import SoftButton from "@/components/SoftButton.vue";
|
|||||||
import SoftInput from "@/components/SoftInput.vue";
|
import SoftInput from "@/components/SoftInput.vue";
|
||||||
import { useQuoteStore } from "@/stores/quoteStore";
|
import { useQuoteStore } from "@/stores/quoteStore";
|
||||||
import { useClientStore } from "@/stores/clientStore";
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
import { useClientGroupStore } from "@/stores/clientGroupStore";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const quoteStore = useQuoteStore();
|
const quoteStore = useQuoteStore();
|
||||||
const clientStore = useClientStore();
|
const clientStore = useClientStore();
|
||||||
|
const clientGroupStore = useClientGroupStore();
|
||||||
const { clients } = storeToRefs(clientStore);
|
const { clients } = storeToRefs(clientStore);
|
||||||
|
const { clientGroups } = storeToRefs(clientGroupStore);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const attempted = ref(false);
|
const attempted = ref(false);
|
||||||
@ -174,7 +216,9 @@ const defaultLine = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
client_id: "",
|
recipient_type: "client",
|
||||||
|
client_id: null,
|
||||||
|
group_id: null,
|
||||||
quote_date: new Date().toISOString().split("T")[0],
|
quote_date: new Date().toISOString().split("T")[0],
|
||||||
valid_until: "",
|
valid_until: "",
|
||||||
status: "brouillon",
|
status: "brouillon",
|
||||||
@ -182,6 +226,20 @@ const form = ref({
|
|||||||
lines: [defaultLine()],
|
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(() => {
|
const totals = computed(() => {
|
||||||
let ht = 0;
|
let ht = 0;
|
||||||
let tva = 0;
|
let tva = 0;
|
||||||
@ -197,6 +255,16 @@ const totals = computed(() => {
|
|||||||
const addLine = () => form.value.lines.push(defaultLine());
|
const addLine = () => form.value.lines.push(defaultLine());
|
||||||
const removeLine = (index) => form.value.lines.splice(index, 1);
|
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) =>
|
const formatCurrency = (value) =>
|
||||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(
|
||||||
value
|
value
|
||||||
@ -204,12 +272,15 @@ const formatCurrency = (value) =>
|
|||||||
|
|
||||||
const saveQuote = async () => {
|
const saveQuote = async () => {
|
||||||
attempted.value = true;
|
attempted.value = true;
|
||||||
if (!form.value.client_id) return;
|
if (recipientError.value) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await quoteStore.createQuote({
|
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,
|
status: form.value.status,
|
||||||
quote_date: form.value.quote_date,
|
quote_date: form.value.quote_date,
|
||||||
valid_until: form.value.valid_until,
|
valid_until: form.value.valid_until,
|
||||||
@ -238,10 +309,40 @@ const saveQuote = async () => {
|
|||||||
|
|
||||||
const cancel = () => router.back();
|
const cancel = () => router.back();
|
||||||
|
|
||||||
onMounted(() => clientStore.fetchClients());
|
onMounted(() => {
|
||||||
|
clientStore.fetchClients();
|
||||||
|
clientGroupStore.fetchClientGroups({ per_page: 100 });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 Groups ── */
|
||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@ -42,7 +42,13 @@
|
|||||||
<i :class="statusIcon(quote.status) + ' me-1'"></i>
|
<i :class="statusIcon(quote.status) + ' me-1'"></i>
|
||||||
{{ getStatusLabel(quote.status) }}
|
{{ getStatusLabel(quote.status) }}
|
||||||
</soft-badge>
|
</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
|
Export PDF
|
||||||
</soft-button>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
@ -56,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h6 class="text-lg mb-0 mt-1">
|
<h6 class="text-lg mb-0 mt-1">
|
||||||
{{ quote.client?.name || "Client inconnu" }}
|
{{ recipientName }}
|
||||||
</h6>
|
</h6>
|
||||||
<p class="text-sm mb-3">
|
<p class="text-sm mb-3">
|
||||||
{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.
|
{{ quote.lines?.length || 0 }} ligne(s) dans ce devis.
|
||||||
@ -109,18 +115,18 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<h6 class="mb-3 text-sm">
|
<h6 class="mb-3 text-sm">
|
||||||
{{ quote.client?.name || "Client inconnu" }}
|
{{ recipientName }}
|
||||||
</h6>
|
</h6>
|
||||||
<span class="mb-2 text-xs">
|
<span class="mb-2 text-xs">
|
||||||
Email Address:
|
Email Address:
|
||||||
<span class="text-dark ms-2 font-weight-bold">{{
|
<span class="text-dark ms-2 font-weight-bold">{{
|
||||||
quote.client?.email || "—"
|
quote.client?.email || groupDetailsFallback
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="mb-2 text-xs">
|
<span class="mb-2 text-xs">
|
||||||
Phone:
|
Phone:
|
||||||
<span class="text-dark ms-2 font-weight-bold">{{
|
<span class="text-dark ms-2 font-weight-bold">{{
|
||||||
quote.client?.phone || "—"
|
quote.client?.phone || groupDetailsFallback
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs">
|
<span class="text-xs">
|
||||||
@ -159,7 +165,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, defineProps } from "vue";
|
import { computed, ref, onMounted, defineProps } from "vue";
|
||||||
import { useQuoteStore } from "@/stores/quoteStore";
|
import { useQuoteStore } from "@/stores/quoteStore";
|
||||||
import { useNotificationStore } from "@/stores/notification";
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
import QuoteDetailTemplate from "@/components/templates/Quote/QuoteDetailTemplate.vue";
|
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 SoftButton from "@/components/SoftButton.vue";
|
||||||
import SoftBadge from "@/components/SoftBadge.vue";
|
import SoftBadge from "@/components/SoftBadge.vue";
|
||||||
|
|
||||||
|
const activePdfExports = new Set();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quoteId: { type: [String, Number], required: true },
|
quoteId: { type: [String, Number], required: true },
|
||||||
});
|
});
|
||||||
@ -181,6 +189,16 @@ const updating = ref(false);
|
|||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
const selectedStatus = ref("brouillon");
|
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 () => {
|
const load = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@ -266,6 +284,39 @@ const onStatusSelect = (event) => {
|
|||||||
changeStatus(quote.value.id, newStatus);
|
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 ── */
|
/* ── Status Update ── */
|
||||||
const changeStatus = (id, newStatus) => {
|
const changeStatus = (id, newStatus) => {
|
||||||
if (!id || updating.value) return;
|
if (!id || updating.value) return;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pdp">
|
<div class="pdp">
|
||||||
|
|
||||||
<!-- ── Loading ── -->
|
<!-- ── Loading ── -->
|
||||||
<div v-if="loading" class="pdp__state">
|
<div v-if="loading" class="pdp__state">
|
||||||
<div class="pdp__spinner"></div>
|
<div class="pdp__spinner"></div>
|
||||||
@ -9,17 +8,41 @@
|
|||||||
|
|
||||||
<!-- ── Error ── -->
|
<!-- ── Error ── -->
|
||||||
<div v-else-if="error" class="pdp__state">
|
<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>
|
<h5>Erreur de chargement</h5>
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
<SoftButton color="primary" variant="outline" size="sm" @click="loadProduct">
|
<SoftButton
|
||||||
|
color="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="loadProduct"
|
||||||
|
>
|
||||||
Réessayer
|
Réessayer
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Empty ── -->
|
<!-- ── Empty ── -->
|
||||||
<div v-else-if="!productData" class="pdp__state">
|
<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>
|
<h5>Produit introuvable</h5>
|
||||||
<p>Ce produit n'existe pas ou a été supprimé.</p>
|
<p>Ce produit n'existe pas ou a été supprimé.</p>
|
||||||
<SoftButton color="primary" variant="outline" size="sm" @click="goBack">
|
<SoftButton color="primary" variant="outline" size="sm" @click="goBack">
|
||||||
@ -29,12 +52,25 @@
|
|||||||
|
|
||||||
<!-- ── Main Content ── -->
|
<!-- ── Main Content ── -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div class="pdp__topbar">
|
<div class="pdp__topbar">
|
||||||
<div class="pdp__topbar-left">
|
<div class="pdp__topbar-left">
|
||||||
<SoftButton color="secondary" variant="outline" size="sm" @click="goBack">
|
<SoftButton
|
||||||
<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>
|
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
|
Retour
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
<div class="pdp__breadcrumb">
|
<div class="pdp__breadcrumb">
|
||||||
@ -47,22 +83,84 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pdp__topbar-actions">
|
<div class="pdp__topbar-actions">
|
||||||
<template v-if="!isEditMode">
|
<template v-if="!isEditMode">
|
||||||
<SoftButton color="primary" variant="outline" size="sm" @click="toggleEditMode">
|
<SoftButton
|
||||||
<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>
|
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
|
Modifier
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
<SoftButton color="danger" variant="gradient" size="sm" :disabled="loading" @click="deleteProduct">
|
<SoftButton
|
||||||
<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>
|
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
|
Supprimer
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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
|
Annuler
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
<SoftButton color="primary" variant="gradient" size="sm" :disabled="saving" @click="saveProduct">
|
<SoftButton
|
||||||
<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>
|
color="primary"
|
||||||
<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>
|
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" }}
|
{{ saving ? "Sauvegarde…" : "Sauvegarder" }}
|
||||||
</SoftButton>
|
</SoftButton>
|
||||||
</template>
|
</template>
|
||||||
@ -71,7 +169,17 @@
|
|||||||
|
|
||||||
<!-- Validation errors -->
|
<!-- Validation errors -->
|
||||||
<div v-if="hasValidationErrors" class="pdp__errors" role="alert">
|
<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>
|
<div>
|
||||||
<strong>Erreurs de validation</strong>
|
<strong>Erreurs de validation</strong>
|
||||||
<ul>
|
<ul>
|
||||||
@ -84,7 +192,6 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="pdp__body">
|
<div class="pdp__body">
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<product-sidebar
|
<product-sidebar
|
||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
@ -99,11 +206,19 @@
|
|||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<div class="pdp__panel">
|
<div class="pdp__panel">
|
||||||
|
|
||||||
<!-- Edit mode banner -->
|
<!-- Edit mode banner -->
|
||||||
<transition name="pdp-fade">
|
<transition name="pdp-fade">
|
||||||
<div v-if="isEditMode" class="pdp__edit-banner">
|
<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
|
Mode édition — modifiez les champs puis cliquez sur Sauvegarder
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@ -121,13 +236,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pdp-hero__info">
|
<div class="pdp-hero__info">
|
||||||
<h1 class="pdp-hero__name">{{ productData.nom }}</h1>
|
<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">
|
<div class="pdp-hero__badges">
|
||||||
<product-badge :variant="productData.is_low_stock ? 'warning' : 'success'">
|
<product-badge
|
||||||
{{ productData.is_low_stock ? "Stock faible" : "Stock OK" }}
|
:variant="
|
||||||
|
productData.is_low_stock ? 'warning' : 'success'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
productData.is_low_stock ? "Stock faible" : "Stock OK"
|
||||||
|
}}
|
||||||
</product-badge>
|
</product-badge>
|
||||||
<product-badge v-if="isExpired" variant="danger">Expiré</product-badge>
|
<product-badge v-if="isExpired" variant="danger"
|
||||||
<product-badge v-else-if="isExpiringSoon" variant="info">Expire bientôt</product-badge>
|
>Expiré</product-badge
|
||||||
|
>
|
||||||
|
<product-badge v-else-if="isExpiringSoon" variant="info"
|
||||||
|
>Expire bientôt</product-badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -175,7 +302,19 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
class="pdp-doc"
|
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__name">Fiche technique</span>
|
||||||
<span class="pdp-doc__action">Télécharger</span>
|
<span class="pdp-doc__action">Télécharger</span>
|
||||||
</a>
|
</a>
|
||||||
@ -215,7 +354,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -259,19 +397,70 @@ const activeTab = ref("details");
|
|||||||
const stockMovements = ref([]);
|
const stockMovements = ref([]);
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
nom: "", reference: "", categorie_id: "", fabricant: "",
|
nom: "",
|
||||||
numero_lot: "", date_expiration: "", unite: "", description: "",
|
reference: "",
|
||||||
stock_actuel: "", stock_minimum: "", prix_unitaire: "",
|
categorie_id: "",
|
||||||
conditionnement_nom: "", conditionnement_quantite: "",
|
fabricant: "",
|
||||||
|
numero_lot: "",
|
||||||
|
date_expiration: "",
|
||||||
|
unite: "",
|
||||||
|
description: "",
|
||||||
|
stock_actuel: "",
|
||||||
|
stock_minimum: "",
|
||||||
|
prix_unitaire: "",
|
||||||
|
conditionnement_nom: "",
|
||||||
|
conditionnement_quantite: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── tabs config ─────────────────────────────────────────── */
|
/* ── tabs config ─────────────────────────────────────────── */
|
||||||
// Using inline SVG render functions to keep it self-contained
|
// Using inline SVG render functions to keep it self-contained
|
||||||
// Replace with your icon library's components as needed
|
// Replace with your icon library's components as needed
|
||||||
import { defineComponent, h } from "vue";
|
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 IconInfo = defineComponent({
|
||||||
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" })]) });
|
render: () =>
|
||||||
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" })]) });
|
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 = [
|
const tabs = [
|
||||||
{ id: "details", label: "Détails", icon: IconInfo },
|
{ id: "details", label: "Détails", icon: IconInfo },
|
||||||
@ -282,17 +471,25 @@ const tabs = [
|
|||||||
/* ── computed ────────────────────────────────────────────── */
|
/* ── computed ────────────────────────────────────────────── */
|
||||||
const productId = computed(() => parseInt(route.params.id));
|
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 isExpired = computed(() => ProductService.isExpired(productData.value));
|
||||||
const isExpiringSoon = computed(() => ProductService.isExpiringSoon(productData.value, 30));
|
const isExpiringSoon = computed(() =>
|
||||||
const hasDocuments = computed(() => !!productData.value?.media?.fiche_technique_url);
|
ProductService.isExpiringSoon(productData.value, 30)
|
||||||
|
);
|
||||||
|
const hasDocuments = computed(
|
||||||
|
() => !!productData.value?.media?.fiche_technique_url
|
||||||
|
);
|
||||||
|
|
||||||
const displayCategoryName = computed(() => {
|
const displayCategoryName = computed(() => {
|
||||||
if (productData.value?.category?.name) return productData.value.category.name;
|
if (productData.value?.category?.name) return productData.value.category.name;
|
||||||
const id = productData.value?.categorie_id;
|
const id = productData.value?.categorie_id;
|
||||||
if (!id) return "Non catégorisé";
|
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 ─────────────────────────────────────────────── */
|
/* ── methods ─────────────────────────────────────────────── */
|
||||||
@ -316,7 +513,11 @@ const initializeFormData = (data) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadProduct = async () => {
|
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;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
validationErrors.value = {};
|
validationErrors.value = {};
|
||||||
@ -327,7 +528,8 @@ const loadProduct = async () => {
|
|||||||
initializeFormData(res);
|
initializeFormData(res);
|
||||||
originalData.value = { ...res };
|
originalData.value = { ...res };
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -337,12 +539,20 @@ const loadCategories = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await productCategoryStore.fetchAllCategories();
|
const res = await productCategoryStore.fetchAllCategories();
|
||||||
categories.value = res?.data || [];
|
categories.value = res?.data || [];
|
||||||
} catch { categories.value = []; }
|
} catch {
|
||||||
|
categories.value = [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goBack = () => router.push("/stock/produits");
|
const goBack = () => router.push("/stock/produits");
|
||||||
const toggleEditMode = () => { isEditMode.value = true; initializeFormData(productData.value); };
|
const toggleEditMode = () => {
|
||||||
const cancelEdit = () => { if (originalData.value) initializeFormData(originalData.value); isEditMode.value = false; };
|
isEditMode.value = true;
|
||||||
|
initializeFormData(productData.value);
|
||||||
|
};
|
||||||
|
const cancelEdit = () => {
|
||||||
|
if (originalData.value) initializeFormData(originalData.value);
|
||||||
|
isEditMode.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const saveProduct = async () => {
|
const saveProduct = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
@ -351,7 +561,10 @@ const saveProduct = async () => {
|
|||||||
await productStore.updateProduct(productId.value, {
|
await productStore.updateProduct(productId.value, {
|
||||||
nom: formData.value.nom,
|
nom: formData.value.nom,
|
||||||
reference: formData.value.reference,
|
reference: formData.value.reference,
|
||||||
categorie_id: parseInt(formData.value.categorie_id) || productData.value?.categorie_id || null,
|
categorie_id:
|
||||||
|
parseInt(formData.value.categorie_id) ||
|
||||||
|
productData.value?.categorie_id ||
|
||||||
|
null,
|
||||||
fabricant: formData.value.fabricant,
|
fabricant: formData.value.fabricant,
|
||||||
numero_lot: formData.value.numero_lot,
|
numero_lot: formData.value.numero_lot,
|
||||||
date_expiration: formData.value.date_expiration || null,
|
date_expiration: formData.value.date_expiration || null,
|
||||||
@ -361,7 +574,8 @@ const saveProduct = async () => {
|
|||||||
stock_minimum: parseInt(formData.value.stock_minimum) || 0,
|
stock_minimum: parseInt(formData.value.stock_minimum) || 0,
|
||||||
prix_unitaire: parseFloat(formData.value.prix_unitaire) || 0,
|
prix_unitaire: parseFloat(formData.value.prix_unitaire) || 0,
|
||||||
conditionnement_nom: formData.value.conditionnement_nom || null,
|
conditionnement_nom: formData.value.conditionnement_nom || null,
|
||||||
conditionnement_quantite: parseInt(formData.value.conditionnement_quantite) || null,
|
conditionnement_quantite:
|
||||||
|
parseInt(formData.value.conditionnement_quantite) || null,
|
||||||
conditionnement_unite: formData.value.unite || null,
|
conditionnement_unite: formData.value.unite || null,
|
||||||
});
|
});
|
||||||
await loadProduct();
|
await loadProduct();
|
||||||
@ -370,7 +584,8 @@ const saveProduct = async () => {
|
|||||||
if (err.response?.status === 422 && err.response?.data?.errors) {
|
if (err.response?.status === 422 && err.response?.data?.errors) {
|
||||||
validationErrors.value = err.response.data.errors;
|
validationErrors.value = err.response.data.errors;
|
||||||
} else {
|
} else {
|
||||||
error.value = err.response?.data?.message || "Erreur lors de la sauvegarde";
|
error.value =
|
||||||
|
err.response?.data?.message || "Erreur lors de la sauvegarde";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
@ -389,16 +604,26 @@ const deleteProduct = async () => {
|
|||||||
|
|
||||||
const handleViewSupplier = (supplier) => emit("view-supplier", supplier);
|
const handleViewSupplier = (supplier) => emit("view-supplier", supplier);
|
||||||
|
|
||||||
const getFieldLabel = (field) => ({
|
const getFieldLabel = (field) =>
|
||||||
nom: "Nom", reference: "Référence", categorie_id: "Catégorie",
|
({
|
||||||
fabricant: "Fabricant", numero_lot: "N° de lot",
|
nom: "Nom",
|
||||||
date_expiration: "Date d'expiration", unite: "Unité",
|
reference: "Référence",
|
||||||
stock_actuel: "Stock actuel", stock_minimum: "Stock minimum",
|
categorie_id: "Catégorie",
|
||||||
prix_unitaire: "Prix unitaire", conditionnement_nom: "Conditionnement",
|
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",
|
conditionnement_quantite: "Qté conditionnement",
|
||||||
})[field] || field;
|
}[field] || field);
|
||||||
|
|
||||||
onMounted(() => { loadCategories(); loadProduct(); });
|
onMounted(() => {
|
||||||
|
loadCategories();
|
||||||
|
loadProduct();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -427,18 +652,37 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
.pdp__state h5 { font-size: 16px; font-weight: 600; color: #111827; margin: 0; }
|
.pdp__state h5 {
|
||||||
.pdp__state p { font-size: 14px; margin: 0; }
|
font-size: 16px;
|
||||||
.pdp__state-icon { width: 36px; height: 36px; color: #d1d5db; }
|
font-weight: 600;
|
||||||
.pdp__state-icon--danger { color: #fca5a5; }
|
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 {
|
.pdp__spinner {
|
||||||
width: 24px; height: 24px;
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
border: 2px solid #e5e7eb;
|
border: 2px solid #e5e7eb;
|
||||||
border-top-color: #111827;
|
border-top-color: #111827;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pdp-spin 0.75s linear infinite;
|
animation: pdp-spin 0.75s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes pdp-spin { to { transform: rotate(360deg); } }
|
@keyframes pdp-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── top bar ─────────────────────────────────────────────── */
|
/* ── top bar ─────────────────────────────────────────────── */
|
||||||
.pdp__topbar {
|
.pdp__topbar {
|
||||||
@ -448,8 +692,16 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.pdp__topbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
.pdp__topbar-left {
|
||||||
.pdp__topbar-actions { display: flex; align-items: center; gap: 0.5rem; }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.pdp__topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pdp__breadcrumb {
|
.pdp__breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -458,8 +710,13 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
.pdp__breadcrumb-sep { color: #d1d5db; }
|
.pdp__breadcrumb-sep {
|
||||||
.pdp__breadcrumb-current { color: #374151; font-weight: 500; }
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.pdp__breadcrumb-current {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── errors ──────────────────────────────────────────────── */
|
/* ── errors ──────────────────────────────────────────────── */
|
||||||
.pdp__errors {
|
.pdp__errors {
|
||||||
@ -474,10 +731,22 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
.pdp__errors svg { flex-shrink: 0; margin-top: 1px; }
|
.pdp__errors svg {
|
||||||
.pdp__errors strong { display: block; font-weight: 600; margin-bottom: 4px; }
|
flex-shrink: 0;
|
||||||
.pdp__errors ul { margin: 0; padding-left: 1.25rem; }
|
margin-top: 1px;
|
||||||
.pdp__errors li { margin-bottom: 2px; }
|
}
|
||||||
|
.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 ─────────────────────────────────────────── */
|
/* ── edit banner ─────────────────────────────────────────── */
|
||||||
.pdp__edit-banner {
|
.pdp__edit-banner {
|
||||||
@ -529,8 +798,12 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
.pdp-card__body { padding: 1.25rem; }
|
.pdp-card__body {
|
||||||
.pdp-card__body--flush { padding: 0; }
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.pdp-card__body--flush {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── hero ────────────────────────────────────────────────── */
|
/* ── hero ────────────────────────────────────────────────── */
|
||||||
.pdp-hero {
|
.pdp-hero {
|
||||||
@ -559,9 +832,13 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8392ab;
|
color: #8392ab;
|
||||||
margin: 0 0 0.625rem;
|
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 ────────────────────────────────────────────── */
|
/* ── doc item ────────────────────────────────────────────── */
|
||||||
.pdp-doc {
|
.pdp-doc {
|
||||||
@ -576,9 +853,18 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
transition: background 0.12s, border-color 0.12s;
|
transition: background 0.12s, border-color 0.12s;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
.pdp-doc:hover { background: #f3f4f6; border-color: #d1d5db; }
|
.pdp-doc:hover {
|
||||||
.pdp-doc svg { color: #dc2626; flex-shrink: 0; }
|
background: #f3f4f6;
|
||||||
.pdp-doc__name { font-size: 14px; font-weight: 500; }
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.pdp-doc svg {
|
||||||
|
color: #dc2626;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pdp-doc__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.pdp-doc__action {
|
.pdp-doc__action {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@ -600,10 +886,19 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
transition: background 0.12s, border-color 0.12s, color 0.12s, transform 0.1s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s, transform 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pdp-btn:active:not(:disabled) { transform: scale(0.97); }
|
.pdp-btn:active:not(:disabled) {
|
||||||
.pdp-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
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 {
|
.pdp-btn--dark {
|
||||||
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
|
background: linear-gradient(310deg, #5e72e4 0%, #825ee4 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -623,26 +918,58 @@ onMounted(() => { loadCategories(); loadProduct(); });
|
|||||||
border-color: #5e72e4;
|
border-color: #5e72e4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdp-btn--ghost { background: transparent; color: #6b7280; border-color: transparent; }
|
.pdp-btn--ghost {
|
||||||
.pdp-btn--ghost:hover:not(:disabled) { background: #f3f4f6; color: #344767; }
|
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 {
|
||||||
.pdp-btn--danger:hover:not(:disabled) { background: #fee2e2; border-color: #fca5a5; }
|
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 ───────────────────────────────────────────────── */
|
/* ── panel ───────────────────────────────────────────────── */
|
||||||
.pdp__panel { min-width: 0; }
|
.pdp__panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── transitions ─────────────────────────────────────────── */
|
/* ── transitions ─────────────────────────────────────────── */
|
||||||
.pdp-fade-enter-active, .pdp-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
.pdp-fade-enter-active,
|
||||||
.pdp-fade-enter-from, .pdp-fade-leave-to { opacity: 0; transform: translateY(-4px); }
|
.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 ──────────────────────────────────────────── */
|
/* ── responsive ──────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pdp__body { grid-template-columns: 1fr; }
|
.pdp__body {
|
||||||
.pdp { padding: 1rem; }
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.pdp {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -203,6 +203,7 @@ const { currentGoodsReceipt: goodsReceipt, loading, error } = storeToRefs(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUpdatingStatus = ref(false);
|
const isUpdatingStatus = ref(false);
|
||||||
|
let receiptStatusRequestId = 0;
|
||||||
|
|
||||||
const availableStatuses = ["draft", "posted"];
|
const availableStatuses = ["draft", "posted"];
|
||||||
|
|
||||||
@ -262,6 +263,8 @@ const getStatusClass = (status) => {
|
|||||||
const changeStatus = async (newStatus) => {
|
const changeStatus = async (newStatus) => {
|
||||||
if (!goodsReceipt.value || goodsReceipt.value.status === newStatus) return;
|
if (!goodsReceipt.value || goodsReceipt.value.status === newStatus) return;
|
||||||
|
|
||||||
|
const requestId = ++receiptStatusRequestId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isUpdatingStatus.value = true;
|
isUpdatingStatus.value = true;
|
||||||
await goodsReceiptStore.updateGoodsReceipt({
|
await goodsReceiptStore.updateGoodsReceipt({
|
||||||
@ -272,8 +275,10 @@ const changeStatus = async (newStatus) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to update receipt status", e);
|
console.error("Failed to update receipt status", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === receiptStatusRequestId) {
|
||||||
isUpdatingStatus.value = false;
|
isUpdatingStatus.value = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleValidate = async () => {
|
const handleValidate = async () => {
|
||||||
|
|||||||
@ -27,7 +27,9 @@ defineProps({
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
.field-row:last-child { border-bottom: none; }
|
.field-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.field-row__label {
|
.field-row__label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -43,6 +45,12 @@ defineProps({
|
|||||||
color: #111827;
|
color: #111827;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.field-row__value.expired { color: #dc2626; font-weight: 500; }
|
.field-row__value.expired {
|
||||||
.field-row__value.expiring-soon { color: #d97706; font-weight: 500; }
|
color: #dc2626;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.field-row__value.expiring-soon {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -54,7 +54,11 @@ defineEmits(["update:modelValue"]);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.field-input-group__label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -63,7 +67,10 @@ defineEmits(["update:modelValue"]);
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
.field-input-group__req { color: #dc2626; margin-left: 2px; }
|
.field-input-group__req {
|
||||||
|
color: #dc2626;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.field-input-group__ctrl {
|
.field-input-group__ctrl {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@ -82,8 +89,12 @@ defineEmits(["update:modelValue"]);
|
|||||||
border-color: #5e72e4;
|
border-color: #5e72e4;
|
||||||
box-shadow: 0 0 0 3px rgba(94, 114, 228, 0.18);
|
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 {
|
||||||
.field-input-group__ctrl.is-invalid:focus { box-shadow: 0 0 0 3px rgba(220,38,38,0.08); }
|
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 {
|
.field-input-group__error {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@ -39,18 +39,43 @@ defineProps({
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-badge--success { background: rgba(45, 206, 137, 0.16); color: #2dce89; }
|
.product-badge--success {
|
||||||
.product-badge--success .product-badge__dot { background: #2dce89; }
|
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--warning .product-badge__dot { background: #fb6340; }
|
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--danger .product-badge__dot { background: #f5365c; }
|
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--info .product-badge__dot { background: #11cdef; }
|
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--neutral .product-badge__dot { background: #8392ab; }
|
background: rgba(131, 146, 171, 0.16);
|
||||||
|
color: #8392ab;
|
||||||
|
}
|
||||||
|
.product-badge--neutral .product-badge__dot {
|
||||||
|
background: #8392ab;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -49,8 +49,12 @@ defineProps({
|
|||||||
color: #111827;
|
color: #111827;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
.stat-card__number.low { color: #dc2626; }
|
.stat-card__number.low {
|
||||||
.stat-card__number.ok { color: #059669; }
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.stat-card__number.ok {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
.stat-card__unit {
|
.stat-card__unit {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@ -30,6 +30,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="row mt-4">
|
||||||
<div class="col-md-12 d-flex justify-content-end">
|
<div class="col-md-12 d-flex justify-content-end">
|
||||||
<soft-button
|
<soft-button
|
||||||
@ -63,13 +98,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, defineProps, defineEmits } from "vue";
|
import { computed, ref, watch, defineProps, defineEmits } from "vue";
|
||||||
import SoftButton from "@/components/SoftButton.vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
import ClientSearchInput from "@/components/molecules/client/ClientSearchInput.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialData: {
|
initialData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ name: "", description: "" }),
|
default: () => ({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
clients: [],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
isEdit: {
|
isEdit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -86,8 +126,15 @@ const emit = defineEmits(["submit", "cancel"]);
|
|||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: props.initialData.name || "",
|
name: props.initialData.name || "",
|
||||||
description: props.initialData.description || "",
|
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(
|
watch(
|
||||||
() => props.initialData,
|
() => props.initialData,
|
||||||
(newData) => {
|
(newData) => {
|
||||||
@ -95,13 +142,34 @@ watch(
|
|||||||
formData.value = {
|
formData.value = {
|
||||||
name: newData.name || "",
|
name: newData.name || "",
|
||||||
description: newData.description || "",
|
description: newData.description || "",
|
||||||
|
clients: newData.clients || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ 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 = () => {
|
const handleSubmit = () => {
|
||||||
emit("submit", { ...formData.value });
|
emit("submit", {
|
||||||
|
name: formData.value.name,
|
||||||
|
description: formData.value.description,
|
||||||
|
client_ids: selectedClientIds.value,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -31,20 +31,24 @@
|
|||||||
<!-- En-tête avec titre et badge de statut -->
|
<!-- En-tête avec titre et badge de statut -->
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h5 class="mb-0">Détails de l'Intervention</h5>
|
<h5 class="mb-0">Détails de l'Intervention</h5>
|
||||||
|
<SoftBadge :color="statusObject.color" :variant="statusObject.variant" size="sm">
|
||||||
|
{{ statusObject.label }}
|
||||||
|
</SoftBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Informations Client -->
|
<!-- Informations Client -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<h6 class="mb-0">Informations Client</h6>
|
<h6 class="mb-0">Informations Client</h6>
|
||||||
<button
|
<SoftButton
|
||||||
type="button"
|
color="secondary"
|
||||||
class="btn btn-sm bg-gradient-secondary"
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="toggleEditMode"
|
@click="toggleEditMode"
|
||||||
>
|
>
|
||||||
{{ editMode ? "Sauvegarder" : "Modifier" }}
|
{{ editMode ? "Sauvegarder" : "Modifier" }}
|
||||||
</button>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -116,22 +120,25 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div v-if="editMode">
|
<div v-if="editMode">
|
||||||
<button
|
<SoftButton
|
||||||
type="button"
|
color="danger"
|
||||||
class="btn btn-sm bg-gradient-danger me-2"
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
|
class="me-2"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="resetChanges"
|
@click="resetChanges"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</SoftButton>
|
||||||
<button
|
<SoftButton
|
||||||
type="button"
|
color="success"
|
||||||
class="btn btn-sm bg-gradient-success"
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
:disabled="loading || !hasChanges"
|
:disabled="loading || !hasChanges"
|
||||||
@click="saveChanges"
|
@click="saveChanges"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save me-2"></i>Sauvegarder
|
<i class="fas fa-save me-2"></i>Sauvegarder
|
||||||
</button>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -193,20 +200,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<SoftButton
|
||||||
type="button"
|
color="secondary"
|
||||||
class="btn btn-sm bg-gradient-secondary"
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
@click="showTeamModal = false"
|
@click="showTeamModal = false"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</SoftButton>
|
||||||
<button
|
<SoftButton
|
||||||
type="button"
|
color="primary"
|
||||||
class="btn btn-sm bg-gradient-primary"
|
variant="gradient"
|
||||||
|
size="sm"
|
||||||
@click="saveTeamSelection"
|
@click="saveTeamSelection"
|
||||||
>
|
>
|
||||||
Valider la sélection
|
Valider la sélection
|
||||||
</button>
|
</SoftButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -217,6 +226,7 @@
|
|||||||
import { ref, computed, watch, defineProps, defineEmits } from "vue";
|
import { ref, computed, watch, defineProps, defineEmits } from "vue";
|
||||||
import SoftInput from "@/components/SoftInput.vue";
|
import SoftInput from "@/components/SoftInput.vue";
|
||||||
import SoftBadge from "@/components/SoftBadge.vue";
|
import SoftBadge from "@/components/SoftBadge.vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
import LocationManager from "@/components/Organism/Location/LocationManager.vue";
|
import LocationManager from "@/components/Organism/Location/LocationManager.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@ -3,10 +3,22 @@
|
|||||||
<!-- Read mode -->
|
<!-- Read mode -->
|
||||||
<template v-if="!editMode">
|
<template v-if="!editMode">
|
||||||
<field-display label="Nom du produit" :value="product.nom" />
|
<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="Référence"
|
||||||
|
:value="product.reference"
|
||||||
|
fallback="Sans référence"
|
||||||
|
/>
|
||||||
<field-display label="Catégorie" :value="categoryName" />
|
<field-display label="Catégorie" :value="categoryName" />
|
||||||
<field-display label="Fabricant" :value="product.fabricant" fallback="Non renseigné" />
|
<field-display
|
||||||
<field-display label="Numéro de lot" :value="product.numero_lot" fallback="Non renseigné" />
|
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="Unité" :value="product.unite" />
|
||||||
<field-display
|
<field-display
|
||||||
label="Date d'expiration"
|
label="Date d'expiration"
|
||||||
@ -14,7 +26,9 @@
|
|||||||
:value-class="expirationClass"
|
:value-class="expirationClass"
|
||||||
/>
|
/>
|
||||||
<field-display label="Description">
|
<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>
|
</field-display>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -35,7 +49,9 @@
|
|||||||
required
|
required
|
||||||
:error="errors.reference?.[0]"
|
:error="errors.reference?.[0]"
|
||||||
placeholder="Référence produit"
|
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
|
<field-input
|
||||||
label="Catégorie"
|
label="Catégorie"
|
||||||
@ -43,10 +59,16 @@
|
|||||||
:model-value="form.categorie_id"
|
:model-value="form.categorie_id"
|
||||||
required
|
required
|
||||||
:error="errors.categorie_id?.[0]"
|
: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 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 }}
|
{{ cat.name }}
|
||||||
</option>
|
</option>
|
||||||
</field-input>
|
</field-input>
|
||||||
@ -55,21 +77,27 @@
|
|||||||
:model-value="form.fabricant"
|
:model-value="form.fabricant"
|
||||||
:error="errors.fabricant?.[0]"
|
:error="errors.fabricant?.[0]"
|
||||||
placeholder="Fabricant"
|
placeholder="Fabricant"
|
||||||
@update:model-value="$emit('update:form', { ...form, fabricant: $event })"
|
@update:model-value="
|
||||||
|
$emit('update:form', { ...form, fabricant: $event })
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<field-input
|
<field-input
|
||||||
label="Numéro de lot"
|
label="Numéro de lot"
|
||||||
:model-value="form.numero_lot"
|
:model-value="form.numero_lot"
|
||||||
:error="errors.numero_lot?.[0]"
|
:error="errors.numero_lot?.[0]"
|
||||||
placeholder="Numéro de lot"
|
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
|
<field-input
|
||||||
label="Date d'expiration"
|
label="Date d'expiration"
|
||||||
type="date"
|
type="date"
|
||||||
:model-value="form.date_expiration"
|
:model-value="form.date_expiration"
|
||||||
:error="errors.date_expiration?.[0]"
|
: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
|
<field-input
|
||||||
label="Unité"
|
label="Unité"
|
||||||
@ -85,7 +113,9 @@
|
|||||||
:model-value="form.description"
|
:model-value="form.description"
|
||||||
placeholder="Description du produit..."
|
placeholder="Description du produit..."
|
||||||
style="grid-column: 1 / -1"
|
style="grid-column: 1 / -1"
|
||||||
@update:model-value="$emit('update:form', { ...form, description: $event })"
|
@update:model-value="
|
||||||
|
$emit('update:form', { ...form, description: $event })
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -110,7 +140,9 @@ defineEmits(["update:form"]);
|
|||||||
const formattedExpiration = computed(() => {
|
const formattedExpiration = computed(() => {
|
||||||
if (!props.product?.date_expiration) return "Non renseignée";
|
if (!props.product?.date_expiration) return "Non renseignée";
|
||||||
return new Date(props.product.date_expiration).toLocaleDateString("fr-FR", {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.info-section { display: flex; flex-direction: column; }
|
.info-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.info-section__grid {
|
.info-section__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.info-section__grid { grid-template-columns: 1fr; }
|
.info-section__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="!movements.length" class="movements-empty">
|
<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>
|
<p>Aucun mouvement de stock enregistré</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="movements-table-wrap">
|
<div v-else class="movements-table-wrap">
|
||||||
@ -16,7 +25,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="m in movements" :key="m.id">
|
<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>
|
<td>
|
||||||
<span class="movements-table__type" :class="typeClass(m.type)">
|
<span class="movements-table__type" :class="typeClass(m.type)">
|
||||||
{{ m.type || "—" }}
|
{{ m.type || "—" }}
|
||||||
@ -25,7 +36,9 @@
|
|||||||
<td class="movements-table__qty" :class="qtyClass(m)">
|
<td class="movements-table__qty" :class="qtyClass(m)">
|
||||||
{{ formatQty(m) }}
|
{{ formatQty(m) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="movements-table__ref">{{ m.reference || m.reason || "—" }}</td>
|
<td class="movements-table__ref">
|
||||||
|
{{ m.reference || m.reason || "—" }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -43,7 +56,9 @@ defineProps({
|
|||||||
const formatDate = (d) => {
|
const formatDate = (d) => {
|
||||||
if (!d) return "—";
|
if (!d) return "—";
|
||||||
return new Date(d).toLocaleDateString("fr-FR", {
|
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) => {
|
const typeClass = (type) => {
|
||||||
if (!type) return "";
|
if (!type) return "";
|
||||||
const t = type.toLowerCase();
|
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";
|
if (t.includes("sortie") || t.includes("utilisation")) return "type-out";
|
||||||
return "type-neutral";
|
return "type-neutral";
|
||||||
};
|
};
|
||||||
@ -78,9 +94,14 @@ const qtyClass = (m) => {
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
text-align: center;
|
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 {
|
.movements-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -106,11 +127,21 @@ const qtyClass = (m) => {
|
|||||||
border-bottom: 1px solid #f9fafb;
|
border-bottom: 1px solid #f9fafb;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.movements-table tr:last-child td { border-bottom: none; }
|
.movements-table tr:last-child td {
|
||||||
.movements-table tr:hover td { background: #f9fafb; }
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.movements-table tr:hover td {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
.movements-table__date { color: #6b7280; font-variant-numeric: tabular-nums; }
|
.movements-table__date {
|
||||||
.movements-table__ref { color: #9ca3af; font-size: 12px; }
|
color: #6b7280;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.movements-table__ref {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.movements-table__type {
|
.movements-table__type {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -121,11 +152,27 @@ const qtyClass = (m) => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.type-in { background: #ecfdf5; color: #065f46; }
|
.type-in {
|
||||||
.type-out { background: #fef2f2; color: #991b1b; }
|
background: #ecfdf5;
|
||||||
.type-neutral { background: #f3f4f6; color: #374151; }
|
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; }
|
.movements-table__qty {
|
||||||
.qty-positive { color: #059669; }
|
font-weight: 600;
|
||||||
.qty-negative { color: #dc2626; }
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.qty-positive {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.qty-negative {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="product-sidebar">
|
<aside class="product-sidebar">
|
||||||
<div class="product-sidebar__img-wrap">
|
<div class="product-sidebar__img-wrap">
|
||||||
<product-image
|
<product-image :image-url="imageUrl" :alt-text="`Image de ${name}`" />
|
||||||
:image-url="imageUrl"
|
|
||||||
:alt-text="`Image de ${name}`"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-sidebar__meta">
|
<div class="product-sidebar__meta">
|
||||||
@ -15,7 +12,9 @@
|
|||||||
{{ isLowStock ? "Stock faible" : "Stock OK" }}
|
{{ isLowStock ? "Stock faible" : "Stock OK" }}
|
||||||
</product-badge>
|
</product-badge>
|
||||||
<product-badge v-if="isExpired" variant="danger">Expiré</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -94,7 +93,7 @@ defineEmits(["update:modelValue"]);
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
.product-sidebar__badges {
|
.product-sidebar__badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -126,10 +125,17 @@ defineEmits(["update:modelValue"]);
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.12s, color 0.12s;
|
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 {
|
.product-sidebar__nav-item.is-active {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
.nav-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -30,7 +30,9 @@
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
@update:model-value="$emit('update:form', { ...form, stock_actuel: $event })"
|
@update:model-value="
|
||||||
|
$emit('update:form', { ...form, stock_actuel: $event })
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<field-input
|
<field-input
|
||||||
label="Stock minimum"
|
label="Stock minimum"
|
||||||
@ -41,7 +43,9 @@
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
@update:model-value="$emit('update:form', { ...form, stock_minimum: $event })"
|
@update:model-value="
|
||||||
|
$emit('update:form', { ...form, stock_minimum: $event })
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<field-input
|
<field-input
|
||||||
label="Prix unitaire (€)"
|
label="Prix unitaire (€)"
|
||||||
@ -52,14 +56,18 @@
|
|||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="0.00"
|
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
|
<field-input
|
||||||
label="Conditionnement"
|
label="Conditionnement"
|
||||||
:model-value="form.conditionnement_nom"
|
:model-value="form.conditionnement_nom"
|
||||||
:error="errors.conditionnement_nom?.[0]"
|
:error="errors.conditionnement_nom?.[0]"
|
||||||
placeholder="ex: Carton 12 unités"
|
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
|
<field-input
|
||||||
label="Qté par conditionnement"
|
label="Qté par conditionnement"
|
||||||
@ -69,7 +77,9 @@
|
|||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
@update:model-value="$emit('update:form', { ...form, conditionnement_quantite: $event })"
|
@update:model-value="
|
||||||
|
$emit('update:form', { ...form, conditionnement_quantite: $event })
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -80,7 +90,8 @@
|
|||||||
<span class="cond-value">
|
<span class="cond-value">
|
||||||
{{ product.conditionnement?.nom }}
|
{{ product.conditionnement?.nom }}
|
||||||
<span v-if="product.conditionnement?.quantite">
|
<span v-if="product.conditionnement?.quantite">
|
||||||
· {{ product.conditionnement.quantite }} {{ product.conditionnement.unite }}
|
· {{ product.conditionnement.quantite }}
|
||||||
|
{{ product.conditionnement.unite }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -127,11 +138,16 @@ defineEmits(["update:form"]);
|
|||||||
const formattedPrice = computed(() => {
|
const formattedPrice = computed(() => {
|
||||||
const p = props.product.prix_unitaire;
|
const p = props.product.prix_unitaire;
|
||||||
if (!p && p !== 0) return "—";
|
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(() =>
|
const hasConditioning = computed(
|
||||||
props.product.conditionnement?.nom || props.product.conditionnement?.quantite
|
() =>
|
||||||
|
props.product.conditionnement?.nom ||
|
||||||
|
props.product.conditionnement?.quantite
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -146,7 +162,11 @@ const hasConditioning = computed(() =>
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.stock-indicators { display: flex; flex-wrap: wrap; gap: 6px; }
|
.stock-indicators {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.cond-row {
|
.cond-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -164,9 +184,16 @@ const hasConditioning = computed(() =>
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
.cond-value { font-size: 14px; color: #111827; }
|
.cond-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.stock-stats { grid-template-columns: 1fr 1fr; }
|
.stock-stats {
|
||||||
.stock-grid { grid-template-columns: 1fr; }
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.stock-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,18 +6,39 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="supplier-card__body">
|
<div class="supplier-card__body">
|
||||||
<p class="supplier-card__name">{{ supplier.name || supplier.nom }}</p>
|
<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.email" class="supplier-card__email">
|
||||||
<p v-if="supplier.phone" class="supplier-card__phone">{{ supplier.phone }}</p>
|
{{ supplier.email }}
|
||||||
|
</p>
|
||||||
|
<p v-if="supplier.phone" class="supplier-card__phone">
|
||||||
|
{{ supplier.phone }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="supplier-card__cta" @click="$emit('view', supplier)">
|
<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">
|
<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" />
|
<path d="M3 13L13 3M13 3H7M13 3v6" />
|
||||||
</svg>
|
</svg>
|
||||||
Voir le fournisseur
|
Voir le fournisseur
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="supplier-empty">
|
<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é
|
Aucun fournisseur associé
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,7 +54,12 @@ defineEmits(["view"]);
|
|||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
const name = props.supplier?.name || props.supplier?.nom || "";
|
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>
|
</script>
|
||||||
|
|
||||||
@ -60,7 +86,10 @@ const initials = computed(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.supplier-card__body { flex: 1; min-width: 0; }
|
.supplier-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.supplier-card__name {
|
.supplier-card__name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -90,7 +119,10 @@ const initials = computed(() => {
|
|||||||
transition: background 0.12s, border-color 0.12s;
|
transition: background 0.12s, border-color 0.12s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.supplier-card__cta:hover { background: #f3f4f6; border-color: #9ca3af; }
|
.supplier-card__cta:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.supplier-empty {
|
.supplier-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<div class="loading-spinner">
|
<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>
|
<span class="visually-hidden">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -108,13 +108,7 @@
|
|||||||
<!-- Product Column -->
|
<!-- Product Column -->
|
||||||
<td class="font-weight-bold">
|
<td class="font-weight-bold">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<soft-avatar
|
<soft-checkbox />
|
||||||
:img="product.media?.photo_url || getRandomAvatar()"
|
|
||||||
size="xs"
|
|
||||||
class="me-2"
|
|
||||||
alt="product image"
|
|
||||||
circular
|
|
||||||
/>
|
|
||||||
<span>{{ product.nom }}</span>
|
<span>{{ product.nom }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -208,15 +202,16 @@
|
|||||||
<i class="fas fa-clock me-1"></i>
|
<i class="fas fa-clock me-1"></i>
|
||||||
Expire bientôt
|
Expire bientôt
|
||||||
</soft-button>
|
</soft-button>
|
||||||
|
|
||||||
<!-- Normal Status -->
|
<!-- Normal Status -->
|
||||||
<span
|
<soft-button
|
||||||
v-if="!product.is_low_stock && !isExpiringSoon(product)"
|
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>
|
<i class="fas fa-check me-1"></i>
|
||||||
Normal
|
Stock Normal
|
||||||
</span>
|
</soft-button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -466,22 +461,30 @@ onMounted(() => {
|
|||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 50%;
|
||||||
right: 20px;
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-spinner-circle {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-width: 0.28em;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-content {
|
.loading-content {
|
||||||
opacity: 0.7;
|
opacity: 0.55;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-row {
|
.skeleton-row {
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-checkbox {
|
.skeleton-checkbox {
|
||||||
@ -585,11 +588,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.loading-spinner {
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-text.long {
|
.skeleton-text.long {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,241 +1,502 @@
|
|||||||
<template>
|
<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"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<i
|
|
||||||
v-if="selectedPractitioner?.id === practitioner.id"
|
|
||||||
class="fas fa-check text-success"
|
|
||||||
></i>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</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 -->
|
<!-- Backdrop -->
|
||||||
<div v-if="isOpen" class="modal-backdrop fade show"></div>
|
<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' }"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch, defineProps, defineEmits } from "vue";
|
||||||
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
|
||||||
import { defineProps, defineEmits } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isOpen: {
|
isOpen: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["close", "assign"]);
|
const emit = defineEmits(["close", "assign"]);
|
||||||
|
|
||||||
const thanatopractitionerStore = useThanatopractitionerStore();
|
const form = ref({ practitionerId: "", role: "principal" });
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
const searchQuery = ref("");
|
// Reset form when modal opens
|
||||||
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
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
(newValue) => {
|
(open) => {
|
||||||
if (!newValue) {
|
if (open) {
|
||||||
resetForm();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal.show {
|
/* ── Tokens ── */
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
.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;
|
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 {
|
/* Body */
|
||||||
background-color: #f8f9fa;
|
.modal-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.active {
|
.form-group {
|
||||||
background-color: #e3f2fd;
|
display: flex;
|
||||||
border-color: #2196f3;
|
flex-direction: column;
|
||||||
color: inherit;
|
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 {
|
.form-input {
|
||||||
background-color: #bbdefb;
|
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>
|
</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>
|
<template>
|
||||||
<ul class="nav nav-pills flex-column">
|
<nav class="tab-nav">
|
||||||
<TabNavigationItem
|
<button
|
||||||
icon="fas fa-eye"
|
v-for="tab in tabs"
|
||||||
label="Vue d'ensemble"
|
:key="tab.id"
|
||||||
:is-active="activeTab === 'overview'"
|
class="tab-item"
|
||||||
spacing=""
|
:class="{ active: activeTab === tab.id }"
|
||||||
@click="$emit('change-tab', 'overview')"
|
@click="$emit('change-tab', tab.id)"
|
||||||
/>
|
>
|
||||||
<TabNavigationItem
|
<span class="tab-icon" v-html="tab.icon"></span>
|
||||||
icon="fas fa-list"
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
label="Détails"
|
<span v-if="tab.id === 'team' && teamCount > 0" class="tab-badge">{{
|
||||||
:is-active="activeTab === 'details'"
|
teamCount
|
||||||
@click="$emit('change-tab', 'details')"
|
}}</span>
|
||||||
/>
|
<span
|
||||||
<TabNavigationItem
|
v-if="tab.id === 'documents' && documentsCount > 0"
|
||||||
icon="fas fa-users"
|
class="tab-badge"
|
||||||
label="Équipe"
|
>{{ documentsCount }}</span
|
||||||
:is-active="activeTab === 'team'"
|
>
|
||||||
:badge="teamCount > 0 ? teamCount : null"
|
</button>
|
||||||
@click="$emit('change-tab', 'team')"
|
</nav>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import TabNavigationItem from "@/components/atoms/client/TabNavigationItem.vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
|
|
||||||
import { defineProps, defineEmits, computed } from "vue";
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
activeTab: {
|
activeTab: { type: String, required: true },
|
||||||
type: String,
|
teamCount: { type: Number, default: 0 },
|
||||||
required: true,
|
documentsCount: { type: Number, default: 0 },
|
||||||
},
|
|
||||||
teamCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
documentsCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["change-tab"]);
|
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>
|
</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",
|
miniIcon: "F",
|
||||||
text: "Factures",
|
text: "Factures",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "price-lists",
|
||||||
|
route: { name: "Liste Price Lists" },
|
||||||
|
miniIcon: "P",
|
||||||
|
text: "Listes prix",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "ventes-stats",
|
id: "ventes-stats",
|
||||||
route: { name: "Statistiques ventes" },
|
route: { name: "Statistiques ventes" },
|
||||||
|
|||||||
@ -555,6 +555,11 @@ const routes = [
|
|||||||
name: "Invoice Details",
|
name: "Invoice Details",
|
||||||
component: () => import("@/views/pages/Ventes/InvoiceDetail.vue"),
|
component: () => import("@/views/pages/Ventes/InvoiceDetail.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/ventes/listes-prix",
|
||||||
|
name: "Liste Price Lists",
|
||||||
|
component: () => import("@/views/pages/Ventes/PriceLists.vue"),
|
||||||
|
},
|
||||||
// Avoirs
|
// Avoirs
|
||||||
{
|
{
|
||||||
path: "/avoirs",
|
path: "/avoirs",
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { request } from "./http";
|
import { request } from "./http";
|
||||||
|
import type { Client } from "./client";
|
||||||
|
|
||||||
export interface ClientGroup {
|
export interface ClientGroup {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
clients_count?: number;
|
||||||
|
client_ids?: number[];
|
||||||
|
clients?: Client[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -25,6 +29,7 @@ export interface ClientGroupResponse {
|
|||||||
export interface CreateClientGroupPayload {
|
export interface CreateClientGroupPayload {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
client_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateClientGroupPayload
|
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}`,
|
url: `/api/products/${id}`,
|
||||||
method: "get",
|
method: "get",
|
||||||
});
|
});
|
||||||
|
console.log(response);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { request } from "./http";
|
import { http, request } from "./http";
|
||||||
import { Client } from "./client";
|
import { Client } from "./client";
|
||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
id: number;
|
id: number;
|
||||||
client_id: number;
|
client_id: number | null;
|
||||||
group_id: number | null;
|
group_id: number | null;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
||||||
@ -50,7 +50,7 @@ export interface QuoteLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateQuotePayload {
|
export interface CreateQuotePayload {
|
||||||
client_id: number;
|
client_id: number | null;
|
||||||
group_id?: number | null;
|
group_id?: number | null;
|
||||||
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
|
||||||
quote_date: string;
|
quote_date: string;
|
||||||
@ -111,6 +111,17 @@ export const QuoteService = {
|
|||||||
return response;
|
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
|
* Update an existing quote
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -283,6 +283,7 @@ export const useFournisseurStore = defineStore("fournisseur", () => {
|
|||||||
currentFournisseur,
|
currentFournisseur,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
searchResults,
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
allFournisseurs,
|
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
|
* Update an existing quote
|
||||||
*/
|
*/
|
||||||
@ -217,6 +228,7 @@ export const useQuoteStore = defineStore("quote", () => {
|
|||||||
fetchQuotes,
|
fetchQuotes,
|
||||||
fetchQuote,
|
fetchQuote,
|
||||||
createQuote,
|
createQuote,
|
||||||
|
downloadQuotePdf,
|
||||||
updateQuote,
|
updateQuote,
|
||||||
deleteQuote,
|
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