Compare commits

..

24 Commits

Author SHA1 Message Date
gitea-actions
36fe9ae54c chore: bump version to v0.1.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-02 09:50:14 +00:00
6395ffbe1c feat : modification des sélecteurs de date sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:50:02 +01:00
gitea-actions
b5e7395760 chore: bump version to v0.1.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-02 09:34:21 +00:00
380c72c242 fix : règle de calcule des heures travaillées sur les contrats Forfait
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:33:42 +01:00
gitea-actions
107417a571 chore: bump version to v0.1.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-27 09:22:05 +00:00
5ff7e356be fix : validation RH qui invalidé les sites
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-27 10:21:54 +01:00
gitea-actions
635e24e9e1 chore: bump version to v0.1.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-26 16:15:25 +00:00
4d90f2cb42 feat : ajout du nouveau système de contrat et ajout de filtre d'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 17:15:13 +01:00
gitea-actions
9261cb5b1a chore: bump version to v0.1.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 13:49:38 +00:00
b68fef61c4 fix : correction des Heures et ajout d'une validation pour les chefs de site
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-26 14:49:28 +01:00
gitea-actions
5cced46254 chore: bump version to v0.1.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-23 14:36:06 +00:00
07b84a2512 fix : modification du composant TimeSelect.vue pour pouvoir taper les heures au clavier
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-23 15:35:57 +01:00
gitea-actions
ca26b7f934 chore: bump version to v0.1.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-23 13:51:19 +00:00
9cf978f0f2 feat : ajout des titles + update README.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-23 14:51:06 +01:00
gitea-actions
ad9e8705ae chore: bump version to v0.1.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-02-20 16:17:28 +00:00
f8ca5e50a0 [#339] Ajout d'une page listant les règles de calcules (#5)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #339          |        Ajout d'une page listant les règles de calcules         |

## Description de la PR
[#339] Ajout d'une page listant les règles de calcules

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #5
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 16:17:22 +00:00
gitea-actions
49fecfc27a chore: bump version to v0.1.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-20 11:23:59 +00:00
ee16779777 [#322] Page horaire (#4)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #322          |        Page horaire         |

## Description de la PR
[#322] Page horaire

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 11:23:52 +00:00
gitea-actions
f6c1f7eead chore: bump version to v0.1.8
All checks were successful
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-17 08:01:32 +00:00
69e8d74f4d [#328] Corrections (#3)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #3
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-17 08:01:25 +00:00
gitea-actions
2a9b047913 chore: bump version to v0.1.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-16 07:19:12 +00:00
76f1363457 [#321] Gestion des rôles dans l'application (#2)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #321          |        Gestion des rôles dans l'application         |

## Description de la PR
[#321] Gestion des rôles dans l'application

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #2
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-16 07:19:05 +00:00
gitea-actions
4845230429 chore: bump version to v0.1.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m7s
2026-02-10 15:49:16 +00:00
0b0ca60af7 feat : ajout d'un ordre d'affichage pour les employés + ajout du drag and drop pour changer l'ordre des employés dans le calendrier et l'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-10 16:49:03 +01:00
123 changed files with 9497 additions and 262 deletions

2
.env
View File

@@ -46,4 +46,6 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
JWT_COOKIE_SECURE=0
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
###< lexik/jwt-authentication-bundle ###

7
.idea/SIRH.iml generated
View File

@@ -145,6 +145,13 @@
<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-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>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
</component>
</project>

131
AGENTS.md Normal file
View File

@@ -0,0 +1,131 @@
# AGENTS.md
État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
## 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`, `type`
- `trackingMode`:
- `TIME`: suivi en heures
- `PRESENCE`: suivi en demi-journées/journées
- Enums backend:
- `App\Enum\TrackingMode`
- `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
- Historique de contrat par employé:
- table `employee_contract_periods`
- résolu par `App\Service\Contracts\EmployeeContractResolver`
### Heures / absences
- Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
- Les absences `countAsWorkedHours=true` créditent:
- minutes (contrats TIME)
- unités de présence (contrats PRESENCE)
- Les absences AM/PM effacent les plages horaires concernées.
## 4) Validations (important)
### Validation RH (admin)
- Champ: `work_hours.is_valid`
- Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
- Gérée côté front par `updateWorkHourValidation`.
### Validation site (chef de site)
- Champ: `work_hours.is_site_valid`
- Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
- Processor: `src/State/WorkHourSiteValidationProcessor.php`
- Autorisé uniquement aux utilisateurs "Sites" (ni `ROLE_ADMIN`, ni `ROLE_SELF`) dans leur scope site.
### Règles de verrouillage
- `is_valid=true`: ligne verrouillée pour tout le monde (admin inclus pour saisie heures/absence; peut toujours décocher validation RH).
- `is_site_valid=true`:
- non-admin: ligne verrouillée (heures + absences)
- admin: ligne éditable
- Toute modification de ligne (heures/présence/absence) remet:
- `is_site_valid=false`
- `is_valid=false`
## 5) Page Heures (front)
- Page: `frontend/pages/hours.vue`
- Composable principal: `frontend/composables/useHoursPage.ts`
- Composants:
- `frontend/components/hours/HoursToolbar.vue`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
### Comportement par profil (vue jour)
- Admin:
- colonne RH avec checkbox
- badge `Site validé` affiché près du site
- Chef de site:
- colonne `Validation site` avec checkbox
- colonne RH en lecture (`Validé`/`-`)
- Employé:
- colonne `Validation site` en lecture
- colonne RH en lecture
## 6) Résumé hebdo / calculs
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
- DTOs:
- `src/Dto/WorkHours/WeeklySummaryRow.php`
- `src/Dto/WorkHours/WeeklyDaySummary.php`
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
Règles majorations:
- Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
- Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
- `INTERIM`: pas de 25% / 50% / récup
## 7) Migrations sensibles
- `migrations/Version20260226183000.php`
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- non destructive (pas de perte de données)
## 8) Points de vigilance prod
- Toujours exécuter migration avant déploiement code backend/front lié.
- Après déploiement backend, si route manquante côté runtime:
- `php bin/console cache:clear && php bin/console cache:warmup`
- Vérifier présence route:
- `/api/work_hours/{id}/site-validation` (PATCH)
## 9) Conventions techniques
- Favoriser DTO explicites plutôt que tableaux associatifs.
- Garder règles métier dans backend (providers/processors/services), front orienté affichage/interaction.
- Maintenir alignement backend DTO PHP / frontend DTO TS (`frontend/services/dto/*`).
- Mettre à jour TU si signature constructor/service change.
## 10) Fichiers à lire avant modification
- `src/State/WorkHourBulkUpsertProcessor.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourSiteValidationProcessor.php`
- `src/State/WorkHourWeeklySummaryProvider.php`
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
- `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`

View File

@@ -1,2 +1,19 @@
# SIRH
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
```

View File

@@ -2,6 +2,7 @@ lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
remove_token_from_body_when_cookies_used: true
token_extractors:
authorization_header:
@@ -13,7 +14,7 @@ lexik_jwt_authentication:
enabled: false
set_cookies:
BEARER:
lifetime: 86400
lifetime: '%env(int:JWT_COOKIE_TTL)%'
samesite: lax
path: /
secure: '%env(bool:JWT_COOKIE_SECURE)%'

View File

@@ -22,5 +22,9 @@ services:
App\:
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
# please note that last definitions always *replace* previous ones

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.5'
app.version: '0.1.17'

View File

@@ -9,6 +9,7 @@
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
:disabled="props.lockEmployee"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
@@ -48,6 +49,7 @@
v-model="absenceForm.startDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.startHalf"
@@ -67,6 +69,7 @@
v-model="absenceForm.endDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.endHalf"
@@ -80,7 +83,7 @@
</div>
</div>
<div>
<div v-if="props.showComment !== false">
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
@@ -142,6 +145,9 @@ const props = defineProps<{
}
editingAbsence: Absence | null
isSubmitting: boolean
lockEmployee?: boolean
lockDates?: boolean
showComment?: boolean
}>()
const emit = defineEmits<{

View File

@@ -54,6 +54,48 @@
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Type de contrat <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
<input
:id="`print-contract-nature-${nature.value}`"
v-model="printForm.contractNatures"
:value="nature.value"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showContractNaturesError" class="text-sm text-red-600">
Sélectionne au moins un type de contrat.
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Temps de travail <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
<input
:id="`print-work-contract-${workContract.id}`"
v-model="printForm.workContractIds"
:value="workContract.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showWorkContractsError" class="text-sm text-red-600">
Sélectionne au moins un temps de travail.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string
}
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: {
from: string
to: string
siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
}
}>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
sites: false,
contractNatures: false,
workContracts: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isContractNaturesValid = computed(() => {
if (props.contractNatures.length === 0) return true
return printForm.value.contractNatures.length > 0
})
const isWorkContractsValid = computed(() => {
if (props.workContracts.length === 0) return true
return printForm.value.workContractIds.length > 0
})
const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value
() =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
@@ -150,6 +223,8 @@ const handleSubmit = () => {
validationTouched.from = true
validationTouched.to = true
validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return
emit('submit')
}
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
}
}
)

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
<div
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
>
@@ -10,28 +10,41 @@
<div
v-for="day in daysInMonth"
:key="day.date"
class="sticky top-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
:class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
<div
class="text-[10px]"
:class="isHoveredColumn(day.date) ? 'text-white/90' : 'text-neutral-500'"
>
{{ day.weekday }}
</div>
</div>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer transition-shadow"
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
:style="rowHeaderStyle(employee)"
draggable="true"
@dragstart="handleDragStart($event, employee)"
@dragover="handleDragOver"
@drop="handleDrop($event, employee)"
>
{{ formatEmployeeName(employee) }}
</div>
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 transition-colors"
:class="cellContainerClass(employee.id, day.date)"
@mouseenter="setHoveredCell(employee.id, day.date)"
>
<template v-if="getCellInfo(employee.id, day.date)">
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -59,7 +72,7 @@
<template v-else>
<button
type="button"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -85,7 +98,7 @@ type DayInfo = {
weekday: string
}
defineProps<{
const props = defineProps<{
daysInMonth: DayInfo[]
visibleEmployees: Employee[]
gridStyle: Record<string, string>
@@ -97,9 +110,79 @@ defineProps<{
const emit = defineEmits<{
(event: 'cell-click', employee: Employee, date: string): void
(event: 'reorder', payload: { dragId: number; dropId: number }): void
}>()
const handleCellClick = (employee: Employee, date: string) => {
emit('cell-click', employee, date)
}
const handleDragStart = (event: DragEvent, employee: Employee) => {
if (!event.dataTransfer) return
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(employee.id))
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDrop = (event: DragEvent, employee: Employee) => {
event.preventDefault()
const dragId = Number(event.dataTransfer?.getData('text/plain'))
if (!dragId || dragId === employee.id) return
emit('reorder', { dragId, dropId: employee.id })
}
// Etat de la cellule actuellement survolee.
const hoveredEmployeeId = ref<number | null>(null)
const hoveredDate = ref<string | null>(null)
const setHoveredCell = (employeeId: number, date: string) => {
hoveredEmployeeId.value = employeeId
hoveredDate.value = date
}
const clearHoveredCell = () => {
hoveredEmployeeId.value = null
hoveredDate.value = null
}
const isHoveredRow = (employeeId: number) => hoveredEmployeeId.value === employeeId
const isHoveredColumn = (date: string) => hoveredDate.value === date
// On garde la couleur du site tant que la ligne n'est pas survolee.
const rowHeaderStyle = (employee: Employee) => {
if (isHoveredRow(employee.id)) return undefined
return { backgroundColor: employee.site?.color ?? '#304998' }
}
// Index de ligne par employe pour savoir si une case est "au-dessus" de la case survolee.
const employeeIndexById = computed(() => {
const indexMap = new Map<number, number>()
props.visibleEmployees.forEach((employee, index) => {
indexMap.set(employee.id, index)
})
return indexMap
})
const cellContainerClass = (employeeId: number, date: string) => {
if (!hoveredEmployeeId.value || !hoveredDate.value) return 'hover:bg-primary-500'
const hoveredRowIndex = employeeIndexById.value.get(hoveredEmployeeId.value)
const currentRowIndex = employeeIndexById.value.get(employeeId)
// Forme en L:
// - ligne: toutes les cases a gauche (et la case cible)
// - colonne: toutes les cases au-dessus (et la case cible)
const isOnLeftSegment = isHoveredRow(employeeId) && date <= hoveredDate.value
const isOnTopSegment = isHoveredColumn(date)
&& typeof hoveredRowIndex === 'number'
&& typeof currentRowIndex === 'number'
&& currentRowIndex <= hoveredRowIndex
if (isOnLeftSegment || isOnTopSegment) return 'bg-primary-500'
return 'hover:bg-primary-500'
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<input
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
/>
</template>
<script setup lang="ts">
const model = defineModel<string>({ required: true })
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: 'Chercher un employé (nom ou prénom)'
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
<input
ref="nativeInput"
:value="pickerValue"
:type="pickerType"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
@input="onPickerInput"
@change="onPickerInput"
/>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="prevAriaLabel"
@click="emit('prev')"
>
</button>
<button
type="button"
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
@click="openPicker"
>
{{ label }}
</button>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="nextAriaLabel"
@click="emit('next')"
>
</button>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
label: string
pickerType: 'date' | 'week' | 'month'
pickerValue: string
widthClass?: string
prevAriaLabel?: string
nextAriaLabel?: string
}>(), {
widthClass: 'w-[320px]',
prevAriaLabel: 'Précédent',
nextAriaLabel: 'Suivant'
})
const emit = defineEmits<{
(e: 'prev'): void
(e: 'next'): void
(e: 'pick', value: string): void
}>()
const nativeInput = ref<HTMLInputElement | null>(null)
const openPicker = () => {
const input = nativeInput.value
if (!input) return
if (typeof input.showPicker === 'function') {
input.showPicker()
return
}
input.focus()
input.click()
}
const onPickerInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
if (!value) return
emit('pick', value)
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
defineProps<{
sites: Site[]
}>()
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<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"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>
<span class="pr-2">Fin après-midi</span>
<span class="pl-2">Début soir</span>
<span class="pr-2">Fin soir</span>
<span class="pl-2">Jour</span>
<span>Nuit</span>
<span>Total</span>
<span v-if="isAdmin" class="inline-flex items-center gap-2">
<span>Valider</span>
<input
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div
v-for="employee in employees"
:key="employee.id"
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: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pl-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pl-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pl-2 text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div>
<div v-if="isAdmin">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else>
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!canToggleSiteValidation(employee.id) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
isAdmin: boolean
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
isEveningLockedByAbsence: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
}>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="py-6 flex flex-col gap-3">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<div
v-if="viewMode === 'day'"
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="shortcutButtonClass('yesterday')"
@click="emit('set-yesterday')"
>
Hier
</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="shortcutButtonClass('today')"
@click="emit('set-today')"
>
Aujourd'hui
</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="shortcutButtonClass('tomorrow')"
@click="emit('set-tomorrow')"
>
Demain
</button>
</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>
<PeriodStepperPicker
width-class="w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
prev-aria-label="Période précédente"
next-aria-label="Période suivante"
@prev="emit('shift-date', -1)"
@next="emit('shift-date', 1)"
@pick="onPickerValue"
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="inline-flex items-center gap-2 border-l 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="viewModeButtonClass('week')"
@click="viewMode = 'week'"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</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>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
isAdmin: boolean
sites: Site[]
absenceTypes: AbsenceType[]
formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>()
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): 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
}>()
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value
})
const viewModeButtonClass = (mode: 'day' | 'week') => {
if (viewMode.value === mode) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const onPickerValue = (value: string) => {
if (!value) return
if (viewMode.value === 'week') {
const ymd = weekInputValueToYmd(value)
if (!ymd) return
selectedDate.value = ymd
return
}
selectedDate.value = value
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<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"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
<span>Jour/Nuit <br>sem.</span>
<span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
</div>
<div
v-for="row in weeklySummary?.rows ?? []"
: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"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div>
<div
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-else>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
</template>
</div>
<div class="font-semibold leading-4">
<template v-if="row.trackingMode === 'PRESENCE'">-</template>
<template v-else>
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
</template>
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 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>
</template>
<script setup lang="ts">
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<{
isWeekLoading: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,13 @@
export type HourRow = {
workHourId: number | null
morningFrom: string
morningTo: string
afternoonFrom: string
afternoonTo: string
eveningFrom: string
eveningTo: string
isPresentMorning: boolean
isPresentAfternoon: boolean
isSiteValid: boolean
isValid: boolean
}

View File

@@ -0,0 +1,252 @@
<template>
<div ref="root" class="relative w-full">
<div
ref="trigger"
class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
:class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
>
<input
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
:placeholder="placeholder"
:disabled="props.disabled"
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
@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:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
>
<Icon name="mdi:chevron-down" />
</button>
</div>
</div>
<Teleport to="body">
<div
v-if="isOpen"
ref="menu"
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
:style="menuStyle"
>
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
{{ placeholder }}
</button>
<button
v-for="slot in filteredTimeSlots"
:key="slot"
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue(slot)"
>
{{ slot }}
</button>
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
Aucun résultat
</p>
</div>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
}>(), {
placeholder: '--',
disabled: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const isOpen = ref(false)
const inputValue = ref('')
const menuStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
maxHeight: '224px'
})
const timeSlots = computed(() => {
const slots: string[] = []
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
}
}
return slots
})
const filteredTimeSlots = computed(() => {
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 triggerEl = trigger.value
if (!triggerEl) return
const rect = triggerEl.getBoundingClientRect()
const menuHeight = 224
const belowTop = rect.bottom + 4
const aboveTop = Math.max(8, rect.top - menuHeight - 4)
const canOpenBelow = belowTop + menuHeight <= window.innerHeight - 8
const top = canOpenBelow ? belowTop : aboveTop
menuStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: `${menuHeight}px`
}
}
const toggleOpen = () => {
if (props.disabled) return
const next = !isOpen.value
isOpen.value = next
if (next) {
nextTick(updateMenuPosition)
}
}
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) => {
if (props.disabled) return
emit('update:modelValue', value)
inputValue.value = value
isOpen.value = false
nextTick(() => inputRef.value?.focus())
}
const onDocumentClick = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (root.value?.contains(target) || menu.value?.contains(target)) return
isOpen.value = false
}
const onWindowChange = () => {
if (!isOpen.value) return
updateMenuPosition()
}
watch(isOpen, (open) => {
if (open) {
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
nextTick(updateMenuPosition)
} else {
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
}
})
watch(() => props.disabled, (disabled) => {
if (disabled) {
isOpen.value = false
}
})
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
})
</script>

View File

@@ -21,6 +21,7 @@ export type ApiClient = {
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastOn401?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
@@ -102,9 +103,31 @@ export const useApi = (): ApiClient => {
}
},
async onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (response?.status === 401) {
const requestUrl = typeof options?.url === 'string' ? options.url : ''
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) {
const isLoginCheck = requestUrl.includes('/login_check')
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) {
isHandlingUnauthorized = true
auth.clearSession()
@@ -115,10 +138,10 @@ export const useApi = (): ApiClient => {
isHandlingUnauthorized = false
}
}
return
}
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return
}

View File

@@ -0,0 +1,985 @@
import { computed, onMounted, ref, watch } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import type { WorkHour, WorkHourDayContext, 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 { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourSiteValidation,
updateWorkHourValidation
} from '~/services/work-hours'
import {
formatDateLongFr,
formatWeekDayHeaderFr,
formatWeekRangeFr,
getIsoWeekNumber,
getOffsetFromTodayYmd,
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
} from '~/utils/date'
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
export const useHoursPage = () => {
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
const viewMode = ref<'day' | 'week'>('day')
const selectedDate = ref(getTodayYmd())
const employees = ref<Employee[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, HourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
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 isWeekLoading = ref(false)
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
for (const employee of employees.value) {
if (employee.site) {
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(siteMap.values()).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
})
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
})
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null
return {
...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
}
})
const saveButtonClass = computed(() => {
if (isSubmitting.value || employees.value.length === 0) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const canToggleSiteValidation = (employeeId: number) => {
if (!isSiteManager.value) return false
const row = rows.value[employeeId]
if (!row?.workHourId) return false
// Une validation RH fige la ligne côté chef de site.
if (row.isValid) return false
return true
}
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 targetDate = target === 'yesterday'
? getOffsetFromTodayYmd(-1)
: target === 'tomorrow'
? getOffsetFromTodayYmd(1)
: getTodayYmd()
if (selectedDate.value === targetDate) {
return 'bg-primary-500 text-white'
}
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 parsed = parseYmd(selectedDate.value)
if (!parsed) return selectedDate.value
if (viewMode.value === 'week') {
return formatWeekRangeFr(parsed)
}
return formatDateLongFr(parsed)
})
const selectedYear = computed(() => {
const parsed = parseYmd(selectedDate.value)
return parsed ? parsed.getFullYear() : null
})
const selectedHolidayLabel = computed(() => {
const year = selectedYear.value
if (!year) return ''
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
})
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
const weekDayHeaders = computed(() => {
const days = weeklySummary.value?.days ?? []
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
})
const shiftDate = (steps: number) => {
const offset = viewMode.value === 'week' ? (steps * 7) : steps
const next = shiftYmd(selectedDate.value, offset)
if (!next) return
selectedDate.value = next
}
const setToday = () => {
selectedDate.value = getTodayYmd()
}
const setYesterday = () => {
setToday()
shiftDate(-1)
}
const setTomorrow = () => {
setToday()
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 => ({
workHourId: null,
morningFrom: '',
morningTo: '',
afternoonFrom: '',
afternoonTo: '',
eveningFrom: '',
eveningTo: '',
isPresentMorning: false,
isPresentAfternoon: false,
isSiteValid: false,
isValid: false
})
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
if (row.isValid) return true
if (!isAdmin.value && row.isSiteValid) return true
return false
}
const contractLabel = (employee: Employee) => {
const contract = employee.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.INTERIM) {
return contract.name
}
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
return `${contract.weeklyHours}h`
}
return contract.name
}
const normalizeTime = (value: string): string | null => {
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
const toMinutes = (time: string | null | undefined): number | null => {
if (!time) return null
const [hours, minutes] = time.split(':').map(Number)
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
return (hours * 60) + minutes
}
const resolveInterval = (from: string | null | undefined, to: string | null | undefined): [number, number] | null => {
const fromMinutes = toMinutes(from)
const toMinutesValue = toMinutes(to)
if (fromMinutes === null || toMinutesValue === null) return null
const end = toMinutesValue <= fromMinutes ? toMinutesValue + 1440 : toMinutesValue
return [fromMinutes, end]
}
const intervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => {
const interval = resolveInterval(from, to)
if (!interval) return 0
const [start, end] = interval
return Math.max(0, end - start)
}
const overlap = (startA: number, endA: number, startB: number, endB: number): number => {
const start = Math.max(startA, startB)
const end = Math.min(endA, endB)
return Math.max(0, end - start)
}
const nightIntervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => {
const interval = resolveInterval(from, to)
if (!interval) return 0
const [start, end] = interval
const nightWindows: Array<[number, number]> = [
[0, 360],
[1260, 1440]
]
let total = 0
for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
const shift = dayOffset * 1440
for (const [nightStart, nightEnd] of nightWindows) {
total += overlap(start, end, nightStart + shift, nightEnd + shift)
}
}
return total
}
const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow()
const ranges = [
[row.morningFrom, row.morningTo],
[row.afternoonFrom, row.afternoonTo],
[row.eveningFrom, row.eveningTo]
] as const
let totalMinutes = 0
let nightMinutes = 0
for (const [from, to] of ranges) {
totalMinutes += intervalMinutes(from, to)
nightMinutes += nightIntervalMinutes(from, to)
}
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
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 getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const absentMorning = dayRow?.absentMorning ?? false
const absentAfternoon = dayRow?.absentAfternoon ?? false
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
const total = Math.min(1, basePresence + creditedPresence)
return Number.isInteger(total) ? String(total) : total.toFixed(1)
}
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
return dayRow.hasContractAtDate !== false
}
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
}
const isEveningLockedByAbsence = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return dayRow.absentAfternoon
}
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60)
const rest = safeMinutes % 60
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
const hydrateRows = (workHours: WorkHour[]) => {
const byEmployeeId = new Map<number, WorkHour>()
for (const workHour of workHours) {
byEmployeeId.set(workHour.employee.id, workHour)
}
const nextRows: Record<number, HourRow> = {}
for (const employee of employees.value) {
const workHour = byEmployeeId.get(employee.id)
nextRows[employee.id] = {
workHourId: workHour?.id ?? null,
morningFrom: workHour?.morningFrom ?? '',
morningTo: workHour?.morningTo ?? '',
afternoonFrom: workHour?.afternoonFrom ?? '',
afternoonTo: workHour?.afternoonTo ?? '',
eveningFrom: workHour?.eveningFrom ?? '',
eveningTo: workHour?.eveningTo ?? '',
isPresentMorning: workHour?.isPresentMorning ?? false,
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false
}
}
rows.value = nextRows
}
const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidaysForSelectedYear = async () => {
const year = selectedYear.value
if (!year) return
if (publicHolidaysByYear.value[year]) return
const holidays = await listPublicHolidays('metropole', year)
publicHolidaysByYear.value = {
...publicHolidaysByYear.value,
[year]: holidays
}
}
const loadAbsences = async () => {
absences.value = await listAbsences({
from: selectedDate.value,
to: selectedDate.value,
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
})
}
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
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 dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId]
try {
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isValid = checked
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleSiteValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isSiteValidationPending(employeeId)) return
if (!canToggleSiteValidation(employeeId)) return
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
try {
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isSiteValid = checked
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.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 scopedEmployees = await listScopedEmployees()
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
}
const loadWorkHours = async () => {
const workHours = await listWorkHoursByDate(selectedDate.value)
hydrateRows(workHours)
}
const loadWeeklySummary = async () => {
isWeekLoading.value = true
try {
weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
} finally {
isWeekLoading.value = false
}
}
const loadDayContext = async () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
}
const loadPage = async () => {
isLoading.value = true
try {
await loadPublicHolidaysForSelectedYear()
await loadEmployees()
await loadAbsenceTypes()
await refreshByDate()
} finally {
isLoading.value = false
}
}
onMounted(loadPage)
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 })
watch(isAdmin, async (admin) => {
if (!admin) {
viewMode.value = 'day'
weeklySummary.value = null
await Promise.all([loadAbsenceTypes(), loadAbsences()])
return
}
await loadAbsenceTypes()
await loadAbsences()
}, { immediate: true })
watch(selectedDate, async () => {
await loadPublicHolidaysForSelectedYear()
await refreshByDate()
})
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
isSubmitting.value = true
try {
const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id))
.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
if (isPresenceTracking(employee)) {
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: row.isPresentMorning,
isPresentAfternoon: row.isPresentAfternoon
}
}
return {
employeeId,
morningFrom: normalizeTime(row.morningFrom),
morningTo: normalizeTime(row.morningTo),
afternoonFrom: normalizeTime(row.afternoonFrom),
afternoonTo: normalizeTime(row.afternoonTo),
eveningFrom: normalizeTime(row.eveningFrom),
eveningTo: normalizeTime(row.eveningTo),
isPresentMorning: false,
isPresentAfternoon: false
}
})
if (entries.length === 0) {
return
}
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries
})
await refreshByDate()
} finally {
isSubmitting.value = false
}
}
return {
isAdmin,
isSelfUser,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
}
}

View File

@@ -31,6 +31,11 @@
"create": "Impossible de créer l'absence.",
"update": "Impossible de mettre à jour l'absence.",
"delete": "Impossible de supprimer l'absence."
},
"user": {
"create": "Impossible de créer l'utilisateur.",
"update": "Impossible de mettre à jour l'utilisateur.",
"delete": "Impossible de supprimer l'utilisateur."
}
},
"success": {
@@ -57,6 +62,11 @@
"create": "Absence créée.",
"update": "Absence mise à jour.",
"delete": "Absence supprimée."
},
"user": {
"create": "Utilisateur créé.",
"update": "Utilisateur mis à jour.",
"delete": "Utilisateur supprimé."
}
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-50 via-white to-neutral-100 text-neutral-900">
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>

View File

@@ -6,41 +6,59 @@
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
<nav class="flex-1 px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
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"
active-class="bg-tertiary-500 text-primary-500"
>
Tableau de bord
</NuxtLink>
<NuxtLink
to="/calendar"
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"
>
Calendrier
</NuxtLink>
</template>
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600 border-t border-secondary-500"
active-class="bg-primary-50 text-primary-600"
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"
>
Tableau de bord
</NuxtLink>
<NuxtLink
to="/calendar"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
>
Calendrier
</NuxtLink>
<NuxtLink
to="/employees"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
>
Employés
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
>
Sites
</NuxtLink>
<NuxtLink
to="/absence-types"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
>
Types d'absence
Heures
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
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"
active-class="bg-tertiary-500 text-primary-500"
>
Employés
</NuxtLink>
<NuxtLink
to="/sites"
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"
>
Sites
</NuxtLink>
<NuxtLink
to="/absence-types"
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"
>
Types d'absence
</NuxtLink>
<NuxtLink
to="/users"
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"
>
Utilisateurs
</NuxtLink>
</template>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
@@ -65,6 +83,7 @@
<script setup lang="ts">
const auth = useAuthStore()
const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const handleLogout = async () => {
await auth.logout()

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.checked) {
await auth.ensureSession()
}
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
if (!isAdmin) {
return navigateTo('/')
}
})

View File

@@ -3,13 +3,16 @@ export default defineNuxtConfig({
devtools: {enabled: false},
ssr: false,
app: {
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n'
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon'
],
runtimeConfig: {
public: {

View File

@@ -7,6 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",
@@ -32,6 +33,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1220,6 +1234,47 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.651",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.651.tgz",
"integrity": "sha512-ALGlYxNVOIylxNHjFaylqPTzgNaMHeoFA8ao/piPHjYGD526xEp847F7KePy9jvOLChy2bzQVwAV9Em3HiicjQ==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
@@ -2372,6 +2427,28 @@
"devtools-wizard": "cli.mjs"
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",

View File

@@ -11,6 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",

View File

@@ -19,10 +19,11 @@
</div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<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">
<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">
<span class="text-left">Code</span>
<span class="text-left">Libellé</span>
<span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
@@ -32,7 +33,7 @@
<div
v-for="type in absenceTypes"
:key="type.id"
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"
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"
>
<span class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span>
@@ -43,6 +44,14 @@
/>
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</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">
<button
type="button"
@@ -94,6 +103,31 @@
Le libellé est obligatoire.
</p>
</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>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
@@ -136,6 +170,10 @@
import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
@@ -150,7 +188,8 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
color: '#222783'
color: '#222783',
countAsWorkedHours: true
})
const validationTouched = reactive({
@@ -171,7 +210,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
@@ -214,6 +253,7 @@ const resetForm = () => {
form.code = ''
form.label = ''
form.color = '#222783'
form.countAsWorkedHours = true
}
const openCreate = () => {
@@ -227,6 +267,7 @@ const openEdit = (type: AbsenceType) => {
form.code = type.code
form.label = type.label
form.color = type.color
form.countAsWorkedHours = type.countAsWorkedHours
isDrawerOpen.value = true
}
@@ -249,13 +290,15 @@ const handleSubmit = async () => {
await updateAbsenceType(editingType.value.id, {
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
} else {
await createAbsenceType({
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
}

View File

@@ -3,38 +3,10 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
</div>
<div class="py-6">
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
</div>
<div class="flex gap-4">
<button
@@ -53,13 +25,30 @@
</button>
</div>
</div>
<div class="mt-3 flex items-center gap-4">
<input
v-model="employeeFilter"
type="text"
placeholder="Chercher un employé (nom ou prénom)"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
/>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
:label="selectedMonthLabel"
picker-type="month"
:picker-value="monthPickerValue"
prev-aria-label="Mois précédent"
next-aria-label="Mois suivant"
@prev="shiftMonth(-1)"
@next="shiftMonth(1)"
@pick="onMonthPickerValue"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-6 py-2">
<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>
@@ -73,6 +62,7 @@
:format-employee-name="formatEmployeeName"
:is-holiday-date="isHolidayDate"
@cell-click="openCreate"
@reorder="handleReorder"
/>
</div>
@@ -91,6 +81,8 @@
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
@@ -104,14 +96,22 @@ import type {AbsenceType} from '~/services/dto/absence-type'
import type {Absence} from '~/services/dto/absence'
import type {HalfDay} from '~/services/dto/half-day'
import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees} from '~/services/employees'
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Calendrier'
})
// Données principales affichées dans la grille.
const employees = ref<Employee[]>([])
@@ -122,7 +122,12 @@ const sites = computed(() => {
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
return Array.from(siteMap.values()).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
})
// Filtres de sites (par défaut: tous sélectionnés à l'init).
@@ -133,21 +138,11 @@ watch(sites, (next) => {
if (sitesInitialized.value || next.length === 0) return
selectedSiteIds.value = next.map((site) => site.id)
sitesInitialized.value = true
}, { immediate: true })
}, {immediate: true})
// Tri stable: site -> nom -> prénom.
const sortedEmployees = computed(() => {
return [...employees.value].sort((employeeA, employeeB) => {
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
return sortEmployeesBySiteAndOrder(employees.value)
})
// Employés visibles selon le filtre de sites.
@@ -196,8 +191,8 @@ const months = [
{value: 11, label: 'Décembre'}
]
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
// Infos de calendrier calculées.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
@@ -224,7 +219,25 @@ const form = reactive({
const printForm = reactive({
from: '',
to: '',
siteIds: [] as number[]
siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Remet le formulaire à zéro.
@@ -252,6 +265,8 @@ const openPrint = () => {
printForm.from = monthStart
printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true
}
@@ -259,13 +274,6 @@ const closePrint = () => {
isPrintOpen.value = false
}
// Parse "YYYY-MM-DD" en Date (ou null).
const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
const getHalfForDate = (
startDate: string,
@@ -304,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
return next
}
const shiftMonth = (delta: number) => {
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
selectedYear.value = next.getFullYear()
selectedMonth.value = next.getMonth()
}
const onMonthPickerValue = (value: string) => {
if (!value) return
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
selectedYear.value = year
selectedMonth.value = month - 1
}
// Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => {
if (!printForm.from) return
@@ -659,7 +683,7 @@ const formatEmployeeName = (employee: Employee) => {
}
// Impression PDF de l'intervalle sélectionné.
const { printPdf } = usePdfPrinter()
const {printPdf} = usePdfPrinter()
const handlePrint = async () => {
const params = new URLSearchParams()
params.set('from', printForm.from)
@@ -667,7 +691,45 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(','))
}
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false
}
const handleReorder = async (payload: { dragId: number; dropId: number }) => {
const dragEmployee = employees.value.find((employee) => employee.id === payload.dragId)
const dropEmployee = employees.value.find((employee) => employee.id === payload.dropId)
if (!dragEmployee || !dropEmployee) return
const dragSiteId = dragEmployee.site?.id
const dropSiteId = dropEmployee.site?.id
if (!dragSiteId || !dropSiteId || dragSiteId !== dropSiteId) return
const siteEmployees = [...employees.value]
.filter((employee) => employee.site?.id === dragSiteId)
.sort(compareEmployeesInSite)
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
const [moved] = siteEmployees.splice(fromIndex, 1)
siteEmployees.splice(toIndex, 0, moved)
const updates: Array<{ id: number; displayOrder: number }> = []
siteEmployees.forEach((employee, index) => {
const nextOrder = index + 1
if ((employee.displayOrder ?? 0) !== nextOrder) {
updates.push({id: employee.id, displayOrder: nextOrder})
}
employee.displayOrder = nextOrder
})
if (updates.length === 0) return
await Promise.all(updates.map((update) => updateEmployeeOrder(update.id, update.displayOrder)))
}
</script>

View File

@@ -1,57 +1,82 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<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 class="h-full overflow-hidden flex flex-col">
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
</div>
<div class="flex flex-col gap-3 py-6">
<div class="flex justify-between">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un employé
</button>
</div>
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
</div>
<div
v-if="!isLoading && employees.length === 0"
v-if="!isLoading && filteredEmployees.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_1fr_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">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div
v-for="employee in employees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_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>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
<span>{{ employee.site?.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)"
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div class="h-full overflow-auto">
<div class="min-w-[900px]">
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_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">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Nature</span>
<span class="text-left">Contrat</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div
v-for="employee in filteredEmployees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
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>
<span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
<span
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
>
{{ employee.site?.name ?? '-' }}
</span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</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)"
>
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>
@@ -105,6 +130,72 @@
Le site est obligatoire.
</p>
</div>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div v-if="requiresContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un intérim.
</p>
</div>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -127,14 +218,21 @@
</template>
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import { listContracts } from '~/services/contracts'
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
import { listSites } from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Employés'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
@@ -142,24 +240,76 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
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 contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const form = reactive({
firstName: '',
lastName: '',
siteId: '' as number | ''
siteId: '' as number | '',
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false
siteId: false,
contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDate.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
() =>
isFirstNameValid.value &&
isLastNameValid.value &&
isSiteValid.value &&
(editingEmployee.value
? true
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
)
const showFirstNameError = computed(
@@ -171,9 +321,21 @@ const showLastNameError = computed(
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value
)
const showContractNatureError = computed(
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
)
const showContractStartDateError = computed(
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
)
const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
@@ -194,6 +356,34 @@ const siteFieldClass = computed(() => {
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
@@ -215,15 +405,41 @@ const loadSites = async () => {
sites.value = await listSites()
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites()])
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
})
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 () => {
if (isSubmitting.value) return
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
if (!editingEmployee.value) {
validationTouched.contractId = true
validationTouched.contractNature = true
validationTouched.contractStartDate = true
validationTouched.contractEndDate = true
}
if (!isFormValid.value) return
isSubmitting.value = true
@@ -232,19 +448,28 @@ const handleSubmit = async () => {
await updateEmployee(editingEmployee.value.id, {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
})
}
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -258,6 +483,16 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
validationTouched.contractId = false
validationTouched.contractNature = false
validationTouched.contractStartDate = false
validationTouched.contractEndDate = false
}
})
watch(requiresContractEndDate, (required) => {
if (!required) {
form.contractEndDate = ''
}
})
@@ -269,6 +504,18 @@ const openEdit = (employee: Employee) => {
isDrawerOpen.value = true
}
const openCreate = () => {
editingEmployee.value = null
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
isDrawerOpen.value = true
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

180
frontend/pages/hours.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
</div>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
v-model:selected-site-ids="selectedSiteIds"
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate"
/>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="employees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucun employé accessible.
</div>
<div v-else class="flex min-h-0 flex-col gap-4">
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
:is-row-locked="isRowLocked"
:is-half-locked-by-absence="isHalfLockedByAbsence"
:is-evening-locked-by-absence="isEveningLockedByAbsence"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="saveButtonClass"
:disabled="isSubmitting || visibleEmployees.length === 0"
@click="handleSave"
>
Enregistrer
</button>
</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>
</template>
<script setup lang="ts">
const {
isAdmin,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
} = useHoursPage()
useHead({
title: 'Heures'
})
</script>

View File

@@ -3,5 +3,7 @@
</template>
<script setup lang="ts">
useHead({
title: 'Tableau de bord'
})
</script>

View File

@@ -18,7 +18,7 @@
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
@@ -31,13 +31,13 @@
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
@@ -49,6 +49,9 @@
<script setup lang="ts">
definePageMeta({layout: 'auth'})
useHead({
title: 'Connexion'
})
const router = useRouter()
const auth = useAuthStore()

View File

@@ -32,8 +32,15 @@
v-for="site in sites"
:key="site.id"
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
draggable="true"
@dragstart="handleDragStart($event, site)"
@dragover="handleDragOver"
@drop="handleDrop($event, site)"
>
<span class="text-left">{{ site.name }}</span>
<span class="flex items-center gap-2 text-left cursor-pointer">
<span class="select-none text-xs">::</span>
<span>{{ site.name }}</span>
</span>
<div class="flex items-center gap-2 justify-start">
<span
class="inline-block h-3 w-3 rounded-full"
@@ -114,11 +121,16 @@
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import { createSite, deleteSite, listSites, updateSite } from '~/services/sites'
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
useHead({
title: 'Sites'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isReordering = ref(false)
const sites = ref<Site[]>([])
const editingSite = ref<Site | null>(null)
@@ -142,7 +154,7 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
@@ -207,7 +219,8 @@ const handleSubmit = async () => {
} else {
await createSite({
name: form.name,
color: form.color
color: form.color,
displayOrder: sites.value.length + 1
})
}
@@ -231,4 +244,52 @@ const confirmDelete = async (site: Site) => {
await deleteSite(site.id)
await loadSites()
}
const handleDragStart = (event: DragEvent, site: Site) => {
if (isReordering.value || !event.dataTransfer) return
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(site.id))
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDrop = async (event: DragEvent, site: Site) => {
event.preventDefault()
if (isReordering.value) return
const dragId = Number(event.dataTransfer?.getData('text/plain'))
if (!dragId || dragId === site.id) return
const fromIndex = sites.value.findIndex((item) => item.id === dragId)
const toIndex = sites.value.findIndex((item) => item.id === site.id)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
const reordered = [...sites.value]
const [moved] = reordered.splice(fromIndex, 1)
reordered.splice(toIndex, 0, moved)
const updates: Array<{ id: number; displayOrder: number }> = []
reordered.forEach((item, index) => {
const nextOrder = index + 1
if ((item.displayOrder ?? 0) !== nextOrder) {
updates.push({ id: item.id, displayOrder: nextOrder })
}
item.displayOrder = nextOrder
})
sites.value = reordered
if (updates.length === 0) return
isReordering.value = true
try {
await Promise.all(updates.map((update) => updateSiteOrder(update.id, update.displayOrder)))
} catch {
window.alert("Impossible de reordonner les sites.")
await loadSites()
} finally {
isReordering.value = false
}
}
</script>

467
frontend/pages/users.vue Normal file
View File

@@ -0,0 +1,467 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un utilisateur
</button>
</div>
<div
v-if="!isLoading && users.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun utilisateur pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[1fr_1fr_140px_1fr_140px] 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">Utilisateur</span>
<span class="text-left">Employé</span>
<span class="text-left">Accès</span>
<span class="text-left">Sites</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div
v-for="user in users"
:key="user.id"
class="grid grid-cols-[1fr_1fr_140px_1fr_140px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span class="text-left">{{ user.username }}</span>
<span class="text-left">
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
</span>
<span class="text-left text-sm text-neutral-600">
{{ getAccessLabel(user) }}
</span>
<span class="text-left text-sm text-neutral-600">
{{ getSiteLabels(user) }}
</span>
<div class="flex justify-end">
<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(user)"
>
Modifier
</button>
</div>
</div>
</div>
</div>
<AppDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="username">
Nom d'utilisateur <span class="text-red-600">*</span>
</label>
<input
id="username"
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="password">
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
v-model="form.password"
type="password"
:class="passwordFieldClass"
/>
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div>
<div>
<p class="text-md font-semibold text-neutral-700">Accès</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('admin')"
>
Admin
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('self')"
>
Accès personnel
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('sites')"
>
Sites
</button>
</div>
<p class="mt-2 text-sm text-neutral-500">
{{
form.accessMode === 'admin'
? 'Donne accès à tout.'
: form.accessMode === 'self'
? "Donne accès uniquement à ses propres données."
: 'Donne accès aux employés des sites sélectionnés.'
}}
</p>
</div>
<div v-if="form.accessMode === 'self'">
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé lié
</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucun</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
</div>
<div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
>
<input
type="checkbox"
class="cursor-pointer"
:checked="form.siteIds.includes(site.id)"
@change="toggleSite(site.id)"
/>
<span>{{ site.name }}</span>
</label>
</div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import type { User } from '~/services/dto/user'
import type { UserSiteRole } from '~/services/user-site-roles'
import { listEmployees } from '~/services/employees'
import { listSites } from '~/services/sites'
import { createUser, listUsers, updateUser } from '~/services/users'
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
definePageMeta({ middleware: ['admin'] })
useHead({
title: 'Utilisateurs'
})
const users = ref<User[]>([])
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const userSiteRoles = ref<UserSiteRole[]>([])
const isLoading = ref(false)
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const editingUser = ref<User | null>(null)
const form = reactive({
username: '',
password: '',
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[]
})
const validationTouched = reactive({
username: false,
password: false,
sites: false,
selfEmployee: false
})
const isUsernameValid = computed(() => form.username.trim() !== '')
const isPasswordValid = computed(() =>
editingUser.value ? true : form.password.trim() !== ''
)
const isFormValid = computed(() => isUsernameValid.value && isPasswordValid.value)
const isSitesValid = computed(() => form.siteIds.length > 0)
const isSelfEmployeeValid = computed(() => form.employeeId !== '')
const showUsernameError = computed(
() => validationTouched.username && !isUsernameValid.value
)
const showPasswordError = computed(
() => validationTouched.password && !isPasswordValid.value
)
const showSitesError = computed(
() => validationTouched.sites && form.accessMode === 'sites' && !isSitesValid.value
)
const showSelfEmployeeError = computed(
() =>
validationTouched.selfEmployee &&
form.accessMode === 'self' &&
!isSelfEmployeeValid.value
)
const userAccessById = computed(() => {
const rolesByUser = new Map<number, UserSiteRole[]>()
for (const role of userSiteRoles.value) {
const userId = role.user?.id
if (!userId) continue
const list = rolesByUser.get(userId) ?? []
list.push(role)
rolesByUser.set(userId, list)
}
return rolesByUser
})
const getAccessLabel = (user: User) => {
if (user.roles.includes('ROLE_ADMIN')) return 'Admin'
if (user.roles.includes('ROLE_SELF')) return 'Self'
const siteRoles = userAccessById.value.get(user.id) ?? []
return siteRoles.length > 0 ? 'Sites' : 'Aucun'
}
const getSiteLabels = (user: User) => {
const siteRoles = userAccessById.value.get(user.id) ?? []
if (siteRoles.length === 0) return '-'
const names = siteRoles
.map((role) => role.site?.name)
.filter((name): name is string => Boolean(name))
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
}
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadData = async () => {
isLoading.value = true
try {
const [usersData, employeesData, sitesData, userSiteRolesData] = await Promise.all([
listUsers(),
listEmployees(),
listSites(),
listUserSiteRoles()
])
users.value = usersData
employees.value = employeesData
sites.value = sitesData
userSiteRoles.value = userSiteRolesData
} finally {
isLoading.value = false
}
}
onMounted(loadData)
const resetForm = () => {
form.username = ''
form.password = ''
form.employeeId = ''
form.accessMode = 'admin'
form.siteIds = []
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
validationTouched.sites = false
validationTouched.selfEmployee = false
}
const openCreate = () => {
resetForm()
isDrawerOpen.value = true
}
const openEdit = (user: User) => {
resetForm()
editingUser.value = user
form.username = user.username
form.password = ''
if (user.roles.includes('ROLE_ADMIN')) {
selectAccessMode('admin')
} else if (user.roles.includes('ROLE_SELF')) {
selectAccessMode('self')
} else {
selectAccessMode('sites')
}
form.employeeId = user.employee?.id ?? ''
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
isDrawerOpen.value = true
}
const closeDrawer = () => {
isDrawerOpen.value = false
resetForm()
}
const selectAccessMode = (mode: 'admin' | 'self' | 'sites') => {
form.accessMode = mode
if (mode !== 'sites') {
form.siteIds = []
}
}
const toggleSite = (siteId: number) => {
if (form.siteIds.includes(siteId)) {
form.siteIds = form.siteIds.filter((existing) => existing !== siteId)
} else {
form.siteIds = [...form.siteIds, siteId]
}
}
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.username = true
validationTouched.password = true
validationTouched.sites = true
validationTouched.selfEmployee = true
if (!isFormValid.value) return
if (form.accessMode === 'sites' && !isSitesValid.value) return
if (form.accessMode === 'self' && !isSelfEmployeeValid.value) return
isSubmitting.value = true
try {
const roles =
form.accessMode === 'admin'
? ['ROLE_ADMIN']
: form.accessMode === 'self'
? ['ROLE_SELF']
: []
const employeeId =
form.accessMode === 'self' ? (form.employeeId === '' ? null : Number(form.employeeId)) : null
if (editingUser.value) {
await updateUser(editingUser.value.id, {
username: form.username,
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
if (existingSiteRoles.length > 0) {
await Promise.all(existingSiteRoles.map((role) => deleteUserSiteRole(role.id)))
}
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
await Promise.all(
form.siteIds.map((siteId) =>
createUserSiteRole({
userId: editingUser.value!.id,
siteId,
role: 'SITE_ACCESS'
})
)
)
}
} else {
const created = await createUser({
username: form.username,
plainPassword: form.password,
roles,
employeeId
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
await Promise.all(
form.siteIds.map((siteId) =>
createUserSiteRole({
userId: created.id,
siteId,
role: 'SITE_ACCESS'
})
)
)
}
}
closeDrawer()
await loadData()
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
}
export const createAbsenceType = async (
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.post<AbsenceType>('/absence_types', payload, {
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
export const updateAbsenceType = async (
id: number,
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {

View File

@@ -8,6 +8,7 @@ export const getCurrentUser = () => {
export const login = (username: string, password: string) => {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
}

View File

@@ -0,0 +1,13 @@
import type { Contract } from './dto/contract'
import { extractItems } from '~/utils/api'
export const listContracts = async () => {
const api = useApi()
const data = await api.get<Contract[] | { 'hydra:member'?: Contract[] }>(
'/contracts',
{},
{ toast: false }
)
return extractItems<Contract>(data)
}

View File

@@ -3,4 +3,5 @@ export type AbsenceType = {
code: string
label: string
color: string
countAsWorkedHours: boolean
}

View File

@@ -0,0 +1,25 @@
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 = {
id: number
name: string
trackingMode: TrackingMode
type: ContractType
weeklyHours?: number | null
isActive?: boolean
}

View File

@@ -1,8 +1,14 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type Employee = {
id: number
firstName: string
lastName: string
site: Site
contract?: Contract | null
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
displayOrder?: number
}

View File

@@ -2,4 +2,5 @@ export type Site = {
id: number
name: string
color: string
displayOrder?: number
}

View File

@@ -1,4 +1,5 @@
export type UserData = {
id: number
username: string
roles: string[]
}

View File

@@ -0,0 +1,8 @@
import type { Employee } from './employee'
export type User = {
id: number
username: string
roles: string[]
employee?: Employee | null
}

View File

@@ -0,0 +1,84 @@
import type { Employee } from './employee'
import type { ContractType, TrackingMode } from './contract'
export type WorkHour = {
id: number
employee: Employee
workDate: string
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
isSiteValid?: boolean
isValid?: boolean
}
export type WorkHourEntryPayload = {
employeeId: number
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
}
export type WeeklyWorkHourDailySummary = {
date: string
dayMinutes: number
nightMinutes: number
totalMinutes: number
present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
}
export type WeeklyWorkHourRowSummary = {
employeeId: number
firstName: string
lastName: string
siteName?: string | null
contractName?: string | null
contractType?: ContractType | null
trackingMode?: TrackingMode | null
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
}
export type WeeklyWorkHourSummary = {
weekStart: string
weekEnd: string
days: string[]
rows: WeeklyWorkHourRowSummary[]
}
export type WorkHourDayContextRow = {
employeeId: number
hasContractAtDate: boolean
absenceLabel?: string | null
absenceColor?: string | null
absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}

View File

@@ -11,16 +11,34 @@ export const listEmployees = async () => {
return extractItems<Employee>(data)
}
export const listScopedEmployees = async () => {
const api = useApi()
const data = await api.get<Employee[] | { 'hydra:member'?: Employee[] }>(
'/employees/scoped',
{},
{ toast: false }
)
return extractItems<Employee>(data)
}
export const createEmployee = async (payload: {
firstName: string
lastName: string
siteId?: number | null
contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -33,19 +51,41 @@ export const updateEmployee = async (
firstName: string
lastName: string
siteId?: number | null
contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
displayOrder?: number
}
) => {
const api = useApi()
return api.patch<Employee>(`/employees/${id}`, {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder
}, {
toastSuccessKey: 'success.employee.update',
toastErrorKey: 'errors.employee.update'
})
}
export const updateEmployeeOrder = async (
id: number,
displayOrder: number
) => {
const api = useApi()
return api.patch<Employee>(`/employees/${id}`, {
displayOrder
}, {
toast: false
})
}
export const deleteEmployee = async (id: number) => {
const api = useApi()
return api.delete(`/employees/${id}`, {}, {

View File

@@ -8,10 +8,15 @@ export const listSites = async () => {
{},
{ toast: false }
)
return extractItems<Site>(data)
return extractItems<Site>(data).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
}
export const createSite = async (payload: Pick<Site, 'name' | 'color'>) => {
export const createSite = async (payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }) => {
const api = useApi()
return api.post<Site>('/sites', payload, {
toastSuccessKey: 'success.site.create',
@@ -19,7 +24,10 @@ export const createSite = async (payload: Pick<Site, 'name' | 'color'>) => {
})
}
export const updateSite = async (id: number, payload: Pick<Site, 'name' | 'color'>) => {
export const updateSite = async (
id: number,
payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }
) => {
const api = useApi()
return api.patch<Site>(`/sites/${id}`, payload, {
toastSuccessKey: 'success.site.update',
@@ -27,6 +35,15 @@ export const updateSite = async (id: number, payload: Pick<Site, 'name' | 'color
})
}
export const updateSiteOrder = async (id: number, displayOrder: number) => {
const api = useApi()
return api.patch<Site>(`/sites/${id}`, {
displayOrder
}, {
toast: false
})
}
export const deleteSite = async (id: number) => {
const api = useApi()
return api.delete(`/sites/${id}`, {}, {

View File

@@ -0,0 +1,38 @@
import { extractItems } from '~/utils/api'
export const createUserSiteRole = async (payload: {
userId: number
siteId: number
role: string
}) => {
const api = useApi()
return api.post('/user_site_roles', {
user: `/api/users/${payload.userId}`,
site: `/api/sites/${payload.siteId}`,
role: payload.role
}, {
toast: false
})
}
export type UserSiteRole = {
id: number
user: { id: number }
site: { id: number; name?: string }
role: string
}
export const listUserSiteRoles = async () => {
const api = useApi()
const data = await api.get<UserSiteRole[] | { 'hydra:member'?: UserSiteRole[] }>(
'/user_site_roles',
{},
{ toast: false }
)
return extractItems<UserSiteRole>(data)
}
export const deleteUserSiteRole = async (id: number) => {
const api = useApi()
return api.delete(`/user_site_roles/${id}`, {}, { toast: false })
}

View File

@@ -0,0 +1,57 @@
import type { User } from './dto/user'
import { extractItems } from '~/utils/api'
export const listUsers = async () => {
const api = useApi()
const data = await api.get<User[] | { 'hydra:member'?: User[] }>(
'/users',
{},
{ toast: false }
)
return extractItems<User>(data)
}
export const createUser = async (payload: {
username: string
plainPassword: string
roles: string[]
employeeId?: number | null
}) => {
const api = useApi()
return api.post<User>(
'/users',
{
username: payload.username,
plainPassword: payload.plainPassword,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
},
{
toastSuccessKey: 'success.user.create',
toastErrorKey: 'errors.user.create'
}
)
}
export const updateUser = async (id: number, payload: {
username: string
plainPassword?: string
roles: string[]
employeeId?: number | null
}) => {
const api = useApi()
const body: Record<string, unknown> = {
username: payload.username,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
}
if (payload.plainPassword) {
body.plainPassword = payload.plainPassword
}
return api.patch<User>(`/users/${id}`, body, {
toastSuccessKey: 'success.user.update',
toastErrorKey: 'errors.user.update'
})
}

View File

@@ -0,0 +1,94 @@
import type {
WorkHourDayContext,
WorkHour,
WorkHourEntryPayload,
WeeklyWorkHourSummary
} from './dto/work-hour'
import { extractItems } from '~/utils/api'
export const listWorkHoursByDate = async (workDate: string) => {
const api = useApi()
const data = await api.get<WorkHour[] | { 'hydra:member'?: WorkHour[] }>(
'/work_hours',
{
'workDate[after]': workDate,
'workDate[before]': workDate
},
{ toast: false }
)
return extractItems<WorkHour>(data)
}
export const bulkUpsertWorkHours = async (payload: {
workDate: string
entries: WorkHourEntryPayload[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
processed: number
created: number
updated: number
deleted: number
}>(
'/work-hours/bulk-upsert',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: 'Horaires enregistrés.',
toastErrorMessage: "Impossible d'enregistrer les horaires."
}
)
}
export const updateWorkHourValidation = async (
id: number,
isValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}`,
{ isValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
toastErrorMessage: 'Impossible de mettre à jour la validation.'
}
)
}
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}/site-validation`,
{ isSiteValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
toastErrorMessage: "Impossible de mettre à jour la validation site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(
'/work-hours/weekly-summary',
{ weekStart },
{ toast: false }
)
}
export const getWorkHourDayContext = async (workDate: string) => {
const api = useApi()
return api.get<WorkHourDayContext>(
'/work-hours/day-context',
{ workDate },
{ toast: false }
)
}

View File

@@ -6,6 +6,115 @@ export const toYmd = (year: number, month: number, day: number) => {
export const normalizeDate = (value: string) => value.slice(0, 10)
export const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
export const formatDateLongFr = (date: Date) => {
const label = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date)
return label.charAt(0).toUpperCase() + label.slice(1)
}
export const formatWeekDayHeaderFr = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return dateYmd
return new Intl.DateTimeFormat('fr-FR', {
weekday: 'short',
day: '2-digit',
month: '2-digit'
}).format(parsed)
}
export const getWeekStartDate = (date: Date) => {
const copy = new Date(date)
const day = copy.getDay()
const diff = day === 0 ? -6 : 1 - day
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
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 = () => {
const date = new Date()
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const getOffsetFromTodayYmd = (offset: number) => {
const date = new Date()
date.setDate(date.getDate() + offset)
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const shiftYmd = (value: string, days: number) => {
const parsed = parseYmd(value)
if (!parsed) return null
parsed.setDate(parsed.getDate() + days)
return toYmd(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
}
export const formatWeekRangeFr = (date: Date) => {
const start = getWeekStartDate(date)
const end = new Date(start)
end.setDate(start.getDate() + 6)
const weekNumber = getIsoWeekNumber(start)
const formatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
}
export const getDaysInMonth = (year: number, month: number) => {
const total = new Date(year, month + 1, 0).getDate()
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']

View File

@@ -0,0 +1,31 @@
import type { Employee } from '~/services/dto/employee'
export const compareEmployeesInSite = (employeeA: Employee, employeeB: Employee) => {
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
}
export const compareEmployeesBySiteAndOrder = (employeeA: Employee, employeeB: Employee) => {
const siteOrderA = employeeA.site?.displayOrder ?? 0
const siteOrderB = employeeB.site?.displayOrder ?? 0
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
return compareEmployeesInSite(employeeA, employeeB)
}
export const sortEmployeesBySiteAndOrder = (employees: Employee[]) => {
return [...employees].sort(compareEmployeesBySiteAndOrder)
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260210121500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add display_order to employees';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD display_order INT NOT NULL DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP COLUMN display_order');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260210123000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Backfill employee display_order per site';
}
public function up(Schema $schema): void
{
// Initialise display_order par site selon le tri nom/prénom/id.
$this->addSql('
UPDATE employees e
SET display_order = ranked.rn
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY site_id
ORDER BY last_name ASC, first_name ASC, id ASC
) AS rn
FROM employees
) ranked
WHERE ranked.id = e.id
');
}
public function down(Schema $schema): void
{
// Pas de rollback pertinent.
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260211120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create user_site_roles table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE user_site_roles (id SERIAL NOT NULL, user_id INT NOT NULL, site_id INT NOT NULL, role VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_USER_SITE_ROLES_USER ON user_site_roles (user_id)');
$this->addSql('CREATE INDEX IDX_USER_SITE_ROLES_SITE ON user_site_roles (site_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_USER_SITE_ROLES_USER_SITE_ROLE ON user_site_roles (user_id, site_id, role)');
$this->addSql('ALTER TABLE user_site_roles ADD CONSTRAINT FK_USER_SITE_ROLES_USER FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE user_site_roles ADD CONSTRAINT FK_USER_SITE_ROLES_SITE FOREIGN KEY (site_id) REFERENCES sites (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user_site_roles DROP CONSTRAINT FK_USER_SITE_ROLES_USER');
$this->addSql('ALTER TABLE user_site_roles DROP CONSTRAINT FK_USER_SITE_ROLES_SITE');
$this->addSql('DROP TABLE user_site_roles');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260212120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Link users to employees (one-to-one)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD employee_id INT DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_USERS_EMPLOYEE ON users (employee_id)');
$this->addSql('ALTER TABLE users ADD CONSTRAINT FK_USERS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP CONSTRAINT FK_USERS_EMPLOYEE');
$this->addSql('DROP INDEX UNIQ_USERS_EMPLOYEE');
$this->addSql('ALTER TABLE users DROP COLUMN employee_id');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260216100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create work_hours table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE work_hours (id SERIAL NOT NULL, employee_id INT NOT NULL, work_date DATE NOT NULL, morning_from VARCHAR(5) DEFAULT NULL, morning_to VARCHAR(5) DEFAULT NULL, afternoon_from VARCHAR(5) DEFAULT NULL, afternoon_to VARCHAR(5) DEFAULT NULL, evening_from VARCHAR(5) DEFAULT NULL, evening_to VARCHAR(5) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_WORK_HOURS_EMPLOYEE ON work_hours (employee_id)');
$this->addSql('CREATE INDEX IDX_WORK_HOURS_DATE ON work_hours (work_date)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_WORK_HOURS_EMPLOYEE_DATE ON work_hours (employee_id, work_date)');
$this->addSql('ALTER TABLE work_hours ADD CONSTRAINT FK_WORK_HOURS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP CONSTRAINT FK_WORK_HOURS_EMPLOYEE');
$this->addSql('DROP TABLE work_hours');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260216143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add display_order to sites';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sites ADD display_order INT NOT NULL DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sites DROP COLUMN display_order');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260216143100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Backfill site display_order';
}
public function up(Schema $schema): void
{
$this->addSql('
UPDATE sites s
SET display_order = ranked.rn
FROM (
SELECT id,
ROW_NUMBER() OVER (
ORDER BY name ASC, id ASC
) AS rn
FROM sites
) ranked
WHERE ranked.id = s.id
');
}
public function down(Schema $schema): void
{
// Pas de rollback pertinent.
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217161000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract_hours and is_forfait to employees';
}
public function up(Schema $schema): void
{
// Nettoie l'ancien modele "contracts" s'il a deja ete applique.
$this->addSql('ALTER TABLE employees DROP CONSTRAINT IF EXISTS FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IF EXISTS IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_id');
$this->addSql('DROP TABLE IF EXISTS contracts');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS is_forfait');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217162000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_present and is_valid columns to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_present BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_valid');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace employee contract_hours/is_forfait with contracts table and employee.contract_id';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contracts (id SERIAL NOT NULL, name VARCHAR(120) NOT NULL, tracking_mode VARCHAR(20) NOT NULL, weekly_hours INT DEFAULT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CONTRACTS_TRACKING_MODE ON contracts (tracking_mode)');
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) VALUES ('Forfait', 'PRESENCE', NULL, TRUE)");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT DISTINCT CONCAT(contract_hours::text, 'h'), 'TIME', contract_hours, TRUE FROM employees WHERE contract_hours IS NOT NULL");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT '35h', 'TIME', 35, TRUE WHERE NOT EXISTS (SELECT 1 FROM contracts WHERE tracking_mode = 'TIME' AND weekly_hours = 35)");
$this->addSql('ALTER TABLE employees ADD contract_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_EMPLOYEES_CONTRACT ON employees (contract_id)');
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = TRUE AND c.tracking_mode = 'PRESENCE'");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = FALSE AND e.contract_hours IS NOT NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = e.contract_hours");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.contract_id IS NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = 35");
$this->addSql('ALTER TABLE employees ALTER COLUMN contract_id SET NOT NULL');
$this->addSql('ALTER TABLE employees ADD CONSTRAINT FK_EMPLOYEES_CONTRACT FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN is_forfait');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql("UPDATE employees e SET is_forfait = CASE WHEN c.tracking_mode = 'PRESENCE' THEN TRUE ELSE FALSE END, contract_hours = CASE WHEN c.tracking_mode = 'TIME' THEN c.weekly_hours ELSE NULL END FROM contracts c WHERE e.contract_id = c.id");
$this->addSql('ALTER TABLE employees DROP CONSTRAINT FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_id');
$this->addSql('DROP INDEX IDX_CONTRACTS_TRACKING_MODE');
$this->addSql('DROP TABLE contracts');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace work_hours.is_present with is_present_morning and is_present_afternoon';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_morning BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_afternoon BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_morning');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_afternoon');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present BOOLEAN DEFAULT NULL');
}
}

View File

@@ -0,0 +1,26 @@
<?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');
}
}

View File

@@ -0,0 +1,83 @@
<?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.');
}
}

View File

@@ -0,0 +1,40 @@
<?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');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add site validation flag to work hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD is_site_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP is_site_valid');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226203000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract nature to employee contract periods';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE employee_contract_periods ADD contract_nature VARCHAR(20) DEFAULT 'CDI' NOT NULL");
$this->addSql("
UPDATE employee_contract_periods p
SET contract_nature = 'INTERIM'
FROM contracts c
WHERE p.contract_id = c.id
AND LOWER(c.name) LIKE '%interim%'
");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP contract_nature');
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\IrreversibleMigration;
final class Version20260226210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Reassign legacy INTERIM contract to a TIME 35h contract and remove legacy INTERIM contract row';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
DO $$
DECLARE
legacy_interim_contract_id INT;
target_time_35h_contract_id INT;
BEGIN
-- Contrat legacy "Intérim" (ancien modèle).
SELECT c.id
INTO legacy_interim_contract_id
FROM contracts c
WHERE LOWER(c.name) LIKE '%interim%'
ORDER BY c.id
LIMIT 1;
-- Si déjà supprimé, on ne fait rien.
IF legacy_interim_contract_id IS NULL THEN
RETURN;
END IF;
-- Contrat cible: suivi horaire 35h.
SELECT c.id
INTO target_time_35h_contract_id
FROM contracts c
WHERE c.tracking_mode = 'TIME'
AND c.weekly_hours = 35
AND c.id <> legacy_interim_contract_id
ORDER BY
CASE WHEN LOWER(c.name) = '35h' THEN 0 ELSE 1 END,
c.id
LIMIT 1;
IF target_time_35h_contract_id IS NULL THEN
RAISE EXCEPTION 'No TIME 35h contract found to replace legacy INTERIM contract id=%', legacy_interim_contract_id;
END IF;
-- Ré-assigne l'historique de périodes.
UPDATE employee_contract_periods
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Ré-assigne le pointeur actuel employé (compat legacy / affichage).
UPDATE employees
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Garde-fou FK avant suppression.
IF EXISTS (SELECT 1 FROM employee_contract_periods p WHERE p.contract_id = legacy_interim_contract_id)
OR EXISTS (SELECT 1 FROM employees e WHERE e.contract_id = legacy_interim_contract_id) THEN
RAISE EXCEPTION 'Legacy INTERIM contract id=% is still referenced', legacy_interim_contract_id;
END IF;
DELETE FROM contracts WHERE id = legacy_interim_contract_id;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
throw new IrreversibleMigration('This migration performs data reassignment and contract deletion.');
}
}

View File

@@ -18,7 +18,10 @@ use App\State\AbsencePrintProvider;
new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false),
]
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\ScopedEmployeeProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/employees/scoped',
normalizationContext: ['groups' => ['employee:read', 'site:read']],
security: "is_granted('ROLE_USER')",
provider: ScopedEmployeeProvider::class,
paginationEnabled: false
),
]
)]
final class ScopedEmployee {}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkUpsertProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/bulk-upsert',
security: "is_granted('ROLE_USER')",
output: WorkHourBulkUpsertResult::class,
processor: WorkHourBulkUpsertProcessor::class
),
]
)]
final class WorkHourBulkUpsert
{
public string $workDate = '';
/**
* @var list<array{
* employeeId:int,
* morningFrom?:?string,
* morningTo?:?string,
* afternoonFrom?:?string,
* afternoonTo?:?string,
* eveningFrom?:?string,
* eveningTo?:?string,
* isPresentMorning?:bool,
* isPresentAfternoon?:bool
* }>
*/
public array $entries = [];
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class WorkHourBulkUpsertResult
{
public int $processed = 0;
public int $created = 0;
public int $updated = 0;
public int $deleted = 0;
}

View File

@@ -0,0 +1,38 @@
<?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,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }>
*/
public array $rows = [];
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\State\WorkHourWeeklySummaryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/weekly-summary',
security: "is_granted('ROLE_USER')",
provider: WorkHourWeeklySummaryProvider::class
),
],
paginationEnabled: false
)]
final class WorkHourWeeklySummary
{
public string $weekStart = '';
public string $weekEnd = '';
/** @var list<string> */
public array $days = [];
/** @var list<WeeklySummaryRow> */
public array $rows = [];
}

View File

@@ -0,0 +1,50 @@
<?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);
}
}

