Compare commits

...

13 Commits

Author SHA1 Message Date
6df37d15c1 feat : version mobile écran Utilisateurs
Tableau desktop masqué sous 1024px, remplacé par des cards
cliquables avec username, badge statut, employé lié, accès et sites.
Titre et bouton ajouter adaptés pour mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:11:11 +02:00
08d05a9a52 feat : version mobile écran Heures (jour + semaine)
- HoursDayView : cards empilées en mobile avec inputs par paire,
  pills absence/férié/formation, statut validation explicite
- HoursWeekView : cards par employé avec jours verticaux et totaux
- HoursToolbar : recherche + bouton filtre drawer en mobile,
  navigation date pleine largeur, filtres sites/vue dans drawer
- AppDrawer : ajout bouton fermer (croix) sur tous les drawers
- TimeSelect mobile : pas de dropdown, clavier numérique direct,
  arrondi au quart d'heure, clamp à 23:45 max

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:09:26 +02:00
269f660ddf feat : version mobile écran récap congés + croix sidebar bleue
Récap congés : tableau desktop masqué sous 1024px, remplacé par
des cards empilées (nom, site, contrat, grille 2x2 des compteurs).
Titre responsive (text-2xl mobile, text-4xl desktop).
Croix de fermeture sidebar en bleu primary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:03:20 +02:00
09a0d46320 feat : sidebar et navbar responsive mobile
Sidebar en overlay slide-in sous 1024px avec hamburger menu,
overlay semi-transparent, et fermeture auto au clic sur un lien.
Navbar adaptée avec padding réduit et username masqué sur petit écran.
Dropdown notifications responsive (largeur relative au viewport).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:50:09 +02:00
gitea-actions
b2f6fdf222 chore: bump version to v0.1.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-20 06:25:18 +00:00
0fe82c63c5 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-20 08:25:11 +02:00
849d19f124 fix : autoriser docker/php/config/php.ini dans .dockerignore pour le build prod
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:24:39 +02:00
gitea-actions
d230a252b6 chore: bump version to v0.1.92
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-04-20 06:21:05 +00:00
d46e7c04d5 fix : copier la config PHP custom (memory_limit 512M) dans l'image de prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:20:26 +02:00
gitea-actions
fe0910a661 chore: bump version to v0.1.91
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-17 14:58:36 +00:00
ff7566d4cd feat : export PDF heures groupé depuis la liste employés + memory_limit 256M
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouveau endpoint GET /yearly-hours/print-all (admin, par mois uniquement)
- Service YearlyHoursExportBuilder extrait du provider existant (logique partagée)
- EmployeeYearlyHoursPrintProvider refactorisé pour utiliser le builder
- Template print-all.html.twig avec saut de page entre chaque employé
- Drawer BulkYearlyHoursDrawer avec loader "Génération en cours..."
- Bouton "Export heures" ajouté sur la page liste employés
- PHP memory_limit passé de 128M à 256M dans php.ini (nécessaire pour Dompdf multi-employés)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:57:58 +02:00
gitea-actions
2f25a3cd52 chore: bump version to v0.1.90
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-17 09:47:42 +00:00
1fe7f2cdde feat : agence d'intérim sur les contrats INTERIM + renommage Types d'absence en Types de statut + colonne Absence en Statut
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouvelle entité InterimAgency (table interim_agencies, API lecture seule)
- Sélecteur agence conditionnel dans les formulaires création employé et ajout contrat
- Affichage "Intérim (NomAgence)" sur la liste employés et l'historique contrat
- Date de fin obligatoire côté frontend pour CDD et INTERIM (aligné backend)
- Renommage "Types d'absence" → "Types de statut" (sidebar, page, titre)
- Renommage en-tête "Absence" → "Statut" sur les vues jour heures et conducteurs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:47:14 +02:00
45 changed files with 1757 additions and 455 deletions

View File

@@ -3,6 +3,7 @@
.env.local
.env.test
docker/
!docker/php/config/php.ini
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example

View File

@@ -30,6 +30,7 @@
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.89'
app.version: '0.1.93'

View File

@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \

View File

@@ -130,6 +130,7 @@ Documents complementaires:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
## 6bis) Heures Conducteurs

View File

@@ -1,4 +1,7 @@
[Date]
; Defines the default timezone used by the date functions
; http://php.net/date.timezone
date.timezone = Europe/Paris
date.timezone = Europe/Paris
[PHP]
memory_limit = 256M

