From 9cbc1bcbdb847b25502a09372bc8ef4a40cd363f Mon Sep 17 00:00:00 2001
From: nyavokevin
Date: Thu, 2 Apr 2026 12:07:11 +0300
Subject: [PATCH] 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.
---
.../Controllers/Api/ClientGroupController.php | 60 +-
.../Controllers/Api/PriceListController.php | 154 ++
.../Api/PurchaseOrderController.php | 64 +-
.../Http/Controllers/Api/QuoteController.php | 31 +
.../Http/Requests/StoreClientGroupRequest.php | 6 +
.../Http/Requests/StorePriceListRequest.php | 43 +
.../app/Http/Requests/StoreQuoteRequest.php | 26 +-
.../Requests/UpdateClientGroupRequest.php | 11 +-
.../Http/Requests/UpdatePriceListRequest.php | 54 +
.../Resources/Client/ClientGroupResource.php | 4 +
.../app/Http/Resources/PriceListResource.php | 25 +
thanasoft-back/app/Models/Client.php | 5 +
.../app/Providers/AppServiceProvider.php | 4 +
.../app/Repositories/PriceListRepository.php | 15 +
.../PriceListRepositoryInterface.php | 9 +
.../app/Repositories/ProductRepository.php | 17 +-
.../app/Repositories/QuoteRepository.php | 4 +-
...4_02_082000_make_quote_client_nullable.php | 28 +
.../resources/views/pdf/quote_pdf.blade.php | 14 +-
thanasoft-back/routes/api.php | 3 +
.../AssignPractitionerModal.vue | 268 +++
.../InterventionDetailPresentation.vue | 1056 +++++++++
.../InterventionDetailSidebar.vue | 230 ++
.../refont_interview/InterventionDetails.vue | 803 +++++++
.../InterventionTabNavigation.vue | 77 +
.../ClientGroupDetailPresentation.vue | 32 +
.../ClientGroupFormPresentation.vue | 33 +-
.../Commande/CommandeDetailPresentation.vue | 38 +-
.../Commande/NewCommandePresentation.vue | 2 +-
.../InterventionDetailPresentation.vue | 1764 ++++++++++++--
.../InterventionDetailSidebar.vue | 502 +++-
.../Invoice/InvoiceDetailPresentation.vue | 7 +-
.../Quote/QuoteCreationPresentation.vue | 121 +-
.../Quote/QuoteDetailPresentation.vue | 63 +-
.../Stock/ProductDetailsPresentation.vue | 605 +++--
.../Stock/ReceptionDetailPresentation.vue | 7 +-
.../components/atoms/Product/FieldDisplay.vue | 20 +-
.../components/atoms/Product/FieldInput.vue | 35 +-
.../components/atoms/Product/ProductBadge.vue | 45 +-
.../atoms/Product/ProductStatCard.vue | 16 +-
.../molecules/ClientGroup/ClientGroupForm.vue | 74 +-
.../molecules/Commande/NewCommandeForm.vue | 1992 +++++++---------
.../Interventions/interventionDetails.vue | 50 +-
.../molecules/Product/ProductInfoSection.vue | 85 +-
.../Product/ProductMovementsSection.vue | 81 +-
.../molecules/Product/ProductSidebar.vue | 38 +-
.../molecules/Product/ProductStockSection.vue | 67 +-
.../Product/ProductSupplierSection.vue | 48 +-
.../molecules/Stock/NewReceptionForm.vue | 2 +-
.../molecules/Tables/Stock/ProductTable.vue | 44 +-
.../intervention/AssignPractitionerModal.vue | 665 ++++--
.../molecules/intervention/DataRow.vue | 26 +
.../molecules/intervention/InfoSection.vue | 25 +
.../InterventionTabNavigation.vue | 187 +-
.../molecules/intervention/StatusPill.vue | 21 +
.../src/examples/Sidenav/SidenavList.vue | 6 +
thanasoft-front/src/router/index.js | 5 +
thanasoft-front/src/services/clientGroup.ts | 5 +
thanasoft-front/src/services/priceList.ts | 78 +
thanasoft-front/src/services/product.ts | 1 +
thanasoft-front/src/services/quote.ts | 17 +-
.../src/stores/fournisseurStore.ts | 1 +
thanasoft-front/src/stores/quoteStore.ts | 12 +
.../src/views/pages/Ventes/PriceLists.vue | 12 +
thanasoft-front/tsconfig.tsbuildinfo | 2030 +++++++++++++++++
65 files changed, 9902 insertions(+), 1971 deletions(-)
create mode 100644 thanasoft-back/app/Http/Controllers/Api/PriceListController.php
create mode 100644 thanasoft-back/app/Http/Requests/StorePriceListRequest.php
create mode 100644 thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php
create mode 100644 thanasoft-back/app/Http/Resources/PriceListResource.php
create mode 100644 thanasoft-back/app/Repositories/PriceListRepository.php
create mode 100644 thanasoft-back/app/Repositories/PriceListRepositoryInterface.php
create mode 100644 thanasoft-back/database/migrations/2026_04_02_082000_make_quote_client_nullable.php
create mode 100644 thanasoft-front/refont_interview/AssignPractitionerModal.vue
create mode 100644 thanasoft-front/refont_interview/InterventionDetailPresentation.vue
create mode 100644 thanasoft-front/refont_interview/InterventionDetailSidebar.vue
create mode 100644 thanasoft-front/refont_interview/InterventionDetails.vue
create mode 100644 thanasoft-front/refont_interview/InterventionTabNavigation.vue
create mode 100644 thanasoft-front/src/components/molecules/intervention/DataRow.vue
create mode 100644 thanasoft-front/src/components/molecules/intervention/InfoSection.vue
create mode 100644 thanasoft-front/src/components/molecules/intervention/StatusPill.vue
create mode 100644 thanasoft-front/src/services/priceList.ts
create mode 100644 thanasoft-front/src/views/pages/Ventes/PriceLists.vue
create mode 100644 thanasoft-front/tsconfig.tsbuildinfo
diff --git a/thanasoft-back/app/Http/Controllers/Api/ClientGroupController.php b/thanasoft-back/app/Http/Controllers/Api/ClientGroupController.php
index 14dde48..4eec3fe 100644
--- a/thanasoft-back/app/Http/Controllers/Api/ClientGroupController.php
+++ b/thanasoft-back/app/Http/Controllers/Api/ClientGroupController.php
@@ -13,7 +13,9 @@ use App\Models\Client;
use App\Repositories\ClientGroupRepositoryInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\DB;
class ClientGroupController extends Controller
{
@@ -49,7 +51,22 @@ class ClientGroupController extends Controller
public function store(StoreClientGroupRequest $request): ClientGroupResource|JsonResponse
{
try {
- $clientGroup = $this->clientGroupRepository->create($request->validated());
+ $validated = $request->validated();
+
+ $clientGroup = DB::transaction(function () use ($validated) {
+ $clientIds = Arr::get($validated, 'client_ids', []);
+
+ $clientGroup = $this->clientGroupRepository->create(Arr::except($validated, ['client_ids']));
+
+ if (!empty($clientIds)) {
+ Client::query()
+ ->whereIn('id', $clientIds)
+ ->update(['group_id' => $clientGroup->id]);
+ }
+
+ return $clientGroup->load(['clients'])->loadCount('clients');
+ });
+
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error creating client group: ' . $e->getMessage(), [
@@ -79,6 +96,8 @@ class ClientGroupController extends Controller
], 404);
}
+ $clientGroup->load(['clients'])->loadCount('clients');
+
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error fetching client group: ' . $e->getMessage(), [
@@ -100,7 +119,32 @@ class ClientGroupController extends Controller
public function update(UpdateClientGroupRequest $request, string $id): ClientGroupResource|JsonResponse
{
try {
- $updated = $this->clientGroupRepository->update($id, $request->validated());
+ $validated = $request->validated();
+
+ $updated = DB::transaction(function () use ($id, $validated) {
+ $updated = $this->clientGroupRepository->update($id, Arr::except($validated, ['client_ids']));
+
+ if (!$updated) {
+ return false;
+ }
+
+ if (array_key_exists('client_ids', $validated)) {
+ $clientIds = $validated['client_ids'] ?? [];
+
+ Client::query()
+ ->where('group_id', (int) $id)
+ ->whereNotIn('id', $clientIds)
+ ->update(['group_id' => null]);
+
+ if (!empty($clientIds)) {
+ Client::query()
+ ->whereIn('id', $clientIds)
+ ->update(['group_id' => (int) $id]);
+ }
+ }
+
+ return true;
+ });
if (!$updated) {
return response()->json([
@@ -109,6 +153,8 @@ class ClientGroupController extends Controller
}
$clientGroup = $this->clientGroupRepository->find($id);
+ $clientGroup?->load(['clients'])->loadCount('clients');
+
return new ClientGroupResource($clientGroup);
} catch (\Exception $e) {
Log::error('Error updating client group: ' . $e->getMessage(), [
@@ -172,9 +218,13 @@ class ClientGroupController extends Controller
$clientIds = $request->validated('client_ids');
- $updatedCount = Client::query()
- ->whereIn('id', $clientIds)
- ->update(['group_id' => $clientGroup->id]);
+ $updatedCount = DB::transaction(function () use ($clientIds, $clientGroup) {
+ return Client::query()
+ ->whereIn('id', $clientIds)
+ ->update(['group_id' => $clientGroup->id]);
+ });
+
+ $clientGroup->load(['clients'])->loadCount('clients');
return response()->json([
'message' => 'Clients assignés au groupe avec succès.',
diff --git a/thanasoft-back/app/Http/Controllers/Api/PriceListController.php b/thanasoft-back/app/Http/Controllers/Api/PriceListController.php
new file mode 100644
index 0000000..73c6f41
--- /dev/null
+++ b/thanasoft-back/app/Http/Controllers/Api/PriceListController.php
@@ -0,0 +1,154 @@
+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);
+ }
+ }
+}
diff --git a/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php b/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php
index dd7eb69..37ffa6d 100644
--- a/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php
+++ b/thanasoft-back/app/Http/Controllers/Api/PurchaseOrderController.php
@@ -21,7 +21,8 @@ class PurchaseOrderController extends Controller
public function __construct(
protected PurchaseOrderRepositoryInterface $purchaseOrderRepository,
protected GoodsReceiptRepositoryInterface $goodsReceiptRepository
- ) {
+ )
+ {
}
/**
@@ -32,7 +33,8 @@ class PurchaseOrderController extends Controller
try {
$purchaseOrders = $this->purchaseOrderRepository->all();
return PurchaseOrderResource::collection($purchaseOrders);
- } catch (\Exception $e) {
+ }
+ catch (\Exception $e) {
Log::error('Error fetching purchase orders: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@@ -52,8 +54,15 @@ class PurchaseOrderController extends Controller
{
try {
$purchaseOrder = $this->purchaseOrderRepository->create($request->validated());
+
+ // If PO is created directly as validated/delivered, ensure a draft goods receipt exists.
+ if ($purchaseOrder && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)) {
+ $this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
+ }
+
return new PurchaseOrderResource($purchaseOrder);
- } catch (\Exception $e) {
+ }
+ catch (\Exception $e) {
Log::error('Error creating purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@@ -82,7 +91,8 @@ class PurchaseOrderController extends Controller
}
return new PurchaseOrderResource($purchaseOrder);
- } catch (\Exception $e) {
+ }
+ catch (\Exception $e) {
Log::error('Error fetching purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@@ -102,9 +112,6 @@ class PurchaseOrderController extends Controller
public function update(UpdatePurchaseOrderRequest $request, string $id): PurchaseOrderResource|JsonResponse
{
try {
- $existingPurchaseOrder = $this->purchaseOrderRepository->find($id);
- $previousStatus = $existingPurchaseOrder?->status;
-
$updated = $this->purchaseOrderRepository->update($id, $request->validated());
if (!$updated) {
@@ -115,17 +122,15 @@ class PurchaseOrderController extends Controller
$purchaseOrder = $this->purchaseOrderRepository->find($id);
- // On validation/delivery (status => confirmee|livree), create a draft goods receipt automatically.
- if (
- $purchaseOrder
- && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)
- && !in_array($previousStatus, ['confirmee', 'livree'], true)
- ) {
+ // Ensure draft goods receipt exists when PO is validated/delivered.
+ // Idempotent: guarded by purchase_order_id existence check in helper.
+ if ($purchaseOrder && in_array($purchaseOrder->status, ['confirmee', 'livree'], true)) {
$this->createGoodsReceiptFromValidatedPurchaseOrder($purchaseOrder);
}
return new PurchaseOrderResource($purchaseOrder);
- } catch (\Exception $e) {
+ }
+ catch (\Exception $e) {
Log::error('Error updating purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@@ -158,28 +163,28 @@ class PurchaseOrderController extends Controller
throw new \RuntimeException('Aucun entrepôt disponible pour créer la réception de marchandise.');
}
- $receiptNumber = 'GR-' . now()->format('Ym') . '-' . str_pad((string) $purchaseOrder->id, 4, '0', STR_PAD_LEFT);
+ $receiptNumber = 'GR-' . now()->format('Ym') . '-' . str_pad((string)$purchaseOrder->id, 4, '0', STR_PAD_LEFT);
$lines = collect($purchaseOrder->lines ?? [])
->filter(fn($line) => !empty($line->product_id))
->map(function ($line) {
- return [
- 'product_id' => (int) $line->product_id,
- 'packaging_id' => null,
- 'packages_qty_received' => null,
- 'units_qty_received' => (float) $line->quantity,
- 'qty_received_base' => (float) $line->quantity,
- 'unit_price' => (float) $line->unit_price,
- 'unit_price_per_package' => null,
- 'tva_rate_id' => null,
- ];
- })
+ return [
+ 'product_id' => (int)$line->product_id,
+ 'packaging_id' => null,
+ 'packages_qty_received' => null,
+ 'units_qty_received' => (float)$line->quantity,
+ 'qty_received_base' => (float)$line->quantity,
+ 'unit_price' => (float)$line->unit_price,
+ 'unit_price_per_package' => null,
+ 'tva_rate_id' => null,
+ ];
+ })
->values()
->all();
$this->goodsReceiptRepository->create([
'purchase_order_id' => $purchaseOrder->id,
- 'warehouse_id' => (int) $warehouseId,
+ 'warehouse_id' => (int)$warehouseId,
'receipt_number' => $receiptNumber,
'receipt_date' => now()->toDateString(),
'status' => 'draft',
@@ -204,7 +209,8 @@ class PurchaseOrderController extends Controller
return response()->json([
'message' => 'Commande fournisseur supprimée avec succès.',
], 200);
- } catch (\Exception $e) {
+ }
+ catch (\Exception $e) {
Log::error('Error deleting purchase order: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString(),
@@ -217,4 +223,4 @@ class PurchaseOrderController extends Controller
], 500);
}
}
-}
+}
\ No newline at end of file
diff --git a/thanasoft-back/app/Http/Controllers/Api/QuoteController.php b/thanasoft-back/app/Http/Controllers/Api/QuoteController.php
index 04c5fe2..c293ae1 100644
--- a/thanasoft-back/app/Http/Controllers/Api/QuoteController.php
+++ b/thanasoft-back/app/Http/Controllers/Api/QuoteController.php
@@ -17,6 +17,7 @@ use Illuminate\Http\Request;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Mail;
use App\Mail\DocumentMail;
+use Symfony\Component\HttpFoundation\Response;
class QuoteController extends Controller
{
@@ -199,4 +200,34 @@ class QuoteController extends Controller
], 500);
}
}
+
+ /**
+ * Download the quote as a PDF.
+ */
+ public function downloadPdf(string $id): Response|JsonResponse
+ {
+ try {
+ $quote = Quote::with(['client', 'group', 'lines'])->find($id);
+
+ if (! $quote) {
+ return response()->json([
+ 'message' => 'Devis non trouvé.',
+ ], 404);
+ }
+
+ $pdf = Pdf::loadView('pdf.quote_pdf', ['quote' => $quote]);
+
+ return $pdf->download('Devis_' . $quote->reference . '.pdf');
+ } catch (\Exception $e) {
+ Log::error('Error downloading quote PDF: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'quote_id' => $id,
+ ]);
+
+ return response()->json([
+ 'message' => 'Une erreur est survenue lors de la generation du PDF.',
+ 'error' => config('app.debug') ? $e->getMessage() : null,
+ ], 500);
+ }
+ }
}
diff --git a/thanasoft-back/app/Http/Requests/StoreClientGroupRequest.php b/thanasoft-back/app/Http/Requests/StoreClientGroupRequest.php
index 01bc264..18156ec 100644
--- a/thanasoft-back/app/Http/Requests/StoreClientGroupRequest.php
+++ b/thanasoft-back/app/Http/Requests/StoreClientGroupRequest.php
@@ -24,6 +24,8 @@ class StoreClientGroupRequest extends FormRequest
return [
'name' => 'required|string|max:191|unique:client_groups,name',
'description' => 'nullable|string',
+ 'client_ids' => 'sometimes|array',
+ 'client_ids.*' => 'integer|distinct|exists:clients,id',
];
}
@@ -35,6 +37,10 @@ class StoreClientGroupRequest extends FormRequest
'name.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
'name.unique' => 'Un groupe avec ce nom existe déjà.',
'description.string' => 'La description doit être une chaîne de caractères.',
+ 'client_ids.array' => 'La liste des clients doit être un tableau.',
+ 'client_ids.*.integer' => 'Chaque ID client doit être un entier.',
+ 'client_ids.*.distinct' => 'Un client ne peut pas être sélectionné plusieurs fois.',
+ 'client_ids.*.exists' => 'Un ou plusieurs clients sélectionnés sont introuvables.',
];
}
}
diff --git a/thanasoft-back/app/Http/Requests/StorePriceListRequest.php b/thanasoft-back/app/Http/Requests/StorePriceListRequest.php
new file mode 100644
index 0000000..bf65d36
--- /dev/null
+++ b/thanasoft-back/app/Http/Requests/StorePriceListRequest.php
@@ -0,0 +1,43 @@
+|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.',
+ ];
+ }
+}
diff --git a/thanasoft-back/app/Http/Requests/StoreQuoteRequest.php b/thanasoft-back/app/Http/Requests/StoreQuoteRequest.php
index 26e6c32..83cb3d2 100644
--- a/thanasoft-back/app/Http/Requests/StoreQuoteRequest.php
+++ b/thanasoft-back/app/Http/Requests/StoreQuoteRequest.php
@@ -22,7 +22,7 @@ class StoreQuoteRequest extends FormRequest
public function rules(): array
{
return [
- 'client_id' => 'required|exists:clients,id',
+ 'client_id' => 'nullable|exists:clients,id',
'group_id' => 'nullable|exists:client_groups,id',
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'required|date',
@@ -46,10 +46,32 @@ class StoreQuoteRequest extends FormRequest
];
}
+ public function withValidator($validator): void
+ {
+ $validator->after(function ($validator) {
+ $hasClient = filled($this->input('client_id'));
+ $hasGroup = filled($this->input('group_id'));
+
+ if (! $hasClient && ! $hasGroup) {
+ $message = 'Un client ou un groupe de clients est obligatoire.';
+
+ $validator->errors()->add('client_id', $message);
+ $validator->errors()->add('group_id', $message);
+ }
+
+ if ($hasClient && $hasGroup) {
+ $message = 'Sélectionnez soit un client, soit un groupe de clients.';
+
+ $validator->errors()->add('client_id', $message);
+ $validator->errors()->add('group_id', $message);
+ }
+ });
+ }
+
public function messages(): array
{
return [
- 'client_id.required' => 'Le client est obligatoire.',
+ 'client_id.nullable' => 'Le client sélectionné est invalide.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'status.required' => 'Le statut est obligatoire.',
diff --git a/thanasoft-back/app/Http/Requests/UpdateClientGroupRequest.php b/thanasoft-back/app/Http/Requests/UpdateClientGroupRequest.php
index 18c31b0..0f16264 100644
--- a/thanasoft-back/app/Http/Requests/UpdateClientGroupRequest.php
+++ b/thanasoft-back/app/Http/Requests/UpdateClientGroupRequest.php
@@ -22,7 +22,10 @@ class UpdateClientGroupRequest extends FormRequest
*/
public function rules(): array
{
- $clientGroupId = $this->route('client_group') ? $this->route('client_group')->id : $this->route('id');
+ $routeClientGroup = $this->route('client_group');
+ $clientGroupId = is_object($routeClientGroup)
+ ? $routeClientGroup->id
+ : ($routeClientGroup ?? $this->route('id'));
return [
'name' => [
@@ -32,6 +35,8 @@ class UpdateClientGroupRequest extends FormRequest
Rule::unique('client_groups', 'name')->ignore($clientGroupId)
],
'description' => 'nullable|string',
+ 'client_ids' => 'sometimes|array',
+ 'client_ids.*' => 'integer|distinct|exists:clients,id',
];
}
@@ -43,6 +48,10 @@ class UpdateClientGroupRequest extends FormRequest
'name.max' => 'Le nom du groupe ne peut pas dépasser 191 caractères.',
'name.unique' => 'Un groupe avec ce nom existe déjà.',
'description.string' => 'La description doit être une chaîne de caractères.',
+ 'client_ids.array' => 'La liste des clients doit être un tableau.',
+ 'client_ids.*.integer' => 'Chaque ID client doit être un entier.',
+ 'client_ids.*.distinct' => 'Un client ne peut pas être sélectionné plusieurs fois.',
+ 'client_ids.*.exists' => 'Un ou plusieurs clients sélectionnés sont introuvables.',
];
}
}
diff --git a/thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php b/thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php
new file mode 100644
index 0000000..fd86bcb
--- /dev/null
+++ b/thanasoft-back/app/Http/Requests/UpdatePriceListRequest.php
@@ -0,0 +1,54 @@
+|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.',
+ ];
+ }
+}
diff --git a/thanasoft-back/app/Http/Resources/Client/ClientGroupResource.php b/thanasoft-back/app/Http/Resources/Client/ClientGroupResource.php
index c4e85cb..7b85a69 100644
--- a/thanasoft-back/app/Http/Resources/Client/ClientGroupResource.php
+++ b/thanasoft-back/app/Http/Resources/Client/ClientGroupResource.php
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Client;
+use App\Http\Resources\Client\ClientResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -18,6 +19,9 @@ class ClientGroupResource extends JsonResource
'id' => $this->id,
'name' => $this->name,
'description' => $this->description ?? null,
+ 'clients_count' => $this->whenCounted('clients'),
+ 'client_ids' => $this->whenLoaded('clients', fn () => $this->clients->pluck('id')->values()),
+ 'clients' => ClientResource::collection($this->whenLoaded('clients')),
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
];
diff --git a/thanasoft-back/app/Http/Resources/PriceListResource.php b/thanasoft-back/app/Http/Resources/PriceListResource.php
new file mode 100644
index 0000000..aae8112
--- /dev/null
+++ b/thanasoft-back/app/Http/Resources/PriceListResource.php
@@ -0,0 +1,25 @@
+
+ */
+ 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,
+ ];
+ }
+}
diff --git a/thanasoft-back/app/Models/Client.php b/thanasoft-back/app/Models/Client.php
index c3d4a0c..16e8b2e 100644
--- a/thanasoft-back/app/Models/Client.php
+++ b/thanasoft-back/app/Models/Client.php
@@ -61,6 +61,11 @@ class Client extends Model
return $this->belongsTo(ClientCategory::class, 'client_category_id');
}
+ public function group(): BelongsTo
+ {
+ return $this->belongsTo(ClientGroup::class, 'group_id');
+ }
+
/**
* Get the human-readable label for the client type.
*/
diff --git a/thanasoft-back/app/Providers/AppServiceProvider.php b/thanasoft-back/app/Providers/AppServiceProvider.php
index cf3bfee..1ac5a1c 100644
--- a/thanasoft-back/app/Providers/AppServiceProvider.php
+++ b/thanasoft-back/app/Providers/AppServiceProvider.php
@@ -24,6 +24,10 @@ class AppServiceProvider extends ServiceProvider
return new \App\Repositories\ClientGroupRepository($app->make(\App\Models\ClientGroup::class));
});
+ $this->app->bind(\App\Repositories\PriceListRepositoryInterface::class, function ($app) {
+ return new \App\Repositories\PriceListRepository($app->make(\App\Models\PriceList::class));
+ });
+
$this->app->bind(\App\Repositories\ClientContactRepositoryInterface::class, function ($app) {
return new \App\Repositories\ClientContactRepository($app->make(\App\Models\ClientContact::class));
});
diff --git a/thanasoft-back/app/Repositories/PriceListRepository.php b/thanasoft-back/app/Repositories/PriceListRepository.php
new file mode 100644
index 0000000..e827e3d
--- /dev/null
+++ b/thanasoft-back/app/Repositories/PriceListRepository.php
@@ -0,0 +1,15 @@
+where(function ($q) use ($filters) {
$q->where('nom', 'like', '%' . $filters['search'] . '%')
- ->orWhere('reference', 'like', '%' . $filters['search'] . '%')
- ->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
+ ->orWhere('reference', 'like', '%' . $filters['search'] . '%')
+ ->orWhere('fabricant', 'like', '%' . $filters['search'] . '%');
});
}
@@ -44,7 +44,7 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
if (isset($filters['expiring_soon'])) {
$query->where('date_expiration', '<=', now()->addDays(30)->toDateString())
- ->where('date_expiration', '>=', now()->toDateString());
+ ->where('date_expiration', '>=', now()->toDateString());
}
if (isset($filters['is_intervention'])) {
@@ -82,11 +82,12 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
if ($exactMatch) {
$query->where('nom', $name);
- } else {
+ }
+ else {
$query->where('nom', 'like', '%' . $name . '%');
}
- return $query->paginate($perPage);
+ return $query->get();
}
/**
@@ -147,8 +148,8 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
{
return $this->model->newQuery()
->whereHas('category', function ($query) {
- $query->where('intervention', true);
- })
+ $query->where('intervention', true);
+ })
->first();
}
-}
+}
\ No newline at end of file
diff --git a/thanasoft-back/app/Repositories/QuoteRepository.php b/thanasoft-back/app/Repositories/QuoteRepository.php
index ea50c01..c1544cc 100644
--- a/thanasoft-back/app/Repositories/QuoteRepository.php
+++ b/thanasoft-back/app/Repositories/QuoteRepository.php
@@ -24,7 +24,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
- return $this->model->with(['client', 'lines.product'])->get($columns);
+ return $this->model->with(['client', 'group', 'lines.product'])->get($columns);
}
public function create(array $data): Quote
@@ -123,7 +123,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function find(int|string $id, array $columns = ['*']): ?Quote
{
- return $this->model->with(['client', 'lines.product', 'history.user'])->find($id, $columns);
+ return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $quoteId, ?string $oldStatus, string $newStatus, ?string $comment = null): void
diff --git a/thanasoft-back/database/migrations/2026_04_02_082000_make_quote_client_nullable.php b/thanasoft-back/database/migrations/2026_04_02_082000_make_quote_client_nullable.php
new file mode 100644
index 0000000..184a7f6
--- /dev/null
+++ b/thanasoft-back/database/migrations/2026_04_02_082000_make_quote_client_nullable.php
@@ -0,0 +1,28 @@
+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();
+ });
+ }
+};
diff --git a/thanasoft-back/resources/views/pdf/quote_pdf.blade.php b/thanasoft-back/resources/views/pdf/quote_pdf.blade.php
index 6118313..98376fd 100644
--- a/thanasoft-back/resources/views/pdf/quote_pdf.blade.php
+++ b/thanasoft-back/resources/views/pdf/quote_pdf.blade.php
@@ -26,17 +26,19 @@
Solutions de gestion funéraire
-
CLIENT
-
{{ $quote->client->name }}
-
{{ $quote->client->billing_address_line1 }}
-
{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}
+
{{ $quote->client ? 'CLIENT' : 'GROUPE CLIENT' }}
+
{{ $quote->client?->name ?? $quote->group?->name ?? 'Destinataire inconnu' }}
+ @if($quote->client)
+
{{ $quote->client->billing_address_line1 }}
+
{{ $quote->client->billing_postal_code }} {{ $quote->client->billing_city }}
+ @endif
Devis : {{ $quote->reference }}
Date : {{ $quote->quote_date->format('d/m/Y') }}
- Valable jusqu'au : {{ $quote->valid_until->format('d/m/Y') }}
+ Valable jusqu'au : {{ $quote->valid_until?->format('d/m/Y') ?? 'Non definie' }}
@@ -51,7 +53,7 @@
@foreach($quote->lines as $line)
| {{ $line->description }} |
- {{ $line->quantity }} |
+ {{ $line->quantity ?? $line->units_qty ?? $line->qty_base ?? 0 }} |
{{ number_format($line->unit_price, 2, ',', ' ') }} {{ $quote->currency }} |
{{ number_format($line->total_ht, 2, ',', ' ') }} {{ $quote->currency }} |
diff --git a/thanasoft-back/routes/api.php b/thanasoft-back/routes/api.php
index f9af2fa..9ef1773 100644
--- a/thanasoft-back/routes/api.php
+++ b/thanasoft-back/routes/api.php
@@ -21,6 +21,7 @@ use App\Http\Controllers\Api\FileAttachmentController;
use App\Http\Controllers\Api\QuoteController;
use App\Http\Controllers\Api\ClientActivityTimelineController;
use App\Http\Controllers\Api\PurchaseOrderController;
+use App\Http\Controllers\Api\PriceListController;
use App\Http\Controllers\Api\TvaRateController;
use App\Http\Controllers\Api\GoodsReceiptController;
@@ -57,6 +58,7 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('clients', ClientController::class);
Route::post('client-groups/{id}/assign-clients', [ClientGroupController::class, 'assignClients']);
Route::apiResource('client-groups', ClientGroupController::class);
+ Route::apiResource('price-lists', PriceListController::class);
Route::apiResource('client-locations', ClientLocationController::class);
Route::apiResource('client-locations', ClientLocationController::class);
@@ -77,6 +79,7 @@ Route::middleware('auth:sanctum')->group(function () {
// Quote management
Route::post('/quotes/{id}/send-email', [QuoteController::class, 'sendByEmail']);
+ Route::get('/quotes/{id}/download-pdf', [QuoteController::class, 'downloadPdf']);
Route::apiResource('quotes', QuoteController::class);
// Invoice management
diff --git a/thanasoft-front/refont_interview/AssignPractitionerModal.vue b/thanasoft-front/refont_interview/AssignPractitionerModal.vue
new file mode 100644
index 0000000..489a7ad
--- /dev/null
+++ b/thanasoft-front/refont_interview/AssignPractitionerModal.vue
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thanasoft-front/refont_interview/InterventionDetailPresentation.vue b/thanasoft-front/refont_interview/InterventionDetailPresentation.vue
new file mode 100644
index 0000000..69d1ce7
--- /dev/null
+++ b/thanasoft-front/refont_interview/InterventionDetailPresentation.vue
@@ -0,0 +1,1056 @@
+
+
+
+
+
+
+ Interventions
+
+
+
+ #{{ mappedIntervention.id }}
+
+
+
+
+
+
+
+
+
+
Chargement de l'intervention…
+
+
+
+
+
+
+
+
Erreur de chargement
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ mappedIntervention.description || 'Aucune description disponible.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ getInitials(p.name) }}
+
+
{{ p.name }}
+
+
+ {{ p.role === 'principal' ? 'Principal' : 'Assistant' }}
+
+
+
+
+
+
+
+
+
+
Aucun praticien assigné
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Statut
+
+ {{ getQuoteStatusLabel(mappedIntervention.quote.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Description |
+ Qté |
+ Prix unitaire |
+ Total HT |
+
+
+
+
+ | {{ line.description || '-' }} |
+ {{ line.units_qty || 0 }} |
+ {{ formatCurrency(line.unit_price) }} |
+ {{ formatCurrency(line.total_ht) }} |
+
+
+
+
+
+
+
+
+
+
Aucun devis associé à cette intervention
+
+
+
+
+
+
+
+
+
Historique des modifications
+
Fonctionnalité à venir
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thanasoft-front/refont_interview/InterventionDetailSidebar.vue b/thanasoft-front/refont_interview/InterventionDetailSidebar.vue
new file mode 100644
index 0000000..71c3a75
--- /dev/null
+++ b/thanasoft-front/refont_interview/InterventionDetailSidebar.vue
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
diff --git a/thanasoft-front/refont_interview/InterventionDetails.vue b/thanasoft-front/refont_interview/InterventionDetails.vue
new file mode 100644
index 0000000..87da79a
--- /dev/null
+++ b/thanasoft-front/refont_interview/InterventionDetails.vue
@@ -0,0 +1,803 @@
+
+
+
+
+
+
+
+ Interventions
+
+
+
+ #{{ intervention.id }}
+ ·
+ {{ getDeceasedName(intervention) }}
+
+
+
+
+
+
+
+
+
Chargement de l'intervention…
+
+
+
+
+
+
+
+
Erreur de chargement
+
{{ interventionStore.getError }}
+
Retour à la liste
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ intervention.notes || 'Aucune description disponible.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ getInitials(getPractName(p)) }}
+
+
{{ getPractName(p) }}
+
+ {{ p.pivot?.role === 'principal' ? 'Principal' : 'Assistant' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {}"
+ @upload-files="handleUploadFiles"
+ @delete-document="handleDeleteDocument"
+ @delete-documents="handleDeleteDocuments"
+ @update-document-label="handleUpdateDocumentLabel"
+ @retry="loadDocuments"
+ />
+
+
+
+
+
+
+ Ouvrir le devis
+
+
+
+
+
+
+
+
+
+
+
+ Statut
+ {{ getQuoteLabel(intervention.quote.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
Lignes du devis
+
+
+ | Description | Qté | PU HT | Total HT |
+
+
+ | {{ l.description || '-' }} |
+ {{ l.units_qty || 0 }} |
+ {{ fmtCurrency(l.unit_price) }} |
+ {{ fmtCurrency(l.total_ht) }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fonctionnalité à venir
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thanasoft-front/refont_interview/InterventionTabNavigation.vue b/thanasoft-front/refont_interview/InterventionTabNavigation.vue
new file mode 100644
index 0000000..27bc108
--- /dev/null
+++ b/thanasoft-front/refont_interview/InterventionTabNavigation.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
diff --git a/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupDetailPresentation.vue b/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupDetailPresentation.vue
index eb2b7bc..a592ed3 100644
--- a/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupDetailPresentation.vue
+++ b/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupDetailPresentation.vue
@@ -57,6 +57,38 @@
{{ formatDate(clientGroup.updated_at) }}
+
+
Clients du groupe
+
+ {{ clientGroup.clients_count || clientGroup.clients?.length || 0 }}
+ client(s)
+
+
+
+
+
+ {{ client.name }}
+
+ {{ client.email || "Pas d'email" }}
+
+
+ {{ client.phone }}
+
+
+
+
+
+
+ Aucun client assigné à ce groupe.
+
+
diff --git a/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupFormPresentation.vue b/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupFormPresentation.vue
index 5c5d6b1..2c1d0e2 100644
--- a/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupFormPresentation.vue
+++ b/thanasoft-front/src/components/Organism/ClientGroup/ClientGroupFormPresentation.vue
@@ -32,25 +32,38 @@ const router = useRouter();
const clientGroupStore = useClientGroupStore();
const notificationStore = useNotificationStore();
-const formData = ref({ name: "", description: "" });
+const formData = ref({
+ name: "",
+ description: "",
+ clients: [],
+});
const loading = ref(false);
const isEdit = ref(!!props.groupId);
onMounted(async () => {
- if (props.groupId) {
- try {
- const group = await clientGroupStore.fetchClientGroup(props.groupId);
+ try {
+ const group = props.groupId
+ ? await clientGroupStore.fetchClientGroup(props.groupId)
+ : null;
+
+ if (group) {
formData.value = {
name: group.name,
description: group.description || "",
+ clients: group.clients || [],
};
- } catch (error) {
- notificationStore.error(
- "Erreur",
- "Impossible de charger le groupe",
- 3000
- );
+ }
+ } catch (error) {
+ notificationStore.error(
+ "Erreur",
+ "Impossible de charger le groupe",
+ 3000
+ );
+
+ try {
router.push("/clients/groups");
+ } catch (navigationError) {
+ console.error(navigationError);
}
}
});
diff --git a/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue b/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue
index b20bae3..3a71d10 100644
--- a/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue
+++ b/thanasoft-front/src/components/Organism/Commande/CommandeDetailPresentation.vue
@@ -46,6 +46,26 @@
{{ secondaryActionLabel }}
+
+
+ Modifier
+
+
+
+
+ Ajouter un entrepôt
+
+
import { ref, defineProps, onMounted, computed } from "vue";
+import { useRouter } from "vue-router";
import SoftButton from "@/components/SoftButton.vue";
import { PurchaseOrderService } from "@/services/purchaseOrder";
import { useNotificationStore } from "@/stores/notification";
@@ -221,6 +242,7 @@ const props = defineProps({
});
const notificationStore = useNotificationStore();
+const router = useRouter();
const commande = ref(null);
const loading = ref(true);
const error = ref(null);
@@ -415,7 +437,12 @@ const changeStatus = async (newStatus) => {
);
} catch (err) {
console.error("Error updating status:", err);
- notificationStore.error("Erreur", "Échec de la mise à jour du statut.");
+ const backendMessage =
+ err?.response?.data?.error ||
+ err?.response?.data?.message ||
+ "Échec de la mise à jour du statut.";
+
+ notificationStore.error("Erreur", backendMessage);
} finally {
if (requestId === statusUpdateRequestId.value) {
isUpdatingStatus.value = false;
@@ -444,6 +471,15 @@ const downloadPdf = () => {
window.print();
};
+const goToModifyCommande = () => {
+ if (!commande.value?.id) return;
+ router.push(`/fournisseurs/commandes/new?edit=${commande.value.id}`);
+};
+
+const goToAddWarehouse = () => {
+ router.push("/stock/warehouses/new");
+};
+
onMounted(() => {
fetchCommande();
});
diff --git a/thanasoft-front/src/components/Organism/Commande/NewCommandePresentation.vue b/thanasoft-front/src/components/Organism/Commande/NewCommandePresentation.vue
index 52bebcc..cd538d8 100644
--- a/thanasoft-front/src/components/Organism/Commande/NewCommandePresentation.vue
+++ b/thanasoft-front/src/components/Organism/Commande/NewCommandePresentation.vue
@@ -5,7 +5,7 @@
- | {{ formatDate(m.date || m.created_at) }} |
+
+ {{ formatDate(m.date || m.created_at) }}
+ |
{{ m.type || "—" }}
@@ -25,7 +36,9 @@
|
{{ formatQty(m) }}
|
- {{ m.reference || m.reason || "—" }} |
+
+ {{ m.reference || m.reason || "—" }}
+ |
@@ -43,7 +56,9 @@ defineProps({
const formatDate = (d) => {
if (!d) return "—";
return new Date(d).toLocaleDateString("fr-FR", {
- day: "2-digit", month: "short", year: "numeric",
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
});
};
@@ -57,7 +72,8 @@ const formatQty = (m) => {
const typeClass = (type) => {
if (!type) return "";
const t = type.toLowerCase();
- if (t.includes("entree") || t.includes("entrée") || t.includes("achat")) return "type-in";
+ if (t.includes("entree") || t.includes("entrée") || t.includes("achat"))
+ return "type-in";
if (t.includes("sortie") || t.includes("utilisation")) return "type-out";
return "type-neutral";
};
@@ -78,9 +94,14 @@ const qtyClass = (m) => {
color: #9ca3af;
text-align: center;
}
-.movements-empty p { font-size: 14px; margin: 0; }
+.movements-empty p {
+ font-size: 14px;
+ margin: 0;
+}
-.movements-table-wrap { overflow-x: auto; }
+.movements-table-wrap {
+ overflow-x: auto;
+}
.movements-table {
width: 100%;
@@ -106,11 +127,21 @@ const qtyClass = (m) => {
border-bottom: 1px solid #f9fafb;
vertical-align: middle;
}
-.movements-table tr:last-child td { border-bottom: none; }
-.movements-table tr:hover td { background: #f9fafb; }
+.movements-table tr:last-child td {
+ border-bottom: none;
+}
+.movements-table tr:hover td {
+ background: #f9fafb;
+}
-.movements-table__date { color: #6b7280; font-variant-numeric: tabular-nums; }
-.movements-table__ref { color: #9ca3af; font-size: 12px; }
+.movements-table__date {
+ color: #6b7280;
+ font-variant-numeric: tabular-nums;
+}
+.movements-table__ref {
+ color: #9ca3af;
+ font-size: 12px;
+}
.movements-table__type {
display: inline-block;
@@ -121,11 +152,27 @@ const qtyClass = (m) => {
text-transform: uppercase;
letter-spacing: 0.04em;
}
-.type-in { background: #ecfdf5; color: #065f46; }
-.type-out { background: #fef2f2; color: #991b1b; }
-.type-neutral { background: #f3f4f6; color: #374151; }
+.type-in {
+ background: #ecfdf5;
+ color: #065f46;
+}
+.type-out {
+ background: #fef2f2;
+ color: #991b1b;
+}
+.type-neutral {
+ background: #f3f4f6;
+ color: #374151;
+}
-.movements-table__qty { font-weight: 600; font-variant-numeric: tabular-nums; }
-.qty-positive { color: #059669; }
-.qty-negative { color: #dc2626; }
+.movements-table__qty {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+.qty-positive {
+ color: #059669;
+}
+.qty-negative {
+ color: #dc2626;
+}
diff --git a/thanasoft-front/src/components/molecules/Product/ProductSidebar.vue b/thanasoft-front/src/components/molecules/Product/ProductSidebar.vue
index ce895a4..7f97d7e 100644
--- a/thanasoft-front/src/components/molecules/Product/ProductSidebar.vue
+++ b/thanasoft-front/src/components/molecules/Product/ProductSidebar.vue
@@ -1,10 +1,7 @@
@@ -80,7 +90,8 @@
{{ product.conditionnement?.nom }}
- · {{ product.conditionnement.quantite }} {{ product.conditionnement.unite }}
+ · {{ product.conditionnement.quantite }}
+ {{ product.conditionnement.unite }}
@@ -111,27 +122,32 @@
@@ -146,7 +162,11 @@ const hasConditioning = computed(() =>
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
-.stock-indicators { display: flex; flex-wrap: wrap; gap: 6px; }
+.stock-indicators {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
.cond-row {
display: flex;
align-items: center;
@@ -164,9 +184,16 @@ const hasConditioning = computed(() =>
color: #9ca3af;
min-width: 120px;
}
-.cond-value { font-size: 14px; color: #111827; }
+.cond-value {
+ font-size: 14px;
+ color: #111827;
+}
@media (max-width: 640px) {
- .stock-stats { grid-template-columns: 1fr 1fr; }
- .stock-grid { grid-template-columns: 1fr; }
+ .stock-stats {
+ grid-template-columns: 1fr 1fr;
+ }
+ .stock-grid {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/thanasoft-front/src/components/molecules/Product/ProductSupplierSection.vue b/thanasoft-front/src/components/molecules/Product/ProductSupplierSection.vue
index 37f224d..a838dcb 100644
--- a/thanasoft-front/src/components/molecules/Product/ProductSupplierSection.vue
+++ b/thanasoft-front/src/components/molecules/Product/ProductSupplierSection.vue
@@ -6,18 +6,39 @@
{{ supplier.name || supplier.nom }}
-
{{ supplier.email }}
-
{{ supplier.phone }}
+
+ {{ supplier.email }}
+
+
+ {{ supplier.phone }}
+
-
+
+
+
+
Aucun fournisseur associé
@@ -33,7 +54,12 @@ defineEmits(["view"]);
const initials = computed(() => {
const name = props.supplier?.name || props.supplier?.nom || "";
- return name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase();
+ return name
+ .split(" ")
+ .map((w) => w[0] || "")
+ .join("")
+ .slice(0, 2)
+ .toUpperCase();
});
@@ -60,7 +86,10 @@ const initials = computed(() => {
font-weight: 700;
flex-shrink: 0;
}
-.supplier-card__body { flex: 1; min-width: 0; }
+.supplier-card__body {
+ flex: 1;
+ min-width: 0;
+}
.supplier-card__name {
font-size: 15px;
font-weight: 600;
@@ -90,7 +119,10 @@ const initials = computed(() => {
transition: background 0.12s, border-color 0.12s;
flex-shrink: 0;
}
-.supplier-card__cta:hover { background: #f3f4f6; border-color: #9ca3af; }
+.supplier-card__cta:hover {
+ background: #f3f4f6;
+ border-color: #9ca3af;
+}
.supplier-empty {
display: flex;
diff --git a/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue b/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue
index ae143f4..ad89c56 100644
--- a/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue
+++ b/thanasoft-front/src/components/molecules/Stock/NewReceptionForm.vue
@@ -210,7 +210,7 @@
-
@@ -108,13 +108,7 @@
-
+
{{ product.nom }}
|
@@ -208,15 +202,16 @@
Expire bientôt
-
-
-
- Normal
-
+
+ Stock Normal
+
@@ -466,22 +461,30 @@ onMounted(() => {
.loading-container {
position: relative;
+ min-height: 260px;
}
.loading-spinner {
position: absolute;
- top: 20px;
- right: 20px;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
z-index: 10;
}
+.loading-spinner-circle {
+ width: 2.25rem;
+ height: 2.25rem;
+ border-width: 0.28em;
+}
+
.loading-content {
- opacity: 0.7;
+ opacity: 0.55;
pointer-events: none;
}
.skeleton-row {
- animation: pulse 1.5s ease-in-out infinite;
+ animation: none;
}
.skeleton-checkbox {
@@ -585,11 +588,6 @@ onMounted(() => {
/* Responsive adjustments */
@media (max-width: 768px) {
- .loading-spinner {
- top: 10px;
- right: 10px;
- }
-
.skeleton-text.long {
width: 80px;
}
diff --git a/thanasoft-front/src/components/molecules/intervention/AssignPractitionerModal.vue b/thanasoft-front/src/components/molecules/intervention/AssignPractitionerModal.vue
index ced5bba..d0c9b84 100644
--- a/thanasoft-front/src/components/molecules/intervention/AssignPractitionerModal.vue
+++ b/thanasoft-front/src/components/molecules/intervention/AssignPractitionerModal.vue
@@ -1,241 +1,502 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Chargement...
-
-
Recherche en cours...
-
-
-
-
-