Compare commits

...

5 Commits

Author SHA1 Message Date
gitea-actions
e022cfac98 chore: bump version to v0.1.43
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-16 15:26:24 +00:00
e827128392 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-16 16:26:13 +01:00
86cdec50c6 feat : ajout de l'export récap congés et RTT 2026-03-16 16:26:06 +01:00
gitea-actions
443ed1e003 chore: bump version to v0.1.42
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-16 13:38:06 +00:00
cef364fcec fix : fix affichage employé sur les pages d'heures + ajout d'un filtre employé sur la liste + fix impression recap salaire
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 14:37:00 +01:00
21 changed files with 519 additions and 37 deletions

View File

@@ -23,7 +23,8 @@
"Bash(sudo apt-get:*)", "Bash(sudo apt-get:*)",
"Bash(npx xlsx-cli:*)", "Bash(npx xlsx-cli:*)",
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)", "Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
"Bash(pip3 install:*)" "Bash(pip3 install:*)",
"Bash(find:*)"
] ]
} }
} }

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.41' app.version: '0.1.43'

View File

@@ -40,6 +40,10 @@ Documents complementaires:
## 3) Heures (vue jour) ## 3) Heures (vue jour)
- Visibilité des employés:
- vue jour: un employé sans contrat à la date sélectionnée est masqué
- vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
- même règle pour les heures classiques et les heures conducteurs
- Saisie par salarié et par date: - Saisie par salarié et par date:
- matin / après-midi / soir - matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi - pour `PRESENCE`: demi-journées matin/après-midi
@@ -134,9 +138,11 @@ Documents complementaires:
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour` - `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour` - `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs - les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk) - Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine: - Vue semaine:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée - jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si nightMinutes > dayMinutes, et total hebdo dans la colonne Jour/Nuit sem.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée - totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- pas de calcul d'heures supplémentaires pour les conducteurs - pas de calcul d'heures supplémentaires pour les conducteurs
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période) - Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
@@ -170,6 +176,11 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Modification employé: - Modification employé:
- uniquement prénom, nom, site - uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer - pas de modification de contrat depuis ce drawer
- Liste employés — filtre par statut de contrat:
- 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
- "Avec contrat": employés ayant une période de contrat active à la date du jour
- "Sans contrat": employés sans période de contrat active
- "Tous": aucun filtrage sur le contrat
- Détail employé: - Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat - onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours") - chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
@@ -270,7 +281,25 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- affichage: - affichage:
- le compteur global RTT est affiché en **heures** (format `Xh00`) - le compteur global RTT est affiché en **heures** (format `Xh00`)
## 10) Récapitulatif Salaire (PDF mensuel) ## 10) Export récap. congés & RTT (PDF)
- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
- Endpoint: `GET /api/leave-recap/print`
- Seuls les employés avec contrat actif sont inclus
- Données groupées par site
### Colonnes du tableau
| Colonne | Logique |
|---------|---------|
| Nom | lastName + firstName |
| Contrat | Contract.name |
| CP Acquis (N-1) | Report de l'exercice précédent (acquiredDays du computeYearSummary) |
| Samedi acquis | Report N-1 samedis. Forfait: `-` |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`) - Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage - Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
@@ -297,7 +326,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) | | CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle | | Observations | — | Colonne vide pour saisie manuelle |
## 11) Notifications ## 12) Notifications
- Icône cloche en topbar: - Icône cloche en topbar:
- badge = nombre de notifications non lues - badge = nombre de notifications non lues

View File

@@ -14,6 +14,7 @@
<span>+25%</span> <span>+25%</span>
<span>+50%</span> <span>+50%</span>
<span>Total <br>récup.</span> <span>Total <br>récup.</span>
<span>Panier <br>nuit</span>
</div> </div>
<div class="border-x border-b border-primary-500 rounded-b-md"> <div class="border-x border-b border-primary-500 rounded-b-md">
@@ -68,6 +69,9 @@
<div class="font-semibold"> <div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }} {{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div> </div>
<div class="font-semibold">
{{ (row.weeklyNightBasketCount ?? 0) > 0 ? row.weeklyNightBasketCount : '-' }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -107,13 +107,19 @@ export const useDriverHoursPage = () => {
}) })
}) })
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id))) const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => { const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null if (!weeklySummary.value) return null
return { return {
...weeklySummary.value, ...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId)) rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
} }
}) })
@@ -362,7 +368,8 @@ export const useDriverHoursPage = () => {
const getRowMetrics = (employeeId: number) => { const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow() const row = rows.value[employeeId] ?? emptyRow()
const dayMinutes = toMinutes(row.dayHours) const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours) const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours) const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes const totalMinutes = dayMinutes + nightMinutes + workshopMinutes
@@ -917,6 +924,7 @@ export const useDriverHoursPage = () => {
selectedSiteIds, selectedSiteIds,
employees, employees,
visibleEmployees, visibleEmployees,
displayedEmployees,
rows, rows,
absenceTypes, absenceTypes,
absenceForm, absenceForm,

View File

@@ -77,7 +77,7 @@ export const useHoursPage = () => {
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}` return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)' const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) 0.3fr'
const sites = computed<Site[]>(() => { const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>() const siteMap = new Map<number, Site>()
@@ -109,13 +109,19 @@ export const useHoursPage = () => {
}) })
}) })
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id))) const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => { const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null if (!weeklySummary.value) return null
return { return {
...weeklySummary.value, ...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId)) rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
} }
}) })
@@ -1096,6 +1102,7 @@ export const useHoursPage = () => {
selectedSiteIds, selectedSiteIds,
employees, employees,
visibleEmployees, visibleEmployees,
displayedEmployees,
rows, rows,
absenceTypes, absenceTypes,
absenceForm, absenceForm,

View File

@@ -38,7 +38,7 @@
<DriverHoursDayView <DriverHoursDayView
v-if="viewMode === 'day'" v-if="viewMode === 'day'"
v-model:rows="rows" v-model:rows="rows"
:employees="visibleEmployees" :employees="displayedEmployees"
:is-admin="isAdmin" :is-admin="isAdmin"
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
@@ -121,6 +121,7 @@ const {
selectedSiteIds, selectedSiteIds,
employees, employees,
visibleEmployees, visibleEmployees,
displayedEmployees,
rows, rows,
absenceTypes, absenceTypes,
absenceForm, absenceForm,

View File

@@ -4,6 +4,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1> <h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="handleLeaveRecapPrint"
>
Export récap. congés
</button>
<button <button
type="button" type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@@ -20,11 +27,19 @@
</button> </button>
</div> </div>
</div> </div>
<div class="flex gap-10 py-7"> <div class="flex gap-3 py-7">
<div class="w-80"> <div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/> <EmployeeNameFilterInput v-model="employeeFilter"/>
</div> </div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/> <SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<select
v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
>
<option value="active">Avec contrat</option>
<option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
</div> </div>
</div> </div>
@@ -49,7 +64,7 @@
<div class="text-center text-[20px]"> <div class="text-center text-[20px]">
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p> <p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
<p>Nom du poste occupé</p> <p>Nom du poste occupé</p>
<p>Site ({{ employee.site?.name ?? '-' }})</p> <p>{{ employee.site?.name ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -248,20 +263,21 @@ const employees = ref<Employee[]>([])
const sites = ref<Site[]>([]) const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([]) const contracts = ref<Contract[]>([])
const employeeFilter = ref('') const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const filteredEmployees = computed<Employee[]>(() => { const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return [] if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase() const filter = employeeFilter.value.trim().toLowerCase()
const bySite = employees.value.filter((employee) => { return employees.value.filter((employee) => {
const siteId = employee.site?.id const siteId = employee.site?.id
return !!siteId && selectedSiteIds.value.includes(siteId) if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
})
if (!filter) return bySite if (contractStatusFilter.value === 'active' && !employee.hasActiveContract) return false
if (contractStatusFilter.value === 'inactive' && employee.hasActiveContract) return false
return bySite.filter((employee) => { if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? '' const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? '' const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter) return firstName.includes(filter) || lastName.includes(filter)
@@ -521,6 +537,10 @@ const openCreate = () => {
isDrawerOpen.value = true isDrawerOpen.value = true
} }
const handleLeaveRecapPrint = async () => {
await printPdf('/leave-recap/print')
}
const handleSalaryRecapPrint = async (month: string) => { const handleSalaryRecapPrint = async (month: string) => {
await printPdf(`/salary-recap/print?month=${month}`) await printPdf(`/salary-recap/print?month=${month}`)
isSalaryRecapOpen.value = false isSalaryRecapOpen.value = false

View File

@@ -38,7 +38,7 @@
<HoursDayView <HoursDayView
v-if="viewMode === 'day'" v-if="viewMode === 'day'"
v-model:rows="rows" v-model:rows="rows"
:employees="visibleEmployees" :employees="displayedEmployees"
:is-admin="isAdmin" :is-admin="isAdmin"
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
@@ -126,6 +126,7 @@ const {
selectedSiteIds, selectedSiteIds,
employees, employees,
visibleEmployees, visibleEmployees,
displayedEmployees,
rows, rows,
absenceTypes, absenceTypes,
absenceForm, absenceForm,

View File

@@ -27,6 +27,7 @@ export type Employee = {
lastName: string lastName: string
site: Site site: Site
contract?: Contract | null contract?: Contract | null
hasActiveContract?: boolean
isDriver?: boolean isDriver?: boolean
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM' currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null currentContractStartDate?: string | null

View File

@@ -54,6 +54,7 @@ export type WeeklyWorkHourDailySummary = {
hasAbsence?: boolean hasAbsence?: boolean
absenceLabel?: string | null absenceLabel?: string | null
absenceColor?: string | null absenceColor?: string | null
hasNightBasket?: boolean
hasBreakfast?: boolean hasBreakfast?: boolean
hasLunch?: boolean hasLunch?: boolean
hasDinner?: boolean hasDinner?: boolean
@@ -78,11 +79,13 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvertime25Minutes?: number weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number weeklyRecoveryMinutes?: number
weeklyNightBasketCount?: number
isDriver?: boolean isDriver?: boolean
weeklyBreakfastCount?: number weeklyBreakfastCount?: number
weeklyLunchCount?: number weeklyLunchCount?: number
weeklyDinnerCount?: number weeklyDinnerCount?: number
weeklyOvernightCount?: number weeklyOvernightCount?: number
hasContractForWeek?: boolean
} }
export type WeeklyWorkHourSummary = { export type WeeklyWorkHourSummary = {

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\LeaveRecapPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/leave-recap/print',
provider: LeaveRecapPrintProvider::class,
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class LeaveRecapPrint {}

View File

@@ -16,6 +16,7 @@ final class WeeklyDaySummary
public bool $hasAbsence = false, public bool $hasAbsence = false,
public ?string $absenceLabel = null, public ?string $absenceLabel = null,
public ?string $absenceColor = null, public ?string $absenceColor = null,
public bool $hasNightBasket = false,
public bool $hasBreakfast = false, public bool $hasBreakfast = false,
public bool $hasLunch = false, public bool $hasLunch = false,
public bool $hasDinner = false, public bool $hasDinner = false,

View File

@@ -27,10 +27,12 @@ final class WeeklySummaryRow
public int $weeklyOvertime25Minutes, public int $weeklyOvertime25Minutes,
public int $weeklyOvertime50Minutes, public int $weeklyOvertime50Minutes,
public int $weeklyRecoveryMinutes, public int $weeklyRecoveryMinutes,
public int $weeklyNightBasketCount = 0,
public bool $isDriver = false, public bool $isDriver = false,
public int $weeklyBreakfastCount = 0, public int $weeklyBreakfastCount = 0,
public int $weeklyLunchCount = 0, public int $weeklyLunchCount = 0,
public int $weeklyDinnerCount = 0, public int $weeklyDinnerCount = 0,
public int $weeklyOvernightCount = 0, public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true,
) {} ) {}
} }

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: EmployeeWriteProcessor::class, processor: EmployeeWriteProcessor::class,
order: ['site.name' => 'ASC', 'displayOrder' => 'ASC', 'lastName' => 'ASC', 'firstName' => 'ASC'],
)] )]
#[ORM\Entity(repositoryClass: EmployeeRepository::class)] #[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\Table(name: 'employees')] #[ORM\Table(name: 'employees')]
@@ -260,6 +261,12 @@ class Employee
return $this; return $this;
} }
#[Groups(['employee:read'])]
public function getHasActiveContract(): bool
{
return null !== $this->resolveCurrentContractPeriod();
}
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public function getIsDriver(): bool public function getIsDriver(): bool
{ {

View File

@@ -87,8 +87,7 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
->addSelect('s') ->addSelect('s')
->leftJoin('e.contract', 'c') ->leftJoin('e.contract', 'c')
->addSelect('c') ->addSelect('c')
->orderBy('s.displayOrder', 'ASC') ->orderBy('s.name', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC') ->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC') ->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC') ->addOrderBy('e.firstName', 'ASC')

View File

@@ -126,7 +126,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float * previousYearRemainingDays: float
* } * }
*/ */
private function computeYearSummary(Employee $employee, int $targetYear): ?array public function computeYearSummary(Employee $employee, int $targetYear): ?array
{ {
$firstYear = $this->resolveFirstComputationYear($employee); $firstYear = $this->resolveFirstComputationYear($employee);
if ($targetYear < $firstYear) { if ($targetYear < $firstYear) {
@@ -286,6 +286,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $targetSummary; return $targetSummary;
} }
public function resolveLeaveYearForToday(Employee $employee): int
{
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
return $this->resolveCurrentLeaveYear($today);
}
private function resolveEffectivePeriodStart( private function resolveEffectivePeriodStart(
Employee $employee, Employee $employee,
DateTimeImmutable $from, DateTimeImmutable $from,

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\LeaveRuleCode;
use App\Enum\TrackingMode;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Service\PublicHolidayServiceInterface;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private EmployeeRepository $employeeRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$today = new DateTimeImmutable('today');
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
$siteGroups = [];
foreach ($employees as $employee) {
if (!$employee->getHasActiveContract()) {
continue;
}
$site = $employee->getSite();
$siteId = $site ? $site->getId() : 0;
if (!isset($siteGroups[$siteId])) {
$siteGroups[$siteId] = [
'name' => $site ? $site->getName() : 'Sans site',
'color' => $site?->getColor() ?? '#ffd7d7',
'employees' => [],
];
}
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today);
$this->entityManager->clear();
}
// Re-load Twig environment after clear
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('leave-recap/print.html.twig', [
'today' => $today,
'siteGroups' => $siteGroups,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf('recap_conges_%s.pdf', $today->format('Y-m-d'));
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$acquiredDays = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->resolveLeaveYear($employee, $today);
$ruleCode = $isForfait ? LeaveRuleCode::FORFAIT_218 : LeaveRuleCode::CDI_CDD_NON_FORFAIT;
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $leaveYear);
if (null !== $balance) {
$acquiredDays = $balance->getOpeningDays();
$acquiredSaturdays = $isForfait ? '-' : (string) $balance->getOpeningSaturdays();
}
if ($isForfait) {
try {
$cpN = (string) $this->computeForfaitAcquiredDays($employee, $today);
} catch (Throwable) {
$cpN = '-';
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'acquiredDays' => $acquiredDays,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function resolveLeaveYear(Employee $employee, DateTimeImmutable $today): int
{
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
return $month >= 6 ? $year + 1 : $year;
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
{
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBase50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function computeForfaitAcquiredDays(Employee $employee, DateTimeImmutable $today): float
{
$year = (int) $today->format('Y');
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
$contractStartRaw = $employee->getCurrentContractStartDate();
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
$contractStart = DateTimeImmutable::createFromFormat('!Y-m-d', trim($contractStartRaw));
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
$from = $contractStart;
}
}
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
$contractEnd = DateTimeImmutable::createFromFormat('!Y-m-d', trim($contractEndRaw));
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
$to = $contractEnd;
}
}
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
$businessDays = 0;
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
if ($weekDay <= 5 && !isset($holidays[$cursor->format('Y-m-d')])) {
++$businessDays;
}
}
return (float) max(0, $businessDays - 218);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -187,16 +187,17 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
continue; continue;
} }
$weeklyDayMinutes = 0; $weeklyDayMinutes = 0;
$weeklyNightMinutes = 0; $weeklyNightMinutes = 0;
$weeklyWorkshopMinutes = 0; $weeklyWorkshopMinutes = 0;
$weeklyTotalMinutes = 0; $weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0; $weeklyPresenceCount = 0.0;
$weeklyBreakfastCount = 0; $weeklyNightBasketCount = 0;
$weeklyLunchCount = 0; $weeklyBreakfastCount = 0;
$weeklyDinnerCount = 0; $weeklyLunchCount = 0;
$weeklyOvernightCount = 0; $weeklyDinnerCount = 0;
$daily = []; $weeklyOvernightCount = 0;
$daily = [];
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées. // Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd] $weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]] ?? $contractsByEmployeeDate[$employeeId][$days[0]]
@@ -208,8 +209,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]] ?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI; ?? ContractNature::CDI;
$employeeContractsByDate = []; $employeeContractsByDate = [];
$hasContractForWeek = false;
foreach ($days as $date) { foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null; $employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
if (null !== $employeeContractsByDate[$date]) {
$hasContractForWeek = true;
}
} }
foreach ($days as $date) { foreach ($days as $date) {
@@ -228,11 +233,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$dayMinutes = ($entry['dayHoursMinutes'] ?? 0); $dayMinutes = ($entry['dayHoursMinutes'] ?? 0);
$nightMinutes = ($entry['nightHoursMinutes'] ?? 0); $nightMinutes = ($entry['nightHoursMinutes'] ?? 0);
$workshopMinutes = ($entry['workshopHoursMinutes'] ?? 0); $workshopMinutes = ($entry['workshopHoursMinutes'] ?? 0);
$totalMinutes = $dayMinutes + $nightMinutes + $workshopMinutes; $totalMinutes = $dayMinutes + $nightMinutes + $workshopMinutes + $creditedMinutes;
$hasBreakfast = $entry['hasBreakfast'] ?? false; $dayMinutes += $creditedMinutes;
$hasLunch = $entry['hasLunch'] ?? false; $hasBreakfast = $entry['hasBreakfast'] ?? false;
$hasDinner = $entry['hasDinner'] ?? false; $hasLunch = $entry['hasLunch'] ?? false;
$hasOvernight = $entry['hasOvernight'] ?? false; $hasDinner = $entry['hasDinner'] ?? false;
$hasOvernight = $entry['hasOvernight'] ?? false;
if ($hasBreakfast) { if ($hasBreakfast) {
++$weeklyBreakfastCount; ++$weeklyBreakfastCount;
} }
@@ -265,6 +271,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$present = min(1.0, $morning + $afternoon + $creditedPresence); $present = min(1.0, $morning + $afternoon + $creditedPresence);
} }
$hasNightBasket = $nightMinutes > $dayMinutes && $nightMinutes > 0;
if ($hasNightBasket) {
++$weeklyNightBasketCount;
}
$weeklyDayMinutes += $dayMinutes; $weeklyDayMinutes += $dayMinutes;
$weeklyNightMinutes += $nightMinutes; $weeklyNightMinutes += $nightMinutes;
$weeklyWorkshopMinutes += $workshopMinutes; $weeklyWorkshopMinutes += $workshopMinutes;
@@ -283,6 +294,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false, hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null, absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null, absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
hasNightBasket: $hasNightBasket,
hasBreakfast: $hasBreakfast, hasBreakfast: $hasBreakfast,
hasLunch: $hasLunch, hasLunch: $hasLunch,
hasDinner: $hasDinner, hasDinner: $hasDinner,
@@ -325,11 +337,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvertime25Minutes: $weeklyOvertime25Minutes, weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes, weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes, weeklyRecoveryMinutes: $weeklyRecoveryMinutes,
weeklyNightBasketCount: $weeklyNightBasketCount,
isDriver: $isDriver, isDriver: $isDriver,
weeklyBreakfastCount: $weeklyBreakfastCount, weeklyBreakfastCount: $weeklyBreakfastCount,
weeklyLunchCount: $weeklyLunchCount, weeklyLunchCount: $weeklyLunchCount,
weeklyDinnerCount: $weeklyDinnerCount, weeklyDinnerCount: $weeklyDinnerCount,
weeklyOvernightCount: $weeklyOvernightCount, weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
); );
} }