View File

@@ -9,6 +9,13 @@
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
<button
type="button"
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
@click="close"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
<slot />

View File

@@ -1,7 +1,14 @@
<template>
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-6 text-xl text-white">
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
<div class="flex h-full items-center justify-between lg:justify-end">
<button
type="button"
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
@click="$emit('toggleSidebar')"
>
<Icon name="mdi:menu" size="28"/>
</button>
<div class="flex gap-4 text-xl text-white lg:gap-6">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36"/>
@@ -15,8 +22,8 @@
<div
v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
:style="{ top: `${navbarBottom + 10}px` }"
>
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications
@@ -66,7 +73,7 @@
<div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p>
<p class="hidden self-center sm:block">{{ user?.username }}</p>
</button>
<div
v-if="isUserMenuOpen"
@@ -103,6 +110,10 @@ defineProps<{
user?: User
}>()
defineEmits<{
(event: 'toggleSidebar'): void
}>()
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()

View File

@@ -0,0 +1,108 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-month">
Mois <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="" disabled>Sélectionner un mois</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading || selectedMonth === ''"
>
<template v-if="isLoading">
Génération en cours...
</template>
<template v-else>
Imprimer
</template>
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
isLoading?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { year: number; month: number | null }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear)
const currentMonth = new Date().getMonth() + 1
const selectedMonth = ref<number | ''>(currentMonth)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
if (selectedMonth.value === '') return
emit('submit', {
year: selectedYear.value,
month: selectedMonth.value
})
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
selectedMonth.value = currentMonth
}
}
)
</script>

View File

@@ -6,7 +6,7 @@
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-2">Statut</span>
<span class="pl-4">Heure de jour</span>
<span class="pl-2">Heure de nuit</span>
<span class="pl-2">Heure atelier</span>

View File

@@ -16,7 +16,7 @@
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
@@ -221,6 +221,22 @@
</select>
</div>
<div v-if="createContractForm.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
Agence d'intérim
</label>
<select
id="create-interim-agency"
v-model="createContractForm.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span>
@@ -282,6 +298,7 @@
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
import type { InterimAgency } from '~/services/interim-agencies'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = {
@@ -310,6 +327,7 @@ type CreateContractForm = {
endDate: string
isDriver: boolean
workDaysHours: Record<number, number> | null
interimAgencyId: number | ''
}
const props = defineProps<{
@@ -351,6 +369,7 @@ const props = defineProps<{
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
interimAgencies: InterimAgency[]
}>()
const drawerTab = ref<'close' | 'suspend'>('close')

View File

@@ -1,12 +1,180 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<!-- Mobile card layout -->
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="employee in employees"
:key="'m-' + employee.id"
class="rounded-md border border-primary-500 bg-white p-4"
>
<!-- Employee name + site -->
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p>
<p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</p>
</div>
<!-- Absence / Holiday / Formation pills -->
<div class="mb-3 flex flex-col gap-1">
<p
v-if="getRowAbsenceLabel(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white truncate"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) }}
</p>
<p
v-else
class="text-xs text-neutral-400"
>
Aucune absence
</p>
<p
v-if="isHoliday"
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-xs font-semibold underline"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<!-- Time inputs (TIME tracking) -->
<div v-if="isTimeTracking(employee)" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début matin</label>
<TimeSelect
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin matin</label>
<TimeSelect
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début soir</label>
<TimeSelect
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin soir</label>
<TimeSelect
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
</div>
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
</div>
</div>
<!-- Presence tracking -->
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
Matin
</label>
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
Après-midi
</label>
</div>
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
</div>
<!-- Validation status (non-admin) -->
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
<span v-if="!isSiteManager" class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
</span>
<span class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
</span>
</div>
<!-- Validation checkbox (admin) -->
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-neutral-700 font-semibold">Valider</span>
</div>
</div>
</div>
<!-- Desktop table layout -->
<div class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-2">Statut</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>

View File