View File

@@ -0,0 +1,53 @@
<?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\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class WorkHourCollectionExtension 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 {
// N'applique le filtrage qu'à la ressource WorkHour.
if (WorkHour::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
// Pas d'utilisateur => aucune ligne renvoyée.
$queryBuilder->andWhere('1 = 0');
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$employeeAlias = 'employee_scope';
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
->addSelect($employeeAlias)
;
// Filtrage SQL par scope (admin/self/site) avant retour API.
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'work_hour_scope', $user);
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class DayContextRow
{
public function __construct(
public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null,
public ?string $absenceColor = 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,
?string $color,
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';
}
// Si plusieurs types d'absence différents sont fusionnés sur la même journée,
// on retire la couleur métier spécifique.
if (null === $this->absenceColor) {
$this->absenceColor = $color;
} elseif ($color !== $this->absenceColor) {
$this->absenceColor = null;
}
// 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,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }
*/
public function toArray(): array
{
return [
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'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;
}
}

View File

@@ -0,0 +1,19 @@
<?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,
) {}
}

View File

@@ -0,0 +1,30 @@
<?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,
) {}
}

View File

@@ -0,0 +1,26 @@
<?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;
}
}

View File

@@ -9,22 +9,51 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
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\State\AbsenceWriteProcessor;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[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: [
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'datetime_format' => 'Y-m-d',
]
],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
#[ORM\Table(name: 'absences')]
class Absence
{
@@ -50,17 +79,17 @@ class Absence
#[Groups(['absence:read'])]
private DateTimeInterface $startDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'AM'])]
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
#[Groups(['absence:read'])]
private string $startHalf = 'AM';
private HalfDay $startHalf = HalfDay::AM;
#[ORM\Column(type: 'date')]
#[Groups(['absence:read'])]
private DateTimeInterface $endDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'PM'])]
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
#[Groups(['absence:read'])]
private string $endHalf = 'PM';
private HalfDay $endHalf = HalfDay::PM;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['absence:read'])]
@@ -119,24 +148,24 @@ class Absence
return $this;
}
public function getStartHalf(): string
public function getStartHalf(): HalfDay
{
return $this->startHalf;
}
public function setStartHalf(string $startHalf): self
public function setStartHalf(HalfDay $startHalf): self
{
$this->startHalf = $startHalf;
return $this;
}
public function getEndHalf(): string
public function getEndHalf(): HalfDay
{
return $this->endHalf;
}
public function setEndHalf(string $endHalf): self
public function setEndHalf(HalfDay $endHalf): self
{
$this->endHalf = $endHalf;

View File

@@ -5,10 +5,35 @@ declare(strict_types=1);
namespace App\Entity;
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 Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(normalizationContext: ['groups' => ['absence_type:read']])]
#[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']],
paginationEnabled: false,
)]
#[ORM\Entity]
#[ORM\Table(name: 'absence_types')]
class AbsenceType
@@ -31,6 +56,10 @@ class AbsenceType
#[Groups(['absence:read', 'absence_type:read'])]
private string $color = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['absence:read', 'absence_type:read'])]
private bool $countAsWorkedHours = false;
public function getId(): ?int
{
return $this->id;
@@ -71,4 +100,21 @@ class AbsenceType
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;
}
}

