Feat: Stat client et Devis

This commit is contained in:
kevin 2026-05-11 13:30:24 +03:00
parent 18071dcae7
commit 050a38c6bd
58 changed files with 4682 additions and 856 deletions

View File

@ -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).
*/

View File

@ -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);
}
}
}

View 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);
}
}
}

View 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.',
];
}
}

View 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.',
];
}
}

View File

@ -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,
];
}),
];
}
}

View File

@ -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,
]
),
];
}
}

View 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)
),
];
}
}

View File

@ -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.
*/

View 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');
}
}

View 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');
}
}

View File

@ -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));
});

View File

@ -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);
}
/**

View File

@ -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,
];
}
}

View File

@ -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;
}

View File

@ -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,
];
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
interface FinancialStatisticsRepositoryInterface
{
public function getStatistics(): array;
}

View 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,
]);
}
}

View 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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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'),
]) : [],

View File

@ -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');
});
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View 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.');
}
}

View File

@ -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);
}
}

View 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]));
}
}
}
}
}
}

View 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.');
}
}

View File

@ -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']);

View 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">&nbsp;${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>

View File

@ -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>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">
&nbsp;{{ 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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: () => [],

View 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;

View File

@ -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: {

View 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;

View 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;

View 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,
};
}
);

View File

@ -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,

View 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,
};
}
);

View 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,
};
});

View File

@ -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>

View File

@ -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.`

View File

@ -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,

View File

@ -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>