@@ -1,17 +1,68 @@
<template>
<div class="py-6 flex flex-col gap-3">
<div class="flex gap-4">
<div class="py-4 flex flex-col gap-3 lg:py-6">
<!-- Desktop: filters row -->
<div class="hidden lg:flex lg:gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<div v-if="isAdmin" class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<!-- Mobile: search + filter button -->
<div v-if="isAdmin" class="flex gap-2 lg:hidden">
<div class="flex-1 min-w-0">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
@click="filtersDrawerOpen = true"
>
<Icon name="mdi:filter-variant" size="22"/>
</button>
</div>
<!-- Mobile filters drawer -->
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
<div class="space-y-6">
<div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2">
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites" />
</div>
</div>
<div v-if="isAdmin">
<label class="text-md font-semibold text-neutral-700">Vue</label>
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
</div>
</AppDrawer>
<!-- Date navigation -->
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -41,7 +92,7 @@
<div
v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -70,7 +121,7 @@
</div>
<PeriodStepperPicker
width-class="w-[320px]"
width-class="w-full lg:w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
@@ -82,7 +133,8 @@
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<!-- Desktop: view mode toggle -->
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@@ -106,7 +158,7 @@
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
class="hidden lg:flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
@@ -123,6 +175,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -150,6 +203,8 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const filtersDrawerOpen = ref(false)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value

View File

@@ -1,7 +1,71 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<!-- Mobile cards -->
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-xs text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<!-- Daily breakdown -->
<div class="mb-3 space-y-1">
<div
v-for="(daily, i) in row.daily"
:key="daily.date"
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
</div>
</div>
<!-- Weekly totals -->
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between">
<span class="text-neutral-500">Total sem.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">H. supp.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+25%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+50%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">Récup.</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
</div>
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
<span class="text-neutral-500">Panier nuit</span>
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop table -->
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"

View File

@@ -23,7 +23,7 @@
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
@@ -149,8 +149,11 @@ const toggleOpen = () => {
}
}
const isMobile = () => window.innerWidth < 1024
const openMenu = () => {
if (props.disabled) return
if (isMobile()) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
@@ -165,8 +168,28 @@ const closeMenu = () => {
isOpen.value = false
}
const snapToNearest15 = (time: string): string => {
const [h, m] = time.split(':').map(Number)
const snapped = Math.round(m / 15) * 15
if (snapped === 60) {
const newH = h + 1
if (newH > 23) return '23:45'
return `${String(newH).padStart(2, '0')}:00`
}
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
let value = inputValue.value
if (isMobile()) {
value = clampTime(value)
const normalized = normalizeTypedTime(value)
if (normalized !== null && normalized !== '') {
value = snapToNearest15(normalized)
}
inputValue.value = value
}
const normalized = normalizeTypedTime(value)
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
emit('update:modelValue', '')
inputValue.value = ''
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
if (!isMobile()) {
openMenu()
}
}
const clampTime = (value: string): string => {
const normalized = normalizeTypedTime(value)
if (normalized === null || normalized === '') return value
const [h, m] = normalized.split(':').map(Number)
if (h > 23 || (h === 23 && m > 45)) return '23:45'
return normalized
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
if (isMobile()) {
inputValue.value = clampTime(inputValue.value)
}
commitInput()
}, 50)
}

View File

@@ -4,6 +4,7 @@ import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
@@ -17,6 +18,7 @@ type SuspensionForm = {
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
@@ -46,7 +48,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
startDate: '',
endDate: '',
isDriver: false,
workDaysHours: null as Record<number, number> | null
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const createValidationTouched = reactive({
@@ -207,6 +210,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.interimAgencyId = ''
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
@@ -283,7 +287,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
@@ -335,6 +340,16 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
watch(() => createContractForm.contractNature, (nature) => {
if (nature !== 'INTERIM') {
createContractForm.interimAgencyId = ''
}
})
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
@@ -386,6 +401,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
loadContracts
interimAgencies,
loadContracts,
loadInterimAgencies
}
}

View File

@@ -86,7 +86,7 @@ export const useEmployeeDetailPage = () => {
})
onMounted(async () => {
await contract.loadContracts()
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
await loadEmployee()
})

View File

