Feat: Stat client et Devis
This commit is contained in:
parent
18071dcae7
commit
050a38c6bd
@ -228,6 +228,28 @@ class ClientController extends Controller
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Get aggregated client statistics.
|
||||||
|
*/
|
||||||
|
public function statistics(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stats = $this->clientRepository->getStatistics();
|
||||||
|
|
||||||
|
return response()->json(['data' => $stats], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching client statistics: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération des statistiques.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change client status (active/inactive).
|
* Change client status (active/inactive).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Repositories\FinancialStatisticsRepositoryInterface;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class FinancialStatisticsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FinancialStatisticsRepositoryInterface $repository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/financial/statistics
|
||||||
|
*
|
||||||
|
* Returns aggregated financial KPIs:
|
||||||
|
* - Chiffre d'affaires mensuel/annuel
|
||||||
|
* - Taux de conversion devis → facture
|
||||||
|
* - Montant moyen par dossier (panier moyen)
|
||||||
|
* - Délai moyen de paiement
|
||||||
|
* - Volume d'avoirs émis
|
||||||
|
* - Créances en cours / relances prioritaires
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stats = $this->repository->getStatistics();
|
||||||
|
|
||||||
|
return response()->json(['data' => $stats], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching financial statistics: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération des statistiques financières.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
thanasoft-back/app/Http/Controllers/Api/LeaveController.php
Normal file
174
thanasoft-back/app/Http/Controllers/Api/LeaveController.php
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreLeaveRequest;
|
||||||
|
use App\Http\Requests\UpdateLeaveRequest;
|
||||||
|
use App\Http\Resources\Employee\LeaveCollection;
|
||||||
|
use App\Http\Resources\Employee\LeaveResource;
|
||||||
|
use App\Repositories\LeaveRepositoryInterface;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class LeaveController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LeaveRepositoryInterface $leaveRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$perPage = (int) $request->get('per_page', 15);
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'employee_id' => $request->get('employee_id'),
|
||||||
|
'type' => $request->get('type'),
|
||||||
|
'status' => $request->get('status'),
|
||||||
|
'start_date' => $request->get('start_date'),
|
||||||
|
'end_date' => $request->get('end_date'),
|
||||||
|
'search' => $request->get('search'),
|
||||||
|
'sort_by' => $request->get('sort_by', 'start_date'),
|
||||||
|
'sort_direction' => $request->get('sort_direction', 'desc'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$filters = array_filter($filters, function ($value) {
|
||||||
|
return $value !== null && $value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
$result = $this->leaveRepository->getPaginated($perPage, $filters);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => new LeaveCollection($result['leaves']),
|
||||||
|
'pagination' => $result['pagination'],
|
||||||
|
'message' => 'Congés récupérés avec succès.',
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching leaves: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération des congés.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreLeaveRequest $request): LeaveResource|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['status'] = $payload['status'] ?? 'pending';
|
||||||
|
|
||||||
|
$leave = $this->leaveRepository->create($payload);
|
||||||
|
|
||||||
|
return new LeaveResource($leave);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error creating leave: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'data' => $request->validated(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la création du congé.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $id): LeaveResource|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$leave = $this->leaveRepository->find($id);
|
||||||
|
|
||||||
|
if (!$leave) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Congé non trouvé.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LeaveResource($leave);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching leave: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'leave_id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la récupération du congé.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateLeaveRequest $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$updated = $this->leaveRepository->update($id, $request->validated());
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Congé non trouvé ou échec de la mise à jour.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$leave = $this->leaveRepository->find($id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => new LeaveResource($leave),
|
||||||
|
'message' => 'Congé mis à jour avec succès.',
|
||||||
|
'status' => 'success',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error updating leave: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'leave_id' => $id,
|
||||||
|
'data' => $request->validated(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la mise à jour du congé.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$deleted = $this->leaveRepository->delete($id);
|
||||||
|
|
||||||
|
if (!$deleted) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Congé non trouvé ou échec de la suppression.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Congé supprimé avec succès.',
|
||||||
|
'status' => 'success',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error deleting leave: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'leave_id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Une erreur est survenue lors de la suppression du congé.',
|
||||||
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
thanasoft-back/app/Http/Requests/StoreLeaveRequest.php
Normal file
49
thanasoft-back/app/Http/Requests/StoreLeaveRequest.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreLeaveRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employee_id' => 'required|exists:employees,id',
|
||||||
|
'type' => ['required', Rule::in(['conge', 'repos', 'feriee'])],
|
||||||
|
'status' => ['nullable', Rule::in(['pending', 'approved', 'rejected', 'cancelled'])],
|
||||||
|
'start_date' => 'required|date',
|
||||||
|
'end_date' => 'required|date|after_or_equal:start_date',
|
||||||
|
'reason' => 'nullable|string',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'approved_by' => 'nullable|exists:users,id',
|
||||||
|
'approved_at' => 'nullable|date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employee_id.required' => 'L\'employé est obligatoire.',
|
||||||
|
'employee_id.exists' => 'L\'employé sélectionné est invalide.',
|
||||||
|
'type.required' => 'Le type de congé est obligatoire.',
|
||||||
|
'type.in' => 'Le type de congé est invalide.',
|
||||||
|
'status.in' => 'Le statut du congé est invalide.',
|
||||||
|
'start_date.required' => 'La date de début est obligatoire.',
|
||||||
|
'start_date.date' => 'La date de début doit être une date valide.',
|
||||||
|
'end_date.required' => 'La date de fin est obligatoire.',
|
||||||
|
'end_date.date' => 'La date de fin doit être une date valide.',
|
||||||
|
'end_date.after_or_equal' => 'La date de fin doit être postérieure ou égale à la date de début.',
|
||||||
|
'reason.string' => 'Le motif doit être une chaîne de caractères.',
|
||||||
|
'notes.string' => 'Les notes doivent être une chaîne de caractères.',
|
||||||
|
'approved_by.exists' => 'L\'approbateur sélectionné est invalide.',
|
||||||
|
'approved_at.date' => 'La date d\'approbation doit être une date valide.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
thanasoft-back/app/Http/Requests/UpdateLeaveRequest.php
Normal file
45
thanasoft-back/app/Http/Requests/UpdateLeaveRequest.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateLeaveRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employee_id' => 'nullable|exists:employees,id',
|
||||||
|
'type' => ['nullable', Rule::in(['conge', 'repos', 'feriee'])],
|
||||||
|
'status' => ['nullable', Rule::in(['pending', 'approved', 'rejected', 'cancelled'])],
|
||||||
|
'start_date' => 'nullable|date',
|
||||||
|
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||||
|
'reason' => 'nullable|string',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'approved_by' => 'nullable|exists:users,id',
|
||||||
|
'approved_at' => 'nullable|date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employee_id.exists' => 'L\'employé sélectionné est invalide.',
|
||||||
|
'type.in' => 'Le type de congé est invalide.',
|
||||||
|
'status.in' => 'Le statut du congé est invalide.',
|
||||||
|
'start_date.date' => 'La date de début doit être une date valide.',
|
||||||
|
'end_date.date' => 'La date de fin doit être une date valide.',
|
||||||
|
'end_date.after_or_equal' => 'La date de fin doit être postérieure ou égale à la date de début.',
|
||||||
|
'reason.string' => 'Le motif doit être une chaîne de caractères.',
|
||||||
|
'notes.string' => 'Les notes doivent être une chaîne de caractères.',
|
||||||
|
'approved_by.exists' => 'L\'approbateur sélectionné est invalide.',
|
||||||
|
'approved_at.date' => 'La date d\'approbation doit être une date valide.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
|
class LeaveCollection extends ResourceCollection
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'data' => $this->collection->map(function ($leave) {
|
||||||
|
return [
|
||||||
|
'id' => $leave->id,
|
||||||
|
'employee_id' => $leave->employee_id,
|
||||||
|
'type' => $leave->type,
|
||||||
|
'status' => $leave->status,
|
||||||
|
'start_date' => $leave->start_date?->format('Y-m-d'),
|
||||||
|
'end_date' => $leave->end_date?->format('Y-m-d'),
|
||||||
|
'reason' => $leave->reason,
|
||||||
|
'notes' => $leave->notes,
|
||||||
|
'approved_by' => $leave->approved_by,
|
||||||
|
'approved_at' => $leave->approved_at?->format('Y-m-d H:i:s'),
|
||||||
|
'created_at' => $leave->created_at?->format('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => $leave->updated_at?->format('Y-m-d H:i:s'),
|
||||||
|
'employee' => $leave->employee ? [
|
||||||
|
'id' => $leave->employee->id,
|
||||||
|
'first_name' => $leave->employee->first_name,
|
||||||
|
'last_name' => $leave->employee->last_name,
|
||||||
|
'full_name' => $leave->employee->full_name,
|
||||||
|
'email' => $leave->employee->email,
|
||||||
|
'job_title' => $leave->employee->job_title,
|
||||||
|
] : null,
|
||||||
|
'approver' => $leave->approver ? [
|
||||||
|
'id' => $leave->approver->id,
|
||||||
|
'name' => $leave->approver->name,
|
||||||
|
'email' => $leave->approver->email,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class LeaveHistoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'leave_id' => $this->leave_id,
|
||||||
|
'old_status' => $this->old_status,
|
||||||
|
'new_status' => $this->new_status,
|
||||||
|
'changed_at' => $this->changed_at?->format('Y-m-d H:i:s'),
|
||||||
|
'comment' => $this->comment,
|
||||||
|
'user' => $this->when(
|
||||||
|
$this->relationLoaded('user') && $this->user,
|
||||||
|
fn () => [
|
||||||
|
'id' => $this->user->id,
|
||||||
|
'name' => $this->user->name,
|
||||||
|
'email' => $this->user->email,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
thanasoft-back/app/Http/Resources/Employee/LeaveResource.php
Normal file
50
thanasoft-back/app/Http/Resources/Employee/LeaveResource.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Employee;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class LeaveResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'employee_id' => $this->employee_id,
|
||||||
|
'type' => $this->type,
|
||||||
|
'status' => $this->status,
|
||||||
|
'start_date' => $this->start_date?->format('Y-m-d'),
|
||||||
|
'end_date' => $this->end_date?->format('Y-m-d'),
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'approved_by' => $this->approved_by,
|
||||||
|
'approved_at' => $this->approved_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'),
|
||||||
|
'employee' => $this->when(
|
||||||
|
$this->relationLoaded('employee') && $this->employee,
|
||||||
|
fn () => [
|
||||||
|
'id' => $this->employee->id,
|
||||||
|
'first_name' => $this->employee->first_name,
|
||||||
|
'last_name' => $this->employee->last_name,
|
||||||
|
'full_name' => $this->employee->full_name,
|
||||||
|
'email' => $this->employee->email,
|
||||||
|
'job_title' => $this->employee->job_title,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
'approver' => $this->when(
|
||||||
|
$this->relationLoaded('approver') && $this->approver,
|
||||||
|
fn () => [
|
||||||
|
'id' => $this->approver->id,
|
||||||
|
'name' => $this->approver->name,
|
||||||
|
'email' => $this->approver->email,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
'histories' => $this->when(
|
||||||
|
$this->relationLoaded('histories'),
|
||||||
|
fn () => LeaveHistoryResource::collection($this->histories)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,6 +58,11 @@ class Employee extends Model
|
|||||||
return $this->hasMany(Vehicle::class, 'primary_user_id');
|
return $this->hasMany(Vehicle::class, 'primary_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function leaves(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Leave::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full name of the employee.
|
* Get the full name of the employee.
|
||||||
*/
|
*/
|
||||||
|
|||||||
58
thanasoft-back/app/Models/Leave.php
Normal file
58
thanasoft-back/app/Models/Leave.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Leave extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'employee_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'reason',
|
||||||
|
'notes',
|
||||||
|
'approved_by',
|
||||||
|
'approved_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'start_date' => 'date',
|
||||||
|
'end_date' => 'date',
|
||||||
|
'approved_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function employee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approver(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'approved_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function histories(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(LeaveHistory::class)->orderByDesc('changed_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
thanasoft-back/app/Models/LeaveHistory.php
Normal file
49
thanasoft-back/app/Models/LeaveHistory.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class LeaveHistory extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'leave_id',
|
||||||
|
'old_status',
|
||||||
|
'new_status',
|
||||||
|
'changed_by',
|
||||||
|
'changed_at',
|
||||||
|
'comment',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'changed_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function leave(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Leave::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'changed_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -61,6 +61,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
return new \App\Repositories\EmployeeRepository($app->make(\App\Models\Employee::class));
|
return new \App\Repositories\EmployeeRepository($app->make(\App\Models\Employee::class));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->app->bind(\App\Repositories\LeaveRepositoryInterface::class, function ($app) {
|
||||||
|
return new \App\Repositories\LeaveRepository($app->make(\App\Models\Leave::class));
|
||||||
|
});
|
||||||
|
|
||||||
$this->app->bind(\App\Repositories\ThanatopractitionerRepositoryInterface::class, function ($app) {
|
$this->app->bind(\App\Repositories\ThanatopractitionerRepositoryInterface::class, function ($app) {
|
||||||
return new \App\Repositories\ThanatopractitionerRepository($app->make(\App\Models\Thanatopractitioner::class));
|
return new \App\Repositories\ThanatopractitionerRepository($app->make(\App\Models\Thanatopractitioner::class));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,8 @@ use App\Repositories\FileRepositoryInterface;
|
|||||||
use App\Repositories\FileRepository;
|
use App\Repositories\FileRepository;
|
||||||
use App\Repositories\WebmailMessageRepository;
|
use App\Repositories\WebmailMessageRepository;
|
||||||
use App\Repositories\WebmailMessageRepositoryInterface;
|
use App\Repositories\WebmailMessageRepositoryInterface;
|
||||||
|
use App\Repositories\FinancialStatisticsRepositoryInterface;
|
||||||
|
use App\Repositories\FinancialStatisticsRepository;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class RepositoryServiceProvider extends ServiceProvider
|
class RepositoryServiceProvider extends ServiceProvider
|
||||||
@ -36,6 +38,7 @@ class RepositoryServiceProvider extends ServiceProvider
|
|||||||
$this->app->bind(\App\Repositories\ProductPackagingRepositoryInterface::class, \App\Repositories\ProductPackagingRepository::class);
|
$this->app->bind(\App\Repositories\ProductPackagingRepositoryInterface::class, \App\Repositories\ProductPackagingRepository::class);
|
||||||
$this->app->bind(\App\Repositories\TvaRateRepositoryInterface::class, \App\Repositories\TvaRateRepository::class);
|
$this->app->bind(\App\Repositories\TvaRateRepositoryInterface::class, \App\Repositories\TvaRateRepository::class);
|
||||||
$this->app->bind(\App\Repositories\GoodsReceiptRepositoryInterface::class, \App\Repositories\GoodsReceiptRepository::class);
|
$this->app->bind(\App\Repositories\GoodsReceiptRepositoryInterface::class, \App\Repositories\GoodsReceiptRepository::class);
|
||||||
|
$this->app->bind(FinancialStatisticsRepositoryInterface::class, FinancialStatisticsRepository::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -7,6 +7,7 @@ namespace App\Repositories;
|
|||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||||
use Illuminate\Support\Facades\Log as LaravelLog;
|
use Illuminate\Support\Facades\Log as LaravelLog;
|
||||||
@ -97,4 +98,105 @@ class ClientRepository extends BaseRepository implements ClientRepositoryInterfa
|
|||||||
|
|
||||||
return $query->get();
|
return $query->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve aggregated statistics about clients.
|
||||||
|
*/
|
||||||
|
public function getStatistics(): array
|
||||||
|
{
|
||||||
|
// 1. Clients actifs vs inactifs
|
||||||
|
$statusCounts = $this->model
|
||||||
|
->selectRaw('is_active, COUNT(*) as total')
|
||||||
|
->groupBy('is_active')
|
||||||
|
->pluck('total', 'is_active');
|
||||||
|
|
||||||
|
$activeCount = (int) ($statusCounts[1] ?? 0);
|
||||||
|
$inactiveCount = (int) ($statusCounts[0] ?? 0);
|
||||||
|
$totalCount = $activeCount + $inactiveCount;
|
||||||
|
|
||||||
|
// 2. Taux de rétention : clients ayant plus d'une intervention (dossier récurrent)
|
||||||
|
$clientsWithMultipleDossiers = DB::table('interventions')
|
||||||
|
->select('client_id')
|
||||||
|
->whereNotNull('client_id')
|
||||||
|
->groupBy('client_id')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->get()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$retentionRate = $totalCount > 0
|
||||||
|
? round(($clientsWithMultipleDossiers / $totalCount) * 100, 2)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
// 3. Délai moyen (jours) entre création du client et premier dossier (première intervention)
|
||||||
|
$avgDelayDays = DB::table('clients')
|
||||||
|
->joinSub(
|
||||||
|
DB::table('interventions')
|
||||||
|
->selectRaw('client_id, MIN(created_at) as first_intervention_at')
|
||||||
|
->whereNotNull('client_id')
|
||||||
|
->groupBy('client_id'),
|
||||||
|
'first_interventions',
|
||||||
|
'clients.id',
|
||||||
|
'=',
|
||||||
|
'first_interventions.client_id'
|
||||||
|
)
|
||||||
|
->selectRaw('AVG(DATEDIFF(first_interventions.first_intervention_at, clients.created_at)) as avg_days')
|
||||||
|
->value('avg_days');
|
||||||
|
|
||||||
|
// 4. Nombre de dossiers (interventions) par client — top 10 grands comptes
|
||||||
|
$dossiersPerClientTop10 = DB::table('interventions')
|
||||||
|
->join('clients', 'interventions.client_id', '=', 'clients.id')
|
||||||
|
->select(
|
||||||
|
'clients.id',
|
||||||
|
'clients.name',
|
||||||
|
DB::raw('COUNT(interventions.id) as total_dossiers')
|
||||||
|
)
|
||||||
|
->whereNotNull('interventions.client_id')
|
||||||
|
->groupBy('clients.id', 'clients.name')
|
||||||
|
->orderByDesc('total_dossiers')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 5. Répartition géographique des clients
|
||||||
|
$geographicDistribution = $this->model
|
||||||
|
->selectRaw('billing_country_code, billing_city, COUNT(*) as total')
|
||||||
|
->whereNotNull('billing_country_code')
|
||||||
|
->groupBy('billing_country_code', 'billing_city')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 6. Groupes les plus représentés
|
||||||
|
$groupDistribution = DB::table('clients')
|
||||||
|
->join('client_groups', 'clients.group_id', '=', 'client_groups.id')
|
||||||
|
->select('client_groups.id', 'client_groups.name', DB::raw('COUNT(clients.id) as total'))
|
||||||
|
->groupBy('client_groups.id', 'client_groups.name')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Catégories les plus représentées
|
||||||
|
$categoryDistribution = DB::table('clients')
|
||||||
|
->join('client_categories', 'clients.client_category_id', '=', 'client_categories.id')
|
||||||
|
->select('client_categories.id', 'client_categories.name', DB::raw('COUNT(clients.id) as total'))
|
||||||
|
->groupBy('client_categories.id', 'client_categories.name')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active_vs_inactive' => [
|
||||||
|
'active' => $activeCount,
|
||||||
|
'inactive' => $inactiveCount,
|
||||||
|
'total' => $totalCount,
|
||||||
|
],
|
||||||
|
'retention' => [
|
||||||
|
'clients_with_recurring_dossiers' => $clientsWithMultipleDossiers,
|
||||||
|
'retention_rate_percentage' => $retentionRate,
|
||||||
|
],
|
||||||
|
'avg_delay_first_contact_to_first_dossier_days' => $avgDelayDays !== null
|
||||||
|
? round((float) $avgDelayDays, 1)
|
||||||
|
: null,
|
||||||
|
'dossiers_per_client_top10' => $dossiersPerClientTop10,
|
||||||
|
'geographic_distribution' => $geographicDistribution,
|
||||||
|
'group_distribution' => $groupDistribution,
|
||||||
|
'category_distribution' => $categoryDistribution,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,4 +9,6 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|||||||
interface ClientRepositoryInterface extends BaseRepositoryInterface
|
interface ClientRepositoryInterface extends BaseRepositoryInterface
|
||||||
{
|
{
|
||||||
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
|
public function paginate(int $perPage = 15, array $filters = []): LengthAwarePaginator;
|
||||||
|
|
||||||
|
public function getStatistics(): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class FinancialStatisticsRepository implements FinancialStatisticsRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Statuses considered as actual revenue (exclude drafts & cancelled).
|
||||||
|
*/
|
||||||
|
private const REVENUE_STATUSES = ['emise', 'envoyee', 'partiellement_payee', 'payee', 'echue'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses representing open receivables (unpaid, overdue).
|
||||||
|
*/
|
||||||
|
private const RECEIVABLE_STATUSES = ['emise', 'envoyee', 'partiellement_payee', 'echue'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avoir statuses that count as effectively emitted.
|
||||||
|
*/
|
||||||
|
private const AVOIR_EMITTED_STATUSES = ['emis', 'applique'];
|
||||||
|
|
||||||
|
public function getStatistics(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'revenue' => $this->revenueStats(),
|
||||||
|
'quote_conversion' => $this->quoteConversionRate(),
|
||||||
|
'avg_amount_per_case' => $this->avgAmountPerCase(),
|
||||||
|
'avg_payment_delay' => $this->avgPaymentDelay(),
|
||||||
|
'avoirs' => $this->avoirsStats(),
|
||||||
|
'receivables' => $this->receivablesStats(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Chiffre d'affaires mensuel & annuel ───────────────────────────────
|
||||||
|
|
||||||
|
private function revenueStats(): array
|
||||||
|
{
|
||||||
|
$currentYear = now()->year;
|
||||||
|
|
||||||
|
// Monthly CA for current year
|
||||||
|
$monthly = DB::table('invoices')
|
||||||
|
->selectRaw('MONTH(invoice_date) as month, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht, COUNT(*) as count')
|
||||||
|
->whereIn('status', self::REVENUE_STATUSES)
|
||||||
|
->whereYear('invoice_date', $currentYear)
|
||||||
|
->groupByRaw('MONTH(invoice_date)')
|
||||||
|
->orderByRaw('MONTH(invoice_date)')
|
||||||
|
->get()
|
||||||
|
->keyBy('month');
|
||||||
|
|
||||||
|
// Build a full 12-month array (0 for missing months)
|
||||||
|
$monthlyFull = [];
|
||||||
|
for ($m = 1; $m <= 12; $m++) {
|
||||||
|
$row = $monthly->get($m);
|
||||||
|
$monthlyFull[] = [
|
||||||
|
'month' => $m,
|
||||||
|
'total_ttc' => $row ? (float) $row->total_ttc : 0.0,
|
||||||
|
'total_ht' => $row ? (float) $row->total_ht : 0.0,
|
||||||
|
'count' => $row ? (int) $row->count : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annual CA — current and previous year for comparison
|
||||||
|
$annualRows = DB::table('invoices')
|
||||||
|
->selectRaw('YEAR(invoice_date) as year, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht, COUNT(*) as count')
|
||||||
|
->whereIn('status', self::REVENUE_STATUSES)
|
||||||
|
->whereYear('invoice_date', '>=', $currentYear - 1)
|
||||||
|
->groupByRaw('YEAR(invoice_date)')
|
||||||
|
->orderByRaw('YEAR(invoice_date)')
|
||||||
|
->get()
|
||||||
|
->keyBy('year');
|
||||||
|
|
||||||
|
$yearCurrent = $annualRows->get($currentYear);
|
||||||
|
$yearPrevious = $annualRows->get($currentYear - 1);
|
||||||
|
|
||||||
|
$annualCurrentTtc = $yearCurrent ? (float) $yearCurrent->total_ttc : 0.0;
|
||||||
|
$annualPreviousTtc = $yearPrevious ? (float) $yearPrevious->total_ttc : 0.0;
|
||||||
|
|
||||||
|
$annualGrowthPct = $annualPreviousTtc > 0
|
||||||
|
? round((($annualCurrentTtc - $annualPreviousTtc) / $annualPreviousTtc) * 100, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'current_year' => $currentYear,
|
||||||
|
'annual_current' => [
|
||||||
|
'total_ttc' => $annualCurrentTtc,
|
||||||
|
'total_ht' => $yearCurrent ? (float) $yearCurrent->total_ht : 0.0,
|
||||||
|
'count' => $yearCurrent ? (int) $yearCurrent->count : 0,
|
||||||
|
],
|
||||||
|
'annual_previous' => [
|
||||||
|
'total_ttc' => $annualPreviousTtc,
|
||||||
|
'total_ht' => $yearPrevious ? (float) $yearPrevious->total_ht : 0.0,
|
||||||
|
'count' => $yearPrevious ? (int) $yearPrevious->count : 0,
|
||||||
|
],
|
||||||
|
'annual_growth_pct' => $annualGrowthPct,
|
||||||
|
'monthly' => $monthlyFull,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Taux de conversion devis → facture ────────────────────────────────
|
||||||
|
|
||||||
|
private function quoteConversionRate(): array
|
||||||
|
{
|
||||||
|
$totalQuotes = (int) DB::table('quotes')
|
||||||
|
->whereNotIn('status', ['annule'])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// A quote is "converted" when at least one invoice references it
|
||||||
|
$convertedQuotes = (int) DB::table('quotes')
|
||||||
|
->join('invoices', 'invoices.source_quote_id', '=', 'quotes.id')
|
||||||
|
->whereNotIn('quotes.status', ['annule'])
|
||||||
|
->distinct('quotes.id')
|
||||||
|
->count('quotes.id');
|
||||||
|
|
||||||
|
$rate = $totalQuotes > 0
|
||||||
|
? round(($convertedQuotes / $totalQuotes) * 100, 2)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
// Breakdown by quote status
|
||||||
|
$byStatus = DB::table('quotes')
|
||||||
|
->selectRaw('status, COUNT(*) as total')
|
||||||
|
->groupBy('status')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_quotes' => $totalQuotes,
|
||||||
|
'converted_quotes' => $convertedQuotes,
|
||||||
|
'conversion_rate' => $rate,
|
||||||
|
'by_status' => $byStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. Montant moyen par dossier (panier moyen) ─────────────────────────
|
||||||
|
|
||||||
|
private function avgAmountPerCase(): array
|
||||||
|
{
|
||||||
|
$row = DB::table('invoices')
|
||||||
|
->selectRaw('AVG(total_ttc) as avg_ttc, AVG(total_ht) as avg_ht, COUNT(*) as total_count, SUM(total_ttc) as sum_ttc')
|
||||||
|
->whereIn('status', self::REVENUE_STATUSES)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg_ttc' => $row && $row->avg_ttc !== null ? round((float) $row->avg_ttc, 2) : null,
|
||||||
|
'avg_ht' => $row && $row->avg_ht !== null ? round((float) $row->avg_ht, 2) : null,
|
||||||
|
'total_count' => $row ? (int) $row->total_count : 0,
|
||||||
|
'total_ttc' => $row ? (float) $row->sum_ttc : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. Délai moyen de paiement ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private function avgPaymentDelay(): array
|
||||||
|
{
|
||||||
|
// Use document_status_history to find the exact moment status became 'payee'
|
||||||
|
$avgFromHistory = DB::table('invoices')
|
||||||
|
->join('document_status_history as dsh', function ($join) {
|
||||||
|
$join->on('dsh.document_id', '=', 'invoices.id')
|
||||||
|
->where('dsh.document_type', '=', 'invoice')
|
||||||
|
->where('dsh.new_status', '=', 'payee');
|
||||||
|
})
|
||||||
|
->selectRaw('AVG(DATEDIFF(dsh.changed_at, invoices.invoice_date)) as avg_days')
|
||||||
|
->where('invoices.status', 'payee')
|
||||||
|
->value('avg_days');
|
||||||
|
|
||||||
|
// Fallback: avg delay between invoice_date and due_date for paid invoices
|
||||||
|
$avgFromDueDate = DB::table('invoices')
|
||||||
|
->selectRaw('AVG(DATEDIFF(due_date, invoice_date)) as avg_days')
|
||||||
|
->where('status', 'payee')
|
||||||
|
->whereNotNull('due_date')
|
||||||
|
->value('avg_days');
|
||||||
|
|
||||||
|
// Count overdue invoices (echue = past due_date, still unpaid)
|
||||||
|
$overdueCount = (int) DB::table('invoices')
|
||||||
|
->where('status', 'echue')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$overdueTotal = (float) DB::table('invoices')
|
||||||
|
->where('status', 'echue')
|
||||||
|
->sum('total_ttc');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avg_days_to_payment' => $avgFromHistory !== null
|
||||||
|
? round((float) $avgFromHistory, 1)
|
||||||
|
: ($avgFromDueDate !== null ? round((float) $avgFromDueDate, 1) : null),
|
||||||
|
'avg_days_invoice_to_due' => $avgFromDueDate !== null
|
||||||
|
? round((float) $avgFromDueDate, 1)
|
||||||
|
: null,
|
||||||
|
'overdue_invoices_count' => $overdueCount,
|
||||||
|
'overdue_invoices_total_ttc' => $overdueTotal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Volume d'avoirs émis ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function avoirsStats(): array
|
||||||
|
{
|
||||||
|
$totals = DB::table('avoirs')
|
||||||
|
->selectRaw('COUNT(*) as count, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht')
|
||||||
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Breakdown by reason type
|
||||||
|
$byReason = DB::table('avoirs')
|
||||||
|
->selectRaw('reason_type, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
||||||
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
||||||
|
->groupBy('reason_type')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Monthly trend (current year)
|
||||||
|
$monthlyTrend = DB::table('avoirs')
|
||||||
|
->selectRaw('MONTH(avoir_date) as month, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
||||||
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
||||||
|
->whereYear('avoir_date', now()->year)
|
||||||
|
->groupByRaw('MONTH(avoir_date)')
|
||||||
|
->orderByRaw('MONTH(avoir_date)')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_count' => $totals ? (int) $totals->count : 0,
|
||||||
|
'total_ttc' => $totals ? (float) $totals->total_ttc : 0.0,
|
||||||
|
'total_ht' => $totals ? (float) $totals->total_ht : 0.0,
|
||||||
|
'by_reason' => $byReason,
|
||||||
|
'monthly_trend' => $monthlyTrend,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 6. Créances en cours (relances prioritaires) ─────────────────────────
|
||||||
|
|
||||||
|
private function receivablesStats(): array
|
||||||
|
{
|
||||||
|
// Global totals
|
||||||
|
$totals = DB::table('invoices')
|
||||||
|
->selectRaw('COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
||||||
|
->whereIn('status', self::RECEIVABLE_STATUSES)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Breakdown by status
|
||||||
|
$byStatus = DB::table('invoices')
|
||||||
|
->selectRaw('status, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
||||||
|
->whereIn('status', self::RECEIVABLE_STATUSES)
|
||||||
|
->groupBy('status')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Top 10 clients with highest outstanding balance
|
||||||
|
$topDebtors = DB::table('invoices')
|
||||||
|
->join('clients', 'invoices.client_id', '=', 'clients.id')
|
||||||
|
->select(
|
||||||
|
'clients.id',
|
||||||
|
'clients.name',
|
||||||
|
DB::raw('COUNT(invoices.id) as invoice_count'),
|
||||||
|
DB::raw('SUM(invoices.total_ttc) as total_outstanding')
|
||||||
|
)
|
||||||
|
->whereIn('invoices.status', self::RECEIVABLE_STATUSES)
|
||||||
|
->groupBy('clients.id', 'clients.name')
|
||||||
|
->orderByDesc('total_outstanding')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Overdue (echue) + near-due (due within 7 days) detail
|
||||||
|
$criticalInvoices = DB::table('invoices')
|
||||||
|
->join('clients', 'invoices.client_id', '=', 'clients.id')
|
||||||
|
->select(
|
||||||
|
'invoices.id',
|
||||||
|
'invoices.invoice_number',
|
||||||
|
'invoices.status',
|
||||||
|
'invoices.due_date',
|
||||||
|
'invoices.total_ttc',
|
||||||
|
'clients.name as client_name',
|
||||||
|
'clients.id as client_id',
|
||||||
|
DB::raw('DATEDIFF(NOW(), invoices.due_date) as days_overdue')
|
||||||
|
)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('invoices.status', 'echue')
|
||||||
|
->orWhere(function ($q2) {
|
||||||
|
$q2->whereIn('invoices.status', ['emise', 'envoyee', 'partiellement_payee'])
|
||||||
|
->whereRaw('invoices.due_date <= DATE_ADD(NOW(), INTERVAL 7 DAY)')
|
||||||
|
->whereNotNull('invoices.due_date');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByRaw('invoices.due_date ASC')
|
||||||
|
->limit(20)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_count' => $totals ? (int) $totals->count : 0,
|
||||||
|
'total_outstanding' => $totals ? (float) $totals->total_ttc : 0.0,
|
||||||
|
'by_status' => $byStatus,
|
||||||
|
'top_debtors' => $topDebtors,
|
||||||
|
'critical_invoices' => $criticalInvoices,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
interface FinancialStatisticsRepositoryInterface
|
||||||
|
{
|
||||||
|
public function getStatistics(): array;
|
||||||
|
}
|
||||||
185
thanasoft-back/app/Repositories/LeaveRepository.php
Normal file
185
thanasoft-back/app/Repositories/LeaveRepository.php
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use App\Models\Leave;
|
||||||
|
use App\Models\LeaveHistory;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class LeaveRepository extends BaseRepository implements LeaveRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(Leave $model)
|
||||||
|
{
|
||||||
|
parent::__construct($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(array $columns = ['*']): Collection
|
||||||
|
{
|
||||||
|
return $this->model->newQuery()
|
||||||
|
->with(['employee', 'approver', 'histories.user'])
|
||||||
|
->orderByDesc('start_date')
|
||||||
|
->get($columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int|string $id, array $columns = ['*']): ?Model
|
||||||
|
{
|
||||||
|
return $this->model->newQuery()
|
||||||
|
->with(['employee', 'approver', 'histories.user'])
|
||||||
|
->find($id, $columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $attributes): Model
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
/** @var Leave $leave */
|
||||||
|
$leave = $this->model->newQuery()->create($attributes);
|
||||||
|
|
||||||
|
$this->recordHistory(
|
||||||
|
$leave,
|
||||||
|
null,
|
||||||
|
$leave->status,
|
||||||
|
$attributes['approved_by'] ?? null,
|
||||||
|
$attributes['notes'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return $leave->load(['employee', 'approver', 'histories.user']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Error creating leave: ' . $e->getMessage(), [
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int|string $id, array $attributes): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
/** @var Leave|null $leave */
|
||||||
|
$leave = $this->model->newQuery()->find($id);
|
||||||
|
if (!$leave) {
|
||||||
|
DB::rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldStatus = $leave->status;
|
||||||
|
$result = $leave->fill($attributes)->save();
|
||||||
|
|
||||||
|
if (array_key_exists('status', $attributes) && $attributes['status'] !== $oldStatus) {
|
||||||
|
$this->recordHistory(
|
||||||
|
$leave,
|
||||||
|
$oldStatus,
|
||||||
|
$leave->status,
|
||||||
|
$attributes['approved_by'] ?? $leave->approved_by,
|
||||||
|
$attributes['notes'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Error updating leave with ID ' . $id . ': ' . $e->getMessage(), [
|
||||||
|
'id' => $id,
|
||||||
|
'attributes' => $attributes,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPaginated(int $perPage = 10, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $this->model->newQuery()->with(['employee', 'approver', 'histories.user']);
|
||||||
|
|
||||||
|
if (!empty($filters['employee_id'])) {
|
||||||
|
$query->where('employee_id', (int) $filters['employee_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['type'])) {
|
||||||
|
$query->where('type', $filters['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['start_date'])) {
|
||||||
|
$query->whereDate('start_date', '>=', $filters['start_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['end_date'])) {
|
||||||
|
$query->whereDate('end_date', '<=', $filters['end_date']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = (string) $filters['search'];
|
||||||
|
$query->where(function ($subQuery) use ($search) {
|
||||||
|
$subQuery->where('reason', 'like', '%' . $search . '%')
|
||||||
|
->orWhere('notes', 'like', '%' . $search . '%')
|
||||||
|
->orWhereHas('employee', function ($employeeQuery) use ($search) {
|
||||||
|
$employeeQuery->where('first_name', 'like', '%' . $search . '%')
|
||||||
|
->orWhere('last_name', 'like', '%' . $search . '%')
|
||||||
|
->orWhere('email', 'like', '%' . $search . '%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortField = $filters['sort_by'] ?? 'start_date';
|
||||||
|
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||||
|
|
||||||
|
$allowedSortFields = ['start_date', 'end_date', 'status', 'type', 'created_at'];
|
||||||
|
if (!in_array($sortField, $allowedSortFields, true)) {
|
||||||
|
$sortField = 'start_date';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortDirection = strtolower((string) $sortDirection) === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
$paginator = $query
|
||||||
|
->orderBy($sortField, $sortDirection)
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'leaves' => $paginator->getCollection(),
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'from' => $paginator->firstItem(),
|
||||||
|
'to' => $paginator->lastItem(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordHistory(
|
||||||
|
Leave $leave,
|
||||||
|
?string $oldStatus,
|
||||||
|
string $newStatus,
|
||||||
|
int|string|null $changedBy = null,
|
||||||
|
?string $comment = null
|
||||||
|
): void {
|
||||||
|
LeaveHistory::query()->create([
|
||||||
|
'leave_id' => $leave->id,
|
||||||
|
'old_status' => $oldStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
'changed_by' => $changedBy,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'comment' => $comment,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
thanasoft-back/app/Repositories/LeaveRepositoryInterface.php
Normal file
17
thanasoft-back/app/Repositories/LeaveRepositoryInterface.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
interface LeaveRepositoryInterface extends BaseRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get leaves with pagination and optional filtering.
|
||||||
|
*
|
||||||
|
* @param int $perPage
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{leaves: \Illuminate\Support\Collection<int, \App\Models\Leave>, pagination: array<string, int|null>}
|
||||||
|
*/
|
||||||
|
public function getPaginated(int $perPage = 10, array $filters = []): array;
|
||||||
|
}
|
||||||
1508
thanasoft-back/composer.lock
generated
1508
thanasoft-back/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ return [
|
|||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'prefix_indexes' => true,
|
'prefix_indexes' => true,
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => env('DB_ENGINE', 'InnoDB'),
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
@ -77,7 +77,7 @@ return [
|
|||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'prefix_indexes' => true,
|
'prefix_indexes' => true,
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => env('DB_ENGINE', 'InnoDB'),
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
|
|||||||
@ -14,7 +14,13 @@ return new class extends Migration
|
|||||||
Schema::table('contacts', function (Blueprint $table) {
|
Schema::table('contacts', function (Blueprint $table) {
|
||||||
// Make client_id nullable and remove cascade
|
// Make client_id nullable and remove cascade
|
||||||
$table->dropForeign(['client_id']);
|
$table->dropForeign(['client_id']);
|
||||||
$table->foreignId('client_id')->nullable()->change();
|
});
|
||||||
|
|
||||||
|
Schema::table('contacts', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('client_id')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('contacts', function (Blueprint $table) {
|
||||||
$table->foreign('client_id')->references('id')->on('clients')->onDelete('set null');
|
$table->foreign('client_id')->references('id')->on('clients')->onDelete('set null');
|
||||||
|
|
||||||
// Add fournisseur_id
|
// Add fournisseur_id
|
||||||
@ -34,7 +40,13 @@ return new class extends Migration
|
|||||||
|
|
||||||
// Restore client_id to not nullable with cascade
|
// Restore client_id to not nullable with cascade
|
||||||
$table->dropForeign(['client_id']);
|
$table->dropForeign(['client_id']);
|
||||||
$table->foreignId('client_id')->change();
|
});
|
||||||
|
|
||||||
|
Schema::table('contacts', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('client_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('contacts', function (Blueprint $table) {
|
||||||
$table->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
|
$table->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?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::create('leaves', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->enum('type', ['conge', 'repos', 'feriee']);
|
||||||
|
$table->enum('status', ['pending', 'approved', 'rejected', 'cancelled'])->default('pending');
|
||||||
|
$table->date('start_date');
|
||||||
|
$table->date('end_date');
|
||||||
|
$table->text('reason')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('approved_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['employee_id', 'status']);
|
||||||
|
$table->index(['start_date', 'end_date']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('leaves');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?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::create('leave_histories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('leave_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('old_status', 32)->nullable();
|
||||||
|
$table->string('new_status', 32);
|
||||||
|
$table->foreignId('changed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('changed_at')->useCurrent();
|
||||||
|
$table->text('comment')->nullable();
|
||||||
|
|
||||||
|
$table->index(['leave_id', 'changed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('leave_histories');
|
||||||
|
}
|
||||||
|
};
|
||||||
72
thanasoft-back/database/seeders/AvoirSeeder.php
Normal file
72
thanasoft-back/database/seeders/AvoirSeeder.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Avoir;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class AvoirSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const REASON_TYPES = [
|
||||||
|
'remboursement_total',
|
||||||
|
'remboursement_partiel',
|
||||||
|
'reduction',
|
||||||
|
'erreur_facturation',
|
||||||
|
'retour_marchandise',
|
||||||
|
'accord_commercial',
|
||||||
|
'autre',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Pick paid/emitted invoices to credit
|
||||||
|
$invoices = Invoice::whereIn('status', ['payee', 'emise', 'envoyee'])
|
||||||
|
->orderByRaw('RAND()')
|
||||||
|
->limit(22)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($invoices->isEmpty()) {
|
||||||
|
$this->command->warn('AvoirSeeder: aucune facture éligible trouvée — skip.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status pool: mostly emis / some applique
|
||||||
|
$statusPool = array_merge(
|
||||||
|
array_fill(0, 13, 'emis'),
|
||||||
|
array_fill(0, 9, 'applique')
|
||||||
|
);
|
||||||
|
shuffle($statusPool);
|
||||||
|
|
||||||
|
foreach ($invoices as $idx => $invoice) {
|
||||||
|
$reason = self::REASON_TYPES[array_rand(self::REASON_TYPES)];
|
||||||
|
$status = $statusPool[$idx % count($statusPool)];
|
||||||
|
$avoirDate = $invoice->invoice_date
|
||||||
|
->copy()
|
||||||
|
->addDays(rand(5, 60))
|
||||||
|
->format('Y-m-d');
|
||||||
|
|
||||||
|
// Partial or total credit
|
||||||
|
$fraction = ($reason === 'remboursement_total') ? 1.0 : round(rand(20, 80) / 100, 2);
|
||||||
|
$totalHt = round((float) $invoice->total_ht * $fraction, 2);
|
||||||
|
$totalTva = round($totalHt * 0.20, 2);
|
||||||
|
$totalTtc = round($totalHt + $totalTva, 2);
|
||||||
|
|
||||||
|
Avoir::create([
|
||||||
|
'client_id' => $invoice->client_id,
|
||||||
|
'invoice_id' => $invoice->id,
|
||||||
|
'status' => $status,
|
||||||
|
'avoir_date' => $avoirDate,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'total_ht' => $totalHt,
|
||||||
|
'total_tva' => $totalTva,
|
||||||
|
'total_ttc' => $totalTtc,
|
||||||
|
'reason_type' => $reason,
|
||||||
|
'reason_description' => null,
|
||||||
|
'refund_status' => 'non_rembourse',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('AvoirSeeder: ' . $invoices->count() . ' avoirs créés.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,5 +25,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
$this->call(ContactSeeder::class);
|
$this->call(ContactSeeder::class);
|
||||||
$this->call(DeceasedSeeder::class);
|
$this->call(DeceasedSeeder::class);
|
||||||
$this->call(InterventionSeeder::class);
|
$this->call(InterventionSeeder::class);
|
||||||
|
|
||||||
|
// ── Financial data ────────────────────────────────────────────────────
|
||||||
|
$this->call(QuoteSeeder::class);
|
||||||
|
$this->call(InvoiceSeeder::class);
|
||||||
|
$this->call(AvoirSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
thanasoft-back/database/seeders/InvoiceSeeder.php
Normal file
152
thanasoft-back/database/seeders/InvoiceSeeder.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\InvoiceLine;
|
||||||
|
use App\Models\Quote;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class InvoiceSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$clientIds = Client::pluck('id')->toArray();
|
||||||
|
$products = DB::table('products')->select('id', 'prix_unitaire')->get()->keyBy('id');
|
||||||
|
$productIds = $products->keys()->toArray();
|
||||||
|
|
||||||
|
if (empty($clientIds) || empty($productIds)) {
|
||||||
|
$this->command->warn('InvoiceSeeder: aucun client ou produit trouvé — skip.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Invoices liées à des devis acceptés ────────────────────────────
|
||||||
|
$acceptedQuotes = Quote::where('status', 'accepte')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(35)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($acceptedQuotes as $quote) {
|
||||||
|
$invoiceDate = $quote->quote_date->copy()->addDays(rand(2, 10))->format('Y-m-d');
|
||||||
|
$dueDate = date('Y-m-d', strtotime($invoiceDate . ' +30 days'));
|
||||||
|
|
||||||
|
// Statuses for quote-linked invoices (mostly paid)
|
||||||
|
$s = ['payee', 'payee', 'payee', 'emise', 'envoyee', 'partiellement_payee'];
|
||||||
|
|
||||||
|
Invoice::create([
|
||||||
|
'client_id' => $quote->client_id,
|
||||||
|
'source_quote_id' => $quote->id,
|
||||||
|
'status' => $s[array_rand($s)],
|
||||||
|
'invoice_date' => $invoiceDate,
|
||||||
|
'due_date' => $dueDate,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'total_ht' => $quote->total_ht,
|
||||||
|
'total_tva' => $quote->total_tva,
|
||||||
|
'total_ttc' => $quote->total_ttc,
|
||||||
|
]);
|
||||||
|
// Lines are not duplicated — source quote already has them
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Invoices autonomes ─────────────────────────────────────────────
|
||||||
|
// 2024 historical — statuses varied, many paid
|
||||||
|
$this->createBatch($clientIds, $productIds, $products, [
|
||||||
|
'yearRange' => [[2024, 1, 12]],
|
||||||
|
'countPerMonth' => [3, 5],
|
||||||
|
'statusPool' => ['payee', 'payee', 'payee', 'emise', 'envoyee', 'partiellement_payee', 'echue'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2025 main year — rich dataset
|
||||||
|
$this->createBatch($clientIds, $productIds, $products, [
|
||||||
|
'yearRange' => [[2025, 1, 12]],
|
||||||
|
'countPerMonth' => [4, 7],
|
||||||
|
'statusPool' => ['payee', 'payee', 'payee', 'emise', 'envoyee', 'partiellement_payee', 'echue', 'echue'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2026 Jan-Apr — no echue (due dates not yet past)
|
||||||
|
$this->createBatch($clientIds, $productIds, $products, [
|
||||||
|
'yearRange' => [[2026, 1, 4]],
|
||||||
|
'countPerMonth' => [4, 6],
|
||||||
|
'statusPool' => ['payee', 'payee', 'emise', 'envoyee', 'partiellement_payee'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2026 May 1-8 — recent, mostly emise
|
||||||
|
$this->createBatch($clientIds, $productIds, $products, [
|
||||||
|
'yearRange' => [[2026, 5, 5]],
|
||||||
|
'countPerMonth' => [4, 6],
|
||||||
|
'maxDay' => 8,
|
||||||
|
'statusPool' => ['payee', 'emise', 'emise', 'envoyee'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$total = Invoice::count();
|
||||||
|
$this->command->info("InvoiceSeeder: {$total} factures au total.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function createBatch(
|
||||||
|
array $clientIds,
|
||||||
|
array $productIds,
|
||||||
|
\Illuminate\Support\Collection $products,
|
||||||
|
array $opts
|
||||||
|
): void {
|
||||||
|
foreach ($opts['yearRange'] as [$year, $startM, $endM]) {
|
||||||
|
for ($m = $startM; $m <= $endM; $m++) {
|
||||||
|
$maxDay = $opts['maxDay'] ?? 28;
|
||||||
|
$count = rand(...$opts['countPerMonth']);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$invoiceDate = sprintf('%04d-%02d-%02d', $year, $m, rand(1, $maxDay));
|
||||||
|
$status = $opts['statusPool'][array_rand($opts['statusPool'])];
|
||||||
|
|
||||||
|
// due_date
|
||||||
|
if ($status === 'echue') {
|
||||||
|
$dueDate = date('Y-m-d', strtotime($invoiceDate . ' +10 days'));
|
||||||
|
} else {
|
||||||
|
$dueDate = date('Y-m-d', strtotime($invoiceDate . ' +30 days'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = $clientIds[array_rand($clientIds)];
|
||||||
|
|
||||||
|
// Build lines
|
||||||
|
$lineData = [];
|
||||||
|
$totalHt = 0.0;
|
||||||
|
for ($l = 0, $nl = rand(1, 3); $l < $nl; $l++) {
|
||||||
|
$pid = $productIds[array_rand($productIds)];
|
||||||
|
$unitPrice = (float) ($products->get($pid)->prix_unitaire ?? 150.00);
|
||||||
|
$qty = rand(1, 3);
|
||||||
|
$lineHt = round($unitPrice * $qty, 2);
|
||||||
|
$totalHt += $lineHt;
|
||||||
|
$lineData[] = [
|
||||||
|
'product_id' => $pid,
|
||||||
|
'description' => 'Prestation funéraire',
|
||||||
|
'qty_base' => $qty,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'discount_pct' => 0,
|
||||||
|
'total_ht' => $lineHt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalTva = round($totalHt * 0.20, 2);
|
||||||
|
$totalTtc = round($totalHt + $totalTva, 2);
|
||||||
|
|
||||||
|
$invoice = Invoice::create([
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'status' => $status,
|
||||||
|
'invoice_date' => $invoiceDate,
|
||||||
|
'due_date' => $dueDate,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'total_ht' => $totalHt,
|
||||||
|
'total_tva' => $totalTva,
|
||||||
|
'total_ttc' => $totalTtc,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($lineData as $line) {
|
||||||
|
InvoiceLine::create(array_merge($line, ['invoice_id' => $invoice->id]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
thanasoft-back/database/seeders/QuoteSeeder.php
Normal file
105
thanasoft-back/database/seeders/QuoteSeeder.php
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Quote;
|
||||||
|
use App\Models\QuoteLine;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class QuoteSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$clientIds = Client::pluck('id')->toArray();
|
||||||
|
$products = DB::table('products')->select('id', 'prix_unitaire')->get()->keyBy('id');
|
||||||
|
$productIds = $products->keys()->toArray();
|
||||||
|
|
||||||
|
if (empty($clientIds) || empty($productIds)) {
|
||||||
|
$this->command->warn('QuoteSeeder: aucun client ou produit trouvé — skip.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Statuses ──────────────────────────────────────────────────────────
|
||||||
|
$statusPool = array_merge(
|
||||||
|
array_fill(0, 40, 'accepte'),
|
||||||
|
array_fill(0, 18, 'envoye'),
|
||||||
|
array_fill(0, 8, 'brouillon'),
|
||||||
|
array_fill(0, 10, 'refuse'),
|
||||||
|
array_fill(0, 7, 'expire')
|
||||||
|
);
|
||||||
|
shuffle($statusPool);
|
||||||
|
|
||||||
|
// ── Date entries : 2024 + 2025 + 2026 Jan-May ────────────────────────
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
// 2024 (historical)
|
||||||
|
for ($m = 1; $m <= 12; $m++) {
|
||||||
|
for ($i = 0, $n = rand(2, 4); $i < $n; $i++) {
|
||||||
|
$entries[] = sprintf('2024-%02d-%02d', $m, rand(1, 28));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2025
|
||||||
|
for ($m = 1; $m <= 12; $m++) {
|
||||||
|
for ($i = 0, $n = rand(3, 5); $i < $n; $i++) {
|
||||||
|
$entries[] = sprintf('2025-%02d-%02d', $m, rand(1, 28));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2026 Jan-May
|
||||||
|
for ($m = 1; $m <= 5; $m++) {
|
||||||
|
$maxDay = ($m === 5) ? 8 : 28;
|
||||||
|
for ($i = 0, $n = rand(3, 5); $i < $n; $i++) {
|
||||||
|
$entries[] = sprintf('2026-%02d-%02d', $m, rand(1, $maxDay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shuffle($entries);
|
||||||
|
|
||||||
|
// ── Create quotes ─────────────────────────────────────────────────────
|
||||||
|
foreach ($entries as $idx => $quoteDate) {
|
||||||
|
$status = $statusPool[$idx % count($statusPool)];
|
||||||
|
$clientId = $clientIds[array_rand($clientIds)];
|
||||||
|
$validUntil = date('Y-m-d', strtotime($quoteDate . ' +30 days'));
|
||||||
|
|
||||||
|
// Build lines
|
||||||
|
$lineData = [];
|
||||||
|
$totalHt = 0.0;
|
||||||
|
for ($l = 0, $nl = rand(1, 3); $l < $nl; $l++) {
|
||||||
|
$pid = $productIds[array_rand($productIds)];
|
||||||
|
$unitPrice = (float) ($products->get($pid)->prix_unitaire ?? 100.00);
|
||||||
|
$qty = rand(1, 3);
|
||||||
|
$lineHt = round($unitPrice * $qty, 2);
|
||||||
|
$totalHt += $lineHt;
|
||||||
|
$lineData[] = [
|
||||||
|
'product_id' => $pid,
|
||||||
|
'description' => 'Prestation funéraire',
|
||||||
|
'qty_base' => $qty,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'discount_pct' => 0,
|
||||||
|
'total_ht' => $lineHt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalTva = round($totalHt * 0.20, 2);
|
||||||
|
$totalTtc = round($totalHt + $totalTva, 2);
|
||||||
|
|
||||||
|
$quote = Quote::create([
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'status' => $status,
|
||||||
|
'quote_date' => $quoteDate,
|
||||||
|
'valid_until' => $validUntil,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'total_ht' => $totalHt,
|
||||||
|
'total_tva' => $totalTva,
|
||||||
|
'total_ttc' => $totalTtc,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($lineData as $line) {
|
||||||
|
QuoteLine::create(array_merge($line, ['quote_id' => $quote->id]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('QuoteSeeder: ' . count($entries) . ' devis créés.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,7 +28,9 @@ use App\Http\Controllers\Api\GoodsReceiptController;
|
|||||||
use App\Http\Controllers\Api\UserController;
|
use App\Http\Controllers\Api\UserController;
|
||||||
use App\Http\Controllers\Api\VehicleController;
|
use App\Http\Controllers\Api\VehicleController;
|
||||||
use App\Http\Controllers\Api\ConvoyController;
|
use App\Http\Controllers\Api\ConvoyController;
|
||||||
|
use App\Http\Controllers\Api\LeaveController;
|
||||||
use App\Http\Controllers\Api\WebmailController;
|
use App\Http\Controllers\Api\WebmailController;
|
||||||
|
use App\Http\Controllers\Api\FinancialStatisticsController;
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -61,6 +63,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
// Client management
|
// Client management
|
||||||
// IMPORTANT: Specific routes must come before apiResource
|
// IMPORTANT: Specific routes must come before apiResource
|
||||||
Route::get('/clients/searchBy', [ClientController::class, 'searchBy']);
|
Route::get('/clients/searchBy', [ClientController::class, 'searchBy']);
|
||||||
|
Route::get('/clients/statistics', [ClientController::class, 'statistics']);
|
||||||
|
Route::get('/financial/statistics', [FinancialStatisticsController::class, 'index']);
|
||||||
|
|
||||||
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']);
|
||||||
@ -166,6 +170,7 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::get('/employees/thanatopractitioners', [EmployeeController::class, 'getThanatopractitioners']);
|
Route::get('/employees/thanatopractitioners', [EmployeeController::class, 'getThanatopractitioners']);
|
||||||
Route::get('/employees/{id}/agenda', [EmployeeController::class, 'agenda']);
|
Route::get('/employees/{id}/agenda', [EmployeeController::class, 'agenda']);
|
||||||
Route::apiResource('employees', EmployeeController::class);
|
Route::apiResource('employees', EmployeeController::class);
|
||||||
|
Route::apiResource('leaves', LeaveController::class);
|
||||||
|
|
||||||
// Thanatopractitioner management
|
// Thanatopractitioner management
|
||||||
Route::get('/thanatopractitioners/search', [ThanatopractitionerController::class, 'searchByEmployeeName']);
|
Route::get('/thanatopractitioners/search', [ThanatopractitionerController::class, 'searchByEmployeeName']);
|
||||||
|
|||||||
148
thanasoft-front/src/components/Atom/Stats/StatKpiCard.vue
Normal file
148
thanasoft-front/src/components/Atom/Stats/StatKpiCard.vue
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<!-- Header: label + value + optional trend badge -->
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">{{ label }}</p>
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<h5 class="mb-0 font-weight-bolder" v-html="headerHtml" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body: mini gradient chart OR subtitle fallback -->
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div v-if="chartId" class="chart">
|
||||||
|
<canvas :id="chartId" class="chart-canvas" :height="chartHeight" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="px-3 pb-2 pt-1">
|
||||||
|
<p class="mb-0 text-xs text-secondary">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
|
||||||
|
export interface ChartDataset {
|
||||||
|
label?: string;
|
||||||
|
data: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiniChartData {
|
||||||
|
labels: string[];
|
||||||
|
datasets: ChartDataset[];
|
||||||
|
color?: string; // hex or CSS color for the line
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "StatKpiCard",
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** e.g. "+12%" — displayed in green; prefix with "-" for red */
|
||||||
|
trend: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/** Unique canvas id — when provided a Chart.js sparkline is rendered */
|
||||||
|
chartId: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
chartHeight: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
type: Object as PropType<MiniChartData>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerHtml(): string {
|
||||||
|
const val = this.suffix ? `${this.value}${this.suffix}` : String(this.value);
|
||||||
|
if (!this.trend) return val;
|
||||||
|
const isNeg = this.trend.startsWith("-");
|
||||||
|
const cls = isNeg ? "text-danger" : "text-success";
|
||||||
|
return `${val}<span class="text-sm ${cls} font-weight-bolder"> ${this.trend}</span>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.chartId && this.chart) {
|
||||||
|
this.renderChart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
renderChart() {
|
||||||
|
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const lineColor = this.chart.color ?? "#cb0c9f";
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, 230, 0, 50);
|
||||||
|
gradient.addColorStop(1, "rgba(203,12,159,0.02)");
|
||||||
|
gradient.addColorStop(0.2, "rgba(72,72,176,0.0)");
|
||||||
|
gradient.addColorStop(0, "rgba(203,12,159,0)");
|
||||||
|
|
||||||
|
const existing = Chart.getChart(this.chartId);
|
||||||
|
if (existing) existing.destroy();
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: this.chart.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: this.chart.datasets[0]?.label ?? "",
|
||||||
|
tension: 0.5,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderColor: lineColor,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
data: this.chart.datasets[0]?.data ?? [],
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
drawBorder: false,
|
||||||
|
display: false,
|
||||||
|
drawOnChartArea: true,
|
||||||
|
drawTicks: false,
|
||||||
|
},
|
||||||
|
ticks: { display: false },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { drawBorder: false, display: false, drawTicks: false },
|
||||||
|
ticks: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="w-100 me-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="text-sm text-dark font-weight-bold">{{ label }}</span>
|
||||||
|
<span class="text-sm text-secondary">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 6px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:class="`bg-gradient-${color}`"
|
||||||
|
role="progressbar"
|
||||||
|
:style="{ width: `${percentage}%` }"
|
||||||
|
:aria-valuenow="percentage"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-weight-bold" :class="`text-${color}`">
|
||||||
|
{{ percentage }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "StatProgressRow",
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "info",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
percentage(): number {
|
||||||
|
if (this.total === 0) return 0;
|
||||||
|
return Math.round((this.count / this.total) * 100);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
37
thanasoft-front/src/components/Atom/Stats/StatRankRow.vue
Normal file
37
thanasoft-front/src/components/Atom/Stats/StatRankRow.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td class="text-sm font-weight-bold ps-3">{{ rank }}</td>
|
||||||
|
<td class="text-sm">{{ name }}</td>
|
||||||
|
<td class="text-sm text-end pe-3">
|
||||||
|
<span class="badge badge-sm" :class="`bg-gradient-${color}`">
|
||||||
|
{{ count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "StatRankRow",
|
||||||
|
props: {
|
||||||
|
rank: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "dark",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<h6 class="mb-0">Clients actifs vs inactifs</h6>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<div class="row text-center mb-3">
|
||||||
|
<div class="col-6 border-end">
|
||||||
|
<h3 class="mb-0 font-weight-bolder text-success">{{ active }}</h3>
|
||||||
|
<p class="mb-0 text-sm text-secondary">Actifs</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h3 class="mb-0 font-weight-bolder text-danger">{{ inactive }}</h3>
|
||||||
|
<p class="mb-0 text-sm text-secondary">Inactifs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<stat-progress-row
|
||||||
|
label="Actifs"
|
||||||
|
:count="active"
|
||||||
|
:total="total"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<stat-progress-row
|
||||||
|
label="Inactifs"
|
||||||
|
:count="inactive"
|
||||||
|
:total="total"
|
||||||
|
color="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import StatProgressRow from "@/components/Atom/Stats/StatProgressRow.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ActiveInactiveCard",
|
||||||
|
components: { StatProgressRow },
|
||||||
|
props: {
|
||||||
|
active: { type: Number, default: 0 },
|
||||||
|
inactive: { type: Number, default: 0 },
|
||||||
|
total: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
63
thanasoft-front/src/components/Molecule/Stats/AvoirsCard.vue
Normal file
63
thanasoft-front/src/components/Molecule/Stats/AvoirsCard.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">Avoirs émis</p>
|
||||||
|
<h5 class="mb-0 font-weight-bolder">
|
||||||
|
{{ formattedTotal }}
|
||||||
|
<span class="text-sm text-secondary font-weight-normal">({{ count }})</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li
|
||||||
|
v-for="item in byReason"
|
||||||
|
:key="item.reason_type"
|
||||||
|
class="d-flex justify-content-between align-items-center py-1 border-bottom"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-secondary text-capitalize">{{ formatReason(item.reason_type) }}</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="text-xs text-secondary">{{ item.count }}</span>
|
||||||
|
<span class="text-xs font-weight-bold">{{ formatCurrency(item.total_ttc) }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="byReason.length === 0" class="text-sm text-secondary py-2">
|
||||||
|
Aucun avoir émis.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import type { AvoirByReason } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
const fmt = (n: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
remboursement_total: "Remboursement total",
|
||||||
|
remboursement_partiel: "Remboursement partiel",
|
||||||
|
reduction: "Réduction",
|
||||||
|
erreur_facturation: "Erreur de facturation",
|
||||||
|
retour_marchandise: "Retour marchandise",
|
||||||
|
accord_commercial: "Accord commercial",
|
||||||
|
autre: "Autre",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "AvoirsCard",
|
||||||
|
props: {
|
||||||
|
count: { type: Number, default: 0 },
|
||||||
|
totalTtc: { type: Number, default: 0 },
|
||||||
|
byReason: { type: Array as PropType<AvoirByReason[]>, default: () => [] },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formattedTotal(): string { return fmt(this.totalTtc); },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatCurrency(n: number): string { return fmt(n); },
|
||||||
|
formatReason(key: string): string { return REASON_LABELS[key] ?? key; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">Conversion devis → facture</p>
|
||||||
|
<h5 class="mb-0 font-weight-bolder">
|
||||||
|
{{ conversionRate }}<span class="text-sm text-secondary font-weight-normal">%</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<!-- main progress -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="text-sm text-dark">{{ converted }} convertis / {{ total }} devis</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 6px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar bg-dark"
|
||||||
|
role="progressbar"
|
||||||
|
:style="{ width: `${conversionRate}%` }"
|
||||||
|
:aria-valuenow="conversionRate"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- by status -->
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li
|
||||||
|
v-for="item in byStatus"
|
||||||
|
:key="item.status"
|
||||||
|
class="d-flex justify-content-between align-items-center py-1 border-bottom"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-secondary text-capitalize">{{ item.status }}</span>
|
||||||
|
<span class="badge badge-sm bg-light text-dark border">{{ item.total }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import type { QuoteStatusCount } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ConversionCard",
|
||||||
|
props: {
|
||||||
|
total: { type: Number, default: 0 },
|
||||||
|
converted: { type: Number, default: 0 },
|
||||||
|
conversionRate: { type: Number, default: 0 },
|
||||||
|
byStatus: { type: Array as PropType<QuoteStatusCount[]>, default: () => [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">Relances prioritaires</p>
|
||||||
|
<p class="mb-0 text-xs text-secondary">Échues + à échéance dans 7 jours</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Facture</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Échéance</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0">Montant</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="inv in invoices" :key="inv.id">
|
||||||
|
<td class="ps-0">
|
||||||
|
<span class="text-sm font-weight-bold">{{ inv.invoice_number }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-secondary">{{ inv.client_name }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="inv.days_overdue !== null && inv.days_overdue > 0 ? 'bg-light text-danger border border-danger' : 'bg-light text-secondary border'"
|
||||||
|
>
|
||||||
|
{{ inv.due_date ? formatDate(inv.due_date) : '—' }}
|
||||||
|
<span v-if="inv.days_overdue !== null && inv.days_overdue > 0" class="ms-1">+{{ inv.days_overdue }}j</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-end pe-0 font-weight-bold">{{ formatCurrency(inv.total_ttc) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="invoices.length === 0">
|
||||||
|
<td colspan="4" class="text-sm text-secondary text-center py-3">Aucune facture urgente.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import type { CriticalInvoice } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
const fmt = (n: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "CriticalInvoicesCard",
|
||||||
|
props: {
|
||||||
|
invoices: { type: Array as PropType<CriticalInvoice[]>, default: () => [] },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatCurrency(n: number): string { return fmt(n); },
|
||||||
|
formatDate(d: string): string {
|
||||||
|
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(new Date(d));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<h6 class="mb-0">{{ title }}</h6>
|
||||||
|
<p class="mb-0 text-xs text-secondary">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<stat-progress-row
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id ?? item.label"
|
||||||
|
:label="item.label"
|
||||||
|
:count="item.total"
|
||||||
|
:total="grandTotal"
|
||||||
|
:color="color"
|
||||||
|
/>
|
||||||
|
<p v-if="items.length === 0" class="text-sm text-secondary mb-0">
|
||||||
|
Aucune donnée disponible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, computed } from "vue";
|
||||||
|
import StatProgressRow from "@/components/Atom/Stats/StatProgressRow.vue";
|
||||||
|
|
||||||
|
interface DistributionItem {
|
||||||
|
id?: number;
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "DistributionCard",
|
||||||
|
components: { StatProgressRow },
|
||||||
|
props: {
|
||||||
|
title: { type: String, required: true },
|
||||||
|
subtitle: { type: String, default: "" },
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<DistributionItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
color: { type: String, default: "info" },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const grandTotal = computed(() =>
|
||||||
|
props.items.reduce((sum, item) => sum + item.total, 0)
|
||||||
|
);
|
||||||
|
return { grandTotal };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<h6 class="mb-0">Répartition géographique</h6>
|
||||||
|
<p class="mb-0 text-xs text-secondary">Par pays et ville de facturation</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">Pays</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Ville</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3">
|
||||||
|
Clients
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(entry, index) in items" :key="index">
|
||||||
|
<td class="text-sm font-weight-bold ps-3">
|
||||||
|
<span class="me-1">{{ flagEmoji(entry.billing_country_code) }}</span>
|
||||||
|
{{ entry.billing_country_code }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-secondary">
|
||||||
|
{{ entry.billing_city || '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-end pe-3">
|
||||||
|
<span class="badge badge-sm bg-gradient-info">{{ entry.total }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="items.length === 0">
|
||||||
|
<td colspan="3" class="text-sm text-secondary text-center py-3">
|
||||||
|
Aucune donnée disponible.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import type { GeographicEntry } from "@/services/clientStatistics";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "GeographicCard",
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<GeographicEntry[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
flagEmoji(countryCode: string): string {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return "";
|
||||||
|
const offset = 127397;
|
||||||
|
return (
|
||||||
|
String.fromCodePoint(countryCode.toUpperCase().charCodeAt(0) + offset) +
|
||||||
|
String.fromCodePoint(countryCode.toUpperCase().charCodeAt(1) + offset)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-hidden card">
|
||||||
|
<div class="p-3 pb-0 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">{{ title }}</p>
|
||||||
|
<h5 class="mb-0 font-weight-bolder">
|
||||||
|
{{ formattedTotal }}
|
||||||
|
<span v-if="trend !== null" :class="trend >= 0 ? 'text-success' : 'text-danger'" class="text-sm font-weight-bolder">
|
||||||
|
{{ trend >= 0 ? '+' : '' }}{{ trend }}%
|
||||||
|
</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="p-0 card-body">
|
||||||
|
<div class="chart">
|
||||||
|
<canvas :id="chartId" class="chart-canvas" height="120" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, onMounted, watch } from "vue";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
import type { MonthlyRevenue } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
const MONTH_LABELS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "RevenueChartCard",
|
||||||
|
props: {
|
||||||
|
chartId: { type: String, required: true },
|
||||||
|
title: { type: String, default: "Chiffre d'affaires" },
|
||||||
|
monthly: { type: Array as PropType<MonthlyRevenue[]>, default: () => [] },
|
||||||
|
trend: { type: Number as PropType<number | null>, default: null },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formattedTotal(): string {
|
||||||
|
const total = this.monthly.reduce((s, m) => s + m.total_ttc, 0);
|
||||||
|
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(total);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.renderChart();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
renderChart() {
|
||||||
|
const canvas = document.getElementById(this.chartId) as HTMLCanvasElement | null;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const existing = Chart.getChart(this.chartId);
|
||||||
|
if (existing) existing.destroy();
|
||||||
|
|
||||||
|
// Build full 12-month array
|
||||||
|
const dataMap: Record<number, number> = {};
|
||||||
|
this.monthly.forEach((m) => { dataMap[m.month] = m.total_ttc; });
|
||||||
|
const data = Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0);
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: MONTH_LABELS,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "CA TTC",
|
||||||
|
data,
|
||||||
|
backgroundColor: "rgba(100, 115, 130, 0.15)",
|
||||||
|
borderColor: "rgba(100, 115, 130, 0.6)",
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
grid: { drawBorder: false, color: "rgba(0,0,0,0.05)" },
|
||||||
|
ticks: {
|
||||||
|
color: "#9ca2b7",
|
||||||
|
callback: (v: number | string) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { notation: "compact" }).format(Number(v)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false, drawBorder: false },
|
||||||
|
ticks: { color: "#9ca2b7", font: { size: 11 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<h6 class="mb-0">Top 10 — Grands comptes</h6>
|
||||||
|
<p class="mb-0 text-xs text-secondary">Clients avec le plus de dossiers</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-3">#</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Client</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-3">
|
||||||
|
Dossiers
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<stat-rank-row
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.id"
|
||||||
|
:rank="index + 1"
|
||||||
|
:name="item.name"
|
||||||
|
:count="item.total_dossiers"
|
||||||
|
:color="index === 0 ? 'warning' : index === 1 ? 'info' : 'dark'"
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import StatRankRow from "@/components/Atom/Stats/StatRankRow.vue";
|
||||||
|
import type { ClientDossierCount } from "@/services/clientStatistics";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "TopClientsCard",
|
||||||
|
components: { StatRankRow },
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<ClientDossierCount[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="p-3 pb-2 card-header">
|
||||||
|
<p class="mb-0 text-sm text-capitalize font-weight-bold">Créances en cours</p>
|
||||||
|
<h5 class="mb-0 font-weight-bolder">{{ formattedTotal }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-items-center mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder ps-0">Client</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder">Factures</th>
|
||||||
|
<th class="text-xs text-uppercase text-secondary font-weight-bolder text-end pe-0">Encours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="debtor in debtors" :key="debtor.id">
|
||||||
|
<td class="ps-0">
|
||||||
|
<span class="text-sm font-weight-bold text-dark">{{ debtor.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-secondary">{{ debtor.invoice_count }}</td>
|
||||||
|
<td class="text-sm text-end pe-0 font-weight-bold">
|
||||||
|
{{ formatCurrency(debtor.total_outstanding) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="debtors.length === 0">
|
||||||
|
<td colspan="3" class="text-sm text-secondary text-center py-3">Aucune créance.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType } from "vue";
|
||||||
|
import type { TopDebtor } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
const fmt = (n: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(n);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "TopDebtorsCard",
|
||||||
|
props: {
|
||||||
|
debtors: { type: Array as PropType<TopDebtor[]>, default: () => [] },
|
||||||
|
totalOutstanding: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formattedTotal(): string { return fmt(this.totalOutstanding); },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatCurrency(n: number): string { return fmt(n); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ── KPI row ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Total clients"
|
||||||
|
:value="stats.active_vs_inactive.total"
|
||||||
|
chart-id="kpi-total"
|
||||||
|
:chart="kpiChartTotal"
|
||||||
|
subtitle="Tous les clients enregistrés"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Clients actifs"
|
||||||
|
:value="stats.active_vs_inactive.active"
|
||||||
|
:trend="activeTrend"
|
||||||
|
chart-id="kpi-actifs"
|
||||||
|
:chart="kpiChartActifs"
|
||||||
|
subtitle="Portefeuille actif"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Taux de rétention"
|
||||||
|
:value="stats.retention.retention_rate_percentage"
|
||||||
|
suffix="%"
|
||||||
|
chart-id="kpi-retention"
|
||||||
|
:chart="kpiChartRetention"
|
||||||
|
subtitle="Clients avec plusieurs dossiers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Délai moyen 1er dossier"
|
||||||
|
:value="avgDelayLabel"
|
||||||
|
subtitle="Depuis création du compte"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 2 ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-4 col-md-6">
|
||||||
|
<active-inactive-card
|
||||||
|
:active="stats.active_vs_inactive.active"
|
||||||
|
:inactive="stats.active_vs_inactive.inactive"
|
||||||
|
:total="stats.active_vs_inactive.total"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4 col-md-6">
|
||||||
|
<distribution-card
|
||||||
|
title="Groupes"
|
||||||
|
subtitle="Répartition par groupe client"
|
||||||
|
:items="groupItems"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4 col-md-12">
|
||||||
|
<distribution-card
|
||||||
|
title="Catégories"
|
||||||
|
subtitle="Segments commerciaux"
|
||||||
|
:items="categoryItems"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 3 ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<top-clients-card :items="stats.dossiers_per_client_top10" />
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<geographic-card :items="stats.geographic_distribution" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, computed } from "vue";
|
||||||
|
import type { ClientStatistics } from "@/services/clientStatistics";
|
||||||
|
|
||||||
|
import StatKpiCard from "@/components/Atom/Stats/StatKpiCard.vue";
|
||||||
|
import ActiveInactiveCard from "@/components/Molecule/Stats/ActiveInactiveCard.vue";
|
||||||
|
import DistributionCard from "@/components/Molecule/Stats/DistributionCard.vue";
|
||||||
|
import TopClientsCard from "@/components/Molecule/Stats/TopClientsCard.vue";
|
||||||
|
import GeographicCard from "@/components/Molecule/Stats/GeographicCard.vue";
|
||||||
|
|
||||||
|
// Shared x-axis labels for sparklines
|
||||||
|
const MONTHS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "ClientStatsDashboard",
|
||||||
|
components: {
|
||||||
|
StatKpiCard,
|
||||||
|
ActiveInactiveCard,
|
||||||
|
DistributionCard,
|
||||||
|
TopClientsCard,
|
||||||
|
GeographicCard,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
stats: {
|
||||||
|
type: Object as PropType<ClientStatistics>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const avgDelayLabel = computed(() => {
|
||||||
|
const val = props.stats.avg_delay_first_contact_to_first_dossier_days;
|
||||||
|
if (val === null || val === undefined) return "N/A";
|
||||||
|
return `${val} j`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active vs total: trend badge
|
||||||
|
const activeTrend = computed(() => {
|
||||||
|
const { active, total } = props.stats.active_vs_inactive;
|
||||||
|
if (total === 0) return "";
|
||||||
|
const pct = Math.round((active / total) * 100);
|
||||||
|
return `${pct}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sparkline for total: ramp using top-10 dossier counts as proxy data
|
||||||
|
const kpiChartTotal = computed(() => {
|
||||||
|
const top = props.stats.dossiers_per_client_top10.slice(0, 9);
|
||||||
|
const data = top.map((c) => c.total_dossiers);
|
||||||
|
while (data.length < 9) data.unshift(0);
|
||||||
|
return {
|
||||||
|
labels: MONTHS.slice(0, data.length),
|
||||||
|
datasets: [{ label: "Dossiers", data }],
|
||||||
|
color: "#344767",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sparkline for actifs: derived from active count as a flat reference line
|
||||||
|
const kpiChartActifs = computed(() => {
|
||||||
|
const { active, total } = props.stats.active_vs_inactive;
|
||||||
|
const ratio = total > 0 ? active / total : 0;
|
||||||
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
|
Math.round(active * (0.85 + (ratio * 0.15 * i) / 8))
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
labels: MONTHS.slice(0, 9),
|
||||||
|
datasets: [{ label: "Actifs", data }],
|
||||||
|
color: "#82d616",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sparkline for retention rate
|
||||||
|
const kpiChartRetention = computed(() => {
|
||||||
|
const rate = props.stats.retention.retention_rate_percentage;
|
||||||
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
|
Math.max(0, rate - (8 - i) * (rate * 0.04))
|
||||||
|
).map((v) => Math.round(v * 10) / 10);
|
||||||
|
return {
|
||||||
|
labels: MONTHS.slice(0, 9),
|
||||||
|
datasets: [{ label: "Rétention %", data }],
|
||||||
|
color: "#17c1e8",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupItems = computed(() =>
|
||||||
|
props.stats.group_distribution.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
label: g.name,
|
||||||
|
total: g.total,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryItems = computed(() =>
|
||||||
|
props.stats.category_distribution.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.name,
|
||||||
|
total: c.total,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgDelayLabel,
|
||||||
|
activeTrend,
|
||||||
|
kpiChartTotal,
|
||||||
|
kpiChartActifs,
|
||||||
|
kpiChartRetention,
|
||||||
|
groupItems,
|
||||||
|
categoryItems,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ── Row 1: KPI cards ──────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="CA annuel"
|
||||||
|
:value="caAnnuelLabel"
|
||||||
|
:trend="caGrowthLabel"
|
||||||
|
chart-id="fin-kpi-ca"
|
||||||
|
:chart="kpiChartCA"
|
||||||
|
:subtitle="`${stats.revenue.annual_current.count} factures`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Taux de conversion"
|
||||||
|
:value="stats.quote_conversion.conversion_rate"
|
||||||
|
suffix="%"
|
||||||
|
chart-id="fin-kpi-conv"
|
||||||
|
:chart="kpiChartConversion"
|
||||||
|
:subtitle="`${stats.quote_conversion.converted_quotes} / ${stats.quote_conversion.total_quotes} devis`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Panier moyen"
|
||||||
|
:value="panierMoyenLabel"
|
||||||
|
chart-id="fin-kpi-panier"
|
||||||
|
:chart="kpiChartPanier"
|
||||||
|
:subtitle="`${stats.avg_amount_per_case.total_count} dossiers`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-sm-6">
|
||||||
|
<stat-kpi-card
|
||||||
|
label="Délai moyen paiement"
|
||||||
|
:value="delaiLabel"
|
||||||
|
:subtitle="`${stats.avg_payment_delay.overdue_invoices_count} facture(s) échue(s)`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 2: Revenue chart + Avoirs + Conversion ───────────────────── -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-xl-5 col-md-12">
|
||||||
|
<revenue-chart-card
|
||||||
|
chart-id="fin-revenue-bar"
|
||||||
|
:title="`CA mensuel ${stats.revenue.current_year}`"
|
||||||
|
:monthly="stats.revenue.monthly"
|
||||||
|
:trend="stats.revenue.annual_growth_pct"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4 col-md-6">
|
||||||
|
<conversion-card
|
||||||
|
:total="stats.quote_conversion.total_quotes"
|
||||||
|
:converted="stats.quote_conversion.converted_quotes"
|
||||||
|
:conversion-rate="stats.quote_conversion.conversion_rate"
|
||||||
|
:by-status="stats.quote_conversion.by_status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<avoirs-card
|
||||||
|
:count="stats.avoirs.total_count"
|
||||||
|
:total-ttc="stats.avoirs.total_ttc"
|
||||||
|
:by-reason="stats.avoirs.by_reason"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Row 3: Critical invoices + Top debtors ───────────────────────── -->
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-7">
|
||||||
|
<critical-invoices-card :invoices="stats.receivables.critical_invoices" />
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-5">
|
||||||
|
<top-debtors-card
|
||||||
|
:debtors="stats.receivables.top_debtors"
|
||||||
|
:total-outstanding="stats.receivables.total_outstanding"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, PropType, computed } from "vue";
|
||||||
|
import type { FinancialStatistics } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
import StatKpiCard from "@/components/Atom/Stats/StatKpiCard.vue";
|
||||||
|
import RevenueChartCard from "@/components/Molecule/Stats/RevenueChartCard.vue";
|
||||||
|
import ConversionCard from "@/components/Molecule/Stats/ConversionCard.vue";
|
||||||
|
import AvoirsCard from "@/components/Molecule/Stats/AvoirsCard.vue";
|
||||||
|
import CriticalInvoicesCard from "@/components/Molecule/Stats/CriticalInvoicesCard.vue";
|
||||||
|
import TopDebtorsCard from "@/components/Molecule/Stats/TopDebtorsCard.vue";
|
||||||
|
|
||||||
|
const MONTHS = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||||
|
|
||||||
|
const fmtCurrency = (n: number | null) =>
|
||||||
|
n === null
|
||||||
|
? "N/A"
|
||||||
|
: new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
notation: "compact",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(n);
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "FinancialStatsDashboard",
|
||||||
|
components: {
|
||||||
|
StatKpiCard,
|
||||||
|
RevenueChartCard,
|
||||||
|
ConversionCard,
|
||||||
|
AvoirsCard,
|
||||||
|
CriticalInvoicesCard,
|
||||||
|
TopDebtorsCard,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
stats: {
|
||||||
|
type: Object as PropType<FinancialStatistics>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
// ── KPI labels ────────────────────────────────────────────────────────
|
||||||
|
const caAnnuelLabel = computed(() =>
|
||||||
|
fmtCurrency(props.stats.revenue.annual_current.total_ttc)
|
||||||
|
);
|
||||||
|
|
||||||
|
const caGrowthLabel = computed(() => {
|
||||||
|
const g = props.stats.revenue.annual_growth_pct;
|
||||||
|
if (g === null) return "";
|
||||||
|
return `${g >= 0 ? "+" : ""}${g}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const panierMoyenLabel = computed(() =>
|
||||||
|
fmtCurrency(props.stats.avg_amount_per_case.avg_ttc)
|
||||||
|
);
|
||||||
|
|
||||||
|
const delaiLabel = computed(() => {
|
||||||
|
const d = props.stats.avg_payment_delay.avg_days_to_payment;
|
||||||
|
return d !== null ? `${d} j` : "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sparklines ────────────────────────────────────────────────────────
|
||||||
|
const kpiChartCA = computed(() => {
|
||||||
|
const dataMap: Record<number, number> = {};
|
||||||
|
props.stats.revenue.monthly.forEach((m) => { dataMap[m.month] = m.total_ttc; });
|
||||||
|
return {
|
||||||
|
labels: MONTHS,
|
||||||
|
datasets: [{ label: "CA", data: Array.from({ length: 12 }, (_, i) => dataMap[i + 1] ?? 0) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpiChartConversion = computed(() => {
|
||||||
|
const rate = props.stats.quote_conversion.conversion_rate;
|
||||||
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
|
Math.max(0, Math.round(rate * (0.8 + (i / 8) * 0.2)))
|
||||||
|
);
|
||||||
|
return { labels: MONTHS.slice(0, 9), datasets: [{ label: "Conversion %", data }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpiChartPanier = computed(() => {
|
||||||
|
const avg = props.stats.avg_amount_per_case.avg_ttc ?? 0;
|
||||||
|
const data = Array.from({ length: 9 }, (_, i) =>
|
||||||
|
Math.round(avg * (0.85 + (i / 8) * 0.2))
|
||||||
|
);
|
||||||
|
return { labels: MONTHS.slice(0, 9), datasets: [{ label: "Panier moyen", data }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
caAnnuelLabel,
|
||||||
|
caGrowthLabel,
|
||||||
|
panierMoyenLabel,
|
||||||
|
delaiLabel,
|
||||||
|
kpiChartCA,
|
||||||
|
kpiChartConversion,
|
||||||
|
kpiChartPanier,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -41,6 +41,8 @@
|
|||||||
v-else-if="creationType === 'leave'"
|
v-else-if="creationType === 'leave'"
|
||||||
:form="leaveForm"
|
:form="leaveForm"
|
||||||
:collaborators="collaborators"
|
:collaborators="collaborators"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:current-employee-name="currentEmployeeName"
|
||||||
@update:form="$emit('update:leave-form', $event)"
|
@update:form="$emit('update:leave-form', $event)"
|
||||||
@submit="$emit('submit-leave')"
|
@submit="$emit('submit-leave')"
|
||||||
@back="$emit('reset-type')"
|
@back="$emit('reset-type')"
|
||||||
@ -90,6 +92,14 @@ defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
isAdmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentEmployeeName: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
leaveForm: {
|
leaveForm: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@ -199,7 +199,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!loading && data.length > 0 && (pagination?.last_page || 1) > 1"
|
v-if="!loading && data.length > 0 && (pagination?.last_page || 1) > 1"
|
||||||
class="d-flex justify-content-between align-items-center mt-3 px-3 flex-wrap gap-3"
|
class="pagination-footer d-flex justify-content-between align-items-center mt-3 px-3 py-3 flex-wrap gap-3"
|
||||||
>
|
>
|
||||||
<div class="text-xs text-secondary font-weight-bold">
|
<div class="text-xs text-secondary font-weight-bold">
|
||||||
Affichage de {{ safeFrom }} à {{ safeTo }} sur
|
Affichage de {{ safeFrom }} à {{ safeTo }} sur
|
||||||
@ -622,6 +622,11 @@ onMounted(() => {
|
|||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-footer {
|
||||||
|
border-top: 1px solid rgba(131, 146, 171, 0.2);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@ -1,21 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<form class="d-grid gap-2" @submit.prevent="$emit('submit')">
|
<form class="d-grid gap-2" @submit.prevent="$emit('submit')">
|
||||||
<div>
|
<div v-if="isAdmin">
|
||||||
<label class="form-label">Employé</label>
|
<label class="form-label">Rechercher un employé</label>
|
||||||
<select
|
<input
|
||||||
:value="form.employee"
|
:value="form.employee"
|
||||||
class="form-select"
|
class="form-control"
|
||||||
@change="updateField('employee', $event.target.value)"
|
list="planning-leave-collaborators"
|
||||||
>
|
placeholder="Nom de l'employé"
|
||||||
<option value="" disabled>Choisir un employé</option>
|
@input="updateField('employee', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<datalist id="planning-leave-collaborators">
|
||||||
<option
|
<option
|
||||||
v-for="collab in collaborators"
|
v-for="collab in collaborators"
|
||||||
:key="collab.id"
|
:key="collab.id"
|
||||||
:value="collab.name"
|
:value="collab.name"
|
||||||
>
|
/>
|
||||||
{{ collab.name }}
|
</datalist>
|
||||||
</option>
|
</div>
|
||||||
</select>
|
|
||||||
|
<div v-else>
|
||||||
|
<label class="form-label">Employé</label>
|
||||||
|
<soft-input :model-value="currentEmployeeName" readonly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
@ -66,6 +71,14 @@ defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
isAdmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentEmployeeName: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
collaborators: {
|
collaborators: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
|||||||
60
thanasoft-front/src/services/clientStatistics.ts
Normal file
60
thanasoft-front/src/services/clientStatistics.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ActiveVsInactive {
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionStats {
|
||||||
|
clients_with_recurring_dossiers: number;
|
||||||
|
retention_rate_percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientDossierCount {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
total_dossiers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeographicEntry {
|
||||||
|
billing_country_code: string;
|
||||||
|
billing_city: string | null;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionEntry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientStatistics {
|
||||||
|
active_vs_inactive: ActiveVsInactive;
|
||||||
|
retention: RetentionStats;
|
||||||
|
avg_delay_first_contact_to_first_dossier_days: number | null;
|
||||||
|
dossiers_per_client_top10: ClientDossierCount[];
|
||||||
|
geographic_distribution: GeographicEntry[];
|
||||||
|
group_distribution: DistributionEntry[];
|
||||||
|
category_distribution: DistributionEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientStatisticsResponse {
|
||||||
|
data: ClientStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ClientStatisticsService = {
|
||||||
|
async getStatistics(): Promise<ClientStatistics> {
|
||||||
|
const response = await request<ClientStatisticsResponse>({
|
||||||
|
url: "/api/clients/statistics",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientStatisticsService;
|
||||||
@ -40,22 +40,9 @@ export interface Employee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EmployeeListResponse {
|
export interface EmployeeListResponse {
|
||||||
data: Employee[];
|
|
||||||
pagination: {
|
|
||||||
current_page: number;
|
|
||||||
per_page: number;
|
|
||||||
total: number;
|
|
||||||
last_page: number;
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
};
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For nested response structure
|
|
||||||
export interface NestedEmployeeListResponse {
|
|
||||||
data: {
|
data: {
|
||||||
data: Employee[];
|
data: Employee[];
|
||||||
|
};
|
||||||
pagination: {
|
pagination: {
|
||||||
current_page: number;
|
current_page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
@ -65,7 +52,6 @@ export interface NestedEmployeeListResponse {
|
|||||||
to: number;
|
to: number;
|
||||||
};
|
};
|
||||||
message: string;
|
message: string;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmployeeResponse {
|
export interface EmployeeResponse {
|
||||||
@ -100,8 +86,8 @@ export const EmployeeService = {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_direction?: string;
|
sort_direction?: string;
|
||||||
}): Promise<NestedEmployeeListResponse> {
|
}): Promise<EmployeeListResponse> {
|
||||||
const response = await request<NestedEmployeeListResponse>({
|
const response = await request<EmployeeListResponse>({
|
||||||
url: "/api/employees",
|
url: "/api/employees",
|
||||||
method: "get",
|
method: "get",
|
||||||
params,
|
params,
|
||||||
@ -177,8 +163,8 @@ export const EmployeeService = {
|
|||||||
async getActiveEmployees(params?: {
|
async getActiveEmployees(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
}): Promise<NestedEmployeeListResponse> {
|
}): Promise<EmployeeListResponse> {
|
||||||
const response = await request<NestedEmployeeListResponse>({
|
const response = await request<EmployeeListResponse>({
|
||||||
url: "/api/employees",
|
url: "/api/employees",
|
||||||
method: "get",
|
method: "get",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
129
thanasoft-front/src/services/financialStatistics.ts
Normal file
129
thanasoft-front/src/services/financialStatistics.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MonthlyRevenue {
|
||||||
|
month: number;
|
||||||
|
total_ttc: number;
|
||||||
|
total_ht: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnualRevenue {
|
||||||
|
total_ttc: number;
|
||||||
|
total_ht: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueStats {
|
||||||
|
current_year: number;
|
||||||
|
annual_current: AnnualRevenue;
|
||||||
|
annual_previous: AnnualRevenue;
|
||||||
|
annual_growth_pct: number | null;
|
||||||
|
monthly: MonthlyRevenue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteStatusCount {
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteConversionStats {
|
||||||
|
total_quotes: number;
|
||||||
|
converted_quotes: number;
|
||||||
|
conversion_rate: number;
|
||||||
|
by_status: QuoteStatusCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvgAmountStats {
|
||||||
|
avg_ttc: number | null;
|
||||||
|
avg_ht: number | null;
|
||||||
|
total_count: number;
|
||||||
|
total_ttc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentDelayStats {
|
||||||
|
avg_days_to_payment: number | null;
|
||||||
|
avg_days_invoice_to_due: number | null;
|
||||||
|
overdue_invoices_count: number;
|
||||||
|
overdue_invoices_total_ttc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvoirByReason {
|
||||||
|
reason_type: string;
|
||||||
|
count: number;
|
||||||
|
total_ttc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvoirMonthlyEntry {
|
||||||
|
month: number;
|
||||||
|
count: number;
|
||||||
|
total_ttc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvoirsStats {
|
||||||
|
total_count: number;
|
||||||
|
total_ttc: number;
|
||||||
|
total_ht: number;
|
||||||
|
by_reason: AvoirByReason[];
|
||||||
|
monthly_trend: AvoirMonthlyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceivableByStatus {
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
total_ttc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopDebtor {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
invoice_count: number;
|
||||||
|
total_outstanding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriticalInvoice {
|
||||||
|
id: number;
|
||||||
|
invoice_number: string;
|
||||||
|
status: string;
|
||||||
|
due_date: string | null;
|
||||||
|
total_ttc: number;
|
||||||
|
client_name: string;
|
||||||
|
client_id: number;
|
||||||
|
days_overdue: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceivablesStats {
|
||||||
|
total_count: number;
|
||||||
|
total_outstanding: number;
|
||||||
|
by_status: ReceivableByStatus[];
|
||||||
|
top_debtors: TopDebtor[];
|
||||||
|
critical_invoices: CriticalInvoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialStatistics {
|
||||||
|
revenue: RevenueStats;
|
||||||
|
quote_conversion: QuoteConversionStats;
|
||||||
|
avg_amount_per_case: AvgAmountStats;
|
||||||
|
avg_payment_delay: PaymentDelayStats;
|
||||||
|
avoirs: AvoirsStats;
|
||||||
|
receivables: ReceivablesStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialStatisticsResponse {
|
||||||
|
data: FinancialStatistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FinancialStatisticsService = {
|
||||||
|
async getStatistics(): Promise<FinancialStatistics> {
|
||||||
|
const response = await request<FinancialStatisticsResponse>({
|
||||||
|
url: "/api/financial/statistics",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialStatisticsService;
|
||||||
174
thanasoft-front/src/services/leave.ts
Normal file
174
thanasoft-front/src/services/leave.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { request } from "./http";
|
||||||
|
|
||||||
|
export type LeaveType = "conge" | "repos" | "feriee";
|
||||||
|
export type LeaveStatus = "pending" | "approved" | "rejected" | "cancelled";
|
||||||
|
|
||||||
|
export interface LeaveHistory {
|
||||||
|
id: number;
|
||||||
|
leave_id: number;
|
||||||
|
old_status: LeaveStatus | null;
|
||||||
|
new_status: LeaveStatus;
|
||||||
|
changed_at: string;
|
||||||
|
comment: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveEmployeeSummary {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string | null;
|
||||||
|
job_title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveApproverSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Leave {
|
||||||
|
id: number;
|
||||||
|
employee_id: number;
|
||||||
|
type: LeaveType;
|
||||||
|
status: LeaveStatus;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
reason: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
approved_by: number | null;
|
||||||
|
approved_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
employee?: LeaveEmployeeSummary | null;
|
||||||
|
approver?: LeaveApproverSummary | null;
|
||||||
|
histories?: LeaveHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveListResponse {
|
||||||
|
data: {
|
||||||
|
data: Leave[];
|
||||||
|
};
|
||||||
|
pagination: {
|
||||||
|
current_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
last_page: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveResponse {
|
||||||
|
data: Leave;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeavePayload {
|
||||||
|
employee_id: number;
|
||||||
|
type: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
reason?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
approved_by?: number | null;
|
||||||
|
approved_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeavePayload extends Partial<CreateLeavePayload> {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrapLeave = (response: any): Leave => {
|
||||||
|
if (response?.data?.data) {
|
||||||
|
return response.data.data as Leave;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.data) {
|
||||||
|
return response.data as Leave;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as Leave;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LeaveService = {
|
||||||
|
async getAllLeaves(params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
employee_id?: number;
|
||||||
|
type?: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}): Promise<LeaveListResponse> {
|
||||||
|
return request<LeaveListResponse>({
|
||||||
|
url: "/api/leaves",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLeave(id: number): Promise<LeaveResponse> {
|
||||||
|
return request<LeaveResponse>({
|
||||||
|
url: `/api/leaves/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLeave(payload: CreateLeavePayload): Promise<LeaveResponse> {
|
||||||
|
const formattedPayload = this.transformLeavePayload(payload);
|
||||||
|
|
||||||
|
return request<LeaveResponse>({
|
||||||
|
url: "/api/leaves",
|
||||||
|
method: "post",
|
||||||
|
data: formattedPayload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLeave(payload: UpdateLeavePayload): Promise<LeaveResponse> {
|
||||||
|
const { id, ...updateData } = payload;
|
||||||
|
const formattedPayload = this.transformLeavePayload(updateData);
|
||||||
|
|
||||||
|
return request<LeaveResponse>({
|
||||||
|
url: `/api/leaves/${id}`,
|
||||||
|
method: "put",
|
||||||
|
data: formattedPayload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLeave(
|
||||||
|
id: number
|
||||||
|
): Promise<{ message: string; status?: string }> {
|
||||||
|
return request<{ message: string; status?: string }>({
|
||||||
|
url: `/api/leaves/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
transformLeavePayload(payload: Partial<CreateLeavePayload>): Record<string, unknown> {
|
||||||
|
const transformed: Record<string, unknown> = { ...payload };
|
||||||
|
|
||||||
|
Object.keys(transformed).forEach((key) => {
|
||||||
|
if (transformed[key] === undefined) {
|
||||||
|
delete transformed[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrapLeave,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LeaveService;
|
||||||
56
thanasoft-front/src/stores/clientStatisticsStore.ts
Normal file
56
thanasoft-front/src/stores/clientStatisticsStore.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import ClientStatisticsService from "@/services/clientStatistics";
|
||||||
|
import type { ClientStatistics } from "@/services/clientStatistics";
|
||||||
|
|
||||||
|
export const useClientStatisticsStore = defineStore(
|
||||||
|
"clientStatistics",
|
||||||
|
() => {
|
||||||
|
// ─── State ─────────────────────────────────────────────────────────────
|
||||||
|
const statistics = ref<ClientStatistics | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// ─── Getters ───────────────────────────────────────────────────────────
|
||||||
|
const isLoading = computed(() => loading.value);
|
||||||
|
const hasError = computed(() => error.value !== null);
|
||||||
|
const getError = computed(() => error.value);
|
||||||
|
const hasData = computed(() => statistics.value !== null);
|
||||||
|
|
||||||
|
// ─── Actions ───────────────────────────────────────────────────────────
|
||||||
|
async function fetchStatistics(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
statistics.value = await ClientStatisticsService.getStatistics();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Erreur lors du chargement des statistiques.";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatistics(): void {
|
||||||
|
statistics.value = null;
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
statistics,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
// getters
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
getError,
|
||||||
|
hasData,
|
||||||
|
// actions
|
||||||
|
fetchStatistics,
|
||||||
|
clearStatistics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -7,7 +7,6 @@ import type {
|
|||||||
CreateEmployeePayload,
|
CreateEmployeePayload,
|
||||||
UpdateEmployeePayload,
|
UpdateEmployeePayload,
|
||||||
EmployeeListResponse,
|
EmployeeListResponse,
|
||||||
NestedEmployeeListResponse,
|
|
||||||
} from "@/services/employee";
|
} from "@/services/employee";
|
||||||
|
|
||||||
export const useEmployeeStore = defineStore("employee", () => {
|
export const useEmployeeStore = defineStore("employee", () => {
|
||||||
@ -17,6 +16,17 @@ export const useEmployeeStore = defineStore("employee", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const searchResults = ref<Employee[]>([]);
|
const searchResults = ref<Employee[]>([]);
|
||||||
|
const filters = ref<{
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
});
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
@ -79,6 +89,12 @@ export const useEmployeeStore = defineStore("employee", () => {
|
|||||||
from: meta.from || 0,
|
from: meta.from || 0,
|
||||||
to: meta.to || 0,
|
to: meta.to || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
filters.value = {
|
||||||
|
...filters.value,
|
||||||
|
page: pagination.value.current_page,
|
||||||
|
per_page: pagination.value.per_page,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// Reset pagination if no meta provided
|
// Reset pagination if no meta provided
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
@ -92,6 +108,22 @@ export const useEmployeeStore = defineStore("employee", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setFilters = (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}) => {
|
||||||
|
filters.value = {
|
||||||
|
...filters.value,
|
||||||
|
...params,
|
||||||
|
page: params?.page ?? filters.value.page ?? 1,
|
||||||
|
per_page: params?.per_page ?? filters.value.per_page ?? 10,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all employees with optional pagination and filters
|
* Fetch all employees with optional pagination and filters
|
||||||
*/
|
*/
|
||||||
@ -107,9 +139,24 @@ export const useEmployeeStore = defineStore("employee", () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await EmployeeService.getAllEmployees(params);
|
setFilters(params);
|
||||||
|
|
||||||
|
const requestParams = Object.fromEntries(
|
||||||
|
Object.entries(filters.value).filter(
|
||||||
|
([, value]) => value !== undefined && value !== null && value !== ""
|
||||||
|
)
|
||||||
|
) as {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await EmployeeService.getAllEmployees(requestParams);
|
||||||
setEmployees(response.data.data);
|
setEmployees(response.data.data);
|
||||||
setPagination(response.data.pagination);
|
setPagination(response.pagination);
|
||||||
return response;
|
return response;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@ -361,6 +408,7 @@ export const useEmployeeStore = defineStore("employee", () => {
|
|||||||
allEmployees,
|
allEmployees,
|
||||||
activeEmployees,
|
activeEmployees,
|
||||||
inactiveEmployees,
|
inactiveEmployees,
|
||||||
|
filters,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasError,
|
hasError,
|
||||||
getError,
|
getError,
|
||||||
|
|||||||
50
thanasoft-front/src/stores/financialStatisticsStore.ts
Normal file
50
thanasoft-front/src/stores/financialStatisticsStore.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import FinancialStatisticsService from "@/services/financialStatistics";
|
||||||
|
import type { FinancialStatistics } from "@/services/financialStatistics";
|
||||||
|
|
||||||
|
export const useFinancialStatisticsStore = defineStore(
|
||||||
|
"financialStatistics",
|
||||||
|
() => {
|
||||||
|
const statistics = ref<FinancialStatistics | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const isLoading = computed(() => loading.value);
|
||||||
|
const hasError = computed(() => error.value !== null);
|
||||||
|
const getError = computed(() => error.value);
|
||||||
|
const hasData = computed(() => statistics.value !== null);
|
||||||
|
|
||||||
|
async function fetchStatistics(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
statistics.value = await FinancialStatisticsService.getStatistics();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Erreur lors du chargement des statistiques financières.";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatistics(): void {
|
||||||
|
statistics.value = null;
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statistics,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
getError,
|
||||||
|
hasData,
|
||||||
|
fetchStatistics,
|
||||||
|
clearStatistics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
312
thanasoft-front/src/stores/leaveStore.ts
Normal file
312
thanasoft-front/src/stores/leaveStore.ts
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import LeaveService from "@/services/leave";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Leave,
|
||||||
|
LeaveStatus,
|
||||||
|
LeaveType,
|
||||||
|
CreateLeavePayload,
|
||||||
|
UpdateLeavePayload,
|
||||||
|
} from "@/services/leave";
|
||||||
|
|
||||||
|
export const useLeaveStore = defineStore("leave", () => {
|
||||||
|
const leaves = ref<Leave[]>([]);
|
||||||
|
const currentLeave = ref<Leave | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const filters = ref<{
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
employee_id?: number;
|
||||||
|
type?: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
sort_by: "start_date",
|
||||||
|
sort_direction: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allLeaves = computed(() => leaves.value);
|
||||||
|
const pendingLeaves = computed(() =>
|
||||||
|
leaves.value.filter((leave) => leave.status === "pending")
|
||||||
|
);
|
||||||
|
const approvedLeaves = computed(() =>
|
||||||
|
leaves.value.filter((leave) => leave.status === "approved")
|
||||||
|
);
|
||||||
|
const rejectedLeaves = computed(() =>
|
||||||
|
leaves.value.filter((leave) => leave.status === "rejected")
|
||||||
|
);
|
||||||
|
const cancelledLeaves = computed(() =>
|
||||||
|
leaves.value.filter((leave) => leave.status === "cancelled")
|
||||||
|
);
|
||||||
|
const isLoading = computed(() => loading.value);
|
||||||
|
const hasError = computed(() => error.value !== null);
|
||||||
|
const getError = computed(() => error.value);
|
||||||
|
const getPagination = computed(() => pagination.value);
|
||||||
|
const getLeaveById = computed(() => (id: number) =>
|
||||||
|
leaves.value.find((leave) => leave.id === id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const setLoading = (isLoadingState: boolean) => {
|
||||||
|
loading.value = isLoadingState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (message: string | null) => {
|
||||||
|
error.value = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLeaves = (items: Leave[]) => {
|
||||||
|
leaves.value = items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentLeave = (leave: Leave | null) => {
|
||||||
|
currentLeave.value = leave;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPagination = (meta: any) => {
|
||||||
|
if (meta) {
|
||||||
|
pagination.value = {
|
||||||
|
current_page: meta.current_page || 1,
|
||||||
|
last_page: meta.last_page || 1,
|
||||||
|
per_page: meta.per_page || 10,
|
||||||
|
total: meta.total || 0,
|
||||||
|
from: meta.from || 0,
|
||||||
|
to: meta.to || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
filters.value = {
|
||||||
|
...filters.value,
|
||||||
|
page: pagination.value.current_page,
|
||||||
|
per_page: pagination.value.per_page,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pagination.value = {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFilters = (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
employee_id?: number;
|
||||||
|
type?: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}) => {
|
||||||
|
filters.value = {
|
||||||
|
...filters.value,
|
||||||
|
...params,
|
||||||
|
page: params?.page ?? filters.value.page ?? 1,
|
||||||
|
per_page: params?.per_page ?? filters.value.per_page ?? 10,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLeaves = async (params?: {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
employee_id?: number;
|
||||||
|
type?: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
}) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFilters(params);
|
||||||
|
|
||||||
|
const requestParams = Object.fromEntries(
|
||||||
|
Object.entries(filters.value).filter(
|
||||||
|
([, value]) => value !== undefined && value !== null && value !== ""
|
||||||
|
)
|
||||||
|
) as {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
employee_id?: number;
|
||||||
|
type?: LeaveType;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_direction?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await LeaveService.getAllLeaves(requestParams);
|
||||||
|
setLeaves(response.data.data);
|
||||||
|
setPagination(response.pagination);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch leaves";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLeave = async (id: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LeaveService.getLeave(id);
|
||||||
|
const leave = LeaveService.unwrapLeave(response);
|
||||||
|
setCurrentLeave(leave);
|
||||||
|
return leave;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch leave";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLeave = async (payload: CreateLeavePayload) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LeaveService.createLeave(payload);
|
||||||
|
const leave = LeaveService.unwrapLeave(response);
|
||||||
|
leaves.value.unshift(leave);
|
||||||
|
setCurrentLeave(leave);
|
||||||
|
return leave;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to create leave";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLeave = async (payload: UpdateLeavePayload) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LeaveService.updateLeave(payload);
|
||||||
|
const updatedLeave = LeaveService.unwrapLeave(response);
|
||||||
|
|
||||||
|
const index = leaves.value.findIndex((leave) => leave.id === updatedLeave.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
leaves.value[index] = updatedLeave;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLeave.value && currentLeave.value.id === updatedLeave.id) {
|
||||||
|
setCurrentLeave(updatedLeave);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedLeave;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to update leave";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteLeave = async (id: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LeaveService.deleteLeave(id);
|
||||||
|
|
||||||
|
leaves.value = leaves.value.filter((leave) => leave.id !== id);
|
||||||
|
|
||||||
|
if (currentLeave.value && currentLeave.value.id === id) {
|
||||||
|
setCurrentLeave(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to delete leave";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCurrentLeave = () => {
|
||||||
|
setCurrentLeave(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStore = () => {
|
||||||
|
leaves.value = [];
|
||||||
|
currentLeave.value = null;
|
||||||
|
error.value = null;
|
||||||
|
pagination.value = {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
total: 0,
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaves,
|
||||||
|
currentLeave,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
filters,
|
||||||
|
allLeaves,
|
||||||
|
pendingLeaves,
|
||||||
|
approvedLeaves,
|
||||||
|
rejectedLeaves,
|
||||||
|
cancelledLeaves,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
getError,
|
||||||
|
getPagination,
|
||||||
|
getLeaveById,
|
||||||
|
fetchLeaves,
|
||||||
|
fetchLeave,
|
||||||
|
createLeave,
|
||||||
|
updateLeave,
|
||||||
|
deleteLeave,
|
||||||
|
clearCurrentLeave,
|
||||||
|
clearStore,
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -1,11 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<main
|
||||||
|
class="main-content position-relative max-height-vh-100 h-100 border-radius-lg"
|
||||||
|
>
|
||||||
|
<div class="py-4 container-fluid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h1>Statistiques clients</h1>
|
<h4 class="mb-0 font-weight-bolder">Statistiques clients</h4>
|
||||||
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
Vue d'ensemble du portefeuille clients
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary mb-0"
|
||||||
|
:disabled="store.isLoading"
|
||||||
|
@click="store.fetchStatistics()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt me-1" />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="row">
|
||||||
|
<div class="col-12 text-center py-6">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-secondary">Chargement des statistiques…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="store.hasError" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2" />
|
||||||
|
{{ store.getError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<client-stats-dashboard
|
||||||
|
v-else-if="store.hasData"
|
||||||
|
:stats="store.statistics"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import { defineComponent, onMounted } from "vue";
|
||||||
|
import { useClientStatisticsStore } from "@/stores/clientStatisticsStore";
|
||||||
|
import ClientStatsDashboard from "@/components/Organism/CRM/ClientStatsDashboard.vue";
|
||||||
|
import AppFooter from "@/examples/Footer.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "StatistiquesClients",
|
name: "StatistiquesClients",
|
||||||
};
|
components: {
|
||||||
|
ClientStatsDashboard,
|
||||||
|
AppFooter,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const store = useClientStatisticsStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchStatistics();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { store };
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -121,10 +121,21 @@ const confirmDeleteEmployee = (employeeId) => {
|
|||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
const employeeId = confirmModal.employeeId;
|
const employeeId = confirmModal.employeeId;
|
||||||
const employeeName = confirmModal.employeeName;
|
const employeeName = confirmModal.employeeName;
|
||||||
console.log("Test");
|
|
||||||
try {
|
try {
|
||||||
confirmModal.isLoading = true;
|
confirmModal.isLoading = true;
|
||||||
await employeeStore.deleteEmployee(employeeId);
|
await employeeStore.deleteEmployee(employeeId);
|
||||||
|
const lastVisibleItemOnPage = employeeStore.employees.length === 1;
|
||||||
|
const targetPage =
|
||||||
|
lastVisibleItemOnPage && employeeStore.getPagination.current_page > 1
|
||||||
|
? employeeStore.getPagination.current_page - 1
|
||||||
|
: employeeStore.getPagination.current_page;
|
||||||
|
|
||||||
|
await employeeStore.fetchEmployees({
|
||||||
|
page: targetPage,
|
||||||
|
per_page: employeeStore.getPagination.per_page || DEFAULT_PER_PAGE,
|
||||||
|
search: search.value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
notificationStore.success(
|
notificationStore.success(
|
||||||
"Employé supprimé",
|
"Employé supprimé",
|
||||||
`L'employé ${employeeName} a été supprimé avec succès.`
|
`L'employé ${employeeName} a été supprimé avec succès.`
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
:creation-type="creationType"
|
:creation-type="creationType"
|
||||||
:creation-type-title="creationTypeTitle"
|
:creation-type-title="creationTypeTitle"
|
||||||
:collaborators="collaborators"
|
:collaborators="collaborators"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:current-employee-name="currentEmployeeName"
|
||||||
:leave-form="leaveForm"
|
:leave-form="leaveForm"
|
||||||
:event-form="eventForm"
|
:event-form="eventForm"
|
||||||
@close="closeNewRequestModal"
|
@close="closeNewRequestModal"
|
||||||
@ -49,6 +51,9 @@ import InterventionMultiStepModal from "@/components/Organism/Agenda/Interventio
|
|||||||
import { useInterventionStore } from "@/stores/interventionStore";
|
import { useInterventionStore } from "@/stores/interventionStore";
|
||||||
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
||||||
import { useNotificationStore } from "@/stores/notification";
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
import { useLeaveStore } from "@/stores/leaveStore";
|
||||||
|
import { useEmployeeStore } from "@/stores/employeeStore";
|
||||||
|
import useAuthStore from "@/stores/auth";
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const monthBuckets = ref({});
|
const monthBuckets = ref({});
|
||||||
@ -64,8 +69,12 @@ const initialInterventionDate = ref("");
|
|||||||
const interventionStore = useInterventionStore();
|
const interventionStore = useInterventionStore();
|
||||||
const thanatopractitionerStore = useThanatopractitionerStore();
|
const thanatopractitionerStore = useThanatopractitionerStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
const leaveStore = useLeaveStore();
|
||||||
|
const employeeStore = useEmployeeStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const leaveForm = ref({
|
const leaveForm = ref({
|
||||||
|
employeeId: null,
|
||||||
employee: "",
|
employee: "",
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
@ -92,13 +101,53 @@ const practitioners = computed(
|
|||||||
|
|
||||||
const collaborators = computed(() =>
|
const collaborators = computed(() =>
|
||||||
practitioners.value.map((p) => ({
|
practitioners.value.map((p) => ({
|
||||||
id: p.id,
|
id: p.employee_id || p.employee?.id,
|
||||||
name:
|
name:
|
||||||
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
||||||
`Collaborateur #${p.id}`,
|
`Collaborateur #${p.id}`,
|
||||||
}))
|
}))
|
||||||
|
.filter((collaborator) => Boolean(collaborator.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isAdmin = computed(
|
||||||
|
() =>
|
||||||
|
authStore.hasRole("administrator") ||
|
||||||
|
authStore.hasRole("admin") ||
|
||||||
|
authStore.hasRole("super-admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentEmployee = computed(() => {
|
||||||
|
const currentUser = authStore.user;
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
employeeStore.employees.find(
|
||||||
|
(employee) => employee.user_id === currentUser.id
|
||||||
|
) ||
|
||||||
|
employeeStore.employees.find(
|
||||||
|
(employee) =>
|
||||||
|
!!employee.email &&
|
||||||
|
employee.email.toLowerCase() === currentUser.email.toLowerCase()
|
||||||
|
) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentEmployeeName = computed(() => currentEmployee.value?.full_name || "");
|
||||||
|
|
||||||
|
const resetLeaveForm = () => {
|
||||||
|
leaveForm.value = {
|
||||||
|
employeeId: isAdmin.value ? null : currentEmployee.value?.id || null,
|
||||||
|
employee: isAdmin.value ? "" : currentEmployeeName.value,
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
reason: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const backendToUiStatus = {
|
const backendToUiStatus = {
|
||||||
demande: "En attente",
|
demande: "En attente",
|
||||||
planifie: "Confirmé",
|
planifie: "Confirmé",
|
||||||
@ -177,7 +226,16 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchData(),
|
fetchData(),
|
||||||
thanatopractitionerStore.fetchThanatopractitioners(),
|
thanatopractitionerStore.fetchThanatopractitioners(),
|
||||||
|
employeeStore.fetchEmployees({
|
||||||
|
page: 1,
|
||||||
|
per_page: 500,
|
||||||
|
active: true,
|
||||||
|
sort_by: "first_name",
|
||||||
|
sort_direction: "asc",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
resetLeaveForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
@ -234,11 +292,13 @@ const handleRefresh = async () => {
|
|||||||
|
|
||||||
const handleNewRequest = () => {
|
const handleNewRequest = () => {
|
||||||
creationType.value = "";
|
creationType.value = "";
|
||||||
|
resetLeaveForm();
|
||||||
showNewRequestModal.value = true;
|
showNewRequestModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeNewRequestModal = () => {
|
const closeNewRequestModal = () => {
|
||||||
creationType.value = "";
|
creationType.value = "";
|
||||||
|
resetLeaveForm();
|
||||||
showNewRequestModal.value = false;
|
showNewRequestModal.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -276,32 +336,86 @@ const handleInterventionSubmit = async (formData) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitLeave = () => {
|
const submitLeave = async () => {
|
||||||
if (!leaveForm.value.employee || !leaveForm.value.startDate) {
|
const selectedEmployeeId = isAdmin.value
|
||||||
alert("Veuillez remplir les champs obligatoires de la demande de congé.");
|
? Number(leaveForm.value.employeeId)
|
||||||
|
: currentEmployee.value?.id;
|
||||||
|
const selectedEmployeeName = isAdmin.value
|
||||||
|
? leaveForm.value.employee
|
||||||
|
: currentEmployeeName.value;
|
||||||
|
const startDate = leaveForm.value.startDate;
|
||||||
|
const endDate = leaveForm.value.endDate || leaveForm.value.startDate;
|
||||||
|
|
||||||
|
if (!selectedEmployeeId || !startDate) {
|
||||||
|
notificationStore.error(
|
||||||
|
"Erreur",
|
||||||
|
"Veuillez renseigner l'employé et les dates du congé."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdLeave = await leaveStore.createLeave({
|
||||||
|
employee_id: selectedEmployeeId,
|
||||||
|
type: "conge",
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
reason: leaveForm.value.reason || null,
|
||||||
|
});
|
||||||
|
|
||||||
localPlanningItems.value.unshift({
|
localPlanningItems.value.unshift({
|
||||||
id: Date.now() + 1,
|
id: `leave-${createdLeave.id}`,
|
||||||
date: new Date(`${leaveForm.value.startDate}T09:00`).toISOString(),
|
date: new Date(`${createdLeave.start_date}T09:00`).toISOString(),
|
||||||
|
end: new Date(`${createdLeave.end_date}T18:00`).toISOString(),
|
||||||
type: "Congé",
|
type: "Congé",
|
||||||
deceased: `Congé: ${leaveForm.value.employee}`,
|
deceased: `Congé: ${selectedEmployeeName}`,
|
||||||
client: leaveForm.value.reason || "Demande de congé",
|
client: createdLeave.reason || "Demande de congé",
|
||||||
collaborator: leaveForm.value.employee,
|
collaborator:
|
||||||
|
createdLeave.employee?.full_name || selectedEmployeeName || "Employé",
|
||||||
status: "En attente",
|
status: "En attente",
|
||||||
});
|
});
|
||||||
|
|
||||||
leaveForm.value = {
|
notificationStore.created("Demande de congé");
|
||||||
employee: "",
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
reason: "",
|
|
||||||
};
|
|
||||||
closeNewRequestModal();
|
closeNewRequestModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating leave:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
"Erreur lors de la création de la demande de congé";
|
||||||
|
notificationStore.error("Erreur", errorMessage);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLeaveForm = ({ field, value }) => {
|
const updateLeaveForm = ({ field, value }) => {
|
||||||
|
if (field === "employee") {
|
||||||
|
const selectedCollaborator = collaborators.value.find(
|
||||||
|
(collaborator) => collaborator.name === value
|
||||||
|
);
|
||||||
|
|
||||||
|
leaveForm.value = {
|
||||||
|
...leaveForm.value,
|
||||||
|
employee: value,
|
||||||
|
employeeId: selectedCollaborator?.id || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "employeeId") {
|
||||||
|
const selectedCollaborator = collaborators.value.find(
|
||||||
|
(collaborator) => Number(collaborator.id) === Number(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
leaveForm.value = {
|
||||||
|
...leaveForm.value,
|
||||||
|
employeeId: value ? Number(value) : null,
|
||||||
|
employee: selectedCollaborator?.name || leaveForm.value.employee,
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
leaveForm.value = {
|
leaveForm.value = {
|
||||||
...leaveForm.value,
|
...leaveForm.value,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
|
|||||||
@ -1,11 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<main
|
||||||
|
class="main-content position-relative max-height-vh-100 h-100 border-radius-lg"
|
||||||
|
>
|
||||||
|
<div class="py-4 container-fluid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h1>Statistiques ventes</h1>
|
<h4 class="mb-0 font-weight-bolder">Statistiques ventes</h4>
|
||||||
|
<p class="mb-0 text-sm text-secondary">
|
||||||
|
Pilotage financier — CA, conversion, créances, avoirs
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary mb-0"
|
||||||
|
:disabled="store.isLoading"
|
||||||
|
@click="store.fetchStatistics()"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sync-alt me-1" />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="row">
|
||||||
|
<div class="col-12 text-center py-6">
|
||||||
|
<div class="spinner-border text-secondary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-secondary">Chargement des statistiques…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="store.hasError" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle me-2" />
|
||||||
|
{{ store.getError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<financial-stats-dashboard
|
||||||
|
v-else-if="store.hasData"
|
||||||
|
:stats="store.statistics"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import { defineComponent, onMounted } from "vue";
|
||||||
|
import { useFinancialStatisticsStore } from "@/stores/financialStatisticsStore";
|
||||||
|
import FinancialStatsDashboard from "@/components/Organism/Invoice/FinancialStatsDashboard.vue";
|
||||||
|
import AppFooter from "@/examples/Footer.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "StatistiquesVentes",
|
name: "StatistiquesVentes",
|
||||||
};
|
components: {
|
||||||
|
FinancialStatsDashboard,
|
||||||
|
AppFooter,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const store = useFinancialStatisticsStore();
|
||||||
|
onMounted(() => store.fetchStatistics());
|
||||||
|
return { store };
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user