View File

@@ -0,0 +1,124 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Récapitulatif Congés & RTT</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 10px;
}
.title-bar {
position: relative;
margin: 0 0 6mm 0;
}
h1 {
text-align: center;
font-size: 18px;
margin: 0;
}
.date-box {
position: absolute;
top: 0;
right: 0;
border: 2px solid #000;
padding: 4px 12px;
font-size: 14px;
font-weight: 700;
}
table.recap {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 4px solid #0a0a0a;
}
th, td {
border: 2px solid #0a0a0a;
padding: 3px 5px;
vertical-align: middle;
overflow: hidden;
white-space: nowrap;
}
.site-header td {
font-weight: 700;
font-size: 12px;
text-align: center;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 10px;
white-space: normal;
}
td.name {
text-align: left;
font-weight: bold;
}
td.base { text-align: center; }
td.num { text-align: center; }
td.obs { min-width: 40mm; }
tbody td { font-size: 10px; }
</style>
</head>
<body>
<div class="title-bar">
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="date-box">{{ today|date('d/m/Y') }}</div>
</div>
<table class="recap">
<thead>
<tr>
<th style="text-align: left;">Nom</th>
<th>Contrat</th>
<th>CP Acquis<br>(N-1)</th>
<th>CP<br>N</th>
<th>Samedi<br>acquis</th>
<th>RTT</th>
<th style="width: 40mm;">Observations</th>
</tr>
</thead>
<tbody>
{% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="7">
{{ group.name }}
</td>
</tr>
{% for row in group.employees %}
<tr>
<td class="name">{{ row.lastName }} {{ row.firstName }}</td>
<td class="base">{{ row.contractName ?? '' }}</td>
<td class="num">{{ row.acquiredDays }}</td>
<td class="num">{{ row.cpN }}</td>
<td class="num">{{ row.acquiredSaturdays }}</td>
<td class="num">{{ row.rtt }}</td>
<td class="obs"></td>
</tr>
{% else %}
<tr>
<td colspan="7">Aucun employé.</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -60,7 +60,7 @@
thead th { thead th {
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
font-size: 11px; font-size: 10px;
white-space: normal; white-space: normal;
} }
@@ -78,7 +78,7 @@
} }
td.obs { } td.obs { }
tbody td { font-size: 12px; } tbody td { font-size: 10px; }
</style> </style>
</head> </head>
<body> <body>