@@ -207,10 +207,10 @@ export const documentationSections: DocSection[] = [
},
{
id: 'gestion-types-absence',
title: 'Gestion des types d\'absence',
title: 'Gestion des types de statut',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
],
@@ -258,7 +258,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
],
},
{

View File

@@ -1,11 +1,40 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div class="h-[75px]">
<!-- Mobile overlay -->
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="sidebarOpen = false"
/>
</Transition>
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex h-[75px] items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto"/>
<button
type="button"
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
@click="sidebarOpen = false"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<nav class="flex-1 px-4 pb-6">
<nav class="flex-1 overflow-y-auto px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
to="/calendar"
@@ -13,6 +42,7 @@
:class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p>
@@ -26,6 +56,7 @@
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
]"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
@@ -38,6 +69,7 @@
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
]"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
@@ -49,6 +81,7 @@
:class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
@@ -60,6 +93,7 @@
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
@@ -70,6 +104,7 @@
:class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:business" size="24"/>
<p>Sites</p>
@@ -80,9 +115,10 @@
:class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p>
<p>Types de statut</p>
</NuxtLink>
<NuxtLink
to="/users"
@@ -90,6 +126,7 @@
:class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p>
@@ -100,6 +137,7 @@
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
@@ -111,6 +149,7 @@
:class="route.path.startsWith('/audit-logs')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p>
@@ -121,6 +160,7 @@
:class="route.path.startsWith('/documentation')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
<p>Documentation</p>
@@ -132,9 +172,9 @@
</div>
</aside>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="flex-1 overflow-y-auto px-4 py-6 lg:px-8 lg:py-12">
<slot/>
</main>
</div>
@@ -150,4 +190,9 @@ const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN
const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute()
const sidebarOpen = ref(false)
const closeSidebarOnMobile = () => {
sidebarOpen.value = false
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@@ -164,7 +164,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
title: 'Types de statut'
})
const isDrawerOpen = ref(false)

View File