122
src/Entity/Contract.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['contract:read']],
denormalizationContext: ['groups' => ['contract:write']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)]
#[ORM\Entity]
#[ORM\Table(name: 'contracts')]
class Contract
{
public const string TRACKING_TIME = TrackingMode::TIME->value;
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['contract:read', 'employee:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 120)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private string $name = '';
#[ORM\Column(type: 'string', length: 20)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private string $trackingMode = self::TRACKING_TIME;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private ?int $weeklyHours = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
#[Groups(['contract:read', 'contract:write'])]
private bool $isActive = true;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getTrackingMode(): string
{
return $this->trackingMode;
}
public function getTrackingModeEnum(): TrackingMode
{
return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
}
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;
}
#[Groups(['contract:read', 'employee:read'])]
public function getType(): ContractType
{
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
}
public function getWeeklyHours(): ?int
{
return $this->weeklyHours;
}
public function setWeeklyHours(?int $weeklyHours): self
{
$this->weeklyHours = $weeklyHours;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getIsActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): self
{
$this->isActive = $isActive;
return $this;
}
}

View File

@@ -4,16 +4,25 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['employee:read', 'site:read']],
denormalizationContext: ['groups' => ['employee:write']]
denormalizationContext: ['groups' => ['employee:write']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
processor: EmployeeWriteProcessor::class,
)]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\Table(name: 'employees')]
class Employee
{
@@ -31,18 +40,44 @@ class Employee
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
private string $lastName = '';
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['employee:read', 'employee:write'])]
private ?Site $site = null;
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Contract::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['employee:read', 'employee:write'])]
private ?Contract $contract = null;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[Groups(['employee:read', 'employee:write'])]
private int $displayOrder = 0;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
/**
* @var Collection<int, EmployeeContractPeriod>
*/
#[ORM\OneToMany(mappedBy: 'employee', targetEntity: EmployeeContractPeriod::class)]
private Collection $contractPeriods;
#[Groups(['employee:write'])]
private ?string $contractNature = null;
#[Groups(['employee:write'])]
private ?string $contractStartDate = null;
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
}
public function getId(): ?int
@@ -86,8 +121,109 @@ class Employee
return $this;
}
public function getContract(): ?Contract
{
return $this->contract;
}
public function setContract(?Contract $contract): self
{
$this->contract = $contract;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getDisplayOrder(): int
{
return $this->displayOrder;
}
public function setDisplayOrder(int $displayOrder): self
{
$this->displayOrder = $displayOrder;
return $this;
}
public function getContractNature(): ?string
{
return $this->contractNature;
}
public function setContractNature(?string $contractNature): self
{
$this->contractNature = $contractNature;
return $this;
}
public function getContractStartDate(): ?string
{
return $this->contractStartDate;
}
public function setContractStartDate(?string $contractStartDate): self
{
$this->contractStartDate = $contractStartDate;
return $this;
}
public function getContractEndDate(): ?string
{
return $this->contractEndDate;
}
public function setContractEndDate(?string $contractEndDate): self
{
$this->contractEndDate = $contractEndDate;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
return $this->resolveCurrentContractPeriod()?->getContractNatureEnum()->value ?? ContractNature::CDI->value;
}
#[Groups(['employee:read'])]
public function getCurrentContractStartDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getStartDate()->format('Y-m-d');
}
#[Groups(['employee:read'])]
public function getCurrentContractEndDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
$current = null;
foreach ($this->contractPeriods as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null !== $endDate && $endDate < $today) {
continue;
}
if (null === $current || $period->getStartDate() > $current->getStartDate()) {
$current = $period;
}
}
return $current;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\ContractNature;
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, inversedBy: 'contractPeriods')]
#[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: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[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 getContractNature(): string
{
return $this->contractNature;
}
public function getContractNatureEnum(): ContractNature
{
return ContractNature::tryFrom($this->contractNature) ?? ContractNature::CDI;
}
public function setContractNature(ContractNature|string $contractNature): self
{
$value = $contractNature instanceof ContractNature ? $contractNature->value : $contractNature;
$this->contractNature = ContractNature::tryFrom($value)?->value ?? ContractNature::CDI->value;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -8,7 +8,11 @@ use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(normalizationContext: ['groups' => ['site:read']])]
#[ApiResource(
normalizationContext: ['groups' => ['site:read']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)]
#[ORM\Entity]
#[ORM\Table(name: 'sites')]
class Site
@@ -27,6 +31,10 @@ class Site
#[Groups(['site:read', 'employee:read'])]
private string $color = '';
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[Groups(['site:read'])]
private int $displayOrder = 0;
public function getId(): ?int
{
return $this->id;
@@ -55,4 +63,16 @@ class Site
return $this;
}
public function getDisplayOrder(): int
{
return $this->displayOrder;
}
public function setDisplayOrder(int $displayOrder): self
{
$this->displayOrder = $displayOrder;
return $this;
}
}

