Feat: Stat client et Devis
This commit is contained in:
parent
18071dcae7
commit
050a38c6bd
@ -228,6 +228,28 @@ class ClientController extends Controller
|
||||
], 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).
|
||||
*/
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
public function leaves(): HasMany
|
||||
{
|
||||
return $this->hasMany(Leave::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
});
|
||||
|
||||
$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) {
|
||||
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\WebmailMessageRepository;
|
||||
use App\Repositories\WebmailMessageRepositoryInterface;
|
||||
use App\Repositories\FinancialStatisticsRepositoryInterface;
|
||||
use App\Repositories\FinancialStatisticsRepository;
|
||||
use Illuminate\Support\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\TvaRateRepositoryInterface::class, \App\Repositories\TvaRateRepository::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 Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use App\Repositories\ClientActivityTimelineRepositoryInterface;
|
||||
use Illuminate\Support\Facades\Log as LaravelLog;
|
||||
@ -97,4 +98,105 @@ class ClientRepository extends BaseRepository implements ClientRepositoryInterfa
|
||||
|
||||
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
|
||||
{
|
||||
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_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'engine' => env('DB_ENGINE', 'InnoDB'),
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
@ -77,7 +77,7 @@ return [
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'engine' => env('DB_ENGINE', 'InnoDB'),
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
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) {
|
||||
// Make client_id nullable and remove cascade
|
||||
$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');
|
||||
|
||||
// Add fournisseur_id
|
||||
@ -34,7 +40,13 @@ return new class extends Migration
|
||||
|
||||
// Restore client_id to not nullable with cascade
|
||||
$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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(DeceasedSeeder::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\VehicleController;
|
||||
use App\Http\Controllers\Api\ConvoyController;
|
||||
use App\Http\Controllers\Api\LeaveController;
|
||||
use App\Http\Controllers\Api\WebmailController;
|
||||
use App\Http\Controllers\Api\FinancialStatisticsController;
|
||||
|
||||
|
||||
/*
|
||||
@ -61,6 +63,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
// Client management
|
||||
// IMPORTANT: Specific routes must come before apiResource
|
||||
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::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/{id}/agenda', [EmployeeController::class, 'agenda']);
|
||||
Route::apiResource('employees', EmployeeController::class);
|
||||
Route::apiResource('leaves', LeaveController::class);
|
||||
|
||||
// Thanatopractitioner management
|
||||
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'"
|
||||
:form="leaveForm"
|
||||
:collaborators="collaborators"
|
||||
:is-admin="isAdmin"
|
||||
:current-employee-name="currentEmployeeName"
|
||||
@update:form="$emit('update:leave-form', $event)"
|
||||
@submit="$emit('submit-leave')"
|
||||
@back="$emit('reset-type')"
|
||||
@ -90,6 +92,14 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentEmployeeName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
leaveForm: {
|
||||
type: Object,
|
||||
required: true,
|
||||
|
||||
@ -199,7 +199,7 @@
|
||||
|
||||
<div
|
||||
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">
|
||||
Affichage de {{ safeFrom }} à {{ safeTo }} sur
|
||||
@ -622,6 +622,11 @@ onMounted(() => {
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.pagination-footer {
|
||||
border-top: 1px solid rgba(131, 146, 171, 0.2);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
|
||||
@ -1,21 +1,26 @@
|
||||
<template>
|
||||
<form class="d-grid gap-2" @submit.prevent="$emit('submit')">
|
||||
<div>
|
||||
<label class="form-label">Employé</label>
|
||||
<select
|
||||
<div v-if="isAdmin">
|
||||
<label class="form-label">Rechercher un employé</label>
|
||||
<input
|
||||
:value="form.employee"
|
||||
class="form-select"
|
||||
@change="updateField('employee', $event.target.value)"
|
||||
>
|
||||
<option value="" disabled>Choisir un employé</option>
|
||||
class="form-control"
|
||||
list="planning-leave-collaborators"
|
||||
placeholder="Nom de l'employé"
|
||||
@input="updateField('employee', $event.target.value)"
|
||||
/>
|
||||
<datalist id="planning-leave-collaborators">
|
||||
<option
|
||||
v-for="collab in collaborators"
|
||||
:key="collab.id"
|
||||
:value="collab.name"
|
||||
>
|
||||
{{ collab.name }}
|
||||
</option>
|
||||
</select>
|
||||
/>
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<label class="form-label">Employé</label>
|
||||
<soft-input :model-value="currentEmployeeName" readonly />
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
@ -66,6 +71,14 @@ defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentEmployeeName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
collaborators: {
|
||||
type: Array,
|
||||
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,7 +40,9 @@ export interface Employee {
|
||||
}
|
||||
|
||||
export interface EmployeeListResponse {
|
||||
data: Employee[];
|
||||
data: {
|
||||
data: Employee[];
|
||||
};
|
||||
pagination: {
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
@ -52,22 +54,6 @@ export interface EmployeeListResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// For nested response structure
|
||||
export interface NestedEmployeeListResponse {
|
||||
data: {
|
||||
data: Employee[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmployeeResponse {
|
||||
data: Employee;
|
||||
message: string;
|
||||
@ -100,8 +86,8 @@ export const EmployeeService = {
|
||||
active?: boolean;
|
||||
sort_by?: string;
|
||||
sort_direction?: string;
|
||||
}): Promise<NestedEmployeeListResponse> {
|
||||
const response = await request<NestedEmployeeListResponse>({
|
||||
}): Promise<EmployeeListResponse> {
|
||||
const response = await request<EmployeeListResponse>({
|
||||
url: "/api/employees",
|
||||
method: "get",
|
||||
params,
|
||||
@ -177,8 +163,8 @@ export const EmployeeService = {
|
||||
async getActiveEmployees(params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<NestedEmployeeListResponse> {
|
||||
const response = await request<NestedEmployeeListResponse>({
|
||||
}): Promise<EmployeeListResponse> {
|
||||
const response = await request<EmployeeListResponse>({
|
||||
url: "/api/employees",
|
||||
method: "get",
|
||||
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,
|
||||
UpdateEmployeePayload,
|
||||
EmployeeListResponse,
|
||||
NestedEmployeeListResponse,
|
||||
} from "@/services/employee";
|
||||
|
||||
export const useEmployeeStore = defineStore("employee", () => {
|
||||
@ -17,6 +16,17 @@ export const useEmployeeStore = defineStore("employee", () => {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
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
|
||||
const pagination = ref({
|
||||
@ -79,6 +89,12 @@ export const useEmployeeStore = defineStore("employee", () => {
|
||||
from: meta.from || 0,
|
||||
to: meta.to || 0,
|
||||
};
|
||||
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
page: pagination.value.current_page,
|
||||
per_page: pagination.value.per_page,
|
||||
};
|
||||
} else {
|
||||
// Reset pagination if no meta provided
|
||||
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
|
||||
*/
|
||||
@ -107,9 +139,24 @@ export const useEmployeeStore = defineStore("employee", () => {
|
||||
setError(null);
|
||||
|
||||
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);
|
||||
setPagination(response.data.pagination);
|
||||
setPagination(response.pagination);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
@ -361,6 +408,7 @@ export const useEmployeeStore = defineStore("employee", () => {
|
||||
allEmployees,
|
||||
activeEmployees,
|
||||
inactiveEmployees,
|
||||
filters,
|
||||
isLoading,
|
||||
hasError,
|
||||
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>
|
||||
<div>
|
||||
<h1>Statistiques clients</h1>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
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",
|
||||
};
|
||||
components: {
|
||||
ClientStatsDashboard,
|
||||
AppFooter,
|
||||
},
|
||||
setup() {
|
||||
const store = useClientStatisticsStore();
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchStatistics();
|
||||
});
|
||||
|
||||
return { store };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -121,10 +121,21 @@ const confirmDeleteEmployee = (employeeId) => {
|
||||
const handleConfirmDelete = async () => {
|
||||
const employeeId = confirmModal.employeeId;
|
||||
const employeeName = confirmModal.employeeName;
|
||||
console.log("Test");
|
||||
try {
|
||||
confirmModal.isLoading = true;
|
||||
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(
|
||||
"Employé supprimé",
|
||||
`L'employé ${employeeName} a été supprimé avec succès.`
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
:creation-type="creationType"
|
||||
:creation-type-title="creationTypeTitle"
|
||||
:collaborators="collaborators"
|
||||
:is-admin="isAdmin"
|
||||
:current-employee-name="currentEmployeeName"
|
||||
:leave-form="leaveForm"
|
||||
:event-form="eventForm"
|
||||
@close="closeNewRequestModal"
|
||||
@ -49,6 +51,9 @@ import InterventionMultiStepModal from "@/components/Organism/Agenda/Interventio
|
||||
import { useInterventionStore } from "@/stores/interventionStore";
|
||||
import { useThanatopractitionerStore } from "@/stores/thanatopractitionerStore";
|
||||
import { useNotificationStore } from "@/stores/notification";
|
||||
import { useLeaveStore } from "@/stores/leaveStore";
|
||||
import { useEmployeeStore } from "@/stores/employeeStore";
|
||||
import useAuthStore from "@/stores/auth";
|
||||
|
||||
// State
|
||||
const monthBuckets = ref({});
|
||||
@ -64,8 +69,12 @@ const initialInterventionDate = ref("");
|
||||
const interventionStore = useInterventionStore();
|
||||
const thanatopractitionerStore = useThanatopractitionerStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const leaveStore = useLeaveStore();
|
||||
const employeeStore = useEmployeeStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const leaveForm = ref({
|
||||
employeeId: null,
|
||||
employee: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
@ -92,13 +101,53 @@ const practitioners = computed(
|
||||
|
||||
const collaborators = computed(() =>
|
||||
practitioners.value.map((p) => ({
|
||||
id: p.id,
|
||||
id: p.employee_id || p.employee?.id,
|
||||
name:
|
||||
`${p.employee?.first_name || ""} ${p.employee?.last_name || ""}`.trim() ||
|
||||
`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 = {
|
||||
demande: "En attente",
|
||||
planifie: "Confirmé",
|
||||
@ -177,7 +226,16 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchData(),
|
||||
thanatopractitionerStore.fetchThanatopractitioners(),
|
||||
employeeStore.fetchEmployees({
|
||||
page: 1,
|
||||
per_page: 500,
|
||||
active: true,
|
||||
sort_by: "first_name",
|
||||
sort_direction: "asc",
|
||||
}),
|
||||
]);
|
||||
|
||||
resetLeaveForm();
|
||||
});
|
||||
|
||||
// Methods
|
||||
@ -234,11 +292,13 @@ const handleRefresh = async () => {
|
||||
|
||||
const handleNewRequest = () => {
|
||||
creationType.value = "";
|
||||
resetLeaveForm();
|
||||
showNewRequestModal.value = true;
|
||||
};
|
||||
|
||||
const closeNewRequestModal = () => {
|
||||
creationType.value = "";
|
||||
resetLeaveForm();
|
||||
showNewRequestModal.value = false;
|
||||
};
|
||||
|
||||
@ -276,32 +336,86 @@ const handleInterventionSubmit = async (formData) => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitLeave = () => {
|
||||
if (!leaveForm.value.employee || !leaveForm.value.startDate) {
|
||||
alert("Veuillez remplir les champs obligatoires de la demande de congé.");
|
||||
const submitLeave = async () => {
|
||||
const selectedEmployeeId = isAdmin.value
|
||||
? 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;
|
||||
}
|
||||
|
||||
localPlanningItems.value.unshift({
|
||||
id: Date.now() + 1,
|
||||
date: new Date(`${leaveForm.value.startDate}T09:00`).toISOString(),
|
||||
type: "Congé",
|
||||
deceased: `Congé: ${leaveForm.value.employee}`,
|
||||
client: leaveForm.value.reason || "Demande de congé",
|
||||
collaborator: leaveForm.value.employee,
|
||||
status: "En attente",
|
||||
});
|
||||
try {
|
||||
const createdLeave = await leaveStore.createLeave({
|
||||
employee_id: selectedEmployeeId,
|
||||
type: "conge",
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
reason: leaveForm.value.reason || null,
|
||||
});
|
||||
|
||||
leaveForm.value = {
|
||||
employee: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
reason: "",
|
||||
};
|
||||
closeNewRequestModal();
|
||||
localPlanningItems.value.unshift({
|
||||
id: `leave-${createdLeave.id}`,
|
||||
date: new Date(`${createdLeave.start_date}T09:00`).toISOString(),
|
||||
end: new Date(`${createdLeave.end_date}T18:00`).toISOString(),
|
||||
type: "Congé",
|
||||
deceased: `Congé: ${selectedEmployeeName}`,
|
||||
client: createdLeave.reason || "Demande de congé",
|
||||
collaborator:
|
||||
createdLeave.employee?.full_name || selectedEmployeeName || "Employé",
|
||||
status: "En attente",
|
||||
});
|
||||
|
||||
notificationStore.created("Demande de congé");
|
||||
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 }) => {
|
||||
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,
|
||||
[field]: value,
|
||||
|
||||
@ -1,11 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Statistiques ventes</h1>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
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",
|
||||
};
|
||||
components: {
|
||||
FinancialStatsDashboard,
|
||||
AppFooter,
|
||||
},
|
||||
setup() {
|
||||
const store = useFinancialStatisticsStore();
|
||||
onMounted(() => store.fetchStatistics());
|
||||
return { store };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user