@@ -148,6 +148,7 @@
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
:interim-agencies="interimAgencies"
/>
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
@@ -295,6 +296,7 @@ const {
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
interimAgencies,
isLeaveLoading,
isRttLoading,
mileageAllowances,

View File

@@ -18,6 +18,13 @@
>
Export récap. salaire
</button>
<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="isYearlyHoursBulkOpen = true"
>
Export heures
</button>
<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"
@@ -72,7 +79,7 @@
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
@@ -147,6 +154,21 @@
Le type de contrat est obligatoire.
</p>
</div>
<div v-if="form.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="interim-agency">
Agence d'intérim
</label>
<select
id="interim-agency"
v-model="form.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
@@ -191,7 +213,7 @@
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD.
La date de fin est obligatoire pour un CDD ou un Intérim.
</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
@@ -234,6 +256,12 @@
v-model="isSalaryRecapOpen"
@submit="handleSalaryRecapPrint"
/>
<BulkYearlyHoursDrawer
v-model="isYearlyHoursBulkOpen"
:is-loading="isYearlyHoursBulkLoading"
@submit="handleBulkYearlyHoursPrint"
/>
</div>
</template>
@@ -246,8 +274,10 @@ import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import BulkYearlyHoursDrawer from '~/components/BulkYearlyHoursDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
@@ -259,6 +289,8 @@ const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isSalaryRecapOpen = ref(false)
const isYearlyHoursBulkOpen = ref(false)
const isYearlyHoursBulkLoading = ref(false)
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
@@ -269,6 +301,7 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const selectedSiteIds = ref<number[]>([])
@@ -300,7 +333,8 @@ const form = reactive({
contractStartDate: '',
contractEndDate: '',
isDriver: false,
workDaysHours: null as Record<number, number> | null
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const validationTouched = reactive({
@@ -451,8 +485,12 @@ const loadContracts = async () => {
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
@@ -503,7 +541,8 @@ const handleSubmit = async () => {
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
})
}
@@ -516,6 +555,7 @@ const handleSubmit = async () => {
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -542,6 +582,12 @@ watch(showsContractEndDateComputed, (shows) => {
}
})
watch(() => form.contractNature, (nature) => {
if (nature !== 'INTERIM') {
form.interimAgencyId = ''
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
@@ -567,6 +613,7 @@ const openCreate = () => {
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
isDrawerOpen.value = true
}
@@ -579,6 +626,17 @@ const handleSalaryRecapPrint = async (month: string) => {
isSalaryRecapOpen.value = false
}
const handleBulkYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
isYearlyHoursBulkLoading.value = true
try {
const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print-all?year=${payload.year}${monthParam}`)
isYearlyHoursBulkOpen.value = false
} finally {
isYearlyHoursBulkLoading.value = false
}
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div>
<HoursToolbar

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
@@ -25,7 +25,8 @@
Aucun employé à afficher.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
>
@@ -64,6 +65,47 @@
</div>
</div>
</div>
<!-- Mobile cards -->
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="row in rows"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.lastName }} {{ row.firstName }}
</p>
<span
v-if="showSiteColumn && row.siteName"
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
</div>
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">CP N-1</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Samedis</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">CP N</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">RTT</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,13 +1,13 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
@click="openCreate"
>
+ Ajouter un utilisateur
+ Ajouter
</button>
</div>
@@ -18,7 +18,8 @@
Aucun utilisateur pour le moment.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
@@ -56,6 +57,42 @@
</div>
</div>
<!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<AppDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"

View File

@@ -20,6 +20,8 @@ export type ContractHistoryItem = {
suspensions?: ContractSuspension[]
isDriver?: boolean
workDaysHours?: Record<number, number> | null
interimAgencyId?: number | null
interimAgencyName?: string | null
}
export type Employee = {
@@ -37,4 +39,6 @@ export type Employee = {
displayOrder?: number
entryDate?: string | null
currentSuspensions?: ContractSuspension[]
currentInterimAgencyId?: number | null
currentInterimAgencyName?: string | null
}

View File

@@ -36,6 +36,7 @@ export const createEmployee = async (payload: {
contractEndDate?: string | null
isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
interimAgencyId?: number | null
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
@@ -47,7 +48,8 @@ export const createEmployee = async (payload: {
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false,
workDaysHoursInput: payload.workDaysHoursInput ?? null
workDaysHoursInput: payload.workDaysHoursInput ?? null,
interimAgencyId: payload.interimAgencyId ?? null
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -69,6 +71,7 @@ export const updateEmployee = async (
displayOrder?: number
isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
interimAgencyId?: number | null
}
) => {
const api = useApi()
@@ -103,6 +106,9 @@ export const updateEmployee = async (
if (payload.workDaysHoursInput !== undefined) {
body.workDaysHoursInput = payload.workDaysHoursInput
}
if (payload.interimAgencyId !== undefined) {
body.interimAgencyId = payload.interimAgencyId
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,16 @@
import { extractItems } from '~/utils/api'
export type InterimAgency = {
id: number
name: string
}
export const listInterimAgencies = async (): Promise<InterimAgency[]> => {
const api = useApi()
const data = await api.get<InterimAgency[] | { 'hydra:member'?: InterimAgency[] }>(
'/interim_agencies',
{},
{ toast: false }
)
return extractItems<InterimAgency>(data)
}

View File

@@ -13,7 +13,7 @@ export const showsContractEndDate = (nature: ContractNature) => {
}
export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD'
return nature === 'CDD' || nature === 'INTERIM'
}
export const isContractNature = (value: string): value is ContractNature => {

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create interim_agencies table and add interim_agency_id to employee_contract_periods';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE interim_agencies (id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE)');
$this->addSql('ALTER TABLE employee_contract_periods ADD interim_agency_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT fk_ecp_interim_agency FOREIGN KEY (interim_agency_id) REFERENCES interim_agencies (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX idx_ecp_interim_agency ON employee_contract_periods (interim_agency_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT IF EXISTS fk_ecp_interim_agency');
$this->addSql('DROP INDEX IF EXISTS idx_ecp_interim_agency');
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN interim_agency_id');
$this->addSql('DROP TABLE interim_agencies');
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\EmployeeYearlyHoursBulkPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/yearly-hours/print-all',
provider: EmployeeYearlyHoursBulkPrintProvider::class,
parameters: [
new QueryParameter(key: 'year', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class EmployeeYearlyHoursBulkPrint {}

View File

@@ -34,5 +34,9 @@ final class ContractHistoryItem
*/
#[Groups(['employee:read'])]
public ?array $workDaysHours = null,
#[Groups(['employee:read'])]
public ?int $interimAgencyId = null,
#[Groups(['employee:read'])]
public ?string $interimAgencyName = null,
) {}
}

View File

@@ -98,6 +98,9 @@ class Employee
#[Groups(['employee:write'])]
private ?array $workDaysHoursInput = null;
#[Groups(['employee:write'])]
private ?int $interimAgencyId = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -295,6 +298,30 @@ class Employee
return $this;
}
public function getInterimAgencyId(): ?int
{
return $this->interimAgencyId;
}
public function setInterimAgencyId(?int $interimAgencyId): self
{
$this->interimAgencyId = $interimAgencyId;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyId(): ?int
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getId();
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyName(): ?string
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getName();
}
#[Groups(['employee:read'])]
public function getHasActiveContract(): bool
{
@@ -393,6 +420,8 @@ class Employee
suspensions: $suspensionData,
isDriver: $period->getIsDriver(),
workDaysHours: $period->getWorkDaysHours(),
interimAgencyId: $period->getInterimAgency()?->getId(),
interimAgencyName: $period->getInterimAgency()?->getName(),
);
},
$periods

View File

@@ -55,6 +55,10 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'json', nullable: true)]
private ?array $workDaysHours = null;
#[ORM\ManyToOne(targetEntity: InterimAgency::class)]
#[ORM\JoinColumn(nullable: true)]
private ?InterimAgency $interimAgency = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
@@ -204,6 +208,18 @@ class EmployeeContractPeriod
return $this;
}
public function getInterimAgency(): ?InterimAgency
{
return $this->interimAgency;
}
public function setInterimAgency(?InterimAgency $interimAgency): self
{
$this->interimAgency = $interimAgency;
return $this;
}
/**
* @return Collection<int, ContractSuspension>
*/

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
],
normalizationContext: ['groups' => ['interim_agency:read']],
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
order: ['name' => 'ASC'],
)]
#[ORM\Entity]
#[ORM\Table(name: 'interim_agencies')]
class InterimAgency
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['interim_agency:read', 'employee:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 150, unique: true)]
#[Groups(['interim_agency:read', 'employee:read'])]
private string $name = '';
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}

View File

@@ -20,6 +20,7 @@ final readonly class EmployeeContractChangeRequest
public ?string $contractComment,
public ?bool $isDriver = null,
public ?array $workDaysHours = null,
public ?int $interimAgencyId = null,
) {}
public function hasPeriodChangeRequest(): bool

View File

@@ -21,6 +21,7 @@ final class EmployeeContractChangeRequestFactory
contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(),
workDaysHours: $employee->getWorkDaysHoursInput(),
interimAgencyId: $employee->getInterimAgencyId(),
);
}

View File

@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature;
use DateTimeImmutable;
@@ -23,6 +24,7 @@ final class EmployeeContractPeriodBuilder
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
@@ -32,6 +34,7 @@ final class EmployeeContractPeriodBuilder
->setContractNature($nature)
->setIsDriver($isDriver)
->setWorkDaysHours($workDaysHours)
->setInterimAgency($interimAgency)
;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
@@ -30,6 +31,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
@@ -39,7 +41,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush();
}
@@ -78,6 +81,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
@@ -90,7 +94,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush();
}
@@ -105,8 +110,23 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->persist($period);
}
private function resolveInterimAgency(?int $id): ?InterimAgency
{
if (null === $id) {
return null;
}
$agency = $this->entityManager->find(InterimAgency::class, $id);
if (null === $agency) {
throw new UnprocessableEntityHttpException(sprintf('Interim agency with id %d not found.', $id));
}
return $agency;
}
}

View File

@@ -23,6 +23,7 @@ interface EmployeeContractPeriodManagerInterface
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void;
public function closeCurrentPeriod(
@@ -45,5 +46,6 @@ interface EmployeeContractPeriodManagerInterface
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void;
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use DateInterval;
use DateTimeImmutable;
class YearlyHoursExportBuilder
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
/**
* @return list<string>
*/
public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @param list<Employee> $employees
*
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
*/
public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
$results = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
$segments = $this->buildSegments(
$days,
$contractMap[$employeeId] ?? [],
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceData,
);
if ([] === $segments) {
continue;
}
$results[] = [
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'contractLabel' => $this->buildContractLabel($employee),
'segments' => $segments,
];
}
return $results;
}
/**
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
*/
public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
return $this->buildForEmployees([$employee], $from, $to);
}
public function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
if (null === $contract) {
return null;
}
$natureRaw = $employee->getCurrentContractNature();
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
$natureLabel = match ($nature) {
ContractNature::CDI => 'CDI',
ContractNature::CDD => 'CDD',
ContractNature::INTERIM => 'Intérim',
};
$contractType = $contract->getType();
if (ContractType::FORFAIT === $contractType) {
return $natureLabel.' Forfait';
}
$weeklyHours = $contract->getWeeklyHours();
if (null !== $weeklyHours && $weeklyHours > 0) {
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
}
$name = $contract->getName();
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
}
/**
* @return array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array<int, list<Absence>>
*/
private function buildAbsenceMap(array $absences, array $days): array
{
$map = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId][] = $absence;
}
return $map;
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
{
$credited = [];
$labels = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
if ($date < $start || $date > $end) {
continue;
}
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($isMorning || $isAfternoon) {
$hasDayAbsence[$date] = true;
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$labels[$date] = $absence->getType()?->getLabel() ?? '';
}
}
$credited[$date] = ($credited[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
}
}
return [
'credited' => $credited,
'labels' => $labels,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
];
}
/**
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absenceData,
): array {
$segments = [];
$currentMode = null;
$currentRows = [];
$currentName = null;
$firstDataDate = null;
foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false);
if ($hasRow) {
$firstDataDate = $date;
break;
}
}
if (null === $firstDataDate) {
return [];
}
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
foreach ($days as $date) {
if ($date < $firstDataDate || $date > $todayYmd) {
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
if (!$hasData && !$isWeekend) {
continue;
}
if (!$hasData && null === $contract) {
continue;
}
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
$contractName = $contract?->getName();
if ($mode !== $currentMode) {
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
$currentMode = $mode;
$currentRows = [];
$currentName = $contractName;
}
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
'isWeekend' => $isWeekend,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['presentMorning'] = $morning > 0;
$row['presentAfternoon'] = $afternoon > 0;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
$row['workshopHours'] = $this->formatMinutes($workshopMin);
$row['total'] = $this->formatMinutes($totalMin);
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
}
$currentRows[] = $row;
}
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
return $segments;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {
return 'driver';
}
if (TrackingMode::PRESENCE->value === $trackingMode) {
return 'presence';
}
return 'time';
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '';
}
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
}
}

View File

@@ -70,6 +70,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
nature: $nature,
isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
interimAgencyId: $changeRequest->interimAgencyId,
);
$data->setEntryDate($startDate);
@@ -140,6 +141,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
todayPeriod: $effectivePeriod,
isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
interimAgencyId: $changeRequest->interimAgencyId,
);
return $result;

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
class EmployeeYearlyHoursBulkPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$yearRaw = (string) $request->query->get('year');
if (!preg_match('/^\d{4}$/', $yearRaw)) {
throw new UnprocessableEntityHttpException('year must use YYYY format.');
}
$year = (int) $yearRaw;
$monthRaw = (string) $request->query->get('month', '');
$month = null;
if ('' !== $monthRaw) {
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
$month = (int) $monthRaw;
}
if (null !== $month) {
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
$to = $from->modify('last day of this month');
} else {
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
}
$employees = $this->employeeRepository->findAll();
usort($employees, fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
$entries = $this->exportBuilder->buildForEmployees($employees, $from, $to);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print-all.html.twig', [
'entries' => $entries,
'year' => $year,
'month' => $month,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = null !== $month
? sprintf('heures_tous_%d-%02d.pdf', $year, $month)
: sprintf('heures_tous_%d.pdf', $year);
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}

View File

@@ -6,19 +6,9 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateInterval;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
@@ -34,11 +24,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -80,27 +66,11 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
}
$days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
$segments = $this->buildSegments(
$employee,
$days,
$contractMap[$employee->getId()] ?? [],
$driverMap[$employee->getId()] ?? [],
$workHourMap[$employee->getId()] ?? [],
$absenceData,
);
$entries = $this->exportBuilder->buildForEmployee($employee, $from, $to);
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$contractLabel = $this->buildContractLabel($employee);
$contractLabel = $this->exportBuilder->buildContractLabel($employee);
$options = new Options();
$options->set('isRemoteEnabled', true);
@@ -111,7 +81,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
'contractLabel' => $contractLabel,
'year' => $year,
'month' => $month,
'segments' => $segments,
'segments' => $entries[0]['segments'] ?? [],
]);
$dompdf->loadHtml($html);
@@ -139,367 +109,6 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
]);
}
private function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
if (null === $contract) {
return null;
}
$natureRaw = $employee->getCurrentContractNature();
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
$natureLabel = match ($nature) {
ContractNature::CDI => 'CDI',
ContractNature::CDD => 'CDD',
ContractNature::INTERIM => 'Intérim',
};
$contractType = $contract->getType();
if (ContractType::FORFAIT === $contractType) {
return $natureLabel.' Forfait';
}
$weeklyHours = $contract->getWeeklyHours();
if (null !== $weeklyHours && $weeklyHours > 0) {
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
}
$name = $contract->getName();
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
}
/**
* @return list<string>
*/
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @return array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
{
$credited = [];
$labels = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
foreach ($absences as $absence) {
$absEmployeeId = $absence->getEmployee()?->getId();
if ($absEmployeeId !== $employee->getId()) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
if ($date < $start || $date > $end) {
continue;
}
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($isMorning || $isAfternoon) {
$hasDayAbsence[$date] = true;
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$labels[$date] = $absence->getType()?->getLabel() ?? '';
}
}
$credited[$date] = ($credited[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
}
}
return [
'credited' => $credited,
'labels' => $labels,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
];
}
/**
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
Employee $employee,
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absenceData,
): array {
$segments = [];
$currentMode = null;
$currentRows = [];
$currentName = null;
// Crop the output window to [first data day, today] to avoid padding the
// export with empty rows (notably weekends before the first saisie or after today).
$firstDataDate = null;
foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false);
if ($hasRow) {
$firstDataDate = $date;
break;
}
}
if (null === $firstDataDate) {
return [];
}
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
foreach ($days as $date) {
if ($date < $firstDataDate || $date > $todayYmd) {
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
// Keep weekend rows even when empty so the reader can distinguish
// worked vs non-worked Saturdays/Sundays at a glance.
if (!$hasData && !$isWeekend) {
continue;
}
if (!$hasData && null === $contract) {
continue;
}
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
$contractName = $contract?->getName();
if ($mode !== $currentMode) {
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
$currentMode = $mode;
$currentRows = [];
$currentName = $contractName;
}
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
'isWeekend' => $isWeekend,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['presentMorning'] = $morning > 0;
$row['presentAfternoon'] = $afternoon > 0;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
$row['workshopHours'] = $this->formatMinutes($workshopMin);
$row['total'] = $this->formatMinutes($totalMin);
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
}
$currentRows[] = $row;
}
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
return $segments;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {
return 'driver';
}
if (TrackingMode::PRESENCE->value === $trackingMode) {
return 'presence';
}
return 'time';
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '';
}
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
}
private function sanitizeFilename(string $name): string
{
$name = str_replace(' ', '_', $name);

View File

@@ -0,0 +1,271 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Export heures - {% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 9px;
}
.employee-section {
page-break-before: always;
}
.employee-section:first-child {
page-break-before: auto;
}
.title-bar {
position: relative;
margin: 0 0 4mm 0;
}
h1 {
text-align: center;
font-size: 16px;
margin: 0;
}
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 9px;
color: #333;
padding-top: 4px;
}
h2 {
font-size: 12px;
margin: 4mm 0 2mm 0;
padding: 2px 6px;
background: #e8e8e8;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 2px solid #0a0a0a;
}
th, td {
border: 1px solid #0a0a0a;
padding: 2px 4px;
vertical-align: middle;
white-space: nowrap;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 9px;
background: #d9e2f3;
}
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; }
.signature-footer {
page-break-inside: avoid;
margin-top: 6mm;
}
.signature-intro {
text-align: center;
font-weight: 700;
margin-bottom: 6mm;
font-size: 11px;
}
.signature-blocks {
display: table;
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 4mm 0;
}
.signature-block {
display: table-cell;
border: 1px solid #0a0a0a;
padding: 3mm;
vertical-align: top;
width: 33.33%;
}
.signature-block .title {
text-align: center;
font-weight: 700;
font-size: 11px;
margin-bottom: 7mm;
text-decoration: underline;
}
.signature-block .line {
margin-bottom: 2mm;
font-size: 10px;
}
.signature-block .signature-line {
margin-top: 6mm;
margin-bottom: 18mm;
font-size: 10px;
}
</style>
</head>
<body>
{% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}
{% for entry in entries %}
<div class="employee-section">
<div class="title-bar">
<h1>
{{ entry.employeeName }}{% if entry.contractLabel %} - {{ entry.contractLabel }}{% endif %}<br>
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div>
{% for segment in entry.segments %}
{% if entry.segments|length > 1 %}
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
{% endif %}
{% if segment.mode == 'presence' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Présence matin</th>
<th>Présence après-midi</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elseif segment.mode == 'driver' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Heures jour</th>
<th>Heures nuit</th>
<th>Heures atelier</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Début matin</th>
<th>Fin matin</th>
<th>Début après-midi</th>
<th>Fin après-midi</th>
<th>Début soir</th>
<th>Fin soir</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>
<td class="time">{{ row.afternoonTo }}</td>
<td class="time">{{ row.eveningFrom }}</td>
<td class="time">{{ row.eveningTo }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
<div class="signature-footer">
<div class="signature-intro">
Nom + Prénom<br>
Signature avec mention « bon pour accord »
</div>
<div class="signature-blocks">
<div class="signature-block">
<p class="title">Direction</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Responsable usine</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Salarié</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
</div>
</div>
</div>
{% endfor %}
</body>
</html>