View File

@@ -4,12 +4,20 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\CurrentUserProvider;
use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
@@ -19,6 +27,29 @@ use Symfony\Component\Security\Core\User\UserInterface;
security: "is_granted('ROLE_USER')",
provider: CurrentUserProvider::class
),
new GetCollection(
normalizationContext: ['groups' => ['user:read', 'employee:read', 'site:read']],
security: "is_granted('ROLE_ADMIN')"
),
new Get(
uriTemplate: '/users/{id}',
normalizationContext: ['groups' => ['user:read', 'employee:read', 'site:read']],
security: "is_granted('ROLE_ADMIN')"
),
new Post(
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
security: "is_granted('ROLE_ADMIN')",
processor: UserPasswordHasherProcessor::class
),
new Patch(
uriTemplate: '/users/{id}',
paginationEnabled: false,
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:write']],
security: "is_granted('ROLE_ADMIN')",
processor: UserPasswordHasherProcessor::class
),
]
)]
#[ORM\Entity]
@@ -29,17 +60,40 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['user:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180)]
#[Groups(['user:read', 'user:write'])]
private string $username = '';
#[ORM\Column(type: 'json')]
#[Groups(['user:write'])]
private array $roles = [];
#[ORM\Column(type: 'string')]
private string $password = '';
#[Groups(['user:write'])]
private string $plainPassword = '';
#[ApiProperty(readableLink: true)]
#[ORM\OneToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: true, unique: true)]
#[Groups(['user:read', 'user:write'])]
private ?Employee $employee = null;
/**
* @var Collection<int, UserSiteRole>
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserSiteRole::class, orphanRemoval: true)]
private Collection $siteRoles;
public function __construct()
{
$this->siteRoles = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -65,6 +119,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* @return list<string>
*/
#[Groups(['user:read'])]
public function getRoles(): array
{
$roles = $this->roles;
@@ -95,5 +150,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getPlainPassword(): string
{
return $this->plainPassword;
}
public function setPlainPassword(string $plainPassword): self
{
$this->plainPassword = $plainPassword;
return $this;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
/**
* @return Collection<int, UserSiteRole>
*/
public function getSiteRoles(): Collection
{
return $this->siteRoles;
}
public function addSiteRole(UserSiteRole $siteRole): self
{
if (!$this->siteRoles->contains($siteRole)) {
$this->siteRoles->add($siteRole);
$siteRole->setUser($this);
}
return $this;
}
public function removeSiteRole(UserSiteRole $siteRole): self
{
if ($this->siteRoles->removeElement($siteRole)) {
if ($siteRole->getUser() === $this) {
$siteRole->setUser(null);
}
}
return $this;
}
public function eraseCredentials(): void {}
}

101
src/Entity/UserSiteRole.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/user_site_roles',
normalizationContext: ['groups' => ['user_site_role:read', 'site:read', 'user:read']],
security: "is_granted('ROLE_ADMIN')"
),
new Post(
uriTemplate: '/user_site_roles',
denormalizationContext: ['groups' => ['user_site_role:write']],
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
uriTemplate: '/user_site_roles/{id}',
security: "is_granted('ROLE_ADMIN')"
),
],
paginationEnabled: false,
)]
#[ORM\Entity()]
#[ORM\Table(name: 'user_site_roles')]
#[ORM\UniqueConstraint(name: 'uniq_user_site_role', fields: ['user', 'site', 'role'])]
class UserSiteRole
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['user_site_role:read'])]
private ?int $id = null;
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'siteRoles')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['user_site_role:read', 'user_site_role:write'])]
private ?User $user = null;
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['user_site_role:read', 'user_site_role:write'])]
private ?Site $site = null;
#[ORM\Column(type: 'string', length: 50)]
#[Groups(['user_site_role:read', 'user_site_role:write'])]
private string $role = '';
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): self
{
$this->site = $site;
return $this;
}
public function getRole(): string
{
return $this->role;
}
public function setRole(string $role): self
{
$this->role = $role;
return $this;
}
}

