Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2e118dc33 | |||
| 4256702add | |||
| 1cfbfb0120 |
Generated
-7
@@ -145,13 +145,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/LOG" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/frontend/.nuxt" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/frontend/.output" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/frontend/dist" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/public" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/var" />
|
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions.
|
|
||||||
|
|
||||||
## 1) Stack et structure
|
|
||||||
|
|
||||||
- Backend: Symfony + API Platform + Doctrine ORM
|
|
||||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
|
|
||||||
- Exécution locale: Docker via `makefile`
|
|
||||||
|
|
||||||
Arborescence clé:
|
|
||||||
- `src/`: domaine, API resources, state providers/processors, services
|
|
||||||
- `tests/`: TU backend (PHPUnit)
|
|
||||||
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
|
||||||
- `migrations/`: migrations Doctrine
|
|
||||||
|
|
||||||
## 2) Commandes utiles
|
|
||||||
|
|
||||||
- Démarrer stack: `make start`
|
|
||||||
- Tests backend: `make test`
|
|
||||||
- Build frontend: `cd frontend && npm run build`
|
|
||||||
- Dev frontend: `make dev-nuxt`
|
|
||||||
|
|
||||||
## 3) Domaine métier (résumé)
|
|
||||||
|
|
||||||
### Contrats
|
|
||||||
- Entité: `Contract`
|
|
||||||
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
|
|
||||||
- `trackingMode`:
|
|
||||||
- `TIME`: suivi par heures
|
|
||||||
- `PRESENCE`: suivi présence demi-journées/journées
|
|
||||||
- Enums backend:
|
|
||||||
- `App\Enum\TrackingMode`
|
|
||||||
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
|
|
||||||
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
|
|
||||||
|
|
||||||
### Heures / absences
|
|
||||||
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
|
|
||||||
- Une ligne d’heures validée est verrouillée côté métier.
|
|
||||||
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
|
|
||||||
- contrats présence: crédit en unités de présence
|
|
||||||
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
|
|
||||||
|
|
||||||
## 4) Écrans principaux
|
|
||||||
|
|
||||||
### Page Heures (`frontend/pages/hours.vue`)
|
|
||||||
- Vue Jour + Vue Semaine (semaine réservée admin)
|
|
||||||
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
|
|
||||||
- Vue jour: `frontend/components/hours/HoursDayView.vue`
|
|
||||||
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
|
|
||||||
- Logique page: `frontend/composables/useHoursPage.ts`
|
|
||||||
|
|
||||||
### Points UX déjà en place
|
|
||||||
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
|
|
||||||
- Légende absences affichée dans la toolbar (admin + vue semaine)
|
|
||||||
- Cellules semaine avec absence: couleur du type d’absence (plus rouge fixe)
|
|
||||||
- Pour user non-admin: restrictions d’édition selon validations/absences
|
|
||||||
|
|
||||||
## 5) API / calculs hebdo
|
|
||||||
|
|
||||||
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
|
|
||||||
- DTOs:
|
|
||||||
- `src/Dto/WorkHours/WeeklySummaryRow.php`
|
|
||||||
- `src/Dto/WorkHours/WeeklyDaySummary.php`
|
|
||||||
- Le résumé hebdo renvoie notamment:
|
|
||||||
- `trackingMode`
|
|
||||||
- `contractName`
|
|
||||||
- `contractType`
|
|
||||||
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
|
|
||||||
|
|
||||||
### Heures supp
|
|
||||||
- Règles métier:
|
|
||||||
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
|
|
||||||
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
|
|
||||||
- contrats `INTERIM`: pas de bonus 25/50 ni récup
|
|
||||||
|
|
||||||
## 6) Conventions techniques
|
|
||||||
|
|
||||||
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
|
|
||||||
- Utiliser les interfaces repository dans providers/processors testés.
|
|
||||||
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
|
|
||||||
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
|
|
||||||
|
|
||||||
## 7) Tests et qualité
|
|
||||||
|
|
||||||
- Les TU backend passent actuellement via `make test`.
|
|
||||||
- Le build frontend passe via `npm run build`.
|
|
||||||
- À chaque évolution métier:
|
|
||||||
- mettre à jour les tests provider/processor/service impactés
|
|
||||||
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
|
|
||||||
|
|
||||||
## 8) Fichiers sensibles (à lire avant modif)
|
|
||||||
|
|
||||||
- `src/State/WorkHourWeeklySummaryProvider.php`
|
|
||||||
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
|
|
||||||
- `src/State/AbsenceWriteProcessor.php`
|
|
||||||
- `src/State/WorkHourBulkUpsertProcessor.php`
|
|
||||||
- `frontend/composables/useHoursPage.ts`
|
|
||||||
- `frontend/components/hours/HoursWeekView.vue`
|
|
||||||
|
|
||||||
## 9) Décisions de conception actuelles
|
|
||||||
|
|
||||||
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
|
|
||||||
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
|
|
||||||
- Le front reste centré sur l’affichage/interaction et réutilise les données enrichies de l’API.
|
|
||||||
@@ -1,19 +1,2 @@
|
|||||||
# SIRH
|
# SIRH
|
||||||
Application de gestion des absences employée
|
Application de gestion des absences employée
|
||||||
|
|
||||||
## Importer un dump de prod en dev
|
|
||||||
Sur adminer fait un export bdd :
|
|
||||||
- Sortie : enregistrer
|
|
||||||
- Format : SQL
|
|
||||||
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
|
|
||||||
- Données : INSERT
|
|
||||||
|
|
||||||
Supprime la bdd et créer la bdd :
|
|
||||||
```shell
|
|
||||||
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
|
||||||
```
|
|
||||||
|
|
||||||
Remplie la base avec le dump :
|
|
||||||
```shell
|
|
||||||
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -22,9 +22,5 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
|
||||||
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.12'
|
app.version: '0.1.8'
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
id="employee"
|
id="employee"
|
||||||
v-model="absenceForm.employeeId"
|
v-model="absenceForm.employeeId"
|
||||||
:class="employeeFieldClass"
|
:class="employeeFieldClass"
|
||||||
:disabled="props.lockEmployee"
|
|
||||||
>
|
>
|
||||||
<option value="" disabled>Choisir un employé</option>
|
<option value="" disabled>Choisir un employé</option>
|
||||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
v-model="absenceForm.startDate"
|
v-model="absenceForm.startDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||||
:disabled="props.lockDates"
|
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-model="absenceForm.startHalf"
|
v-model="absenceForm.startHalf"
|
||||||
@@ -69,7 +67,6 @@
|
|||||||
v-model="absenceForm.endDate"
|
v-model="absenceForm.endDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||||
:disabled="props.lockDates"
|
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-model="absenceForm.endHalf"
|
v-model="absenceForm.endHalf"
|
||||||
@@ -83,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="props.showComment !== false">
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="comment"
|
id="comment"
|
||||||
@@ -145,9 +142,6 @@ const props = defineProps<{
|
|||||||
}
|
}
|
||||||
editingAbsence: Absence | null
|
editingAbsence: Absence | null
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
lockEmployee?: boolean
|
|
||||||
lockDates?: boolean
|
|
||||||
showComment?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
|
||||||
<div class="overflow-y-auto min-h-0">
|
<div class="overflow-y-auto min-h-0">
|
||||||
<div
|
<div
|
||||||
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||||
@@ -12,22 +12,11 @@
|
|||||||
<span class="pr-2">Fin après-midi</span>
|
<span class="pr-2">Fin après-midi</span>
|
||||||
<span class="pl-2">Début soir</span>
|
<span class="pl-2">Début soir</span>
|
||||||
<span class="pr-2">Fin soir</span>
|
<span class="pr-2">Fin soir</span>
|
||||||
<span class="pl-2">Absence</span>
|
<span class="pl-2">Présent</span>
|
||||||
<span class="pl-2">Jour</span>
|
<span class="pl-2">Jour</span>
|
||||||
<span>Nuit</span>
|
<span>Nuit</span>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span class="inline-flex items-center gap-2">
|
<span v-if="isAdmin">Valider</span>
|
||||||
<span v-if="isAdmin">Valider</span>
|
|
||||||
<span v-else>Validation RH</span>
|
|
||||||
<input
|
|
||||||
v-if="isAdmin"
|
|
||||||
ref="bulkValidationInput"
|
|
||||||
:checked="isBulkValidationChecked"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
@change="onBulkValidationChange"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -44,79 +33,38 @@
|
|||||||
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p>
|
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-4">
|
<div class="pl-4">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningFrom"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
v-else-if="isPresenceTracking(employee)"
|
v-else-if="isPresenceTracking(employee)"
|
||||||
v-model="rows[employee.id].isPresentMorning"
|
v-model="rows[employee.id].isPresentMorning"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="cursor-pointer h-4 w-4"
|
class="cursor-pointer h-4 w-4"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
:disabled="isRowLocked(employee.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningTo"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2">
|
<div class="pl-2">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonFrom"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
v-else-if="isPresenceTracking(employee)"
|
v-else-if="isPresenceTracking(employee)"
|
||||||
v-model="rows[employee.id].isPresentAfternoon"
|
v-model="rows[employee.id].isPresentAfternoon"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="cursor-pointer h-4 w-4"
|
class="cursor-pointer h-4 w-4"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
:disabled="isRowLocked(employee.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonTo"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2">
|
<div class="pl-2">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningFrom"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningTo"
|
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
|
|
||||||
<p
|
|
||||||
class="w-full min-w-0 text-sm text-neutral-700 truncate"
|
|
||||||
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
|
||||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
|
||||||
>
|
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="self-start text-left text-xs font-semibold underline"
|
|
||||||
:class="isRowLocked(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
|
||||||
:disabled="isRowLocked(employee.id)"
|
|
||||||
@click="onAbsenceClick(employee.id)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pl-2"></div>
|
||||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,17 +73,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
<div class="text-sm font-semibold text-neutral-700">
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
||||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="isAdmin">
|
||||||
<input
|
<input
|
||||||
v-if="isAdmin"
|
|
||||||
:checked="rows[employee.id]?.isValid ?? false"
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 cursor-pointer"
|
class="h-4 w-4"
|
||||||
|
:class="rows[employee.id]?.workHourId ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
|
:disabled="!rows[employee.id]?.workHourId || isValidationPending(employee.id)"
|
||||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
<span v-else-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,9 +95,8 @@ import TimeSelect from '~/components/ui/TimeSelect.vue'
|
|||||||
import type { HourRow } from './types'
|
import type { HourRow } from './types'
|
||||||
|
|
||||||
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
||||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
employees: Employee[]
|
employees: Employee[]
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
dayGridCols: string
|
dayGridCols: string
|
||||||
@@ -158,31 +104,9 @@ const props = defineProps<{
|
|||||||
isTimeTracking: (employee: Employee) => boolean
|
isTimeTracking: (employee: Employee) => boolean
|
||||||
isPresenceTracking: (employee: Employee) => boolean
|
isPresenceTracking: (employee: Employee) => boolean
|
||||||
isRowLocked: (employeeId: number) => boolean
|
isRowLocked: (employeeId: number) => boolean
|
||||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
|
||||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
|
||||||
isValidationPending: (employeeId: number) => boolean
|
isValidationPending: (employeeId: number) => boolean
|
||||||
canToggleValidation: (employeeId: number) => boolean
|
|
||||||
isBulkValidationChecked: boolean
|
|
||||||
isBulkValidationIndeterminate: boolean
|
|
||||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleValidationBulk: (checked: boolean) => void
|
|
||||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
getRowAbsenceLabel: (employeeId: number) => string
|
|
||||||
getPresenceDayValue: (employeeId: number) => string
|
|
||||||
onAbsenceClick: (employeeId: number) => void
|
|
||||||
formatMinutes: (minutes: number) => string
|
formatMinutes: (minutes: number) => string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const onBulkValidationChange = (event: Event) => {
|
|
||||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.isBulkValidationIndeterminate,
|
|
||||||
(isIndeterminate) => {
|
|
||||||
if (!bulkValidationInput.value) return
|
|
||||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="py-6 flex flex-col gap-3">
|
<div class="py-6 flex flex-col gap-3">
|
||||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
||||||
|
|
||||||
<div class="flex justify-between items-center gap-4">
|
<div class="flex justify-between items-center gap-4">
|
||||||
<div class="flex gap-4 flex-wrap">
|
<div class="flex gap-4 flex-wrap">
|
||||||
@@ -34,46 +34,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 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"
|
|
||||||
:class="weekShortcutButtonClass('previousWeek')"
|
|
||||||
@click="emit('set-previous-week')"
|
|
||||||
>
|
|
||||||
{{ getWeekShortcutLabel('previousWeek') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 border-x border-primary-500 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"
|
|
||||||
:class="weekShortcutButtonClass('thisWeek')"
|
|
||||||
@click="emit('set-this-week')"
|
|
||||||
>
|
|
||||||
{{ getWeekShortcutLabel('thisWeek') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 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"
|
|
||||||
:class="weekShortcutButtonClass('nextWeek')"
|
|
||||||
@click="emit('set-next-week')"
|
|
||||||
>
|
|
||||||
{{ getWeekShortcutLabel('nextWeek') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
<input
|
<input
|
||||||
ref="nativeDateInput"
|
ref="nativeDateInput"
|
||||||
:value="pickerValue"
|
v-model="selectedDate"
|
||||||
:type="viewMode === 'week' ? 'week' : 'date'"
|
type="date"
|
||||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@input="onPickerInput"
|
|
||||||
@change="onPickerInput"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -123,29 +91,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
<div class="w-80 max-w-full">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
|
||||||
class="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">
|
|
||||||
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
|
||||||
<p>{{ type.label }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
|
||||||
|
|
||||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
||||||
@@ -155,28 +110,18 @@ const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
absenceTypes: AbsenceType[]
|
|
||||||
formattedSelectedDate: string
|
formattedSelectedDate: string
|
||||||
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||||
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
|
||||||
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'set-yesterday'): void
|
(e: 'set-yesterday'): void
|
||||||
(e: 'set-today'): void
|
(e: 'set-today'): void
|
||||||
(e: 'set-tomorrow'): void
|
(e: 'set-tomorrow'): void
|
||||||
(e: 'set-previous-week'): void
|
|
||||||
(e: 'set-this-week'): void
|
|
||||||
(e: 'set-next-week'): void
|
|
||||||
(e: 'shift-date', value: number): void
|
(e: 'shift-date', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
||||||
const pickerValue = computed(() => {
|
|
||||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
|
||||||
return selectedDate.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const viewModeButtonClass = (mode: 'day' | 'week') => {
|
const viewModeButtonClass = (mode: 'day' | 'week') => {
|
||||||
if (viewMode.value === mode) {
|
if (viewMode.value === mode) {
|
||||||
@@ -196,18 +141,4 @@ const openDatePicker = () => {
|
|||||||
input.focus()
|
input.focus()
|
||||||
input.click()
|
input.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPickerInput = (event: Event) => {
|
|
||||||
const value = (event.target as HTMLInputElement).value
|
|
||||||
if (!value) return
|
|
||||||
|
|
||||||
if (viewMode.value === 'week') {
|
|
||||||
const ymd = weekInputValueToYmd(value)
|
|
||||||
if (!ymd) return
|
|
||||||
selectedDate.value = ymd
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedDate.value = value
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
|
||||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
<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">
|
<div v-else class="overflow-y-auto min-h-0">
|
||||||
<div
|
<div
|
||||||
@@ -8,18 +8,16 @@
|
|||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
|
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
|
||||||
<span>Jour/Nuit <br>sem.</span>
|
<span>Jour/Nuit sem.</span>
|
||||||
<span>Total <br>sem.</span>
|
<span>Total sem.</span>
|
||||||
<span>Total <br>h. supp.</span>
|
|
||||||
<span>+25%</span>
|
<span>+25%</span>
|
||||||
<span>+50%</span>
|
<span>+50%</span>
|
||||||
<span>Total <br>récup.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="row in weeklySummary?.rows ?? []"
|
v-for="row in weeklySummary?.rows ?? []"
|
||||||
:key="row.employeeId"
|
:key="row.employeeId"
|
||||||
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
|
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||||
:style="{ gridTemplateColumns: weekGridCols }"
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
>
|
>
|
||||||
<div class="text-neutral-900 min-w-0">
|
<div class="text-neutral-900 min-w-0">
|
||||||
@@ -30,14 +28,7 @@
|
|||||||
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4">
|
||||||
v-for="daily in row.daily"
|
|
||||||
:key="daily.date"
|
|
||||||
class="text-left leading-4 rounded-md px-2 py-1"
|
|
||||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
|
||||||
:style="getDailyCellStyle(daily)"
|
|
||||||
:title="daily.absenceLabel ?? ''"
|
|
||||||
>
|
|
||||||
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||||
@@ -56,16 +47,10 @@
|
|||||||
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
|
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold">
|
|
||||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,19 +59,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
|
||||||
|
|
||||||
const isInterimContract = (contractType?: ContractType | null) => {
|
|
||||||
return contractType === CONTRACT_TYPES.INTERIM
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDailyCellStyle = (daily: {
|
|
||||||
hasAbsence?: boolean
|
|
||||||
absenceColor?: string | null
|
|
||||||
}) => {
|
|
||||||
if (!daily.hasAbsence) return undefined
|
|
||||||
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isWeekLoading: boolean
|
isWeekLoading: boolean
|
||||||
|
|||||||
@@ -1,36 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="root" class="relative w-full">
|
<div ref="root" class="relative w-full">
|
||||||
<div
|
<button
|
||||||
ref="trigger"
|
ref="trigger"
|
||||||
class="w-full flex items-center rounded-md border border-neutral-300 bg-white px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
type="button"
|
||||||
:class="props.disabled ? 'cursor-not-allowed bg-neutral-100 text-neutral-500' : ''"
|
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@click="toggleOpen"
|
||||||
>
|
>
|
||||||
<input
|
{{ displayValue }}
|
||||||
ref="inputRef"
|
<Icon name="mdi:chevron-down" class="self-center"/>
|
||||||
v-model="inputValue"
|
</button>
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:disabled="props.disabled"
|
|
||||||
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed"
|
|
||||||
@focus="openMenu"
|
|
||||||
@keydown.down.prevent="openMenuAndFocusFirst"
|
|
||||||
@keydown.enter.prevent="commitInput"
|
|
||||||
@keydown.esc.prevent="closeMenu"
|
|
||||||
@input="onInput($event)"
|
|
||||||
@blur="onInputBlur"
|
|
||||||
/>
|
|
||||||
<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="props.disabled"
|
|
||||||
@mousedown.prevent
|
|
||||||
@click="toggleOpen"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:chevron-down" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -39,11 +18,15 @@
|
|||||||
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
||||||
:style="menuStyle"
|
:style="menuStyle"
|
||||||
>
|
>
|
||||||
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
|
@click="selectValue('')"
|
||||||
|
>
|
||||||
{{ placeholder }}
|
{{ placeholder }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="slot in filteredTimeSlots"
|
v-for="slot in timeSlots"
|
||||||
:key="slot"
|
:key="slot"
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
@@ -51,9 +34,6 @@
|
|||||||
>
|
>
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
|
|
||||||
Aucun résultat
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,9 +55,7 @@ const emit = defineEmits<{
|
|||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
const trigger = ref<HTMLElement | null>(null)
|
const trigger = ref<HTMLElement | null>(null)
|
||||||
const menu = ref<HTMLElement | null>(null)
|
const menu = ref<HTMLElement | null>(null)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const inputValue = ref('')
|
|
||||||
const menuStyle = ref<Record<string, string>>({
|
const menuStyle = ref<Record<string, string>>({
|
||||||
top: '0px',
|
top: '0px',
|
||||||
left: '0px',
|
left: '0px',
|
||||||
@@ -95,31 +73,7 @@ const timeSlots = computed(() => {
|
|||||||
return slots
|
return slots
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredTimeSlots = computed(() => {
|
const displayValue = computed(() => props.modelValue || props.placeholder)
|
||||||
const query = inputValue.value.trim()
|
|
||||||
if (!query) return timeSlots.value
|
|
||||||
return timeSlots.value.filter((slot) => slot.includes(query))
|
|
||||||
})
|
|
||||||
|
|
||||||
const applyTimeMask = (value: string): string => {
|
|
||||||
const digits = value.replace(/\D/g, '').slice(0, 4)
|
|
||||||
if (digits.length <= 2) return digits
|
|
||||||
return `${digits.slice(0, 2)}:${digits.slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeTypedTime = (value: string): string | null => {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (trimmed === '') return ''
|
|
||||||
|
|
||||||
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
|
|
||||||
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
|
||||||
if (!match) return null
|
|
||||||
const hours = Number(match[1])
|
|
||||||
const minutes = Number(match[2])
|
|
||||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
|
||||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
|
||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMenuPosition = () => {
|
const updateMenuPosition = () => {
|
||||||
const triggerEl = trigger.value
|
const triggerEl = trigger.value
|
||||||
@@ -149,57 +103,10 @@ const toggleOpen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMenu = () => {
|
|
||||||
if (props.disabled) return
|
|
||||||
if (!isOpen.value) {
|
|
||||||
isOpen.value = true
|
|
||||||
nextTick(updateMenuPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMenuAndFocusFirst = () => {
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMenu = () => {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitInput = () => {
|
|
||||||
const normalized = normalizeTypedTime(inputValue.value)
|
|
||||||
if (normalized === null) {
|
|
||||||
inputValue.value = props.modelValue
|
|
||||||
closeMenu()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('update:modelValue', normalized)
|
|
||||||
inputValue.value = normalized
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onInput = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
const masked = applyTimeMask(target.value)
|
|
||||||
if (masked !== inputValue.value) {
|
|
||||||
inputValue.value = masked
|
|
||||||
}
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onInputBlur = () => {
|
|
||||||
// Laisse le temps au click menu de passer avant fermeture.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (menu.value?.contains(document.activeElement)) return
|
|
||||||
commitInput()
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectValue = (value: string) => {
|
const selectValue = (value: string) => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
inputValue.value = value
|
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
nextTick(() => inputRef.value?.focus())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDocumentClick = (event: MouseEvent) => {
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
@@ -232,14 +139,6 @@ watch(() => props.disabled, (disabled) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(value) => {
|
|
||||||
inputValue.value = value
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export type ApiClient = {
|
|||||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||||
FetchOptions<ResponseType> & {
|
FetchOptions<ResponseType> & {
|
||||||
toast?: boolean
|
toast?: boolean
|
||||||
toastOn401?: boolean
|
|
||||||
toastTitle?: string
|
toastTitle?: string
|
||||||
toastErrorMessage?: string
|
toastErrorMessage?: string
|
||||||
toastSuccessMessage?: string
|
toastSuccessMessage?: string
|
||||||
@@ -103,31 +102,9 @@ export const useApi = (): ApiClient => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onResponseError({ response, error, options }) {
|
async onResponseError({ response, error, options }) {
|
||||||
const apiOptions = options as ApiFetchOptions<'json'>
|
|
||||||
if (response?.status === 401) {
|
if (response?.status === 401) {
|
||||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||||
const isLoginCheck = requestUrl.includes('/login_check')
|
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) {
|
||||||
const isLogout = requestUrl.includes('/logout')
|
|
||||||
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
|
|
||||||
|
|
||||||
if (shouldToast401) {
|
|
||||||
const errorKey = apiOptions?.toastErrorKey
|
|
||||||
const errorMessage =
|
|
||||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
|
||||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
|
||||||
const message =
|
|
||||||
apiOptions?.toastErrorMessage ||
|
|
||||||
errorMessage ||
|
|
||||||
extractedMessage ||
|
|
||||||
'Une erreur est survenue.'
|
|
||||||
|
|
||||||
toast.error({
|
|
||||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
|
||||||
message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoginCheck && !isLogout) {
|
|
||||||
if (!isHandlingUnauthorized) {
|
if (!isHandlingUnauthorized) {
|
||||||
isHandlingUnauthorized = true
|
isHandlingUnauthorized = true
|
||||||
auth.clearSession()
|
auth.clearSession()
|
||||||
@@ -138,10 +115,10 @@ export const useApi = (): ApiClient => {
|
|||||||
isHandlingUnauthorized = false
|
isHandlingUnauthorized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiOptions = options as ApiFetchOptions<'json'>
|
||||||
if (apiOptions?.toast === false) {
|
if (apiOptions?.toast === false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
import type { WorkHour, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
|
||||||
import type { Absence } from '~/services/dto/absence'
|
|
||||||
import type { HalfDay } from '~/services/dto/half-day'
|
|
||||||
import { CONTRACT_TYPES, TRACKING_MODES } from '~/services/dto/contract'
|
|
||||||
import type { HourRow } from '~/components/hours/types'
|
import type { HourRow } from '~/components/hours/types'
|
||||||
import { listScopedEmployees } from '~/services/employees'
|
import { listScopedEmployees } from '~/services/employees'
|
||||||
import { listAbsenceTypes } from '~/services/absence-types'
|
|
||||||
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
|
||||||
import {
|
import {
|
||||||
bulkUpsertWorkHours,
|
bulkUpsertWorkHours,
|
||||||
getWorkHourDayContext,
|
|
||||||
getWeeklyWorkHourSummary,
|
getWeeklyWorkHourSummary,
|
||||||
listWorkHoursByDate,
|
listWorkHoursByDate,
|
||||||
updateWorkHourValidation
|
updateWorkHourValidation
|
||||||
@@ -21,9 +14,7 @@ import {
|
|||||||
formatDateLongFr,
|
formatDateLongFr,
|
||||||
formatWeekDayHeaderFr,
|
formatWeekDayHeaderFr,
|
||||||
formatWeekRangeFr,
|
formatWeekRangeFr,
|
||||||
getIsoWeekNumber,
|
|
||||||
getOffsetFromTodayYmd,
|
getOffsetFromTodayYmd,
|
||||||
getWeekStartDate,
|
|
||||||
getTodayYmd,
|
getTodayYmd,
|
||||||
parseYmd,
|
parseYmd,
|
||||||
shiftYmd
|
shiftYmd
|
||||||
@@ -32,7 +23,6 @@ import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
|||||||
|
|
||||||
export const useHoursPage = () => {
|
export const useHoursPage = () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
const viewMode = ref<'day' | 'week'>('day')
|
const viewMode = ref<'day' | 'week'>('day')
|
||||||
|
|
||||||
@@ -42,33 +32,19 @@ export const useHoursPage = () => {
|
|||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
const rows = ref<Record<number, HourRow>>({})
|
const rows = ref<Record<number, HourRow>>({})
|
||||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
|
||||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
|
||||||
const absences = ref<Absence[]>([])
|
|
||||||
const isAbsenceDrawerOpen = ref(false)
|
|
||||||
const isAbsenceSubmitting = ref(false)
|
|
||||||
const editingAbsence = ref<Absence | null>(null)
|
|
||||||
const absenceForm = ref({
|
|
||||||
employeeId: '' as number | '',
|
|
||||||
typeId: '' as number | '',
|
|
||||||
startDate: '',
|
|
||||||
startHalf: 'AM' as HalfDay,
|
|
||||||
endDate: '',
|
|
||||||
endHalf: 'PM' as HalfDay,
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isWeekLoading = ref(false)
|
const isWeekLoading = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const validatingRowIds = ref<number[]>([])
|
const validatingRowIds = ref<number[]>([])
|
||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.5fr'
|
||||||
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
const cols = `1.2fr repeat(6, 1fr) ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
||||||
|
return isAdmin.value ? `${cols} ${metricCol}` : cols
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
|
const weekGridCols = '1.6fr repeat(7, 1fr) 1fr 0.8fr 0.8fr 0.8fr'
|
||||||
|
|
||||||
const sites = computed<Site[]>(() => {
|
const sites = computed<Site[]>(() => {
|
||||||
const siteMap = new Map<number, Site>()
|
const siteMap = new Map<number, Site>()
|
||||||
@@ -118,34 +94,6 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
||||||
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
|
|
||||||
|
|
||||||
const validatableEmployeeIds = computed(() => {
|
|
||||||
return employees.value
|
|
||||||
.map((employee) => employee.id)
|
|
||||||
.filter((employeeId) => canToggleValidation(employeeId))
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBulkValidationChecked = computed(() => {
|
|
||||||
const ids = validatableEmployeeIds.value
|
|
||||||
if (ids.length === 0) return false
|
|
||||||
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBulkValidationIndeterminate = computed(() => {
|
|
||||||
const ids = validatableEmployeeIds.value
|
|
||||||
if (ids.length === 0) return false
|
|
||||||
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
|
|
||||||
return checkedCount > 0 && checkedCount < ids.length
|
|
||||||
})
|
|
||||||
|
|
||||||
const dayContextByEmployeeId = computed(() => {
|
|
||||||
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
|
||||||
for (const row of dayContext.value?.rows ?? []) {
|
|
||||||
map.set(row.employeeId, row)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
||||||
const targetDate = target === 'yesterday'
|
const targetDate = target === 'yesterday'
|
||||||
@@ -161,37 +109,6 @@ export const useHoursPage = () => {
|
|||||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
|
||||||
const selected = parseYmd(selectedDate.value)
|
|
||||||
if (!selected) {
|
|
||||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date()
|
|
||||||
const targetDate = new Date(today)
|
|
||||||
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
|
|
||||||
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
|
|
||||||
|
|
||||||
const selectedWeekStart = getWeekStartDate(selected)
|
|
||||||
const targetWeekStart = getWeekStartDate(targetDate)
|
|
||||||
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
return 'bg-primary-500 text-white'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
|
||||||
const today = new Date()
|
|
||||||
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
|
|
||||||
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
|
|
||||||
|
|
||||||
const weekNumber = getIsoWeekNumber(today)
|
|
||||||
return `Sem. S${weekNumber}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedSelectedDate = computed(() => {
|
const formattedSelectedDate = computed(() => {
|
||||||
const parsed = parseYmd(selectedDate.value)
|
const parsed = parseYmd(selectedDate.value)
|
||||||
if (!parsed) return selectedDate.value
|
if (!parsed) return selectedDate.value
|
||||||
@@ -229,40 +146,6 @@ export const useHoursPage = () => {
|
|||||||
shiftDate(1)
|
shiftDate(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setThisWeek = () => {
|
|
||||||
selectedDate.value = getTodayYmd()
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPreviousWeek = () => {
|
|
||||||
const previousWeek = shiftYmd(getTodayYmd(), -7)
|
|
||||||
if (!previousWeek) return
|
|
||||||
selectedDate.value = previousWeek
|
|
||||||
}
|
|
||||||
|
|
||||||
const setNextWeek = () => {
|
|
||||||
const nextWeek = shiftYmd(getTodayYmd(), 7)
|
|
||||||
if (!nextWeek) return
|
|
||||||
selectedDate.value = nextWeek
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetAbsenceForm = () => {
|
|
||||||
absenceForm.value = {
|
|
||||||
employeeId: '',
|
|
||||||
typeId: '',
|
|
||||||
startDate: '',
|
|
||||||
startHalf: 'AM',
|
|
||||||
endDate: '',
|
|
||||||
endHalf: 'PM',
|
|
||||||
comment: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAbsenceDrawer = () => {
|
|
||||||
isAbsenceDrawerOpen.value = false
|
|
||||||
editingAbsence.value = null
|
|
||||||
resetAbsenceForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyRow = (): HourRow => ({
|
const emptyRow = (): HourRow => ({
|
||||||
workHourId: null,
|
workHourId: null,
|
||||||
morningFrom: '',
|
morningFrom: '',
|
||||||
@@ -276,17 +159,14 @@ export const useHoursPage = () => {
|
|||||||
isValid: false
|
isValid: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE'
|
||||||
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||||
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
||||||
|
|
||||||
const contractLabel = (employee: Employee) => {
|
const contractLabel = (employee: Employee) => {
|
||||||
const contract = employee.contract
|
const contract = employee.contract
|
||||||
if (!contract) return '-'
|
if (!contract) return '-'
|
||||||
if (contract.type === CONTRACT_TYPES.INTERIM) {
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') {
|
||||||
return contract.name
|
|
||||||
}
|
|
||||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
|
|
||||||
return `${contract.weeklyHours}h`
|
return `${contract.weeklyHours}h`
|
||||||
}
|
}
|
||||||
return contract.name
|
return contract.name
|
||||||
@@ -363,42 +243,10 @@ export const useHoursPage = () => {
|
|||||||
nightMinutes += nightIntervalMinutes(from, to)
|
nightMinutes += nightIntervalMinutes(from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
|
|
||||||
totalMinutes += creditedMinutes
|
|
||||||
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
|
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
|
||||||
return { dayMinutes, nightMinutes, totalMinutes }
|
return { dayMinutes, nightMinutes, totalMinutes }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRowAbsenceLabel = (employeeId: number) => {
|
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
if (!dayRow?.absenceLabel) return ''
|
|
||||||
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
|
||||||
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
|
||||||
return `${dayRow.absenceLabel} (${halfLabel})`
|
|
||||||
}
|
|
||||||
return `${dayRow.absenceLabel} (journée)`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPresenceDayValue = (employeeId: number) => {
|
|
||||||
const row = rows.value[employeeId]
|
|
||||||
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
|
|
||||||
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
|
|
||||||
const total = Math.min(1, basePresence + creditedPresence)
|
|
||||||
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
|
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
if (!dayRow) return false
|
|
||||||
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEveningLockedByAbsence = (employeeId: number) => {
|
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
if (!dayRow) return false
|
|
||||||
return dayRow.absentAfternoon
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatMinutes = (minutes: number) => {
|
const formatMinutes = (minutes: number) => {
|
||||||
const safeMinutes = Math.max(0, minutes)
|
const safeMinutes = Math.max(0, minutes)
|
||||||
const hours = Math.floor(safeMinutes / 60)
|
const hours = Math.floor(safeMinutes / 60)
|
||||||
@@ -432,197 +280,19 @@ export const useHoursPage = () => {
|
|||||||
rows.value = nextRows
|
rows.value = nextRows
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAbsenceTypes = async () => {
|
const toggleValidation = async (employeeId: number, checked: boolean) => {
|
||||||
absenceTypes.value = await listAbsenceTypes()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadAbsences = async () => {
|
|
||||||
absences.value = await listAbsences({
|
|
||||||
from: selectedDate.value,
|
|
||||||
to: selectedDate.value,
|
|
||||||
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAbsenceDrawer = (employeeId: number) => {
|
|
||||||
const existing = absences.value.find((absence) => {
|
|
||||||
if (absence.employee?.id !== employeeId) return false
|
|
||||||
const start = absence.startDate.slice(0, 10)
|
|
||||||
const end = absence.endDate.slice(0, 10)
|
|
||||||
return selectedDate.value >= start && selectedDate.value <= end
|
|
||||||
}) ?? null
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
editingAbsence.value = existing
|
|
||||||
absenceForm.value = {
|
|
||||||
employeeId,
|
|
||||||
typeId: existing.type?.id ?? '',
|
|
||||||
startDate: existing.startDate.slice(0, 10),
|
|
||||||
startHalf: existing.startHalf ?? 'AM',
|
|
||||||
endDate: existing.endDate.slice(0, 10),
|
|
||||||
endHalf: existing.endHalf ?? 'PM',
|
|
||||||
comment: existing.comment ?? ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editingAbsence.value = null
|
|
||||||
absenceForm.value = {
|
|
||||||
employeeId,
|
|
||||||
typeId: '',
|
|
||||||
startDate: selectedDate.value,
|
|
||||||
startHalf: 'AM',
|
|
||||||
endDate: selectedDate.value,
|
|
||||||
endHalf: 'PM',
|
|
||||||
comment: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isAbsenceDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
|
|
||||||
const row = rows.value[employeeId]
|
|
||||||
if (!row) return
|
|
||||||
|
|
||||||
if (startHalf === 'AM' && endHalf === 'AM') {
|
|
||||||
row.morningFrom = ''
|
|
||||||
row.morningTo = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startHalf === 'PM' && endHalf === 'PM') {
|
|
||||||
row.afternoonFrom = ''
|
|
||||||
row.afternoonTo = ''
|
|
||||||
row.eveningFrom = ''
|
|
||||||
row.eveningTo = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
row.morningFrom = ''
|
|
||||||
row.morningTo = ''
|
|
||||||
row.afternoonFrom = ''
|
|
||||||
row.afternoonTo = ''
|
|
||||||
row.eveningFrom = ''
|
|
||||||
row.eveningTo = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshAfterAbsenceChange = async () => {
|
|
||||||
if (isAdmin.value) {
|
|
||||||
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
weeklySummary.value = null
|
|
||||||
await Promise.all([loadDayContext(), loadAbsences()])
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
|
||||||
const form = absenceForm.value
|
|
||||||
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
|
||||||
|
|
||||||
isAbsenceSubmitting.value = true
|
|
||||||
try {
|
|
||||||
if (editingAbsence.value) {
|
|
||||||
await updateAbsence({
|
|
||||||
id: editingAbsence.value.id,
|
|
||||||
employeeId: Number(form.employeeId),
|
|
||||||
typeId: Number(form.typeId),
|
|
||||||
startDate: form.startDate,
|
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: editingAbsence.value.comment ?? ''
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createAbsence({
|
|
||||||
employeeId: Number(form.employeeId),
|
|
||||||
typeId: Number(form.typeId),
|
|
||||||
startDate: form.startDate,
|
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
|
|
||||||
closeAbsenceDrawer()
|
|
||||||
await refreshAfterAbsenceChange()
|
|
||||||
} finally {
|
|
||||||
isAbsenceSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAbsenceFromDrawer = async () => {
|
|
||||||
if (!editingAbsence.value || isAbsenceSubmitting.value) return
|
|
||||||
|
|
||||||
isAbsenceSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
|
||||||
closeAbsenceDrawer()
|
|
||||||
await refreshAfterAbsenceChange()
|
|
||||||
} finally {
|
|
||||||
isAbsenceSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleValidation = async (
|
|
||||||
employeeId: number,
|
|
||||||
checked: boolean,
|
|
||||||
options: { toast?: boolean } = {}
|
|
||||||
) => {
|
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
if (!row?.workHourId || isValidationPending(employeeId)) return
|
if (!row?.workHourId || isValidationPending(employeeId)) return
|
||||||
|
|
||||||
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
||||||
try {
|
try {
|
||||||
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
|
await updateWorkHourValidation(row.workHourId, checked)
|
||||||
row.isValid = checked
|
row.isValid = checked
|
||||||
} finally {
|
} finally {
|
||||||
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleValidationBulk = async (checked: boolean) => {
|
|
||||||
const employeeIds = validatableEmployeeIds.value
|
|
||||||
if (employeeIds.length === 0) return
|
|
||||||
|
|
||||||
let successCount = 0
|
|
||||||
let failedCount = 0
|
|
||||||
|
|
||||||
for (const employeeId of employeeIds) {
|
|
||||||
if (isValidationPending(employeeId)) continue
|
|
||||||
try {
|
|
||||||
await toggleValidation(employeeId, checked, { toast: false })
|
|
||||||
successCount += 1
|
|
||||||
} catch {
|
|
||||||
failedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedCount === 0) {
|
|
||||||
toast.success({
|
|
||||||
title: 'Succès',
|
|
||||||
message: checked
|
|
||||||
? `${successCount} ligne(s) validée(s).`
|
|
||||||
: `${successCount} validation(s) retirée(s).`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount === 0) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: 'Impossible de mettre à jour les validations.'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEmployees = async () => {
|
const loadEmployees = async () => {
|
||||||
const scopedEmployees = await listScopedEmployees()
|
const scopedEmployees = await listScopedEmployees()
|
||||||
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
|
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
|
||||||
@@ -642,25 +312,20 @@ export const useHoursPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDayContext = async () => {
|
|
||||||
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshByDate = async () => {
|
const refreshByDate = async () => {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
await Promise.all([loadWorkHours(), loadWeeklySummary()])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
weeklySummary.value = null
|
weeklySummary.value = null
|
||||||
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
|
await loadWorkHours()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
await loadAbsenceTypes()
|
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -682,15 +347,11 @@ export const useHoursPage = () => {
|
|||||||
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(isAdmin, async (admin) => {
|
watch(isAdmin, (admin) => {
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
viewMode.value = 'day'
|
viewMode.value = 'day'
|
||||||
weeklySummary.value = null
|
weeklySummary.value = null
|
||||||
await Promise.all([loadAbsenceTypes(), loadAbsences()])
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await loadAbsenceTypes()
|
|
||||||
await loadAbsences()
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(selectedDate, async () => {
|
watch(selectedDate, async () => {
|
||||||
@@ -753,11 +414,6 @@ export const useHoursPage = () => {
|
|||||||
employees,
|
employees,
|
||||||
visibleEmployees,
|
visibleEmployees,
|
||||||
rows,
|
rows,
|
||||||
absenceTypes,
|
|
||||||
absenceForm,
|
|
||||||
isAbsenceDrawerOpen,
|
|
||||||
isAbsenceSubmitting,
|
|
||||||
editingAbsence,
|
|
||||||
weeklySummary,
|
weeklySummary,
|
||||||
filteredWeeklySummary,
|
filteredWeeklySummary,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -769,34 +425,17 @@ export const useHoursPage = () => {
|
|||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
weekDayHeaders,
|
weekDayHeaders,
|
||||||
shortcutButtonClass,
|
shortcutButtonClass,
|
||||||
weekShortcutButtonClass,
|
|
||||||
getWeekShortcutLabel,
|
|
||||||
setToday,
|
setToday,
|
||||||
setYesterday,
|
setYesterday,
|
||||||
setTomorrow,
|
setTomorrow,
|
||||||
setThisWeek,
|
|
||||||
setPreviousWeek,
|
|
||||||
setNextWeek,
|
|
||||||
shiftDate,
|
shiftDate,
|
||||||
contractLabel,
|
contractLabel,
|
||||||
isTimeTracking,
|
isTimeTracking,
|
||||||
isPresenceTracking,
|
isPresenceTracking,
|
||||||
isRowLocked,
|
isRowLocked,
|
||||||
isHalfLockedByAbsence,
|
|
||||||
isEveningLockedByAbsence,
|
|
||||||
isValidationPending,
|
isValidationPending,
|
||||||
canToggleValidation,
|
|
||||||
isBulkValidationChecked,
|
|
||||||
isBulkValidationIndeterminate,
|
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
toggleValidationBulk,
|
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
|
||||||
getPresenceDayValue,
|
|
||||||
openAbsenceDrawer,
|
|
||||||
submitAbsence,
|
|
||||||
deleteAbsenceFromDrawer,
|
|
||||||
closeAbsenceDrawer,
|
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,17 @@
|
|||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="/hours"
|
||||||
|
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
|
>
|
||||||
|
Heures
|
||||||
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
@@ -21,15 +28,6 @@
|
|||||||
>
|
>
|
||||||
Calendrier
|
Calendrier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
|
||||||
<NuxtLink
|
|
||||||
to="/hours"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
|
||||||
>
|
|
||||||
Heures
|
|
||||||
</NuxtLink>
|
|
||||||
<template v-if="isAdmin">
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ export default defineNuxtConfig({
|
|||||||
devtools: {enabled: false},
|
devtools: {enabled: false},
|
||||||
ssr: false,
|
ssr: false,
|
||||||
app: {
|
app: {
|
||||||
baseURL: process.env.NODE_ENV === 'production'
|
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
||||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
|
||||||
: '/'
|
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
@@ -39,4 +37,4 @@ export default defineNuxtConfig({
|
|||||||
typescript: {
|
typescript: {
|
||||||
strict: true
|
strict: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -19,11 +19,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
<div class="grid grid-cols-[120px_120px_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
<span class="text-left">Code</span>
|
<span class="text-left">Code</span>
|
||||||
<span class="text-left">Libellé</span>
|
<span class="text-left">Libellé</span>
|
||||||
<span class="text-left">Couleur</span>
|
<span class="text-left">Couleur</span>
|
||||||
<span class="text-left">Compte en heures</span>
|
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="type in absenceTypes"
|
v-for="type in absenceTypes"
|
||||||
:key="type.id"
|
:key="type.id"
|
||||||
class="grid grid-cols-[120px_160px_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
class="grid grid-cols-[120px_120px_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-left">{{ type.code }}</span>
|
<span class="font-semibold text-left">{{ type.code }}</span>
|
||||||
<span class="text-left">{{ type.label }}</span>
|
<span class="text-left">{{ type.label }}</span>
|
||||||
@@ -44,14 +43,6 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left">
|
|
||||||
<span
|
|
||||||
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
|
|
||||||
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
|
|
||||||
>
|
|
||||||
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -103,31 +94,6 @@
|
|||||||
Le libellé est obligatoire.
|
Le libellé est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700">
|
|
||||||
Compté comme travaillé
|
|
||||||
</label>
|
|
||||||
<div class="mt-2 flex items-center gap-6">
|
|
||||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
|
||||||
<input
|
|
||||||
v-model="form.countAsWorkedHours"
|
|
||||||
type="radio"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:value="true"
|
|
||||||
/>
|
|
||||||
Oui
|
|
||||||
</label>
|
|
||||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
|
||||||
<input
|
|
||||||
v-model="form.countAsWorkedHours"
|
|
||||||
type="radio"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:value="false"
|
|
||||||
/>
|
|
||||||
Non
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
<label class="text-md font-semibold text-neutral-700" for="color">
|
||||||
Couleur <span class="text-red-600">*</span>
|
Couleur <span class="text-red-600">*</span>
|
||||||
@@ -170,10 +136,6 @@
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Types d\'absences'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -188,8 +150,7 @@ const drawerTitle = computed(() =>
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
color: '#222783',
|
color: '#222783'
|
||||||
countAsWorkedHours: true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -253,7 +214,6 @@ const resetForm = () => {
|
|||||||
form.code = ''
|
form.code = ''
|
||||||
form.label = ''
|
form.label = ''
|
||||||
form.color = '#222783'
|
form.color = '#222783'
|
||||||
form.countAsWorkedHours = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -267,7 +227,6 @@ const openEdit = (type: AbsenceType) => {
|
|||||||
form.code = type.code
|
form.code = type.code
|
||||||
form.label = type.label
|
form.label = type.label
|
||||||
form.color = type.color
|
form.color = type.color
|
||||||
form.countAsWorkedHours = type.countAsWorkedHours
|
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,15 +249,13 @@ const handleSubmit = async () => {
|
|||||||
await updateAbsenceType(editingType.value.id, {
|
await updateAbsenceType(editingType.value.id, {
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color,
|
color: form.color
|
||||||
countAsWorkedHours: form.countAsWorkedHours
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createAbsenceType({
|
await createAbsenceType({
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color,
|
color: form.color
|
||||||
countAsWorkedHours: form.countAsWorkedHours
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-80">
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
v-model="selectedMonth"
|
v-model="selectedMonth"
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
@@ -111,10 +109,6 @@ import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
|||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Calendrier'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Données principales affichées dans la grille.
|
// Données principales affichées dans la grille.
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
const sites = computed(() => {
|
||||||
|
|||||||
+46
-105
@@ -1,80 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
<div>
|
||||||
<div class="shrink-0">
|
<div class="flex items-center justify-between pb-12">
|
||||||
<div class="flex items-center justify-between">
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
<div class="flex flex-col gap-3 py-6">
|
@click="isDrawerOpen = true"
|
||||||
<div class="flex justify-between">
|
>
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
Ajouter un employé
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="isDrawerOpen = true"
|
|
||||||
>
|
|
||||||
Ajouter un employé
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="w-80">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && filteredEmployees.length === 0"
|
v-if="!isLoading && employees.length === 0"
|
||||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
>
|
>
|
||||||
Aucun employé pour le moment.
|
Aucun employé pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="h-full overflow-auto">
|
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
<div class="min-w-[900px]">
|
<span class="text-left">Prénom</span>
|
||||||
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
|
<span class="text-left">Nom</span>
|
||||||
<span class="text-left">Prénom</span>
|
<span class="text-left">Site</span>
|
||||||
<span class="text-left">Nom</span>
|
<span class="text-left">Contrat</span>
|
||||||
<span class="text-left">Site</span>
|
<span class="text-right">Actions</span>
|
||||||
<span class="text-left">Contrat</span>
|
</div>
|
||||||
<span class="text-right">Actions</span>
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
</div>
|
Chargement...
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
</div>
|
||||||
Chargement...
|
<div v-else>
|
||||||
</div>
|
<div
|
||||||
<div v-else>
|
v-for="employee in employees"
|
||||||
<div
|
:key="employee.id"
|
||||||
v-for="employee in filteredEmployees"
|
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
:key="employee.id"
|
>
|
||||||
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
<span>{{ employee.firstName }}</span>
|
||||||
|
<span>{{ employee.lastName }}</span>
|
||||||
|
<span>{{ employee.site?.name ?? '-' }}</span>
|
||||||
|
<span>{{ employee.contract?.name ?? '-' }}</span>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="openEdit(employee)"
|
||||||
>
|
>
|
||||||
<span>{{ employee.firstName }}</span>
|
Modifier
|
||||||
<span>{{ employee.lastName }}</span>
|
</button>
|
||||||
<span
|
<button
|
||||||
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
|
type="button"
|
||||||
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
|
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
|
||||||
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
|
@click="confirmDelete(employee)"
|
||||||
>
|
>
|
||||||
{{ employee.site?.name ?? '-' }}
|
Supprimer
|
||||||
</span>
|
</button>
|
||||||
<span>{{ employee.contract?.name ?? '-' }}</span>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="openEdit(employee)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
|
|
||||||
@click="confirmDelete(employee)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,15 +153,10 @@ import type { Site } from '~/services/dto/site'
|
|||||||
import { listContracts } from '~/services/contracts'
|
import { listContracts } from '~/services/contracts'
|
||||||
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
||||||
import { listSites } from '~/services/sites'
|
import { listSites } from '~/services/sites'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
|
||||||
useHead({
|
|
||||||
title: 'Employés'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const sitesInitialized = ref(false)
|
|
||||||
const editingEmployee = ref<Employee | null>(null)
|
const editingEmployee = ref<Employee | null>(null)
|
||||||
const drawerTitle = computed(() =>
|
const drawerTitle = computed(() =>
|
||||||
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
||||||
@@ -191,26 +165,6 @@ const drawerTitle = computed(() =>
|
|||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const contracts = ref<Contract[]>([])
|
const contracts = ref<Contract[]>([])
|
||||||
const employeeFilter = ref('')
|
|
||||||
const selectedSiteIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const filteredEmployees = computed(() => {
|
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
|
||||||
|
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
|
||||||
const bySite = employees.value.filter((employee) => {
|
|
||||||
const siteId = employee.site?.id
|
|
||||||
return !!siteId && selectedSiteIds.value.includes(siteId)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!filter) return bySite
|
|
||||||
|
|
||||||
return bySite.filter((employee) => {
|
|
||||||
const firstName = employee.firstName?.toLowerCase() ?? ''
|
|
||||||
const lastName = employee.lastName?.toLowerCase() ?? ''
|
|
||||||
return firstName.includes(filter) || lastName.includes(filter)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -306,19 +260,6 @@ onMounted(async () => {
|
|||||||
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(sites, (nextSites) => {
|
|
||||||
const currentSiteIds = nextSites.map((site) => site.id)
|
|
||||||
|
|
||||||
if (!sitesInitialized.value) {
|
|
||||||
if (currentSiteIds.length === 0) return
|
|
||||||
selectedSiteIds.value = currentSiteIds
|
|
||||||
sitesInitialized.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
validationTouched.firstName = true
|
validationTouched.firstName = true
|
||||||
|
|||||||
+16
-76
@@ -11,17 +11,11 @@
|
|||||||
v-model:employee-filter="employeeFilter"
|
v-model:employee-filter="employeeFilter"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:sites="sites"
|
:sites="sites"
|
||||||
:absence-types="absenceTypes"
|
|
||||||
:formatted-selected-date="formattedSelectedDate"
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
:shortcut-button-class="shortcutButtonClass"
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
|
||||||
:get-week-shortcut-label="getWeekShortcutLabel"
|
|
||||||
@set-yesterday="setYesterday"
|
@set-yesterday="setYesterday"
|
||||||
@set-today="setToday"
|
@set-today="setToday"
|
||||||
@set-tomorrow="setTomorrow"
|
@set-tomorrow="setTomorrow"
|
||||||
@set-previous-week="setPreviousWeek"
|
|
||||||
@set-this-week="setThisWeek"
|
|
||||||
@set-next-week="setNextWeek"
|
|
||||||
@shift-date="shiftDate"
|
@shift-date="shiftDate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -34,43 +28,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
|
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<div class="flex-1 min-h-0 flex flex-col">
|
<HoursDayView
|
||||||
<HoursDayView
|
v-if="viewMode === 'day'"
|
||||||
v-if="viewMode === 'day'"
|
v-model:rows="rows"
|
||||||
v-model:rows="rows"
|
:employees="visibleEmployees"
|
||||||
:employees="visibleEmployees"
|
:is-admin="isAdmin"
|
||||||
:is-admin="isAdmin"
|
:day-grid-cols="dayGridCols"
|
||||||
:day-grid-cols="dayGridCols"
|
:contract-label="contractLabel"
|
||||||
:contract-label="contractLabel"
|
:is-time-tracking="isTimeTracking"
|
||||||
:is-time-tracking="isTimeTracking"
|
|
||||||
:is-presence-tracking="isPresenceTracking"
|
:is-presence-tracking="isPresenceTracking"
|
||||||
:is-row-locked="isRowLocked"
|
:is-row-locked="isRowLocked"
|
||||||
:is-half-locked-by-absence="isHalfLockedByAbsence"
|
|
||||||
:is-evening-locked-by-absence="isEveningLockedByAbsence"
|
|
||||||
:is-validation-pending="isValidationPending"
|
:is-validation-pending="isValidationPending"
|
||||||
:can-toggle-validation="canToggleValidation"
|
|
||||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
|
||||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
|
||||||
:on-toggle-validation="toggleValidation"
|
:on-toggle-validation="toggleValidation"
|
||||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
|
||||||
:get-row-metrics="getRowMetrics"
|
:get-row-metrics="getRowMetrics"
|
||||||
:get-row-absence-label="getRowAbsenceLabel"
|
|
||||||
:get-presence-day-value="getPresenceDayValue"
|
|
||||||
:on-absence-click="openAbsenceDrawer"
|
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-full"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HoursWeekView
|
<HoursWeekView
|
||||||
v-else-if="isAdmin && viewMode === 'week'"
|
v-else-if="isAdmin && viewMode === 'week'"
|
||||||
:is-week-loading="isWeekLoading"
|
:is-week-loading="isWeekLoading"
|
||||||
:week-grid-cols="weekGridCols"
|
:week-grid-cols="weekGridCols"
|
||||||
:weekly-summary="filteredWeeklySummary"
|
:weekly-summary="filteredWeeklySummary"
|
||||||
:week-day-headers="weekDayHeaders"
|
:week-day-headers="weekDayHeaders"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-full"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
||||||
<button
|
<button
|
||||||
@@ -84,21 +65,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AbsenceFormDrawer
|
|
||||||
v-model="isAbsenceDrawerOpen"
|
|
||||||
:employees="employees"
|
|
||||||
:absence-types="absenceTypes"
|
|
||||||
:form="absenceForm"
|
|
||||||
:editing-absence="editingAbsence"
|
|
||||||
:is-submitting="isAbsenceSubmitting"
|
|
||||||
:lock-employee="true"
|
|
||||||
:lock-dates="true"
|
|
||||||
:show-comment="false"
|
|
||||||
@submit="submitAbsence"
|
|
||||||
@delete="deleteAbsenceFromDrawer"
|
|
||||||
@cancel="closeAbsenceDrawer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -113,11 +79,6 @@ const {
|
|||||||
employees,
|
employees,
|
||||||
visibleEmployees,
|
visibleEmployees,
|
||||||
rows,
|
rows,
|
||||||
absenceTypes,
|
|
||||||
absenceForm,
|
|
||||||
isAbsenceDrawerOpen,
|
|
||||||
isAbsenceSubmitting,
|
|
||||||
editingAbsence,
|
|
||||||
filteredWeeklySummary,
|
filteredWeeklySummary,
|
||||||
isLoading,
|
isLoading,
|
||||||
isWeekLoading,
|
isWeekLoading,
|
||||||
@@ -128,39 +89,18 @@ const {
|
|||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
weekDayHeaders,
|
weekDayHeaders,
|
||||||
shortcutButtonClass,
|
shortcutButtonClass,
|
||||||
weekShortcutButtonClass,
|
|
||||||
getWeekShortcutLabel,
|
|
||||||
setToday,
|
setToday,
|
||||||
setYesterday,
|
setYesterday,
|
||||||
setTomorrow,
|
setTomorrow,
|
||||||
setThisWeek,
|
|
||||||
setPreviousWeek,
|
|
||||||
setNextWeek,
|
|
||||||
shiftDate,
|
shiftDate,
|
||||||
contractLabel,
|
contractLabel,
|
||||||
isTimeTracking,
|
isTimeTracking,
|
||||||
isPresenceTracking,
|
isPresenceTracking,
|
||||||
isRowLocked,
|
isRowLocked,
|
||||||
isHalfLockedByAbsence,
|
|
||||||
isEveningLockedByAbsence,
|
|
||||||
isValidationPending,
|
isValidationPending,
|
||||||
canToggleValidation,
|
|
||||||
isBulkValidationChecked,
|
|
||||||
isBulkValidationIndeterminate,
|
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
toggleValidationBulk,
|
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
|
||||||
getPresenceDayValue,
|
|
||||||
openAbsenceDrawer,
|
|
||||||
submitAbsence,
|
|
||||||
deleteAbsenceFromDrawer,
|
|
||||||
closeAbsenceDrawer,
|
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Heures'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({
|
|
||||||
title: 'Tableau de bord'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,9 +49,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: 'auth'})
|
definePageMeta({layout: 'auth'})
|
||||||
useHead({
|
|
||||||
title: 'Connexion'
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -123,10 +123,6 @@
|
|||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Sites'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|||||||
@@ -209,9 +209,6 @@ import { createUser, listUsers, updateUser } from '~/services/users'
|
|||||||
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['admin'] })
|
||||||
useHead({
|
|
||||||
title: 'Utilisateurs'
|
|
||||||
})
|
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createAbsenceType = async (
|
export const createAbsenceType = async (
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<AbsenceType>('/absence_types', payload, {
|
return api.post<AbsenceType>('/absence_types', payload, {
|
||||||
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
|
|||||||
|
|
||||||
export const updateAbsenceType = async (
|
export const updateAbsenceType = async (
|
||||||
id: number,
|
id: number,
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const getCurrentUser = () => {
|
|||||||
export const login = (username: string, password: string) => {
|
export const login = (username: string, password: string) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post('/login_check', { username, password }, {
|
return api.post('/login_check', { username, password }, {
|
||||||
toastOn401: true,
|
|
||||||
toastErrorKey: 'errors.auth.login'
|
toastErrorKey: 'errors.auth.login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ export type AbsenceType = {
|
|||||||
code: string
|
code: string
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
countAsWorkedHours: boolean
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,7 @@
|
|||||||
export const TRACKING_MODES = {
|
|
||||||
TIME: 'TIME',
|
|
||||||
PRESENCE: 'PRESENCE'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
|
|
||||||
|
|
||||||
export const CONTRACT_TYPES = {
|
|
||||||
FORFAIT: 'FORFAIT',
|
|
||||||
H35: '35H',
|
|
||||||
H39: '39H',
|
|
||||||
INTERIM: 'INTERIM',
|
|
||||||
CUSTOM: 'CUSTOM'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
|
|
||||||
|
|
||||||
export type Contract = {
|
export type Contract = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
trackingMode: TrackingMode
|
trackingMode: 'TIME' | 'PRESENCE'
|
||||||
type: ContractType
|
|
||||||
weeklyHours?: number | null
|
weeklyHours?: number | null
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Employee } from './employee'
|
import type { Employee } from './employee'
|
||||||
import type { ContractType, TrackingMode } from './contract'
|
|
||||||
|
|
||||||
export type WorkHour = {
|
export type WorkHour = {
|
||||||
id: number
|
id: number
|
||||||
@@ -34,9 +33,6 @@ export type WeeklyWorkHourDailySummary = {
|
|||||||
nightMinutes: number
|
nightMinutes: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
present?: number | null
|
present?: number | null
|
||||||
hasAbsence?: boolean
|
|
||||||
absenceLabel?: string | null
|
|
||||||
absenceColor?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
export type WeeklyWorkHourRowSummary = {
|
||||||
@@ -45,17 +41,14 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
lastName: string
|
lastName: string
|
||||||
siteName?: string | null
|
siteName?: string | null
|
||||||
contractName?: string | null
|
contractName?: string | null
|
||||||
contractType?: ContractType | null
|
trackingMode?: 'TIME' | 'PRESENCE' | null
|
||||||
trackingMode?: TrackingMode | null
|
|
||||||
daily: WeeklyWorkHourDailySummary[]
|
daily: WeeklyWorkHourDailySummary[]
|
||||||
weeklyDayMinutes: number
|
weeklyDayMinutes: number
|
||||||
weeklyNightMinutes: number
|
weeklyNightMinutes: number
|
||||||
weeklyTotalMinutes: number
|
weeklyTotalMinutes: number
|
||||||
weeklyPresenceCount?: number
|
weeklyPresenceCount?: number
|
||||||
weeklyOvertimeTotalMinutes?: number
|
|
||||||
weeklyOvertime25Minutes?: number
|
weeklyOvertime25Minutes?: number
|
||||||
weeklyOvertime50Minutes?: number
|
weeklyOvertime50Minutes?: number
|
||||||
weeklyRecoveryMinutes?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
export type WeeklyWorkHourSummary = {
|
||||||
@@ -64,18 +57,3 @@ export type WeeklyWorkHourSummary = {
|
|||||||
days: string[]
|
days: string[]
|
||||||
rows: WeeklyWorkHourRowSummary[]
|
rows: WeeklyWorkHourRowSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourDayContextRow = {
|
|
||||||
employeeId: number
|
|
||||||
absenceLabel?: string | null
|
|
||||||
absenceHalf?: 'AM' | 'PM' | null
|
|
||||||
absentMorning: boolean
|
|
||||||
absentAfternoon: boolean
|
|
||||||
creditedMinutes: number
|
|
||||||
creditedPresenceUnits: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
|
||||||
workDate: string
|
|
||||||
rows: WorkHourDayContextRow[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
WorkHourDayContext,
|
|
||||||
WorkHour,
|
WorkHour,
|
||||||
WorkHourEntryPayload,
|
WorkHourEntryPayload,
|
||||||
WeeklyWorkHourSummary
|
WeeklyWorkHourSummary
|
||||||
@@ -40,17 +39,12 @@ export const bulkUpsertWorkHours = async (payload: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateWorkHourValidation = async (
|
export const updateWorkHourValidation = async (id: number, isValid: boolean) => {
|
||||||
id: number,
|
|
||||||
isValid: boolean,
|
|
||||||
options?: { toast?: boolean }
|
|
||||||
) => {
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<WorkHour>(
|
return api.patch<WorkHour>(
|
||||||
`/work_hours/${id}`,
|
`/work_hours/${id}`,
|
||||||
{ isValid },
|
{ isValid },
|
||||||
{
|
{
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
|
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
|
||||||
toastErrorMessage: 'Impossible de mettre à jour la validation.'
|
toastErrorMessage: 'Impossible de mettre à jour la validation.'
|
||||||
}
|
}
|
||||||
@@ -65,12 +59,3 @@ export const getWeeklyWorkHourSummary = async (weekStart: string) => {
|
|||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWorkHourDayContext = async (workDate: string) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.get<WorkHourDayContext>(
|
|
||||||
'/work-hours/day-context',
|
|
||||||
{ workDate },
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-42
@@ -42,46 +42,6 @@ export const getWeekStartDate = (date: Date) => {
|
|||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getIsoWeekNumber = (date: Date) => {
|
|
||||||
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
|
||||||
const day = utc.getUTCDay() || 7
|
|
||||||
utc.setUTCDate(utc.getUTCDate() + 4 - day)
|
|
||||||
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1))
|
|
||||||
return Math.ceil((((utc.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getIsoWeekYear = (date: Date) => {
|
|
||||||
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
|
||||||
const day = utc.getUTCDay() || 7
|
|
||||||
utc.setUTCDate(utc.getUTCDate() + 4 - day)
|
|
||||||
return utc.getUTCFullYear()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ymdToWeekInputValue = (dateYmd: string) => {
|
|
||||||
const parsed = parseYmd(dateYmd)
|
|
||||||
if (!parsed) return ''
|
|
||||||
const weekDate = getWeekStartDate(parsed)
|
|
||||||
const weekNumber = getIsoWeekNumber(weekDate)
|
|
||||||
const weekYear = getIsoWeekYear(weekDate)
|
|
||||||
return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const weekInputValueToYmd = (weekValue: string) => {
|
|
||||||
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue)
|
|
||||||
if (!match) return null
|
|
||||||
|
|
||||||
const year = Number(match[1])
|
|
||||||
const week = Number(match[2])
|
|
||||||
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) return null
|
|
||||||
|
|
||||||
const jan4 = new Date(year, 0, 4)
|
|
||||||
const week1Monday = getWeekStartDate(jan4)
|
|
||||||
const monday = new Date(week1Monday)
|
|
||||||
monday.setDate(week1Monday.getDate() + ((week - 1) * 7))
|
|
||||||
|
|
||||||
return toYmd(monday.getFullYear(), monday.getMonth(), monday.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTodayYmd = () => {
|
export const getTodayYmd = () => {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
@@ -104,7 +64,6 @@ export const formatWeekRangeFr = (date: Date) => {
|
|||||||
const start = getWeekStartDate(date)
|
const start = getWeekStartDate(date)
|
||||||
const end = new Date(start)
|
const end = new Date(start)
|
||||||
end.setDate(start.getDate() + 6)
|
end.setDate(start.getDate() + 6)
|
||||||
const weekNumber = getIsoWeekNumber(start)
|
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -112,7 +71,7 @@ export const formatWeekRangeFr = (date: Date) => {
|
|||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
})
|
})
|
||||||
|
|
||||||
return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
|
return `Semaine du ${formatter.format(start)} au ${formatter.format(end)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDaysInMonth = (year: number, month: number) => {
|
export const getDaysInMonth = (year: number, month: number) => {
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260218190000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add count_as_worked_hours on absence_types';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE absence_types ADD count_as_worked_hours BOOLEAN DEFAULT FALSE NOT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE absence_types DROP count_as_worked_hours');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use DateInterval;
|
|
||||||
use DatePeriod;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260219180000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Decoupe les absences multi-jours en lignes journalieres.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$rows = $this->connection->fetchAllAssociative(
|
|
||||||
'SELECT id, employee_id, type_id, start_date, end_date, start_half, end_half, comment
|
|
||||||
FROM absences
|
|
||||||
WHERE start_date < end_date
|
|
||||||
ORDER BY id ASC'
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['start_date']);
|
|
||||||
$end = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['end_date']);
|
|
||||||
if (!$start instanceof DateTimeImmutable || !$end instanceof DateTimeImmutable) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$startHalf = (string) $row['start_half'];
|
|
||||||
$endHalf = (string) $row['end_half'];
|
|
||||||
|
|
||||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
|
||||||
foreach ($days as $day) {
|
|
||||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
|
||||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
|
||||||
|
|
||||||
if ($isFirst && 'PM' === $startHalf) {
|
|
||||||
$segmentStartHalf = 'PM';
|
|
||||||
$segmentEndHalf = 'PM';
|
|
||||||
} elseif ($isLast && 'AM' === $endHalf) {
|
|
||||||
$segmentStartHalf = 'AM';
|
|
||||||
$segmentEndHalf = 'AM';
|
|
||||||
} else {
|
|
||||||
$segmentStartHalf = 'AM';
|
|
||||||
$segmentEndHalf = 'PM';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->connection->insert('absences', [
|
|
||||||
'employee_id' => (int) $row['employee_id'],
|
|
||||||
'type_id' => (int) $row['type_id'],
|
|
||||||
'start_date' => $day,
|
|
||||||
'end_date' => $day,
|
|
||||||
'start_half' => $segmentStartHalf,
|
|
||||||
'end_half' => $segmentEndHalf,
|
|
||||||
'comment' => $row['comment'],
|
|
||||||
], [
|
|
||||||
'employee_id' => Types::INTEGER,
|
|
||||||
'type_id' => Types::INTEGER,
|
|
||||||
'start_date' => Types::DATE_IMMUTABLE,
|
|
||||||
'end_date' => Types::DATE_IMMUTABLE,
|
|
||||||
'start_half' => Types::STRING,
|
|
||||||
'end_half' => Types::STRING,
|
|
||||||
'comment' => Types::TEXT,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->connection->delete('absences', ['id' => (int) $row['id']], ['id' => Types::INTEGER]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->throwIrreversibleMigrationException('Cette migration de decoupage est irreversible.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260220133000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add employee contract periods history table and seed current contracts';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)');
|
|
||||||
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)');
|
|
||||||
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
|
|
||||||
// Initialise l\'historique avec le contrat actuel de chaque employé.
|
|
||||||
$this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at)
|
|
||||||
SELECT id, contract_id, DATE '1970-01-01', NULL, NOW()
|
|
||||||
FROM employees
|
|
||||||
WHERE contract_id IS NOT NULL");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C');
|
|
||||||
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD');
|
|
||||||
$this->addSql('DROP TABLE employee_contract_periods');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\ApiResource;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use App\State\WorkHourDayContextProvider;
|
|
||||||
|
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new Get(
|
|
||||||
uriTemplate: '/work-hours/day-context',
|
|
||||||
security: "is_granted('ROLE_USER')",
|
|
||||||
provider: WorkHourDayContextProvider::class
|
|
||||||
),
|
|
||||||
],
|
|
||||||
paginationEnabled: false
|
|
||||||
)]
|
|
||||||
final class WorkHourDayContext
|
|
||||||
{
|
|
||||||
public string $workDate = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<array{
|
|
||||||
* employeeId:int,
|
|
||||||
* absenceLabel:?string,
|
|
||||||
* absenceHalf:?string,
|
|
||||||
* absentMorning:bool,
|
|
||||||
* absentAfternoon:bool,
|
|
||||||
* creditedMinutes:int,
|
|
||||||
* creditedPresenceUnits:float
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public array $rows = [];
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ namespace App\ApiResource;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -27,6 +26,28 @@ final class WorkHourWeeklySummary
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public array $days = [];
|
public array $days = [];
|
||||||
|
|
||||||
/** @var list<WeeklySummaryRow> */
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* employeeId:int,
|
||||||
|
* firstName:string,
|
||||||
|
* lastName:string,
|
||||||
|
* siteName:?string,
|
||||||
|
* contractName:?string,
|
||||||
|
* trackingMode:?string,
|
||||||
|
* daily:list<array{
|
||||||
|
* date:string,
|
||||||
|
* dayMinutes:int,
|
||||||
|
* nightMinutes:int,
|
||||||
|
* totalMinutes:int,
|
||||||
|
* present:?float
|
||||||
|
* }>,
|
||||||
|
* weeklyDayMinutes:int,
|
||||||
|
* weeklyNightMinutes:int,
|
||||||
|
* weeklyTotalMinutes:int,
|
||||||
|
* weeklyPresenceCount:float,
|
||||||
|
* weeklyOvertime25Minutes:int,
|
||||||
|
* weeklyOvertime50Minutes:int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
public array $rows = [];
|
public array $rows = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Doctrine;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Security\EmployeeScopeService;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
|
|
||||||
final readonly class AbsenceCollectionExtension implements QueryCollectionExtensionInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
private EmployeeScopeService $employeeScopeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function applyToCollection(
|
|
||||||
QueryBuilder $queryBuilder,
|
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
|
||||||
string $resourceClass,
|
|
||||||
?Operation $operation = null,
|
|
||||||
array $context = []
|
|
||||||
): void {
|
|
||||||
if (Absence::class !== $resourceClass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
$queryBuilder->andWhere('1 = 0');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
|
||||||
$employeeAlias = 'absence_employee_scope';
|
|
||||||
|
|
||||||
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
|
|
||||||
->addSelect($employeeAlias)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'absence_scope', $user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Dto\WorkHours;
|
|
||||||
|
|
||||||
final class DayContextRow
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $employeeId,
|
|
||||||
public ?string $absenceLabel = null,
|
|
||||||
public ?string $absenceHalf = null,
|
|
||||||
public bool $absentMorning = false,
|
|
||||||
public bool $absentAfternoon = false,
|
|
||||||
public int $creditedMinutes = 0,
|
|
||||||
public float $creditedPresenceUnits = 0.0,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function addAbsence(
|
|
||||||
?string $label,
|
|
||||||
bool $morning,
|
|
||||||
bool $afternoon,
|
|
||||||
int $creditedMinutes,
|
|
||||||
float $creditedPresenceUnits
|
|
||||||
): void {
|
|
||||||
// Fusionne plusieurs absences du même jour sur la ligne salarié.
|
|
||||||
$this->absentMorning = $this->absentMorning || $morning;
|
|
||||||
$this->absentAfternoon = $this->absentAfternoon || $afternoon;
|
|
||||||
|
|
||||||
// Garde un libellé lisible: unique si possible, sinon "Absences multiples".
|
|
||||||
if (null === $this->absenceLabel) {
|
|
||||||
$this->absenceLabel = $label;
|
|
||||||
} elseif ($label !== $this->absenceLabel) {
|
|
||||||
$this->absenceLabel = 'Absences multiples';
|
|
||||||
}
|
|
||||||
|
|
||||||
// AM/PM seulement pour les demi-journées, null pour journée complète.
|
|
||||||
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
|
|
||||||
// Cumule les minutes créditées par les absences "comptées comme travaillées".
|
|
||||||
$this->creditedMinutes += $creditedMinutes;
|
|
||||||
// Cumule les unités de présence créditées (0.5 par demi-journée).
|
|
||||||
$this->creditedPresenceUnits += $creditedPresenceUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* employeeId:int,
|
|
||||||
* absenceLabel:?string,
|
|
||||||
* absenceHalf:?string,
|
|
||||||
* absentMorning:bool,
|
|
||||||
* absentAfternoon:bool,
|
|
||||||
* creditedMinutes:int,
|
|
||||||
* creditedPresenceUnits:float
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'employeeId' => $this->employeeId,
|
|
||||||
'absenceLabel' => $this->absenceLabel,
|
|
||||||
'absenceHalf' => $this->absenceHalf,
|
|
||||||
'absentMorning' => $this->absentMorning,
|
|
||||||
'absentAfternoon' => $this->absentAfternoon,
|
|
||||||
'creditedMinutes' => $this->creditedMinutes,
|
|
||||||
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveHalfLabel(bool $morning, bool $afternoon): ?string
|
|
||||||
{
|
|
||||||
// Matin + après-midi => journée complète, pas de libellé AM/PM.
|
|
||||||
if ($morning && $afternoon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($morning) {
|
|
||||||
return 'AM';
|
|
||||||
}
|
|
||||||
if ($afternoon) {
|
|
||||||
return 'PM';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Dto\WorkHours;
|
|
||||||
|
|
||||||
final class WeeklyDaySummary
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $date,
|
|
||||||
public int $dayMinutes,
|
|
||||||
public int $nightMinutes,
|
|
||||||
public int $totalMinutes,
|
|
||||||
public ?float $present = null,
|
|
||||||
public bool $hasAbsence = false,
|
|
||||||
public ?string $absenceLabel = null,
|
|
||||||
public ?string $absenceColor = null,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Dto\WorkHours;
|
|
||||||
|
|
||||||
final class WeeklySummaryRow
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<WeeklyDaySummary> $daily
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public int $employeeId,
|
|
||||||
public string $firstName,
|
|
||||||
public string $lastName,
|
|
||||||
public ?string $siteName,
|
|
||||||
public ?string $contractName,
|
|
||||||
public ?string $contractType,
|
|
||||||
public ?string $trackingMode,
|
|
||||||
public array $daily,
|
|
||||||
public int $weeklyDayMinutes,
|
|
||||||
public int $weeklyNightMinutes,
|
|
||||||
public int $weeklyTotalMinutes,
|
|
||||||
public float $weeklyPresenceCount,
|
|
||||||
public int $weeklyOvertimeTotalMinutes,
|
|
||||||
public int $weeklyOvertime25Minutes,
|
|
||||||
public int $weeklyOvertime50Minutes,
|
|
||||||
public int $weeklyRecoveryMinutes,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Dto\WorkHours;
|
|
||||||
|
|
||||||
final class WorkMetrics
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $dayMinutes = 0,
|
|
||||||
public int $nightMinutes = 0,
|
|
||||||
public int $totalMinutes = 0,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function addCreditedMinutes(int $creditedMinutes): void
|
|
||||||
{
|
|
||||||
// Ignore les valeurs nulles ou négatives pour ne pas biaiser les totaux.
|
|
||||||
if ($creditedMinutes <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le crédit absence alimente les heures de jour et le total.
|
|
||||||
$this->dayMinutes += $creditedMinutes;
|
|
||||||
$this->totalMinutes += $creditedMinutes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-35
@@ -9,39 +9,12 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\AbsenceRepository;
|
use App\Repository\AbsenceRepository;
|
||||||
use App\State\AbsenceWriteProcessor;
|
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
security: "is_granted('ROLE_USER')"
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('ABSENCE_VIEW', object)"
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
securityPostDenormalize: "is_granted('ABSENCE_EDIT', object)",
|
|
||||||
processor: AbsenceWriteProcessor::class
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('ABSENCE_EDIT', object)",
|
|
||||||
processor: AbsenceWriteProcessor::class
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('ABSENCE_EDIT', object)",
|
|
||||||
processor: AbsenceWriteProcessor::class
|
|
||||||
),
|
|
||||||
],
|
|
||||||
normalizationContext: [
|
normalizationContext: [
|
||||||
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
|
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
|
||||||
'datetime_format' => 'Y-m-d',
|
'datetime_format' => 'Y-m-d',
|
||||||
@@ -50,6 +23,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
'datetime_format' => 'Y-m-d',
|
'datetime_format' => 'Y-m-d',
|
||||||
],
|
],
|
||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||||
@@ -79,17 +53,17 @@ class Absence
|
|||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private DateTimeInterface $startDate;
|
private DateTimeInterface $startDate;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
|
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'AM'])]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private HalfDay $startHalf = HalfDay::AM;
|
private string $startHalf = 'AM';
|
||||||
|
|
||||||
#[ORM\Column(type: 'date')]
|
#[ORM\Column(type: 'date')]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private DateTimeInterface $endDate;
|
private DateTimeInterface $endDate;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
|
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'PM'])]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private HalfDay $endHalf = HalfDay::PM;
|
private string $endHalf = 'PM';
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
@@ -148,24 +122,24 @@ class Absence
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getStartHalf(): HalfDay
|
public function getStartHalf(): string
|
||||||
{
|
{
|
||||||
return $this->startHalf;
|
return $this->startHalf;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setStartHalf(HalfDay $startHalf): self
|
public function setStartHalf(string $startHalf): self
|
||||||
{
|
{
|
||||||
$this->startHalf = $startHalf;
|
$this->startHalf = $startHalf;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEndHalf(): HalfDay
|
public function getEndHalf(): string
|
||||||
{
|
{
|
||||||
return $this->endHalf;
|
return $this->endHalf;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setEndHalf(HalfDay $endHalf): self
|
public function setEndHalf(string $endHalf): self
|
||||||
{
|
{
|
||||||
$this->endHalf = $endHalf;
|
$this->endHalf = $endHalf;
|
||||||
|
|
||||||
|
|||||||
@@ -5,34 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
security: "is_granted('ROLE_USER')"
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('ROLE_USER')"
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
normalizationContext: ['groups' => ['absence_type:read']],
|
normalizationContext: ['groups' => ['absence_type:read']],
|
||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table(name: 'absence_types')]
|
#[ORM\Table(name: 'absence_types')]
|
||||||
@@ -56,10 +35,6 @@ class AbsenceType
|
|||||||
#[Groups(['absence:read', 'absence_type:read'])]
|
#[Groups(['absence:read', 'absence_type:read'])]
|
||||||
private string $color = '';
|
private string $color = '';
|
||||||
|
|
||||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
|
||||||
#[Groups(['absence:read', 'absence_type:read'])]
|
|
||||||
private bool $countAsWorkedHours = false;
|
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -100,21 +75,4 @@ class AbsenceType
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isCountAsWorkedHours(): bool
|
|
||||||
{
|
|
||||||
return $this->countAsWorkedHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCountAsWorkedHours(): bool
|
|
||||||
{
|
|
||||||
return $this->countAsWorkedHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setCountAsWorkedHours(bool $countAsWorkedHours): self
|
|
||||||
{
|
|
||||||
$this->countAsWorkedHours = $countAsWorkedHours;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-23
@@ -5,10 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use App\Enum\ContractType;
|
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use InvalidArgumentException;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -21,8 +18,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'contracts')]
|
#[ORM\Table(name: 'contracts')]
|
||||||
class Contract
|
class Contract
|
||||||
{
|
{
|
||||||
public const string TRACKING_TIME = TrackingMode::TIME->value;
|
public const string TRACKING_TIME = 'TIME';
|
||||||
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
|
public const string TRACKING_PRESENCE = 'PRESENCE';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
@@ -68,29 +65,13 @@ class Contract
|
|||||||
return $this->trackingMode;
|
return $this->trackingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTrackingModeEnum(): TrackingMode
|
public function setTrackingMode(string $trackingMode): self
|
||||||
{
|
{
|
||||||
return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
|
$this->trackingMode = $trackingMode;
|
||||||
}
|
|
||||||
|
|
||||||
public function setTrackingMode(string|TrackingMode $trackingMode): self
|
|
||||||
{
|
|
||||||
$value = $trackingMode instanceof TrackingMode ? $trackingMode->value : $trackingMode;
|
|
||||||
if (null === TrackingMode::tryFrom($value)) {
|
|
||||||
throw new InvalidArgumentException(sprintf('Invalid tracking mode "%s".', $value));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->trackingMode = $value;
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Groups(['contract:read', 'employee:read'])]
|
|
||||||
public function getType(): ContractType
|
|
||||||
{
|
|
||||||
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWeeklyHours(): ?int
|
public function getWeeklyHours(): ?int
|
||||||
{
|
{
|
||||||
return $this->weeklyHours;
|
return $this->weeklyHours;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Entity;
|
|||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\State\EmployeeWriteProcessor;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -16,8 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['employee:write']],
|
denormalizationContext: ['groups' => ['employee:write']],
|
||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
processor: EmployeeWriteProcessor::class,
|
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||||
#[ORM\Table(name: 'employees')]
|
#[ORM\Table(name: 'employees')]
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use App\Repository\EmployeeContractPeriodRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
|
||||||
#[ORM\Table(name: 'employee_contract_periods')]
|
|
||||||
#[ORM\Index(columns: ['employee_id', 'start_date'], name: 'idx_emp_contract_period_employee_start')]
|
|
||||||
#[ORM\Index(columns: ['employee_id', 'end_date'], name: 'idx_emp_contract_period_employee_end')]
|
|
||||||
class EmployeeContractPeriod
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column(type: 'integer')]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
private ?Employee $employee = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
|
||||||
private ?Contract $contract = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable')]
|
|
||||||
private DateTimeImmutable $startDate;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
|
||||||
private ?DateTimeImmutable $endDate = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
|
||||||
private DateTimeImmutable $createdAt;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->createdAt = new DateTimeImmutable();
|
|
||||||
$this->startDate = new DateTimeImmutable('today');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmployee(): ?Employee
|
|
||||||
{
|
|
||||||
return $this->employee;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEmployee(?Employee $employee): self
|
|
||||||
{
|
|
||||||
$this->employee = $employee;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getContract(): ?Contract
|
|
||||||
{
|
|
||||||
return $this->contract;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setContract(?Contract $contract): self
|
|
||||||
{
|
|
||||||
$this->contract = $contract;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStartDate(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->startDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setStartDate(DateTimeImmutable $startDate): self
|
|
||||||
{
|
|
||||||
$this->startDate = $startDate;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEndDate(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEndDate(?DateTimeImmutable $endDate): self
|
|
||||||
{
|
|
||||||
$this->endDate = $endDate;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enum;
|
|
||||||
|
|
||||||
enum ContractType: string
|
|
||||||
{
|
|
||||||
case FORFAIT = 'FORFAIT';
|
|
||||||
case H35 = '35H';
|
|
||||||
case H39 = '39H';
|
|
||||||
case INTERIM = 'INTERIM';
|
|
||||||
case CUSTOM = 'CUSTOM';
|
|
||||||
|
|
||||||
public static function resolve(?string $name, ?string $trackingMode, ?int $weeklyHours): self
|
|
||||||
{
|
|
||||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
|
||||||
return self::FORFAIT;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedName = self::normalize($name);
|
|
||||||
if ('interim' === $normalizedName) {
|
|
||||||
return self::INTERIM;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (35 === $weeklyHours) {
|
|
||||||
return self::H35;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (39 === $weeklyHours) {
|
|
||||||
return self::H39;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::CUSTOM;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalize(?string $value): string
|
|
||||||
{
|
|
||||||
if (null === $value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = mb_strtolower(trim($value));
|
|
||||||
|
|
||||||
return str_replace('é', 'e', $normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enum;
|
|
||||||
|
|
||||||
enum HalfDay: string
|
|
||||||
{
|
|
||||||
case AM = 'AM';
|
|
||||||
case PM = 'PM';
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enum;
|
|
||||||
|
|
||||||
enum TrackingMode: string
|
|
||||||
{
|
|
||||||
case TIME = 'TIME';
|
|
||||||
case PRESENCE = 'PRESENCE';
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,14 @@ namespace App\Repository;
|
|||||||
|
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeInterface;
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<Absence>
|
* @extends ServiceEntityRepository<Absence>
|
||||||
*/
|
*/
|
||||||
final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface
|
final class AbsenceRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -45,58 +43,7 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
|
|||||||
->setParameter('employees', $employees)
|
->setParameter('employees', $employees)
|
||||||
;
|
;
|
||||||
|
|
||||||
// @var list<Absence> $absences
|
/** @var list<Absence> $absences */
|
||||||
return $qb->getQuery()->getResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return list<Absence>
|
|
||||||
*/
|
|
||||||
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
|
|
||||||
{
|
|
||||||
if ([] === $employees) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('a')
|
|
||||||
->leftJoin('a.employee', 'e')
|
|
||||||
->leftJoin('a.type', 't')
|
|
||||||
->addSelect('e', 't')
|
|
||||||
->andWhere('a.startDate <= :date')
|
|
||||||
->andWhere('a.endDate >= :date')
|
|
||||||
->andWhere('a.employee IN (:employees)')
|
|
||||||
->setParameter('date', $date)
|
|
||||||
->setParameter('employees', $employees)
|
|
||||||
;
|
|
||||||
|
|
||||||
// @var list<Absence> $absences
|
|
||||||
return $qb->getQuery()->getResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<Absence>
|
|
||||||
*/
|
|
||||||
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
|
|
||||||
{
|
|
||||||
$fromDate = DateTimeImmutable::createFromInterface($from);
|
|
||||||
$toDate = DateTimeImmutable::createFromInterface($to);
|
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('a')
|
|
||||||
->leftJoin('a.employee', 'e')
|
|
||||||
->leftJoin('a.type', 't')
|
|
||||||
->addSelect('e', 't')
|
|
||||||
->andWhere('a.employee = :employee')
|
|
||||||
->andWhere('a.startDate >= :from')
|
|
||||||
->andWhere('a.startDate <= :to')
|
|
||||||
->setParameter('employee', $employee)
|
|
||||||
->setParameter('from', $fromDate)
|
|
||||||
->setParameter('to', $toDate)
|
|
||||||
->orderBy('a.startDate', 'ASC')
|
|
||||||
;
|
|
||||||
|
|
||||||
// @var list<Absence> $absences
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\Contract;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
interface AbsenceReadRepositoryInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return list<Absence>
|
|
||||||
*/
|
|
||||||
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return list<Absence>
|
|
||||||
*/
|
|
||||||
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<Absence>
|
|
||||||
*/
|
|
||||||
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\Contract;
|
|
||||||
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\User;
|
|
||||||
|
|
||||||
interface EmployeeScopedRepositoryInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return list<Employee>
|
|
||||||
*/
|
|
||||||
public function findScoped(User $user): array;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository\Contract;
|
|
||||||
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
interface WorkHourReadRepositoryInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return list<WorkHour>
|
|
||||||
*/
|
|
||||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
|
||||||
|
|
||||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
|
||||||
|
|
||||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Repository;
|
|
||||||
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\EmployeeContractPeriod;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
|
||||||
*/
|
|
||||||
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
|
|
||||||
{
|
|
||||||
public function __construct(ManagerRegistry $registry)
|
|
||||||
{
|
|
||||||
parent::__construct($registry, EmployeeContractPeriod::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return list<EmployeeContractPeriod>
|
|
||||||
*/
|
|
||||||
public function findByEmployeesAndDateRange(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
||||||
{
|
|
||||||
if ([] === $employees) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->createQueryBuilder('p')
|
|
||||||
->andWhere('p.employee IN (:employees)')
|
|
||||||
->andWhere('p.startDate <= :to')
|
|
||||||
->andWhere('p.endDate IS NULL OR p.endDate >= :from')
|
|
||||||
->setParameter('employees', $employees)
|
|
||||||
->setParameter('from', $from)
|
|
||||||
->setParameter('to', $to)
|
|
||||||
->orderBy('p.startDate', 'ASC')
|
|
||||||
->getQuery()
|
|
||||||
->getResult()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod
|
|
||||||
{
|
|
||||||
return $this->createQueryBuilder('p')
|
|
||||||
->andWhere('p.employee = :employee')
|
|
||||||
->andWhere('p.startDate <= :date')
|
|
||||||
->andWhere('p.endDate IS NULL OR p.endDate >= :date')
|
|
||||||
->setParameter('employee', $employee)
|
|
||||||
->setParameter('date', $date)
|
|
||||||
->orderBy('p.startDate', 'DESC')
|
|
||||||
->setMaxResults(1)
|
|
||||||
->getQuery()
|
|
||||||
->getOneOrNullResult()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
|
||||||
{
|
|
||||||
return $this->createQueryBuilder('p')
|
|
||||||
->update()
|
|
||||||
->set('p.endDate', ':endDate')
|
|
||||||
->andWhere('p.employee = :employee')
|
|
||||||
->andWhere('p.endDate IS NULL')
|
|
||||||
->setParameter('employee', $employee)
|
|
||||||
->setParameter('endDate', $endDate)
|
|
||||||
->getQuery()
|
|
||||||
->execute()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ namespace App\Repository;
|
|||||||
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -14,7 +13,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<Employee>
|
* @extends ServiceEntityRepository<Employee>
|
||||||
*/
|
*/
|
||||||
final class EmployeeRepository extends ServiceEntityRepository implements EmployeeScopedRepositoryInterface
|
final class EmployeeRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ManagerRegistry $registry,
|
ManagerRegistry $registry,
|
||||||
@@ -71,7 +70,7 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
|
|||||||
|
|
||||||
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
|
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
|
||||||
|
|
||||||
// @var list<Employee> $employees
|
/** @var list<Employee> $employees */
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @var list<Employee> $employees
|
/** @var list<Employee> $employees */
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ namespace App\Repository;
|
|||||||
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeInterface;
|
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<WorkHour>
|
* @extends ServiceEntityRepository<WorkHour>
|
||||||
*/
|
*/
|
||||||
final class WorkHourRepository extends ServiceEntityRepository implements WorkHourReadRepositoryInterface
|
final class WorkHourRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -78,43 +76,7 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
->setParameter('employees', $employees)
|
->setParameter('employees', $employees)
|
||||||
;
|
;
|
||||||
|
|
||||||
// @var list<WorkHour> $workHours
|
/** @var list<WorkHour> $workHours */
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
|
|
||||||
{
|
|
||||||
$fromDate = DateTimeImmutable::createFromInterface($from);
|
|
||||||
$toDate = DateTimeImmutable::createFromInterface($to);
|
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('w')
|
|
||||||
->select('COUNT(w.id)')
|
|
||||||
->andWhere('w.employee = :employee')
|
|
||||||
->andWhere('w.workDate >= :from')
|
|
||||||
->andWhere('w.workDate <= :to')
|
|
||||||
->andWhere('w.isValid = :isValid')
|
|
||||||
->setParameter('employee', $employee)
|
|
||||||
->setParameter('from', $fromDate)
|
|
||||||
->setParameter('to', $toDate)
|
|
||||||
->setParameter('isValid', true)
|
|
||||||
;
|
|
||||||
|
|
||||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
|
||||||
{
|
|
||||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('w')
|
|
||||||
->andWhere('w.employee = :employee')
|
|
||||||
->andWhere('w.workDate = :workDate')
|
|
||||||
->setParameter('employee', $employee)
|
|
||||||
->setParameter('workDate', $workDate)
|
|
||||||
->setMaxResults(1)
|
|
||||||
;
|
|
||||||
|
|
||||||
/** @var null|WorkHour $workHour */
|
|
||||||
return $qb->getQuery()->getOneOrNullResult();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\Voter;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Security\EmployeeScopeService;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
|
||||||
|
|
||||||
final class AbsenceVoter extends Voter
|
|
||||||
{
|
|
||||||
public const string VIEW = 'ABSENCE_VIEW';
|
|
||||||
public const string EDIT = 'ABSENCE_EDIT';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly EmployeeScopeService $employeeScopeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
|
||||||
{
|
|
||||||
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof Absence;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
|
||||||
{
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$subject instanceof Absence) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $subject->getEmployee();
|
|
||||||
if (null === $employee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->employeeScopeService->canAccessEmployee($user, $employee);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Contracts;
|
|
||||||
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Repository\EmployeeContractPeriodRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
readonly class EmployeeContractResolver
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EmployeeContractPeriodRepository $periodRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
|
|
||||||
{
|
|
||||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
|
||||||
$contract = $period?->getContract();
|
|
||||||
if (null === $contract) {
|
|
||||||
throw new LogicException(sprintf(
|
|
||||||
'Missing contract period for employee %d on %s.',
|
|
||||||
$employee->getId() ?? 0,
|
|
||||||
$date->format('Y-m-d')
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $contract;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
* @param list<string> $days
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, ?Contract>>
|
|
||||||
*/
|
|
||||||
public function resolveForEmployeesAndDays(array $employees, array $days): array
|
|
||||||
{
|
|
||||||
$resolved = [];
|
|
||||||
if ([] === $employees || [] === $days) {
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($employees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($days as $day) {
|
|
||||||
$resolved[$employeeId][$day] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$from = new DateTimeImmutable(min($days));
|
|
||||||
$to = new DateTimeImmutable(max($days));
|
|
||||||
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
|
|
||||||
foreach ($periods as $period) {
|
|
||||||
$employeeId = $period->getEmployee()?->getId();
|
|
||||||
$contract = $period->getContract();
|
|
||||||
if (!$employeeId || null === $contract) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $period->getStartDate()->format('Y-m-d');
|
|
||||||
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
|
|
||||||
foreach ($days as $day) {
|
|
||||||
if ($day < $start || $day > $end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$resolved[$employeeId][$day] = $contract;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($employees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($days as $day) {
|
|
||||||
if (null === ($resolved[$employeeId][$day] ?? null)) {
|
|
||||||
throw new LogicException(sprintf(
|
|
||||||
'Missing contract period for employee %d on %s.',
|
|
||||||
$employeeId,
|
|
||||||
$day
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\WorkHours;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
|
|
||||||
final class AbsenceSegmentsResolver
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{bool, bool}
|
|
||||||
*/
|
|
||||||
public function resolveForDate(Absence $absence, string $dateYmd): array
|
|
||||||
{
|
|
||||||
$startDate = $absence->getStartDate()->format('Y-m-d');
|
|
||||||
$endDate = $absence->getEndDate()->format('Y-m-d');
|
|
||||||
$startHalf = $absence->getStartHalf();
|
|
||||||
$endHalf = $absence->getEndHalf();
|
|
||||||
|
|
||||||
// Cas d'une absence sur une seule date: on déduit matin/après-midi depuis les bornes.
|
|
||||||
if ($startDate === $endDate) {
|
|
||||||
if (HalfDay::AM === $startHalf && HalfDay::AM === $endHalf) {
|
|
||||||
// Uniquement le matin absent.
|
|
||||||
return [true, false];
|
|
||||||
}
|
|
||||||
if (HalfDay::PM === $startHalf && HalfDay::PM === $endHalf) {
|
|
||||||
// Uniquement l'après-midi absent.
|
|
||||||
return [false, true];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sinon, on considère la journée complète absente.
|
|
||||||
return [true, true];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Premier jour d'une absence multi-jours qui commence l'après-midi.
|
|
||||||
if ($dateYmd === $startDate && HalfDay::PM === $startHalf) {
|
|
||||||
return [false, true];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dernier jour d'une absence multi-jours qui se termine le matin.
|
|
||||||
if ($dateYmd === $endDate && HalfDay::AM === $endHalf) {
|
|
||||||
return [true, false];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Les jours intermédiaires sont entièrement absents.
|
|
||||||
return [true, true];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\WorkHours;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use DateMalformedStringException;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class WorkedHoursCreditPolicy
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EmployeeContractResolver $contractResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws DateMalformedStringException
|
|
||||||
*/
|
|
||||||
public function computeCreditedMinutes(Absence $absence, string $dateYmd, bool $absentMorning, bool $absentAfternoon): int
|
|
||||||
{
|
|
||||||
$type = $absence->getType();
|
|
||||||
// Certaines absences ne doivent jamais générer d'heures créditées.
|
|
||||||
if (!$type?->getCountAsWorkedHours()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
|
||||||
if (null === $employee) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
$workDate = new DateTimeImmutable($dateYmd);
|
|
||||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
|
||||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
|
||||||
if (TrackingMode::TIME->value !== $contract?->getTrackingMode()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$weekday = (int) $workDate->format('N');
|
|
||||||
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
|
||||||
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
|
|
||||||
if ($dayMinutes <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crédit en demi-journées: matin = 0.5, après-midi = 0.5.
|
|
||||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
|
||||||
|
|
||||||
return (int) round(($dayMinutes / 2) * $halfUnits);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws DateMalformedStringException
|
|
||||||
*/
|
|
||||||
public function computeCreditedPresenceUnits(
|
|
||||||
Absence $absence,
|
|
||||||
string $dateYmd,
|
|
||||||
bool $absentMorning,
|
|
||||||
bool $absentAfternoon
|
|
||||||
): float {
|
|
||||||
$type = $absence->getType();
|
|
||||||
if (!$type?->getCountAsWorkedHours()) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
|
||||||
if (null === $employee) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, new DateTimeImmutable($dateYmd));
|
|
||||||
if (TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
|
||||||
|
|
||||||
return $halfUnits * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
|
||||||
{
|
|
||||||
// Week-end non travaillé dans cette politique.
|
|
||||||
if ($isoWeekDay >= 6) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règle fixe: 35h => 7h/jour.
|
|
||||||
if (35 === $weeklyHours) {
|
|
||||||
return 7 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
|
|
||||||
if (39 === $weeklyHours) {
|
|
||||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
|
|
||||||
if (4 === $weeklyHours) {
|
|
||||||
return 2 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contrat non renseigné/invalide: aucun crédit.
|
|
||||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback générique: répartition homogène sur 5 jours ouvrés.
|
|
||||||
return (int) round(($weeklyHours * 60) / 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\AbsenceRepository;
|
use App\Repository\AbsenceRepository;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
@@ -165,13 +164,13 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
|
|
||||||
if ($isSameDay) {
|
if ($isSameDay) {
|
||||||
if ($startHalf === $endHalf) {
|
if ($startHalf === $endHalf) {
|
||||||
$halfLabel = $startHalf->value;
|
$halfLabel = $startHalf;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ($isStartDay && HalfDay::PM === $startHalf) {
|
if ($isStartDay && 'PM' === $startHalf) {
|
||||||
$halfLabel = 'PM';
|
$halfLabel = 'PM';
|
||||||
}
|
}
|
||||||
if ($isEndDay && HalfDay::AM === $endHalf) {
|
if ($isEndDay && 'AM' === $endHalf) {
|
||||||
$halfLabel = 'AM';
|
$halfLabel = 'AM';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
||||||
use DateInterval;
|
|
||||||
use DatePeriod;
|
|
||||||
use DateTime;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
||||||
|
|
||||||
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
|
||||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof Absence) {
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $data->getEmployee();
|
|
||||||
if (null === $employee) {
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
|
|
||||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->remove($data);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$segments = $this->expandAbsenceRange($data);
|
|
||||||
if ([] === $segments) {
|
|
||||||
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
|
|
||||||
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
|
|
||||||
|
|
||||||
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
|
|
||||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = $this->absenceRepository->findByEmployeeAndDateRange($employee, $from, $to);
|
|
||||||
foreach ($existing as $existingAbsence) {
|
|
||||||
if ($existingAbsence->getId() === $data->getId()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ConflictHttpException('Cette période chevauche déjà une absence existante.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = array_shift($segments);
|
|
||||||
if (null === $first) {
|
|
||||||
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$data
|
|
||||||
->setStartDate($this->toMutableDate($first['date']))
|
|
||||||
->setEndDate($this->toMutableDate($first['date']))
|
|
||||||
->setStartHalf($first['startHalf'])
|
|
||||||
->setEndHalf($first['endHalf'])
|
|
||||||
;
|
|
||||||
$this->clearWorkHoursForSegment($employee, $first);
|
|
||||||
$this->entityManager->persist($data);
|
|
||||||
|
|
||||||
foreach ($segments as $segment) {
|
|
||||||
$absence = new Absence()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setType($data->getType())
|
|
||||||
->setComment($data->getComment())
|
|
||||||
->setStartDate($this->toMutableDate($segment['date']))
|
|
||||||
->setEndDate($this->toMutableDate($segment['date']))
|
|
||||||
->setStartHalf($segment['startHalf'])
|
|
||||||
->setEndHalf($segment['endHalf'])
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->clearWorkHoursForSegment($employee, $segment);
|
|
||||||
$this->entityManager->persist($absence);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay}>
|
|
||||||
*/
|
|
||||||
private function expandAbsenceRange(Absence $absence): array
|
|
||||||
{
|
|
||||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
|
|
||||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
|
|
||||||
|
|
||||||
if ($start > $end) {
|
|
||||||
throw new UnprocessableEntityHttpException('La date de fin ne peut pas être avant la date de début.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
$start->format('Y-m-d') === $end->format('Y-m-d')
|
|
||||||
&& HalfDay::PM === $absence->getStartHalf()
|
|
||||||
&& HalfDay::AM === $absence->getEndHalf()
|
|
||||||
) {
|
|
||||||
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
|
||||||
|
|
||||||
$segments = [];
|
|
||||||
foreach ($days as $day) {
|
|
||||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
|
||||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
|
||||||
$isSame = $isFirst && $isLast;
|
|
||||||
|
|
||||||
if ($isSame) {
|
|
||||||
$segments[] = [
|
|
||||||
'date' => $day,
|
|
||||||
'startHalf' => $absence->getStartHalf(),
|
|
||||||
'endHalf' => $absence->getEndHalf(),
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isFirst && HalfDay::PM === $absence->getStartHalf()) {
|
|
||||||
$segments[] = [
|
|
||||||
'date' => $day,
|
|
||||||
'startHalf' => HalfDay::PM,
|
|
||||||
'endHalf' => HalfDay::PM,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isLast && HalfDay::AM === $absence->getEndHalf()) {
|
|
||||||
$segments[] = [
|
|
||||||
'date' => $day,
|
|
||||||
'startHalf' => HalfDay::AM,
|
|
||||||
'endHalf' => HalfDay::AM,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$segments[] = [
|
|
||||||
'date' => $day,
|
|
||||||
'startHalf' => HalfDay::AM,
|
|
||||||
'endHalf' => HalfDay::PM,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toMutableDate(DateTimeImmutable $date): DateTime
|
|
||||||
{
|
|
||||||
return DateTime::createFromImmutable($date);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
|
||||||
*/
|
|
||||||
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
|
|
||||||
{
|
|
||||||
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
|
|
||||||
if (null === $workHour) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demi-journée matin: on efface uniquement la plage du matin.
|
|
||||||
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
|
|
||||||
$workHour
|
|
||||||
->setMorningFrom(null)
|
|
||||||
->setMorningTo(null)
|
|
||||||
;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demi-journée après-midi: on efface après-midi + soirée.
|
|
||||||
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
|
|
||||||
$workHour
|
|
||||||
->setAfternoonFrom(null)
|
|
||||||
->setAfternoonTo(null)
|
|
||||||
->setEveningFrom(null)
|
|
||||||
->setEveningTo(null)
|
|
||||||
;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Journée complète: on efface toutes les plages horaires.
|
|
||||||
$workHour
|
|
||||||
->setMorningFrom(null)
|
|
||||||
->setMorningTo(null)
|
|
||||||
->setAfternoonFrom(null)
|
|
||||||
->setAfternoonTo(null)
|
|
||||||
->setEveningFrom(null)
|
|
||||||
->setEveningTo(null)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\EmployeeContractPeriod;
|
|
||||||
use App\Repository\EmployeeContractPeriodRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private ProcessorInterface $persistProcessor,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
|
||||||
private ProcessorInterface $removeProcessor,
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private EmployeeContractPeriodRepository $periodRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(
|
|
||||||
mixed $data,
|
|
||||||
Operation $operation,
|
|
||||||
array $uriVariables = [],
|
|
||||||
array $context = []
|
|
||||||
): mixed {
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$data instanceof Employee) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isNew = null === $data->getId();
|
|
||||||
$previousContract = $this->resolvePreviousContract($data);
|
|
||||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
|
|
||||||
$currentContract = $data->getContract();
|
|
||||||
if (!$currentContract instanceof Contract) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$today = new DateTimeImmutable('today');
|
|
||||||
if ($isNew) {
|
|
||||||
$this->ensureContractPeriodExists($data, $currentContract, $today);
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isSameContract($previousContract, $currentContract)) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
|
||||||
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) {
|
|
||||||
$todayPeriod->setContract($currentContract);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
|
|
||||||
$this->createPeriod($data, $currentContract, $today);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePreviousContract(Employee $employee): ?Contract
|
|
||||||
{
|
|
||||||
if (null === $employee->getId()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee);
|
|
||||||
$original = $originalData['contract'] ?? null;
|
|
||||||
|
|
||||||
return $original instanceof Contract ? $original : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isSameContract(?Contract $first, ?Contract $second): bool
|
|
||||||
{
|
|
||||||
if (null === $first || null === $second) {
|
|
||||||
return $first === $second;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $first->getId() === $second->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
|
||||||
{
|
|
||||||
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
|
||||||
if (null !== $covered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->createPeriod($employee, $contract, $startDate);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
|
||||||
{
|
|
||||||
$period = new EmployeeContractPeriod()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setContract($contract)
|
|
||||||
->setStartDate($startDate)
|
|
||||||
->setEndDate(null)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->entityManager->persist($period);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,11 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\WorkHourBulkUpsert;
|
use App\ApiResource\WorkHourBulkUpsert;
|
||||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||||
|
use App\Entity\Contract;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -28,7 +27,6 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
private Security $security,
|
private Security $security,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
private EmployeeContractResolver $contractResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(
|
public function process(
|
||||||
@@ -77,8 +75,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProviderInterface;
|
|
||||||
use App\ApiResource\WorkHourDayContext;
|
|
||||||
use App\Dto\WorkHours\DayContextRow;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
||||||
|
|
||||||
final readonly class WorkHourDayContextProvider implements ProviderInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
private RequestStack $requestStack,
|
|
||||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
|
|
||||||
{
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
// Endpoint protégé: on exige un utilisateur authentifié.
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
throw new AccessDeniedHttpException('Authentication required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$workDate = $this->resolveWorkDate();
|
|
||||||
$employees = $this->employeeRepository->findScoped($user);
|
|
||||||
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
|
|
||||||
|
|
||||||
$rowsByEmployeeId = [];
|
|
||||||
foreach ($employees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On initialise toutes les lignes, même sans absence ce jour-là.
|
|
||||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dateKey = $workDate->format('Y-m-d');
|
|
||||||
foreach ($absences as $absence) {
|
|
||||||
$employeeId = $absence->getEmployee()?->getId();
|
|
||||||
// Ignore les absences orphelines ou hors scope utilisateur.
|
|
||||||
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $dateKey);
|
|
||||||
// Pas de segment absent sur ce jour: rien à injecter dans la ligne.
|
|
||||||
if (!$absentMorning && !$absentAfternoon) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
|
||||||
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
|
||||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
|
|
||||||
$rowsByEmployeeId[$employeeId]->addAbsence(
|
|
||||||
label: $absence->getType()?->getLabel(),
|
|
||||||
morning: $absentMorning,
|
|
||||||
afternoon: $absentAfternoon,
|
|
||||||
creditedMinutes: $creditedMinutes,
|
|
||||||
creditedPresenceUnits: $creditedPresenceUnits
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new WorkHourDayContext();
|
|
||||||
$response->workDate = $dateKey;
|
|
||||||
$response->rows = array_map(
|
|
||||||
static fn (DayContextRow $row): array => $row->toArray(),
|
|
||||||
array_values($rowsByEmployeeId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveWorkDate(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
$query = $this->requestStack->getCurrentRequest()?->query;
|
|
||||||
$raw = (string) ($query?->get('workDate') ?? '');
|
|
||||||
|
|
||||||
// Sans paramètre, on cible la date du jour.
|
|
||||||
if ('' === $raw) {
|
|
||||||
return new DateTimeImmutable('today');
|
|
||||||
}
|
|
||||||
|
|
||||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
|
||||||
// Validation stricte du format pour éviter les ambiguïtés de parsing.
|
|
||||||
if (!$date || $date->format('Y-m-d') !== $raw) {
|
|
||||||
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,22 +7,11 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\WorkHourWeeklySummary;
|
use App\ApiResource\WorkHourWeeklySummary;
|
||||||
use App\Dto\WorkHours\WeeklyDaySummary;
|
|
||||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Enum\ContractType;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Enum\TrackingMode;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
@@ -34,18 +23,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private RequestStack $requestStack,
|
private RequestStack $requestStack,
|
||||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
||||||
private EmployeeContractResolver $contractResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
{
|
{
|
||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
|
|
||||||
if (!$user instanceof User) {
|
if (!$user instanceof User) {
|
||||||
throw new AccessDeniedHttpException('Authentication required.');
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
}
|
}
|
||||||
@@ -55,13 +39,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$employees = $this->employeeRepository->findScoped($user);
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
||||||
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
|
||||||
|
|
||||||
$summary = new WorkHourWeeklySummary();
|
$summary = new WorkHourWeeklySummary();
|
||||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
$summary->days = $days;
|
$summary->days = $days;
|
||||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
$summary->rows = $this->buildRows($employees, $workHours, $days);
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -71,13 +54,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$query = $this->requestStack->getCurrentRequest()?->query;
|
$query = $this->requestStack->getCurrentRequest()?->query;
|
||||||
$raw = (string) ($query?->get('weekStart') ?? '');
|
$raw = (string) ($query?->get('weekStart') ?? '');
|
||||||
|
|
||||||
// Sans paramètre, on ancre la semaine sur aujourd'hui.
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
return new DateTimeImmutable('today');
|
return new DateTimeImmutable('today');
|
||||||
}
|
}
|
||||||
|
|
||||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
||||||
// Validation stricte du format attendu.
|
|
||||||
if (!$date || $date->format('Y-m-d') !== $raw) {
|
if (!$date || $date->format('Y-m-d') !== $raw) {
|
||||||
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
|
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
|
||||||
}
|
}
|
||||||
@@ -90,7 +71,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
*/
|
*/
|
||||||
private function resolveWeek(DateTimeImmutable $anchorDate): array
|
private function resolveWeek(DateTimeImmutable $anchorDate): array
|
||||||
{
|
{
|
||||||
// Convention ISO: semaine de lundi (1) à dimanche (7).
|
|
||||||
$dayOfWeek = (int) $anchorDate->format('N');
|
$dayOfWeek = (int) $anchorDate->format('N');
|
||||||
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
|
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
|
||||||
$weekEnd = $weekStart->modify('+6 days');
|
$weekEnd = $weekStart->modify('+6 days');
|
||||||
@@ -106,22 +86,33 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @param list<Employee> $employees
|
* @param list<Employee> $employees
|
||||||
* @param list<WorkHour> $workHours
|
* @param list<WorkHour> $workHours
|
||||||
* @param list<Absence> $absences
|
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
*
|
*
|
||||||
* @return list<WeeklySummaryRow>
|
* @return list<array{
|
||||||
|
* employeeId:int,
|
||||||
|
* firstName:string,
|
||||||
|
* lastName:string,
|
||||||
|
* siteName:?string,
|
||||||
|
* contractName:?string,
|
||||||
|
* trackingMode:?string,
|
||||||
|
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
|
||||||
|
* weeklyDayMinutes:int,
|
||||||
|
* weeklyNightMinutes:int,
|
||||||
|
* weeklyTotalMinutes:int,
|
||||||
|
* weeklyPresenceCount:float,
|
||||||
|
* weeklyOvertime25Minutes:int,
|
||||||
|
* weeklyOvertime50Minutes:int
|
||||||
|
* }>
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
private function buildRows(array $employees, array $workHours, array $days): array
|
||||||
{
|
{
|
||||||
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
$metricsByEmployeeDate = [];
|
||||||
$metricsByEmployeeDate = [];
|
|
||||||
foreach ($workHours as $workHour) {
|
foreach ($workHours as $workHour) {
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
if (!$employeeId) {
|
if (!$employeeId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
|
|
||||||
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
|
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
|
||||||
$metricsByEmployeeDate[$employeeId][$dateKey] = [
|
$metricsByEmployeeDate[$employeeId][$dateKey] = [
|
||||||
'metrics' => $this->computeMetrics($workHour),
|
'metrics' => $this->computeMetrics($workHour),
|
||||||
@@ -130,42 +121,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$creditedByEmployeeDate = [];
|
|
||||||
$creditedPresenceByEmployeeDate = [];
|
|
||||||
$absenceByEmployeeDate = [];
|
|
||||||
$absenceLabelByEmployeeDate = [];
|
|
||||||
$absenceColorByEmployeeDate = [];
|
|
||||||
foreach ($absences as $absence) {
|
|
||||||
$employeeId = $absence->getEmployee()?->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $absence->getStartDate()->format('Y-m-d');
|
|
||||||
$end = $absence->getEndDate()->format('Y-m-d');
|
|
||||||
foreach ($days as $date) {
|
|
||||||
// On ne crédite que les dates couvertes par l'intervalle d'absence.
|
|
||||||
if ($date < $start || $date > $end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
|
||||||
if ($absentMorning || $absentAfternoon) {
|
|
||||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
|
||||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
|
||||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
|
||||||
}
|
|
||||||
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
|
|
||||||
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
|
||||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($employees as $employee) {
|
foreach ($employees as $employee) {
|
||||||
$employeeId = $employee->getId();
|
$employeeId = $employee->getId();
|
||||||
@@ -178,91 +133,62 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$weeklyTotalMinutes = 0;
|
$weeklyTotalMinutes = 0;
|
||||||
$weeklyPresenceCount = 0.0;
|
$weeklyPresenceCount = 0.0;
|
||||||
$daily = [];
|
$daily = [];
|
||||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
|
||||||
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
|
||||||
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
|
||||||
?? null;
|
|
||||||
$employeeContractsByDate = [];
|
|
||||||
foreach ($days as $date) {
|
|
||||||
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
$metrics = $entry['metrics'] ?? [
|
||||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
'dayMinutes' => 0,
|
||||||
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
'nightMinutes' => 0,
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
'totalMinutes' => 0,
|
||||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
];
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
|
||||||
$present = null;
|
$present = null;
|
||||||
if ($isPresenceTracking) {
|
if ($isPresenceTracking) {
|
||||||
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
||||||
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
||||||
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
$present = $morning + $afternoon;
|
||||||
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyDayMinutes += $metrics->dayMinutes;
|
$weeklyDayMinutes += $metrics['dayMinutes'];
|
||||||
$weeklyNightMinutes += $metrics->nightMinutes;
|
$weeklyNightMinutes += $metrics['nightMinutes'];
|
||||||
$weeklyTotalMinutes += $metrics->totalMinutes;
|
$weeklyTotalMinutes += $metrics['totalMinutes'];
|
||||||
if (null !== $present) {
|
if (null !== $present) {
|
||||||
$weeklyPresenceCount += $present;
|
$weeklyPresenceCount += $present;
|
||||||
}
|
}
|
||||||
|
|
||||||
$daily[] = new WeeklyDaySummary(
|
$daily[] = [
|
||||||
date: $date,
|
'date' => $date,
|
||||||
dayMinutes: $metrics->dayMinutes,
|
'dayMinutes' => $metrics['dayMinutes'],
|
||||||
nightMinutes: $metrics->nightMinutes,
|
'nightMinutes' => $metrics['nightMinutes'],
|
||||||
totalMinutes: $metrics->totalMinutes,
|
'totalMinutes' => $metrics['totalMinutes'],
|
||||||
present: $present,
|
'present' => $present,
|
||||||
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
];
|
||||||
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
|
||||||
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
$rows[] = [
|
||||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
|
'employeeId' => $employeeId,
|
||||||
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
'firstName' => $employee->getFirstName(),
|
||||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
'lastName' => $employee->getLastName(),
|
||||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
'siteName' => $employee->getSite()?->getName(),
|
||||||
? 0
|
'contractName' => $employee->getContract()?->getName(),
|
||||||
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
'trackingMode' => $employee->getContract()?->getTrackingMode(),
|
||||||
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
'daily' => $daily,
|
||||||
? 0
|
'weeklyDayMinutes' => $weeklyDayMinutes,
|
||||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
'weeklyNightMinutes' => $weeklyNightMinutes,
|
||||||
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
'weeklyTotalMinutes' => $weeklyTotalMinutes,
|
||||||
? 0
|
'weeklyPresenceCount' => $weeklyPresenceCount,
|
||||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
'weeklyOvertime25Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime25Minutes($weeklyTotalMinutes),
|
||||||
$weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
'weeklyOvertime50Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime50Minutes($weeklyTotalMinutes),
|
||||||
? 0
|
];
|
||||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
|
||||||
|
|
||||||
$rows[] = new WeeklySummaryRow(
|
|
||||||
employeeId: $employeeId,
|
|
||||||
firstName: $employee->getFirstName(),
|
|
||||||
lastName: $employee->getLastName(),
|
|
||||||
siteName: $employee->getSite()?->getName(),
|
|
||||||
contractName: $weekAnchorContract?->getName(),
|
|
||||||
contractType: $weekAnchorContract?->getType()->value,
|
|
||||||
trackingMode: $weekAnchorContract?->getTrackingMode(),
|
|
||||||
daily: $daily,
|
|
||||||
weeklyDayMinutes: $weeklyDayMinutes,
|
|
||||||
weeklyNightMinutes: $weeklyNightMinutes,
|
|
||||||
weeklyTotalMinutes: $weeklyTotalMinutes,
|
|
||||||
weeklyPresenceCount: $weeklyPresenceCount,
|
|
||||||
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
|
||||||
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
|
||||||
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
|
||||||
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
/**
|
||||||
|
* @return array{dayMinutes:int, nightMinutes:int, totalMinutes:int}
|
||||||
|
*/
|
||||||
|
private function computeMetrics(WorkHour $workHour): array
|
||||||
{
|
{
|
||||||
$ranges = [
|
$ranges = [
|
||||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
@@ -280,11 +206,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
return new WorkMetrics(
|
return [
|
||||||
dayMinutes: $dayMinutes,
|
'dayMinutes' => $dayMinutes,
|
||||||
nightMinutes: $nightMinutes,
|
'nightMinutes' => $nightMinutes,
|
||||||
totalMinutes: $totalMinutes,
|
'totalMinutes' => $totalMinutes,
|
||||||
);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,7 +224,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si fin <= début, on considère un passage à minuit.
|
|
||||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||||
|
|
||||||
return [$fromMinutes, $end];
|
return [$fromMinutes, $end];
|
||||||
@@ -335,11 +260,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
[$start, $end] = $interval;
|
||||||
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
|
$windows = [[0, 360], [1260, 1440]];
|
||||||
$windows = [[0, 360], [1260, 1440]];
|
$total = 0;
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
|
|
||||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||||
$shift = $dayOffset * 1440;
|
$shift = $dayOffset * 1440;
|
||||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||||
@@ -358,85 +281,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function computeOvertime25Minutes(int $weeklyTotalMinutes): int
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
|
||||||
*/
|
|
||||||
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
|
|
||||||
{
|
{
|
||||||
$total = 0;
|
return max(0, min($weeklyTotalMinutes, 43 * 60) - (35 * 60));
|
||||||
foreach ($days as $date) {
|
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
|
||||||
$hours = $contract?->getWeeklyHours();
|
|
||||||
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
|
|
||||||
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function computeOvertime50Minutes(int $weeklyTotalMinutes): int
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
|
||||||
*/
|
|
||||||
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
|
|
||||||
{
|
{
|
||||||
$total = 0;
|
return max(0, $weeklyTotalMinutes - (43 * 60));
|
||||||
foreach ($days as $date) {
|
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
|
||||||
$hours = $contract?->getWeeklyHours();
|
|
||||||
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
|
||||||
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
|
|
||||||
{
|
|
||||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
|
|
||||||
|
|
||||||
return (int) round($trancheMinutes * 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
|
|
||||||
{
|
|
||||||
// Bonus 50% appliqué au-delà de 43h.
|
|
||||||
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
|
|
||||||
|
|
||||||
return (int) round($trancheMinutes * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
|
|
||||||
{
|
|
||||||
$type = ContractType::resolve(
|
|
||||||
$contract?->getName(),
|
|
||||||
$contract?->getTrackingMode(),
|
|
||||||
$contract?->getWeeklyHours()
|
|
||||||
);
|
|
||||||
|
|
||||||
return ContractType::INTERIM === $type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
|
|
||||||
{
|
|
||||||
// Week-end hors base de référence.
|
|
||||||
if ($isoWeekDay >= 6) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $weeklyHours || $weeklyHours <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (39 === $weeklyHours) {
|
|
||||||
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (35 === $weeklyHours) {
|
|
||||||
return 7 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) round(($weeklyHours * 60) / 5);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Service\WorkHours;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use DateTime;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class AbsenceSegmentsResolverTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testResolveForSameDayMorningOnly(): void
|
|
||||||
{
|
|
||||||
$absence = new Absence()
|
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
|
||||||
->setEndDate(new DateTime('2026-02-16'))
|
|
||||||
->setStartHalf(HalfDay::AM)
|
|
||||||
->setEndHalf(HalfDay::AM)
|
|
||||||
;
|
|
||||||
|
|
||||||
$resolver = new AbsenceSegmentsResolver();
|
|
||||||
|
|
||||||
self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-16'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testResolveForSameDayAfternoonOnly(): void
|
|
||||||
{
|
|
||||||
$absence = new Absence()
|
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
|
||||||
->setEndDate(new DateTime('2026-02-16'))
|
|
||||||
->setStartHalf(HalfDay::PM)
|
|
||||||
->setEndHalf(HalfDay::PM)
|
|
||||||
;
|
|
||||||
|
|
||||||
$resolver = new AbsenceSegmentsResolver();
|
|
||||||
|
|
||||||
self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testResolveForMultiDayBoundaries(): void
|
|
||||||
{
|
|
||||||
$absence = new Absence()
|
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
|
||||||
->setEndDate(new DateTime('2026-02-18'))
|
|
||||||
->setStartHalf(HalfDay::PM)
|
|
||||||
->setEndHalf(HalfDay::AM)
|
|
||||||
;
|
|
||||||
|
|
||||||
$resolver = new AbsenceSegmentsResolver();
|
|
||||||
|
|
||||||
self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16'));
|
|
||||||
self::assertSame([true, true], $resolver->resolveForDate($absence, '2026-02-17'));
|
|
||||||
self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-18'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Service\WorkHours;
|
|
||||||
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\AbsenceType;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use DateTime;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WorkedHoursCreditPolicyTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
|
||||||
{
|
|
||||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
|
||||||
|
|
||||||
self::assertSame(210, $minutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
|
||||||
{
|
|
||||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
|
||||||
|
|
||||||
self::assertSame(120, $minutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
|
||||||
{
|
|
||||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
|
||||||
|
|
||||||
$units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false);
|
|
||||||
|
|
||||||
self::assertSame(0.5, $units);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
|
||||||
{
|
|
||||||
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
|
||||||
|
|
||||||
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
|
||||||
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
|
||||||
{
|
|
||||||
$contract = new Contract()
|
|
||||||
->setName('Contrat test')
|
|
||||||
->setTrackingMode($trackMode)
|
|
||||||
->setWeeklyHours($weeklyHours)
|
|
||||||
;
|
|
||||||
$employee = new Employee()
|
|
||||||
->setFirstName('Alice')
|
|
||||||
->setLastName('Durand')
|
|
||||||
->setContract($contract)
|
|
||||||
;
|
|
||||||
$type = new AbsenceType()
|
|
||||||
->setCode('CP')
|
|
||||||
->setLabel('Congés')
|
|
||||||
->setColor('#000')
|
|
||||||
->setCountAsWorkedHours($countAsWorked)
|
|
||||||
;
|
|
||||||
|
|
||||||
return new Absence()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setType($type)
|
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
|
||||||
->setEndDate(new DateTime('2026-02-16'))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResolverStub(): EmployeeContractResolver
|
|
||||||
{
|
|
||||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeeAndDate')
|
|
||||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
|
||||||
;
|
|
||||||
|
|
||||||
return $resolver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\AbsenceType;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
||||||
use App\State\AbsenceWriteProcessor;
|
|
||||||
use DateTime;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class AbsenceWriteProcessorTest extends TestCase
|
|
||||||
{
|
|
||||||
private AbsenceWriteProcessor $processor;
|
|
||||||
|
|
||||||
public function testPostSplitsRangeIntoDailyEntries(): void
|
|
||||||
{
|
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
||||||
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
|
|
||||||
|
|
||||||
$workHourRepository->expects(self::once())
|
|
||||||
->method('hasValidatedInRange')
|
|
||||||
->willReturn(false)
|
|
||||||
;
|
|
||||||
$absenceRepository->expects(self::once())
|
|
||||||
->method('findByEmployeeAndDateRange')
|
|
||||||
->willReturn([])
|
|
||||||
;
|
|
||||||
$entityManager->expects(self::exactly(3))->method('persist');
|
|
||||||
$entityManager->expects(self::once())->method('flush');
|
|
||||||
|
|
||||||
$result = $this->processor->process($absence, new Post());
|
|
||||||
|
|
||||||
self::assertSame($absence, $result);
|
|
||||||
self::assertSame('2026-02-16', $absence->getStartDate()->format('Y-m-d'));
|
|
||||||
self::assertSame('2026-02-16', $absence->getEndDate()->format('Y-m-d'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteThrowsWhenValidated(): void
|
|
||||||
{
|
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
|
||||||
|
|
||||||
$workHourRepository->expects(self::once())
|
|
||||||
->method('hasValidatedInRange')
|
|
||||||
->willReturn(true)
|
|
||||||
;
|
|
||||||
$entityManager->expects(self::never())->method('remove');
|
|
||||||
$entityManager->expects(self::never())->method('flush');
|
|
||||||
|
|
||||||
$this->expectException(ConflictHttpException::class);
|
|
||||||
$this->processor->process($absence, new Delete());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteRemovesWhenNotValidated(): void
|
|
||||||
{
|
|
||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
|
||||||
|
|
||||||
$workHourRepository->expects(self::once())
|
|
||||||
->method('hasValidatedInRange')
|
|
||||||
->willReturn(false)
|
|
||||||
;
|
|
||||||
$entityManager->expects(self::once())->method('remove')->with($absence);
|
|
||||||
$entityManager->expects(self::once())->method('flush');
|
|
||||||
|
|
||||||
$result = $this->processor->process($absence, new Delete());
|
|
||||||
|
|
||||||
self::assertNull($result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostThrowsOnInvalidHalfDayOrder(): void
|
|
||||||
{
|
|
||||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
|
||||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
|
|
||||||
|
|
||||||
$this->expectException(UnprocessableEntityHttpException::class);
|
|
||||||
$this->processor->process($absence, new Post());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildAbsence(string $startDate, string $endDate, HalfDay $startHalf, HalfDay $endHalf): Absence
|
|
||||||
{
|
|
||||||
$contract = new Contract()->setName('35h')->setTrackingMode(Contract::TRACKING_TIME)->setWeeklyHours(35);
|
|
||||||
$employee = new Employee()->setFirstName('Test')->setLastName('User')->setContract($contract);
|
|
||||||
$type = new AbsenceType()->setCode('CP')->setLabel('Congé')->setColor('#000')->setCountAsWorkedHours(true);
|
|
||||||
|
|
||||||
return new Absence()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setType($type)
|
|
||||||
->setComment('x')
|
|
||||||
->setStartDate(new DateTime($startDate))
|
|
||||||
->setEndDate(new DateTime($endDate))
|
|
||||||
->setStartHalf($startHalf)
|
|
||||||
->setEndHalf($endHalf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\AbsenceType;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use App\State\WorkHourDayContextProvider;
|
|
||||||
use DateTime;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use ReflectionObject;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WorkHourDayContextProviderTest extends TestCase
|
|
||||||
{
|
|
||||||
private Security $security;
|
|
||||||
private EmployeeScopedRepositoryInterface $employeeRepository;
|
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository;
|
|
||||||
private RequestStack $requestStack;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->security = $this->createStub(Security::class);
|
|
||||||
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
|
|
||||||
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
|
||||||
$this->requestStack = new RequestStack();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenAnonymous(): void
|
|
||||||
{
|
|
||||||
$this->security->method('getUser')->willReturn(null);
|
|
||||||
|
|
||||||
$provider = new WorkHourDayContextProvider(
|
|
||||||
$this->security,
|
|
||||||
$this->requestStack,
|
|
||||||
$this->employeeRepository,
|
|
||||||
$this->absenceRepository,
|
|
||||||
new AbsenceSegmentsResolver(),
|
|
||||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
|
||||||
$provider->provide(new Get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenDateFormatInvalid(): void
|
|
||||||
{
|
|
||||||
$this->requestStack->push(new Request(query: ['workDate' => '16-02-2026']));
|
|
||||||
$this->security->method('getUser')->willReturn(new User());
|
|
||||||
|
|
||||||
$provider = new WorkHourDayContextProvider(
|
|
||||||
$this->security,
|
|
||||||
$this->requestStack,
|
|
||||||
$this->employeeRepository,
|
|
||||||
$this->absenceRepository,
|
|
||||||
new AbsenceSegmentsResolver(),
|
|
||||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(UnprocessableEntityHttpException::class);
|
|
||||||
$provider->provide(new Get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBuildsRowsWithAbsenceCredits(): void
|
|
||||||
{
|
|
||||||
$user = new User();
|
|
||||||
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35);
|
|
||||||
$absence = $this->buildAbsence($employee, '2026-02-16', '2026-02-16', true);
|
|
||||||
|
|
||||||
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
|
|
||||||
$this->security->method('getUser')->willReturn($user);
|
|
||||||
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
|
|
||||||
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([$absence]);
|
|
||||||
|
|
||||||
$provider = new WorkHourDayContextProvider(
|
|
||||||
$this->security,
|
|
||||||
$this->requestStack,
|
|
||||||
$this->employeeRepository,
|
|
||||||
$this->absenceRepository,
|
|
||||||
new AbsenceSegmentsResolver(),
|
|
||||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
|
||||||
|
|
||||||
self::assertSame('2026-02-16', $result->workDate);
|
|
||||||
self::assertCount(1, $result->rows);
|
|
||||||
self::assertSame(1, $result->rows[0]['employeeId']);
|
|
||||||
self::assertSame('Maladie', $result->rows[0]['absenceLabel']);
|
|
||||||
self::assertSame('AM', $result->rows[0]['absenceHalf']);
|
|
||||||
self::assertSame(210, $result->rows[0]['creditedMinutes']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
|
|
||||||
{
|
|
||||||
$contract = new Contract()
|
|
||||||
->setName('Contrat')
|
|
||||||
->setTrackingMode($trackingMode)
|
|
||||||
->setWeeklyHours($weeklyHours)
|
|
||||||
;
|
|
||||||
$employee = new Employee()
|
|
||||||
->setFirstName('Jean')
|
|
||||||
->setLastName('Test')
|
|
||||||
->setContract($contract)
|
|
||||||
;
|
|
||||||
$this->setEntityId($employee, $id);
|
|
||||||
|
|
||||||
return $employee;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildAbsence(Employee $employee, string $startDate, string $endDate, bool $countAsWorked): Absence
|
|
||||||
{
|
|
||||||
$type = new AbsenceType()
|
|
||||||
->setCode('MAL')
|
|
||||||
->setLabel('Maladie')
|
|
||||||
->setColor('#f00')
|
|
||||||
->setCountAsWorkedHours($countAsWorked)
|
|
||||||
;
|
|
||||||
|
|
||||||
return new Absence()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setType($type)
|
|
||||||
->setStartDate(new DateTime($startDate))
|
|
||||||
->setEndDate(new DateTime($endDate))
|
|
||||||
->setStartHalf(HalfDay::AM)
|
|
||||||
->setEndHalf(HalfDay::AM)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function setEntityId(object $entity, int $id): void
|
|
||||||
{
|
|
||||||
$reflection = new ReflectionObject($entity);
|
|
||||||
$property = $reflection->getProperty('id');
|
|
||||||
$property->setAccessible(true);
|
|
||||||
$property->setValue($entity, $id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResolverStub(): EmployeeContractResolver
|
|
||||||
{
|
|
||||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeeAndDate')
|
|
||||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
|
||||||
;
|
|
||||||
|
|
||||||
return $resolver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use App\Entity\Absence;
|
|
||||||
use App\Entity\AbsenceType;
|
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Enum\HalfDay;
|
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
|
||||||
use DateTime;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use ReflectionObject;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|
||||||
{
|
|
||||||
private Security $security;
|
|
||||||
private EmployeeScopedRepositoryInterface $employeeRepository;
|
|
||||||
private WorkHourReadRepositoryInterface $workHourRepository;
|
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository;
|
|
||||||
private RequestStack $requestStack;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->security = $this->createStub(Security::class);
|
|
||||||
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
|
|
||||||
$this->workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
|
||||||
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
|
||||||
$this->requestStack = new RequestStack();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenAnonymous(): void
|
|
||||||
{
|
|
||||||
$this->security->method('getUser')->willReturn(null);
|
|
||||||
|
|
||||||
$provider = new WorkHourWeeklySummaryProvider(
|
|
||||||
$this->security,
|
|
||||||
$this->requestStack,
|
|
||||||
$this->employeeRepository,
|
|
||||||
$this->workHourRepository,
|
|
||||||
$this->absenceRepository,
|
|
||||||
new AbsenceSegmentsResolver(),
|
|
||||||
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
|
||||||
$this->buildResolverStub()
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
|
||||||
$provider->provide(new Get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBuildsWeeklyRowsWithOvertimeAndPresence(): void
|
|
||||||
{
|
|
||||||
$user = new User();
|
|
||||||
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
|
|
||||||
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
|
|
||||||
$interimEmployee = $this->buildEmployee(3, 'TIME', 35, 'Charly', 'Interim');
|
|
||||||
$employees = [$timeEmployee, $presenceEmployee, $interimEmployee];
|
|
||||||
|
|
||||||
$workHours = [];
|
|
||||||
foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) {
|
|
||||||
$workHours[] = new WorkHour()
|
|
||||||
->setEmployee($timeEmployee)
|
|
||||||
->setWorkDate(new DateTimeImmutable($date))
|
|
||||||
->setMorningFrom('09:00')
|
|
||||||
->setMorningTo('19:00')
|
|
||||||
;
|
|
||||||
$workHours[] = new WorkHour()
|
|
||||||
->setEmployee($interimEmployee)
|
|
||||||
->setWorkDate(new DateTimeImmutable($date))
|
|
||||||
->setMorningFrom('09:00')
|
|
||||||
->setMorningTo('19:00')
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
$absenceType = new AbsenceType()
|
|
||||||
->setCode('CP')
|
|
||||||
->setLabel('Congé')
|
|
||||||
->setColor('#000')
|
|
||||||
->setCountAsWorkedHours(true)
|
|
||||||
;
|
|
||||||
$presenceAbsence = new Absence()
|
|
||||||
->setEmployee($presenceEmployee)
|
|
||||||
->setType($absenceType)
|
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
|
||||||
->setEndDate(new DateTime('2026-02-16'))
|
|
||||||
->setStartHalf(HalfDay::AM)
|
|
||||||
->setEndHalf(HalfDay::PM)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->requestStack->push(new Request(query: ['weekStart' => '2026-02-16']));
|
|
||||||
$this->security->method('getUser')->willReturn($user);
|
|
||||||
$this->employeeRepository->method('findScoped')->with($user)->willReturn($employees);
|
|
||||||
$this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
|
|
||||||
$this->absenceRepository->method('findForPrint')->willReturn([$presenceAbsence]);
|
|
||||||
|
|
||||||
$provider = new WorkHourWeeklySummaryProvider(
|
|
||||||
$this->security,
|
|
||||||
$this->requestStack,
|
|
||||||
$this->employeeRepository,
|
|
||||||
$this->workHourRepository,
|
|
||||||
$this->absenceRepository,
|
|
||||||
new AbsenceSegmentsResolver(),
|
|
||||||
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
|
||||||
$this->buildWeeklyResolverStub($employees)
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
|
||||||
|
|
||||||
self::assertSame('2026-02-16', $result->weekStart);
|
|
||||||
self::assertSame('2026-02-22', $result->weekEnd);
|
|
||||||
self::assertCount(3, $result->rows);
|
|
||||||
|
|
||||||
self::assertSame(3000, $result->rows[0]->weeklyTotalMinutes);
|
|
||||||
self::assertSame(900, $result->rows[0]->weeklyOvertimeTotalMinutes);
|
|
||||||
self::assertSame(120, $result->rows[0]->weeklyOvertime25Minutes);
|
|
||||||
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
|
||||||
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
|
||||||
|
|
||||||
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
|
|
||||||
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
|
||||||
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
|
||||||
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
|
||||||
self::assertSame(0, $result->rows[1]->weeklyOvertimeTotalMinutes);
|
|
||||||
self::assertSame(0, $result->rows[2]->weeklyOvertime25Minutes);
|
|
||||||
self::assertSame(0, $result->rows[2]->weeklyOvertime50Minutes);
|
|
||||||
self::assertSame(0, $result->rows[2]->weeklyRecoveryMinutes);
|
|
||||||
self::assertSame(900, $result->rows[2]->weeklyOvertimeTotalMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName, ?string $contractName = null): Employee
|
|
||||||
{
|
|
||||||
$contract = new Contract()
|
|
||||||
->setName($contractName ?? $trackingMode)
|
|
||||||
->setTrackingMode($trackingMode)
|
|
||||||
->setWeeklyHours($weeklyHours)
|
|
||||||
;
|
|
||||||
$employee = new Employee()
|
|
||||||
->setFirstName($firstName)
|
|
||||||
->setLastName('Test')
|
|
||||||
->setContract($contract)
|
|
||||||
;
|
|
||||||
$this->setEntityId($employee, $id);
|
|
||||||
|
|
||||||
return $employee;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function setEntityId(object $entity, int $id): void
|
|
||||||
{
|
|
||||||
$reflection = new ReflectionObject($entity);
|
|
||||||
$property = $reflection->getProperty('id');
|
|
||||||
$property->setAccessible(true);
|
|
||||||
$property->setValue($entity, $id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResolverStub(): EmployeeContractResolver
|
|
||||||
{
|
|
||||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeeAndDate')
|
|
||||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
|
||||||
;
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeesAndDays')
|
|
||||||
->willReturn([])
|
|
||||||
;
|
|
||||||
|
|
||||||
return $resolver;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*/
|
|
||||||
private function buildWeeklyResolverStub(array $employees): EmployeeContractResolver
|
|
||||||
{
|
|
||||||
$resolver = $this->createStub(EmployeeContractResolver::class);
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeeAndDate')
|
|
||||||
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
|
||||||
;
|
|
||||||
$resolver
|
|
||||||
->method('resolveForEmployeesAndDays')
|
|
||||||
->willReturnCallback(static function (array $scopedEmployees, array $days): array {
|
|
||||||
$map = [];
|
|
||||||
foreach ($scopedEmployees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($days as $day) {
|
|
||||||
$map[$employeeId][$day] = $employee->getContract();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $map;
|
|
||||||
})
|
|
||||||
;
|
|
||||||
|
|
||||||
return $resolver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user