277
src/Entity/WorkHour.php Normal file
View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
paginationEnabled: false,
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
security: "is_granted('ROLE_USER')"
),
new Get(
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
security: "is_granted('WORK_HOUR_VIEW', object)"
),
new Patch(
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:validate']],
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
uriTemplate: '/work_hours/{id}/site-validation',
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:site_validate']],
security: "is_granted('ROLE_USER')",
processor: WorkHourSiteValidationProcessor::class
),
],
)]
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
#[ORM\Entity(repositoryClass: WorkHourRepository::class)]
#[ORM\Table(name: 'work_hours')]
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
class WorkHour
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['work_hour:read'])]
private ?int $id = null;
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['work_hour:read'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['work_hour:read'])]
private DateTimeInterface $workDate;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $morningFrom = null;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $morningTo = null;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $afternoonFrom = null;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $afternoonTo = null;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $eveningFrom = null;
#[ORM\Column(type: 'string', length: 5, nullable: true)]
#[Groups(['work_hour:read'])]
private ?string $eveningTo = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $isPresentMorning = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $isPresentAfternoon = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
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 getWorkDate(): DateTimeInterface
{
return $this->workDate;
}
public function setWorkDate(DateTimeInterface $workDate): self
{
$this->workDate = $workDate;
return $this;
}
public function getMorningFrom(): ?string
{
return $this->morningFrom;
}
public function setMorningFrom(?string $morningFrom): self
{
$this->morningFrom = $morningFrom;
return $this;
}
public function getMorningTo(): ?string
{
return $this->morningTo;
}
public function setMorningTo(?string $morningTo): self
{
$this->morningTo = $morningTo;
return $this;
}
public function getAfternoonFrom(): ?string
{
return $this->afternoonFrom;
}
public function setAfternoonFrom(?string $afternoonFrom): self
{
$this->afternoonFrom = $afternoonFrom;
return $this;
}
public function getAfternoonTo(): ?string
{
return $this->afternoonTo;
}
public function setAfternoonTo(?string $afternoonTo): self
{
$this->afternoonTo = $afternoonTo;
return $this;
}
public function getEveningFrom(): ?string
{
return $this->eveningFrom;
}
public function setEveningFrom(?string $eveningFrom): self
{
$this->eveningFrom = $eveningFrom;
return $this;
}
public function getEveningTo(): ?string
{
return $this->eveningTo;
}
public function setEveningTo(?string $eveningTo): self
{
$this->eveningTo = $eveningTo;
return $this;
}
public function isPresentMorning(): bool
{
return $this->isPresentMorning;
}
public function getIsPresentMorning(): bool
{
return $this->isPresentMorning;
}
public function setIsPresentMorning(bool $isPresentMorning): self
{
$this->isPresentMorning = $isPresentMorning;
return $this;
}
public function isPresentAfternoon(): bool
{
return $this->isPresentAfternoon;
}
public function getIsPresentAfternoon(): bool
{
return $this->isPresentAfternoon;
}
public function setIsPresentAfternoon(bool $isPresentAfternoon): self
{
$this->isPresentAfternoon = $isPresentAfternoon;
return $this;
}
public function isValid(): bool
{
return $this->isValid;
}
public function getIsValid(): bool
{
return $this->isValid;
}
public function setIsValid(bool $isValid): self
{
$this->isValid = $isValid;
return $this;
}
public function isSiteValid(): bool
{
return $this->isSiteValid;
}
public function getIsSiteValid(): bool
{
return $this->isSiteValid;
}
public function setIsSiteValid(bool $isSiteValid): self
{
$this->isSiteValid = $isSiteValid;
return $this;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractNature: string
{
case CDI = 'CDI';
case CDD = 'CDD';
case INTERIM = 'INTERIM';
public function requiresEndDate(): bool
{
return self::CDD === $this || self::INTERIM === $this;
}
}

47
src/Enum/ContractType.php Normal file
View File

@@ -0,0 +1,47 @@
<?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);
}
}

11
src/Enum/HalfDay.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum HalfDay: string
{
case AM = 'AM';
case PM = 'PM';
}

11
src/Enum/TrackingMode.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum TrackingMode: string
{
case TIME = 'TIME';
case PRESENCE = 'PRESENCE';
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Absence>
*/
final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Absence::class);
}
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->andWhere('a.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @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();
}
}

View File

@@ -0,0 +1,32 @@
<?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;
}

View File

@@ -0,0 +1,16 @@
<?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;
}

View File

@@ -0,0 +1,26 @@
<?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;
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
}

View File

@@ -0,0 +1,75 @@
<?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()
;
}
}

Some files were not shown because too many files have changed in this diff Show More