Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad9e8705ae | ||
| f8ca5e50a0 | |||
|
|
49fecfc27a | ||
| ee16779777 | |||
|
|
f6c1f7eead | ||
| 69e8d74f4d | |||
|
|
2a9b047913 | ||
| 76f1363457 | |||
|
|
4845230429 | ||
| 0b0ca60af7 | |||
|
|
fe6a0e8fc9 | ||
| c10c774ac8 | |||
| 4b847eb1a2 | |||
|
|
fd48c9937e | ||
| 2a8c874985 | |||
|
|
4cf00e6ef3 | ||
| 5be33abb03 | |||
|
|
92d5fa55d0 | ||
| 65f6d8a530 |
2
.env
2
.env
@@ -46,4 +46,6 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
|||||||
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
|
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
|
||||||
JWT_COOKIE_SECURE=0
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_COOKIE_SAMESITE=lax
|
JWT_COOKIE_SAMESITE=lax
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
###< lexik/jwt-authentication-bundle ###
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|||||||
10
.idea/SIRH.iml
generated
10
.idea/SIRH.iml
generated
@@ -142,6 +142,16 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
|
||||||
|
<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>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
|
||||||
<data-source source="LOCAL" name="postgres@localhost" uuid="9cad43df-2147-4989-b7a4-443067034884">
|
|
||||||
<driver-ref>postgresql</driver-ref>
|
|
||||||
<synchronize>true</synchronize>
|
|
||||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
|
||||||
<jdbc-url>jdbc:postgresql://localhost:5433/postgres</jdbc-url>
|
|
||||||
<working-dir>$ProjectFileDir$</working-dir>
|
|
||||||
</data-source>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/data_source_mapping.xml
generated
10
.idea/data_source_mapping.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourcePerFileMappings">
|
<component name="DataSourcePerFileMappings">
|
||||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
6
.idea/db-forest-config.xml
generated
Normal file
6
.idea/db-forest-config.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-tree-configuration">
|
||||||
|
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 " />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
3
.idea/php.xml
generated
3
.idea/php.xml
generated
@@ -150,6 +150,9 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
||||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
6
.idea/sqldialects.xml
generated
Normal file
6
.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
105
AGENTS.md
Normal file
105
AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions.
|
||||||
|
|
||||||
|
## 1) Stack et structure
|
||||||
|
|
||||||
|
- Backend: Symfony + API Platform + Doctrine ORM
|
||||||
|
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
|
||||||
|
- Exécution locale: Docker via `makefile`
|
||||||
|
|
||||||
|
Arborescence clé:
|
||||||
|
- `src/`: domaine, API resources, state providers/processors, services
|
||||||
|
- `tests/`: TU backend (PHPUnit)
|
||||||
|
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||||
|
- `migrations/`: migrations Doctrine
|
||||||
|
|
||||||
|
## 2) Commandes utiles
|
||||||
|
|
||||||
|
- Démarrer stack: `make start`
|
||||||
|
- Tests backend: `make test`
|
||||||
|
- Build frontend: `cd frontend && npm run build`
|
||||||
|
- Dev frontend: `make dev-nuxt`
|
||||||
|
|
||||||
|
## 3) Domaine métier (résumé)
|
||||||
|
|
||||||
|
### Contrats
|
||||||
|
- Entité: `Contract`
|
||||||
|
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
|
||||||
|
- `trackingMode`:
|
||||||
|
- `TIME`: suivi par heures
|
||||||
|
- `PRESENCE`: suivi présence demi-journées/journées
|
||||||
|
- Enums backend:
|
||||||
|
- `App\Enum\TrackingMode`
|
||||||
|
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
|
||||||
|
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
|
||||||
|
|
||||||
|
### Heures / absences
|
||||||
|
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
|
||||||
|
- Une ligne d’heures validée est verrouillée côté métier.
|
||||||
|
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
|
||||||
|
- contrats présence: crédit en unités de présence
|
||||||
|
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
|
||||||
|
|
||||||
|
## 4) Écrans principaux
|
||||||
|
|
||||||
|
### Page Heures (`frontend/pages/hours.vue`)
|
||||||
|
- Vue Jour + Vue Semaine (semaine réservée admin)
|
||||||
|
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
|
||||||
|
- Vue jour: `frontend/components/hours/HoursDayView.vue`
|
||||||
|
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
|
||||||
|
- Logique page: `frontend/composables/useHoursPage.ts`
|
||||||
|
|
||||||
|
### Points UX déjà en place
|
||||||
|
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
|
||||||
|
- Légende absences affichée dans la toolbar (admin + vue semaine)
|
||||||
|
- Cellules semaine avec absence: couleur du type d’absence (plus rouge fixe)
|
||||||
|
- Pour user non-admin: restrictions d’édition selon validations/absences
|
||||||
|
|
||||||
|
## 5) API / calculs hebdo
|
||||||
|
|
||||||
|
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
|
||||||
|
- DTOs:
|
||||||
|
- `src/Dto/WorkHours/WeeklySummaryRow.php`
|
||||||
|
- `src/Dto/WorkHours/WeeklyDaySummary.php`
|
||||||
|
- Le résumé hebdo renvoie notamment:
|
||||||
|
- `trackingMode`
|
||||||
|
- `contractName`
|
||||||
|
- `contractType`
|
||||||
|
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
|
||||||
|
|
||||||
|
### Heures supp
|
||||||
|
- Règles métier:
|
||||||
|
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
|
||||||
|
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
|
||||||
|
- contrats `INTERIM`: pas de bonus 25/50 ni récup
|
||||||
|
|
||||||
|
## 6) Conventions techniques
|
||||||
|
|
||||||
|
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
|
||||||
|
- Utiliser les interfaces repository dans providers/processors testés.
|
||||||
|
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
|
||||||
|
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
|
||||||
|
|
||||||
|
## 7) Tests et qualité
|
||||||
|
|
||||||
|
- Les TU backend passent actuellement via `make test`.
|
||||||
|
- Le build frontend passe via `npm run build`.
|
||||||
|
- À chaque évolution métier:
|
||||||
|
- mettre à jour les tests provider/processor/service impactés
|
||||||
|
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
|
||||||
|
|
||||||
|
## 8) Fichiers sensibles (à lire avant modif)
|
||||||
|
|
||||||
|
- `src/State/WorkHourWeeklySummaryProvider.php`
|
||||||
|
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
|
||||||
|
- `src/State/AbsenceWriteProcessor.php`
|
||||||
|
- `src/State/WorkHourBulkUpsertProcessor.php`
|
||||||
|
- `frontend/composables/useHoursPage.ts`
|
||||||
|
- `frontend/components/hours/HoursWeekView.vue`
|
||||||
|
|
||||||
|
## 9) Décisions de conception actuelles
|
||||||
|
|
||||||
|
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
|
||||||
|
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
|
||||||
|
- Le front reste centré sur l’affichage/interaction et réutilise les données enrichies de l’API.
|
||||||
10
README.md
10
README.md
@@ -1,2 +1,12 @@
|
|||||||
# SIRH
|
# SIRH
|
||||||
Application de gestion des absences employée
|
Application de gestion des absences employée
|
||||||
|
|
||||||
|
## Importer un dump de prod en dev
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
```
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
|
|||||||
261
composer.lock
generated
261
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f181b165d122aecdae0a7df1d1e33aec",
|
"content-hash": "71d28cc0a29fa3f385b067186aa43678",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2771,6 +2771,109 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-07-25T09:04:22+00:00"
|
"time": "2025-07-25T09:04:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "monolog/monolog",
|
||||||
|
"version": "3.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Seldaek/monolog.git",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/log": "^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"psr/log-implementation": "3.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"aws/aws-sdk-php": "^3.0",
|
||||||
|
"doctrine/couchdb": "~1.0@dev",
|
||||||
|
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||||
|
"ext-json": "*",
|
||||||
|
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/psr7": "^2.2",
|
||||||
|
"mongodb/mongodb": "^1.8 || ^2.0",
|
||||||
|
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||||
|
"php-console/php-console": "^3.1.8",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||||
|
"predis/predis": "^1.1 || ^2",
|
||||||
|
"rollbar/rollbar": "^4.0",
|
||||||
|
"ruflin/elastica": "^7 || ^8",
|
||||||
|
"symfony/mailer": "^5.4 || ^6",
|
||||||
|
"symfony/mime": "^5.4 || ^6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||||
|
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||||
|
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||||
|
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||||
|
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||||
|
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||||
|
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||||
|
"ext-openssl": "Required to send log messages using SSL",
|
||||||
|
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||||
|
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||||
|
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||||
|
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||||
|
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||||
|
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Monolog\\": "src/Monolog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "https://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||||
|
"homepage": "https://github.com/Seldaek/monolog",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging",
|
||||||
|
"psr-3"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||||
|
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Seldaek",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nelmio/cors-bundle",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
@@ -5271,6 +5374,162 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-28T10:46:31+00:00"
|
"time": "2026-01-28T10:46:31+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bridge",
|
||||||
|
"version": "v8.0.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||||
|
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
|
||||||
|
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"monolog/monolog": "^3",
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/mailer": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/security-core": "^7.4|^8.0",
|
||||||
|
"symfony/var-dumper": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bridge\\Monolog\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides integration for Monolog with various Symfony components",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-07T12:23:22+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bundle",
|
||||||
|
"version": "v4.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||||
|
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
|
||||||
|
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.0",
|
||||||
|
"monolog/monolog": "^3.5",
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/config": "^7.3 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.3 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^7.3 || ^8.0",
|
||||||
|
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
||||||
|
"symfony/polyfill-php84": "^1.30"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
||||||
|
"symfony/console": "^7.3 || ^8.0",
|
||||||
|
"symfony/yaml": "^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bundle\\MonologBundle\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony MonologBundle",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||||
|
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-08T08:00:13+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/password-hasher",
|
"name": "symfony/password-hasher",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
|||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
@@ -20,4 +21,5 @@ return [
|
|||||||
NelmioCorsBundle::class => ['all' => true],
|
NelmioCorsBundle::class => ['all' => true],
|
||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
|
MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ lexik_jwt_authentication:
|
|||||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||||
|
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||||
remove_token_from_body_when_cookies_used: true
|
remove_token_from_body_when_cookies_used: true
|
||||||
token_extractors:
|
token_extractors:
|
||||||
authorization_header:
|
authorization_header:
|
||||||
@@ -13,7 +14,7 @@ lexik_jwt_authentication:
|
|||||||
enabled: false
|
enabled: false
|
||||||
set_cookies:
|
set_cookies:
|
||||||
BEARER:
|
BEARER:
|
||||||
lifetime: 86400
|
lifetime: '%env(int:JWT_COOKIE_TTL)%'
|
||||||
samesite: lax
|
samesite: lax
|
||||||
path: /
|
path: /
|
||||||
secure: '%env(bool:JWT_COOKIE_SECURE)%'
|
secure: '%env(bool:JWT_COOKIE_SECURE)%'
|
||||||
|
|||||||
28
config/packages/monolog.yaml
Normal file
28
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
monolog:
|
||||||
|
channels: [deprecation]
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
channels: ["!event"]
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine", "!console"]
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
channels: ["!deprecation"]
|
||||||
|
deprecation:
|
||||||
|
type: stream
|
||||||
|
channels: [deprecation]
|
||||||
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
@@ -5,7 +5,7 @@ nelmio_cors:
|
|||||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
allow_headers: ['Content-Type', 'Authorization']
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
allow_credentials: true
|
allow_credentials: true
|
||||||
expose_headers: ['Link']
|
expose_headers: ['Link', 'Content-Disposition']
|
||||||
max_age: 3600
|
max_age: 3600
|
||||||
paths:
|
paths:
|
||||||
'^/': null
|
'^/': null
|
||||||
|
|||||||
@@ -1608,6 +1608,149 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type MonologConfig = array{
|
||||||
|
* use_microseconds?: scalar|Param|null, // Default: true
|
||||||
|
* channels?: list<scalar|Param|null>,
|
||||||
|
* handlers?: array<string, array{ // Default: []
|
||||||
|
* type: scalar|Param|null,
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* priority?: scalar|Param|null, // Default: 0
|
||||||
|
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* bubble?: bool|Param, // Default: true
|
||||||
|
* interactive_only?: bool|Param, // Default: false
|
||||||
|
* app_name?: scalar|Param|null, // Default: null
|
||||||
|
* include_stacktraces?: bool|Param, // Default: false
|
||||||
|
* process_psr_3_messages?: array{
|
||||||
|
* enabled?: bool|Param|null, // Default: null
|
||||||
|
* date_format?: scalar|Param|null,
|
||||||
|
* remove_used_context_fields?: bool|Param,
|
||||||
|
* },
|
||||||
|
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
* file_permission?: scalar|Param|null, // Default: null
|
||||||
|
* use_locking?: bool|Param, // Default: false
|
||||||
|
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||||
|
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||||
|
* ident?: scalar|Param|null, // Default: false
|
||||||
|
* logopts?: scalar|Param|null, // Default: 1
|
||||||
|
* facility?: scalar|Param|null, // Default: "user"
|
||||||
|
* max_files?: scalar|Param|null, // Default: 0
|
||||||
|
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* activation_strategy?: scalar|Param|null, // Default: null
|
||||||
|
* stop_buffering?: bool|Param, // Default: true
|
||||||
|
* passthru_level?: scalar|Param|null, // Default: null
|
||||||
|
* excluded_http_codes?: list<array{ // Default: []
|
||||||
|
* code?: scalar|Param|null,
|
||||||
|
* urls?: list<scalar|Param|null>,
|
||||||
|
* }>,
|
||||||
|
* accepted_levels?: list<scalar|Param|null>,
|
||||||
|
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||||
|
* buffer_size?: scalar|Param|null, // Default: 0
|
||||||
|
* flush_on_overflow?: bool|Param, // Default: false
|
||||||
|
* handler?: scalar|Param|null,
|
||||||
|
* url?: scalar|Param|null,
|
||||||
|
* exchange?: scalar|Param|null,
|
||||||
|
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||||
|
* channel?: scalar|Param|null, // Default: null
|
||||||
|
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||||
|
* use_attachment?: scalar|Param|null, // Default: true
|
||||||
|
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||||
|
* include_extra?: scalar|Param|null, // Default: false
|
||||||
|
* icon_emoji?: scalar|Param|null, // Default: null
|
||||||
|
* webhook_url?: scalar|Param|null,
|
||||||
|
* exclude_fields?: list<scalar|Param|null>,
|
||||||
|
* token?: scalar|Param|null,
|
||||||
|
* region?: scalar|Param|null,
|
||||||
|
* source?: scalar|Param|null,
|
||||||
|
* use_ssl?: bool|Param, // Default: true
|
||||||
|
* user?: mixed,
|
||||||
|
* title?: scalar|Param|null, // Default: null
|
||||||
|
* host?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 514
|
||||||
|
* config?: list<scalar|Param|null>,
|
||||||
|
* members?: list<scalar|Param|null>,
|
||||||
|
* connection_string?: scalar|Param|null,
|
||||||
|
* timeout?: scalar|Param|null,
|
||||||
|
* time?: scalar|Param|null, // Default: 60
|
||||||
|
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||||
|
* store?: scalar|Param|null, // Default: null
|
||||||
|
* connection_timeout?: scalar|Param|null,
|
||||||
|
* persistent?: bool|Param,
|
||||||
|
* message_type?: scalar|Param|null, // Default: 0
|
||||||
|
* parse_mode?: scalar|Param|null, // Default: null
|
||||||
|
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||||
|
* disable_notification?: bool|Param|null, // Default: null
|
||||||
|
* split_long_messages?: bool|Param, // Default: false
|
||||||
|
* delay_between_messages?: bool|Param, // Default: false
|
||||||
|
* topic?: int|Param, // Default: null
|
||||||
|
* factor?: int|Param, // Default: 1
|
||||||
|
* tags?: list<scalar|Param|null>,
|
||||||
|
* console_formatter_options?: mixed, // Default: []
|
||||||
|
* formatter?: scalar|Param|null,
|
||||||
|
* nested?: bool|Param, // Default: false
|
||||||
|
* publisher?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hostname?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 12201
|
||||||
|
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||||
|
* encoder?: "json"|"compressed_json"|Param,
|
||||||
|
* },
|
||||||
|
* mongodb?: string|array{
|
||||||
|
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||||
|
* uri?: scalar|Param|null,
|
||||||
|
* username?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null,
|
||||||
|
* database?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* collection?: scalar|Param|null, // Default: "logs"
|
||||||
|
* },
|
||||||
|
* elasticsearch?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* hosts?: list<scalar|Param|null>,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* port?: scalar|Param|null, // Default: 9200
|
||||||
|
* transport?: scalar|Param|null, // Default: "Http"
|
||||||
|
* user?: scalar|Param|null, // Default: null
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* index?: scalar|Param|null, // Default: "monolog"
|
||||||
|
* document_type?: scalar|Param|null, // Default: "logs"
|
||||||
|
* ignore_error?: scalar|Param|null, // Default: false
|
||||||
|
* redis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* password?: scalar|Param|null, // Default: null
|
||||||
|
* port?: scalar|Param|null, // Default: 6379
|
||||||
|
* database?: scalar|Param|null, // Default: 0
|
||||||
|
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||||
|
* },
|
||||||
|
* predis?: string|array{
|
||||||
|
* id?: scalar|Param|null,
|
||||||
|
* host?: scalar|Param|null,
|
||||||
|
* },
|
||||||
|
* from_email?: scalar|Param|null,
|
||||||
|
* to_email?: list<scalar|Param|null>,
|
||||||
|
* subject?: scalar|Param|null,
|
||||||
|
* content_type?: scalar|Param|null, // Default: null
|
||||||
|
* headers?: list<scalar|Param|null>,
|
||||||
|
* mailer?: scalar|Param|null, // Default: null
|
||||||
|
* email_prototype?: string|array{
|
||||||
|
* id: scalar|Param|null,
|
||||||
|
* method?: scalar|Param|null, // Default: null
|
||||||
|
* },
|
||||||
|
* verbosity_levels?: array{
|
||||||
|
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||||
|
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||||
|
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||||
|
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||||
|
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||||
|
* },
|
||||||
|
* channels?: string|array{
|
||||||
|
* type?: scalar|Param|null,
|
||||||
|
* elements?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* }>,
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1620,6 +1763,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1632,6 +1776,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1645,6 +1790,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1658,6 +1804,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* monolog?: MonologConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -22,5 +22,9 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|
||||||
|
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
|
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.1'
|
app.version: '0.1.10'
|
||||||
|
|||||||
43
deploy/nginx/sirh.conf
Normal file
43
deploy/nginx/sirh.conf
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name sirh.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/sirh/frontend/.output/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ^~ /api/ {
|
||||||
|
root /var/www/sirh/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /bundles/ {
|
||||||
|
root /var/www/sirh/public;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/login_check {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_param PATH_INFO /login_check;
|
||||||
|
fastcgi_param REQUEST_URI /login_check;
|
||||||
|
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/index\.php(/|$) {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
|
||||||
|
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
id="employee"
|
id="employee"
|
||||||
v-model="absenceForm.employeeId"
|
v-model="absenceForm.employeeId"
|
||||||
:class="employeeFieldClass"
|
:class="employeeFieldClass"
|
||||||
|
:disabled="props.lockEmployee"
|
||||||
>
|
>
|
||||||
<option value="" disabled>Choisir un employé</option>
|
<option value="" disabled>Choisir un employé</option>
|
||||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||||
@@ -39,28 +40,50 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
|
<label class="text-md font-semibold text-neutral-700" for="start-date">Début</label>
|
||||||
<input
|
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||||
id="start-date"
|
<input
|
||||||
v-model="absenceForm.startDate"
|
id="start-date"
|
||||||
type="date"
|
v-model="absenceForm.startDate"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
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"
|
||||||
|
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||||
|
{{ half.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="end-date">Date de fin</label>
|
<label class="text-md font-semibold text-neutral-700" for="end-date">Fin</label>
|
||||||
<input
|
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||||
id="end-date"
|
<input
|
||||||
v-model="absenceForm.endDate"
|
id="end-date"
|
||||||
type="date"
|
v-model="absenceForm.endDate"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
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"
|
||||||
|
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||||
|
{{ half.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="props.showComment !== false">
|
||||||
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="comment"
|
id="comment"
|
||||||
@@ -103,6 +126,8 @@ import { computed, reactive, toRef, watch } from 'vue'
|
|||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import type { Absence } from '~/services/dto/absence'
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
|
import { HALF_DAYS } from '~/services/dto/half-day'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -113,11 +138,16 @@ const props = defineProps<{
|
|||||||
employeeId: number | ''
|
employeeId: number | ''
|
||||||
typeId: number | ''
|
typeId: number | ''
|
||||||
startDate: string
|
startDate: string
|
||||||
|
startHalf: HalfDay
|
||||||
endDate: string
|
endDate: string
|
||||||
|
endHalf: HalfDay
|
||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
editingAbsence: Absence | null
|
editingAbsence: Absence | null
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
|
lockEmployee?: boolean
|
||||||
|
lockDates?: boolean
|
||||||
|
showComment?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -1,45 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="min-w-[900px]">
|
<div class="min-w-[900px]">
|
||||||
<div class="grid" :style="gridStyle">
|
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
|
||||||
<div
|
<div
|
||||||
class="sticky left-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
|
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"
|
||||||
>
|
>
|
||||||
Employés
|
Employés
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
class="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>{{ 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>
|
</div>
|
||||||
|
|
||||||
<template v-for="employee in visibleEmployees" :key="employee.id">
|
<template v-for="employee in visibleEmployees" :key="employee.id">
|
||||||
<div
|
<div
|
||||||
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
|
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"
|
||||||
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
: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) }}
|
{{ formatEmployeeName(employee) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="employee.id + '-' + day.date"
|
: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)"
|
||||||
>
|
>
|
||||||
<button
|
<template v-if="getCellInfo(employee.id, day.date)">
|
||||||
type="button"
|
<button
|
||||||
class="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"
|
type="button"
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
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"
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
:disabled="isHolidayDate(day.date)"
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
@click="handleCellClick(employee, day.date)"
|
:disabled="isHolidayDate(day.date)"
|
||||||
>
|
@click="handleCellClick(employee, day.date)"
|
||||||
<span v-if="getCellCode(employee.id, day.date)">
|
>
|
||||||
{{ getCellCode(employee.id, day.date) }}
|
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
|
||||||
</span>
|
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||||
</button>
|
</span>
|
||||||
|
<template v-else>
|
||||||
|
<span
|
||||||
|
v-if="getCellInfo(employee.id, day.date)?.halfLabel === 'AM'"
|
||||||
|
class="absolute top-0 left-0 flex h-1/2 w-full items-center justify-center text-[10px] font-semibold"
|
||||||
|
>
|
||||||
|
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="absolute bottom-0 left-0 flex h-1/2 w-full items-center justify-center text-[10px] font-semibold"
|
||||||
|
>
|
||||||
|
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<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 bg-white"
|
||||||
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
|
:disabled="isHolidayDate(day.date)"
|
||||||
|
@click="handleCellClick(employee, day.date)"
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +90,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
|
|
||||||
type DayInfo = {
|
type DayInfo = {
|
||||||
date: string
|
date: string
|
||||||
@@ -56,21 +98,91 @@ type DayInfo = {
|
|||||||
weekday: string
|
weekday: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
daysInMonth: DayInfo[]
|
daysInMonth: DayInfo[]
|
||||||
visibleEmployees: Employee[]
|
visibleEmployees: Employee[]
|
||||||
gridStyle: Record<string, string>
|
gridStyle: Record<string, string>
|
||||||
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
|
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
|
||||||
getCellCode: (employeeId: number, date: string) => string
|
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string } | null
|
||||||
formatEmployeeName: (employee: Employee) => string
|
formatEmployeeName: (employee: Employee) => string
|
||||||
isHolidayDate: (date: string) => boolean
|
isHolidayDate: (date: string) => boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'cell-click', employee: Employee, date: string): void
|
(event: 'cell-click', employee: Employee, date: string): void
|
||||||
|
(event: 'reorder', payload: { dragId: number; dropId: number }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleCellClick = (employee: Employee, date: string) => {
|
const handleCellClick = (employee: Employee, date: string) => {
|
||||||
emit('cell-click', employee, date)
|
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>
|
</script>
|
||||||
|
|||||||
18
frontend/components/EmployeeNameFilterInput.vue
Normal file
18
frontend/components/EmployeeNameFilterInput.vue
Normal 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>
|
||||||
25
frontend/components/SiteFilterSelector.vue
Normal file
25
frontend/components/SiteFilterSelector.vue
Normal 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>
|
||||||
188
frontend/components/hours/HoursDayView.vue
Normal file
188
frontend/components/hours/HoursDayView.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<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-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">Absence</span>
|
||||||
|
<span class="pl-2">Jour</span>
|
||||||
|
<span>Nuit</span>
|
||||||
|
<span>Total</span>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<span v-if="isAdmin">Valider</span>
|
||||||
|
<span v-else>Validation RH</span>
|
||||||
|
<input
|
||||||
|
v-if="isAdmin"
|
||||||
|
ref="bulkValidationInput"
|
||||||
|
:checked="isBulkValidationChecked"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
@change="onBulkValidationChange"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
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">{{ employee.site?.name ?? 'Sans site' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].morningFrom"
|
||||||
|
:disabled="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="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="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="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="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="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="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].eveningTo"
|
||||||
|
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
|
||||||
|
<p
|
||||||
|
class="w-full min-w-0 text-sm text-neutral-700 truncate"
|
||||||
|
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
||||||
|
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||||
|
>
|
||||||
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="self-start text-left text-xs font-semibold underline"
|
||||||
|
:class="isRowLocked(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||||
|
:disabled="isRowLocked(employee.id)"
|
||||||
|
@click="onAbsenceClick(employee.id)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
<input
|
||||||
|
v-if="isAdmin"
|
||||||
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span v-else-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</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
|
||||||
|
dayGridCols: string
|
||||||
|
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
|
||||||
|
isValidationPending: (employeeId: number) => boolean
|
||||||
|
canToggleValidation: (employeeId: number) => boolean
|
||||||
|
isBulkValidationChecked: boolean
|
||||||
|
isBulkValidationIndeterminate: boolean
|
||||||
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
|
onToggleValidationBulk: (checked: boolean) => void
|
||||||
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
|
getRowAbsenceLabel: (employeeId: number) => string
|
||||||
|
getPresenceDayValue: (employeeId: number) => string
|
||||||
|
onAbsenceClick: (employeeId: number) => void
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onBulkValidationChange = (event: Event) => {
|
||||||
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isBulkValidationIndeterminate,
|
||||||
|
(isIndeterminate) => {
|
||||||
|
if (!bulkValidationInput.value) return
|
||||||
|
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
213
frontend/components/hours/HoursToolbar.vue
Normal file
213
frontend/components/hours/HoursToolbar.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
|
<input
|
||||||
|
ref="nativeDateInput"
|
||||||
|
:value="pickerValue"
|
||||||
|
:type="viewMode === 'week' ? 'week' : 'date'"
|
||||||
|
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] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
aria-label="Période précédente"
|
||||||
|
@click="emit('shift-date', -1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
@click="openDatePicker"
|
||||||
|
>
|
||||||
|
{{ formattedSelectedDate }}
|
||||||
|
</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] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
aria-label="Période suivante"
|
||||||
|
@click="emit('shift-date', 1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 { 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 nativeDateInput = ref<HTMLInputElement | null>(null)
|
||||||
|
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 openDatePicker = () => {
|
||||||
|
const input = nativeDateInput.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
|
||||||
|
|
||||||
|
if (viewMode.value === 'week') {
|
||||||
|
const ymd = weekInputValueToYmd(value)
|
||||||
|
if (!ymd) return
|
||||||
|
selectedDate.value = ymd
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDate.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
98
frontend/components/hours/HoursWeekView.vue
Normal file
98
frontend/components/hours/HoursWeekView.vue
Normal 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>
|
||||||
12
frontend/components/hours/types.ts
Normal file
12
frontend/components/hours/types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type HourRow = {
|
||||||
|
workHourId: number | null
|
||||||
|
morningFrom: string
|
||||||
|
morningTo: string
|
||||||
|
afternoonFrom: string
|
||||||
|
afternoonTo: string
|
||||||
|
eveningFrom: string
|
||||||
|
eveningTo: string
|
||||||
|
isPresentMorning: boolean
|
||||||
|
isPresentAfternoon: boolean
|
||||||
|
isValid: boolean
|
||||||
|
}
|
||||||
151
frontend/components/ui/TimeSelect.vue
Normal file
151
frontend/components/ui/TimeSelect.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="root" class="relative w-full">
|
||||||
|
<button
|
||||||
|
ref="trigger"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
{{ displayValue }}
|
||||||
|
<Icon name="mdi:chevron-down" class="self-center"/>
|
||||||
|
</button>
|
||||||
|
</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 timeSlots"
|
||||||
|
: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>
|
||||||
|
</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 isOpen = ref(false)
|
||||||
|
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 displayValue = computed(() => props.modelValue || props.placeholder)
|
||||||
|
|
||||||
|
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 selectValue = (value: string) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
window.removeEventListener('resize', onWindowChange)
|
||||||
|
window.removeEventListener('scroll', onWindowChange, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -4,9 +4,14 @@ import { useAuthStore } from '~/stores/auth'
|
|||||||
|
|
||||||
export type AnyObject = Record<string, unknown>
|
export type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
|
export type BlobResponse = {
|
||||||
|
data: Blob
|
||||||
|
headers: Headers
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiClient = {
|
export type ApiClient = {
|
||||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<Blob>
|
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<BlobResponse>
|
||||||
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
@@ -16,6 +21,7 @@ export type ApiClient = {
|
|||||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||||
FetchOptions<ResponseType> & {
|
FetchOptions<ResponseType> & {
|
||||||
toast?: boolean
|
toast?: boolean
|
||||||
|
toastOn401?: boolean
|
||||||
toastTitle?: string
|
toastTitle?: string
|
||||||
toastErrorMessage?: string
|
toastErrorMessage?: string
|
||||||
toastSuccessMessage?: string
|
toastSuccessMessage?: string
|
||||||
@@ -97,9 +103,31 @@ export const useApi = (): ApiClient => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onResponseError({ response, error, options }) {
|
async onResponseError({ response, error, options }) {
|
||||||
|
const apiOptions = options as ApiFetchOptions<'json'>
|
||||||
if (response?.status === 401) {
|
if (response?.status === 401) {
|
||||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||||
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) {
|
if (!isHandlingUnauthorized) {
|
||||||
isHandlingUnauthorized = true
|
isHandlingUnauthorized = true
|
||||||
auth.clearSession()
|
auth.clearSession()
|
||||||
@@ -110,10 +138,10 @@ export const useApi = (): ApiClient => {
|
|||||||
isHandlingUnauthorized = false
|
isHandlingUnauthorized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiOptions = options as ApiFetchOptions<'json'>
|
|
||||||
if (apiOptions?.toast === false) {
|
if (apiOptions?.toast === false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,7 +193,9 @@ export const useApi = (): ApiClient => {
|
|||||||
return request<T>('GET', url, { ...options, query })
|
return request<T>('GET', url, { ...options, query })
|
||||||
},
|
},
|
||||||
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
||||||
return client<Blob>(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
return client
|
||||||
|
.raw(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
||||||
|
.then((res) => ({ data: res._data as Blob, headers: res.headers }))
|
||||||
},
|
},
|
||||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
return request<T>('POST', url, { ...options, body })
|
return request<T>('POST', url, { ...options, body })
|
||||||
|
|||||||
803
frontend/composables/useHoursPage.ts
Normal file
803
frontend/composables/useHoursPage.ts
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
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 {
|
||||||
|
bulkUpsertWorkHours,
|
||||||
|
getWorkHourDayContext,
|
||||||
|
getWeeklyWorkHourSummary,
|
||||||
|
listWorkHoursByDate,
|
||||||
|
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 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 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 dayGridCols = computed(() => {
|
||||||
|
const metricCol = '0.4fr'
|
||||||
|
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
||||||
|
})
|
||||||
|
|
||||||
|
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 canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
|
||||||
|
|
||||||
|
const validatableEmployeeIds = computed(() => {
|
||||||
|
return employees.value
|
||||||
|
.map((employee) => employee.id)
|
||||||
|
.filter((employeeId) => canToggleValidation(employeeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkValidationChecked = computed(() => {
|
||||||
|
const ids = validatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkValidationIndeterminate = computed(() => {
|
||||||
|
const ids = validatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
|
||||||
|
return checkedCount > 0 && checkedCount < ids.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const dayContextByEmployeeId = computed(() => {
|
||||||
|
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
||||||
|
for (const row of dayContext.value?.rows ?? []) {
|
||||||
|
map.set(row.employeeId, row)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
||||||
|
const 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 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,
|
||||||
|
isValid: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||||
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||||
|
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? 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?.absenceLabel) return ''
|
||||||
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||||
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||||
|
return `${dayRow.absenceLabel} (${halfLabel})`
|
||||||
|
}
|
||||||
|
return `${dayRow.absenceLabel} (journée)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPresenceDayValue = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
|
||||||
|
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
|
||||||
|
const total = Math.min(1, basePresence + creditedPresence)
|
||||||
|
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!dayRow) return false
|
||||||
|
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEveningLockedByAbsence = (employeeId: number) => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!dayRow) return false
|
||||||
|
return dayRow.absentAfternoon
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number) => {
|
||||||
|
const 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,
|
||||||
|
isValid: workHour?.isValid ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.value = nextRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAbsenceTypes = async () => {
|
||||||
|
absenceTypes.value = await listAbsenceTypes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAbsences = async () => {
|
||||||
|
absences.value = await listAbsences({
|
||||||
|
from: selectedDate.value,
|
||||||
|
to: selectedDate.value,
|
||||||
|
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAbsenceDrawer = (employeeId: number) => {
|
||||||
|
const existing = absences.value.find((absence) => {
|
||||||
|
if (absence.employee?.id !== employeeId) return false
|
||||||
|
const start = absence.startDate.slice(0, 10)
|
||||||
|
const end = absence.endDate.slice(0, 10)
|
||||||
|
return selectedDate.value >= start && selectedDate.value <= end
|
||||||
|
}) ?? null
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
editingAbsence.value = existing
|
||||||
|
absenceForm.value = {
|
||||||
|
employeeId,
|
||||||
|
typeId: existing.type?.id ?? '',
|
||||||
|
startDate: existing.startDate.slice(0, 10),
|
||||||
|
startHalf: existing.startHalf ?? 'AM',
|
||||||
|
endDate: existing.endDate.slice(0, 10),
|
||||||
|
endHalf: existing.endHalf ?? 'PM',
|
||||||
|
comment: existing.comment ?? ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editingAbsence.value = null
|
||||||
|
absenceForm.value = {
|
||||||
|
employeeId,
|
||||||
|
typeId: '',
|
||||||
|
startDate: selectedDate.value,
|
||||||
|
startHalf: 'AM',
|
||||||
|
endDate: selectedDate.value,
|
||||||
|
endHalf: 'PM',
|
||||||
|
comment: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAbsenceDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
if (startHalf === 'AM' && endHalf === 'AM') {
|
||||||
|
row.morningFrom = ''
|
||||||
|
row.morningTo = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startHalf === 'PM' && endHalf === 'PM') {
|
||||||
|
row.afternoonFrom = ''
|
||||||
|
row.afternoonTo = ''
|
||||||
|
row.eveningFrom = ''
|
||||||
|
row.eveningTo = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row.morningFrom = ''
|
||||||
|
row.morningTo = ''
|
||||||
|
row.afternoonFrom = ''
|
||||||
|
row.afternoonTo = ''
|
||||||
|
row.eveningFrom = ''
|
||||||
|
row.eveningTo = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAfterAbsenceChange = async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklySummary.value = null
|
||||||
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAbsence = async () => {
|
||||||
|
const form = absenceForm.value
|
||||||
|
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
||||||
|
|
||||||
|
isAbsenceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingAbsence.value) {
|
||||||
|
await updateAbsence({
|
||||||
|
id: editingAbsence.value.id,
|
||||||
|
employeeId: Number(form.employeeId),
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: editingAbsence.value.comment ?? ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createAbsence({
|
||||||
|
employeeId: Number(form.employeeId),
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
|
||||||
|
closeAbsenceDrawer()
|
||||||
|
await refreshAfterAbsenceChange()
|
||||||
|
} finally {
|
||||||
|
isAbsenceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAbsenceFromDrawer = async () => {
|
||||||
|
if (!editingAbsence.value || isAbsenceSubmitting.value) return
|
||||||
|
|
||||||
|
isAbsenceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await deleteAbsence(editingAbsence.value.id)
|
||||||
|
closeAbsenceDrawer()
|
||||||
|
await refreshAfterAbsenceChange()
|
||||||
|
} finally {
|
||||||
|
isAbsenceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleValidation = async (
|
||||||
|
employeeId: number,
|
||||||
|
checked: boolean,
|
||||||
|
options: { toast?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row?.workHourId || isValidationPending(employeeId)) return
|
||||||
|
|
||||||
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
||||||
|
try {
|
||||||
|
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
|
||||||
|
row.isValid = checked
|
||||||
|
} finally {
|
||||||
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleValidationBulk = async (checked: boolean) => {
|
||||||
|
const employeeIds = validatableEmployeeIds.value
|
||||||
|
if (employeeIds.length === 0) return
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const employeeId of employeeIds) {
|
||||||
|
if (isValidationPending(employeeId)) continue
|
||||||
|
try {
|
||||||
|
await toggleValidation(employeeId, checked, { toast: false })
|
||||||
|
successCount += 1
|
||||||
|
} catch {
|
||||||
|
failedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedCount === 0) {
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: checked
|
||||||
|
? `${successCount} ligne(s) validée(s).`
|
||||||
|
: `${successCount} validation(s) retirée(s).`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Impossible de mettre à jour les validations.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
const 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 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 refreshByDate()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const entries = employees.value.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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshByDate()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
rows,
|
||||||
|
absenceTypes,
|
||||||
|
absenceForm,
|
||||||
|
isAbsenceDrawerOpen,
|
||||||
|
isAbsenceSubmitting,
|
||||||
|
editingAbsence,
|
||||||
|
weeklySummary,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
weekShortcutButtonClass,
|
||||||
|
getWeekShortcutLabel,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
setThisWeek,
|
||||||
|
setPreviousWeek,
|
||||||
|
setNextWeek,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isTimeTracking,
|
||||||
|
isPresenceTracking,
|
||||||
|
isRowLocked,
|
||||||
|
isHalfLockedByAbsence,
|
||||||
|
isEveningLockedByAbsence,
|
||||||
|
isValidationPending,
|
||||||
|
canToggleValidation,
|
||||||
|
isBulkValidationChecked,
|
||||||
|
isBulkValidationIndeterminate,
|
||||||
|
toggleValidation,
|
||||||
|
toggleValidationBulk,
|
||||||
|
getRowMetrics,
|
||||||
|
getRowAbsenceLabel,
|
||||||
|
getPresenceDayValue,
|
||||||
|
openAbsenceDrawer,
|
||||||
|
submitAbsence,
|
||||||
|
deleteAbsenceFromDrawer,
|
||||||
|
closeAbsenceDrawer,
|
||||||
|
formatMinutes,
|
||||||
|
handleSave
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,17 @@ export const usePdfPrinter = () => {
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const printPdf = async (url: string): Promise<void> => {
|
const printPdf = async (url: string): Promise<void> => {
|
||||||
const blob = await api.getBlob(url);
|
const res = await api.getBlob(url);
|
||||||
|
const disposition = res.headers.get('content-disposition') || '';
|
||||||
|
const match = disposition.match(/filename="(.+?)"/i);
|
||||||
|
const filename = match?.[1] ?? 'document.pdf';
|
||||||
|
|
||||||
const pdfBlob = blob.type === 'application/pdf'
|
const pdfBlob = res.data.type === 'application/pdf'
|
||||||
? blob
|
? res.data
|
||||||
: new Blob([blob], { type: 'application/pdf' });
|
: new Blob([res.data], { type: 'application/pdf' });
|
||||||
|
|
||||||
const blobUrl = URL.createObjectURL(pdfBlob);
|
const blobUrl = URL.createObjectURL(pdfBlob);
|
||||||
|
|
||||||
const filename = `test.pdf`;
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = blobUrl;
|
a.href = blobUrl;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
"create": "Impossible de créer l'absence.",
|
"create": "Impossible de créer l'absence.",
|
||||||
"update": "Impossible de mettre à jour l'absence.",
|
"update": "Impossible de mettre à jour l'absence.",
|
||||||
"delete": "Impossible de supprimer 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": {
|
"success": {
|
||||||
@@ -57,6 +62,11 @@
|
|||||||
"create": "Absence créée.",
|
"create": "Absence créée.",
|
||||||
"update": "Absence mise à jour.",
|
"update": "Absence mise à jour.",
|
||||||
"delete": "Absence supprimée."
|
"delete": "Absence supprimée."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"create": "Utilisateur créé.",
|
||||||
|
"update": "Utilisateur mis à jour.",
|
||||||
|
"delete": "Utilisateur supprimé."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,41 +6,59 @@
|
|||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
|
<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
|
<NuxtLink
|
||||||
to="/"
|
to="/hours"
|
||||||
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"
|
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-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Tableau de bord
|
Heures
|
||||||
</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
|
|
||||||
</NuxtLink>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 items-center p-4">
|
<div class="flex flex-col gap-2 items-center p-4">
|
||||||
@@ -65,6 +83,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
|
|||||||
12
frontend/middleware/admin.ts
Normal file
12
frontend/middleware/admin.ts
Normal 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('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,13 +3,16 @@ export default defineNuxtConfig({
|
|||||||
devtools: {enabled: false},
|
devtools: {enabled: false},
|
||||||
ssr: false,
|
ssr: false,
|
||||||
app: {
|
app: {
|
||||||
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
baseURL: process.env.NODE_ENV === 'production'
|
||||||
|
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||||
|
: '/'
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'nuxt-toast',
|
'nuxt-toast',
|
||||||
'@nuxtjs/i18n'
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/icon'
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
|
|||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
@@ -32,6 +33,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1220,6 +1234,47 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"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": {
|
"node_modules/@intlify/bundle-utils": {
|
||||||
"version": "11.0.3",
|
"version": "11.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
||||||
@@ -2372,6 +2427,28 @@
|
|||||||
"devtools-wizard": "cli.mjs"
|
"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": {
|
"node_modules/@nuxt/kit": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
|
|||||||
@@ -19,10 +19,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="grid grid-cols-[120px_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">Code</span>
|
||||||
<span class="text-left">Libellé</span>
|
<span class="text-left">Libellé</span>
|
||||||
<span class="text-left">Couleur</span>
|
<span class="text-left">Couleur</span>
|
||||||
|
<span class="text-left">Compte en heures</span>
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="type in absenceTypes"
|
v-for="type in absenceTypes"
|
||||||
:key="type.id"
|
: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="font-semibold text-left">{{ type.code }}</span>
|
||||||
<span class="text-left">{{ type.label }}</span>
|
<span class="text-left">{{ type.label }}</span>
|
||||||
@@ -43,6 +44,14 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
|
||||||
|
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
|
||||||
|
>
|
||||||
|
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -94,6 +103,31 @@
|
|||||||
Le libellé est obligatoire.
|
Le libellé est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Compté comme travaillé
|
||||||
|
</label>
|
||||||
|
<div class="mt-2 flex items-center gap-6">
|
||||||
|
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
||||||
|
<input
|
||||||
|
v-model="form.countAsWorkedHours"
|
||||||
|
type="radio"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:value="true"
|
||||||
|
/>
|
||||||
|
Oui
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
||||||
|
<input
|
||||||
|
v-model="form.countAsWorkedHours"
|
||||||
|
type="radio"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:value="false"
|
||||||
|
/>
|
||||||
|
Non
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
<label class="text-md font-semibold text-neutral-700" for="color">
|
||||||
Couleur <span class="text-red-600">*</span>
|
Couleur <span class="text-red-600">*</span>
|
||||||
@@ -150,7 +184,8 @@ const drawerTitle = computed(() =>
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
color: '#222783'
|
color: '#222783',
|
||||||
|
countAsWorkedHours: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -171,7 +206,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
|
|||||||
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
||||||
|
|
||||||
const baseInputClass =
|
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(() => {
|
const codeFieldClass = computed(() => {
|
||||||
if (showCodeError.value) {
|
if (showCodeError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
@@ -214,6 +249,7 @@ const resetForm = () => {
|
|||||||
form.code = ''
|
form.code = ''
|
||||||
form.label = ''
|
form.label = ''
|
||||||
form.color = '#222783'
|
form.color = '#222783'
|
||||||
|
form.countAsWorkedHours = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -227,6 +263,7 @@ const openEdit = (type: AbsenceType) => {
|
|||||||
form.code = type.code
|
form.code = type.code
|
||||||
form.label = type.label
|
form.label = type.label
|
||||||
form.color = type.color
|
form.color = type.color
|
||||||
|
form.countAsWorkedHours = type.countAsWorkedHours
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +286,15 @@ const handleSubmit = async () => {
|
|||||||
await updateAbsenceType(editingType.value.id, {
|
await updateAbsenceType(editingType.value.id, {
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color
|
color: form.color,
|
||||||
|
countAsWorkedHours: form.countAsWorkedHours
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createAbsenceType({
|
await createAbsenceType({
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color
|
color: form.color,
|
||||||
|
countAsWorkedHours: form.countAsWorkedHours
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between py-6">
|
<div class="flex flex-col gap-3 py-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
<div class="flex items-center gap-4">
|
||||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
||||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
</div>
|
||||||
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
<div class="flex gap-4">
|
||||||
<input
|
<button
|
||||||
:id="`site-${site.id}`"
|
type="button"
|
||||||
v-model="selectedSiteIds"
|
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
:value="site.id"
|
@click="openCreateFromToday"
|
||||||
type="checkbox"
|
>
|
||||||
class="h-4 w-4"
|
Ajouter une absence
|
||||||
/>
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openPrint"
|
||||||
|
>
|
||||||
|
Imprimer
|
||||||
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4">
|
<div class="flex justify-between">
|
||||||
<button
|
<div class="flex items-center gap-4">
|
||||||
type="button"
|
<div class="w-80">
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
@click="openCreateFromToday"
|
</div>
|
||||||
>
|
<select
|
||||||
Ajouter une absence
|
v-model="selectedMonth"
|
||||||
</button>
|
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
<button
|
>
|
||||||
type="button"
|
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
{{ month.label }}
|
||||||
@click="openPrint"
|
</option>
|
||||||
>
|
</select>
|
||||||
Imprimer
|
<select
|
||||||
</button>
|
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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CalendarGrid
|
<div class="flex-1 min-h-0">
|
||||||
:days-in-month="daysInMonth"
|
<CalendarGrid
|
||||||
:visible-employees="visibleEmployees"
|
:days-in-month="daysInMonth"
|
||||||
:grid-style="gridStyle"
|
:visible-employees="visibleEmployees"
|
||||||
:get-cell-style="getCellStyle"
|
:grid-style="gridStyle"
|
||||||
:get-cell-code="getCellCode"
|
:get-cell-style="getCellStyle"
|
||||||
:format-employee-name="formatEmployeeName"
|
:get-cell-info="getCellInfo"
|
||||||
:is-holiday-date="isHolidayDate"
|
:format-employee-name="formatEmployeeName"
|
||||||
@cell-click="openCreate"
|
:is-holiday-date="isHolidayDate"
|
||||||
/>
|
@cell-click="openCreate"
|
||||||
|
@reorder="handleReorder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AbsenceFormDrawer
|
<AbsenceFormDrawer
|
||||||
v-model="isDrawerOpen"
|
v-model="isDrawerOpen"
|
||||||
@@ -90,15 +97,21 @@
|
|||||||
import type {Employee} from '~/services/dto/employee'
|
import type {Employee} from '~/services/dto/employee'
|
||||||
import type {AbsenceType} from '~/services/dto/absence-type'
|
import type {AbsenceType} from '~/services/dto/absence-type'
|
||||||
import type {Absence} from '~/services/dto/absence'
|
import type {Absence} from '~/services/dto/absence'
|
||||||
import {listEmployees} from '~/services/employees'
|
import type {HalfDay} from '~/services/dto/half-day'
|
||||||
|
import {HALF_DAYS} from '~/services/dto/half-day'
|
||||||
|
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
||||||
import {listAbsenceTypes} from '~/services/absence-types'
|
import {listAbsenceTypes} from '~/services/absence-types'
|
||||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
||||||
import {listPublicHolidays} from '~/services/public-holidays'
|
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 CalendarGrid from '~/components/CalendarGrid.vue'
|
||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||||
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
|
// Données principales affichées dans la grille.
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
const sites = computed(() => {
|
||||||
const siteMap = new Map<number, { id: number; name: string; color: string }>()
|
const siteMap = new Map<number, { id: number; name: string; color: string }>()
|
||||||
@@ -107,9 +120,15 @@ const sites = computed(() => {
|
|||||||
siteMap.set(employee.site.id, employee.site)
|
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).
|
||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
|
|
||||||
@@ -117,37 +136,40 @@ watch(sites, (next) => {
|
|||||||
if (sitesInitialized.value || next.length === 0) return
|
if (sitesInitialized.value || next.length === 0) return
|
||||||
selectedSiteIds.value = next.map((site) => site.id)
|
selectedSiteIds.value = next.map((site) => site.id)
|
||||||
sitesInitialized.value = true
|
sitesInitialized.value = true
|
||||||
}, { immediate: true })
|
}, {immediate: true})
|
||||||
|
|
||||||
|
// Tri stable: site -> nom -> prénom.
|
||||||
const sortedEmployees = computed(() => {
|
const sortedEmployees = computed(() => {
|
||||||
return [...employees.value].sort((employeeA, employeeB) => {
|
return sortEmployeesBySiteAndOrder(employees.value)
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Employés visibles selon le filtre de sites.
|
||||||
|
const employeeFilter = ref('')
|
||||||
|
|
||||||
const visibleEmployees = computed(() => {
|
const visibleEmployees = computed(() => {
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
return sortedEmployees.value.filter((employee) => {
|
return sortedEmployees.value.filter((employee) => {
|
||||||
return employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
||||||
|
if (!siteOk) return false
|
||||||
|
if (!filter) return true
|
||||||
|
const first = employee.firstName?.toLowerCase() ?? ''
|
||||||
|
const last = employee.lastName?.toLowerCase() ?? ''
|
||||||
|
return first.includes(filter) || last.includes(filter)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
// Données de référence et absences du mois affiché.
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
const absences = ref<Absence[]>([])
|
const absences = ref<Absence[]>([])
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
const publicHolidays = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
// États UI.
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const editingAbsence = ref<Absence | null>(null)
|
const editingAbsence = ref<Absence | null>(null)
|
||||||
const isPrintOpen = ref(false)
|
const isPrintOpen = ref(false)
|
||||||
|
|
||||||
|
// Sélecteurs de période.
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const selectedMonth = ref(now.getMonth())
|
const selectedMonth = ref(now.getMonth())
|
||||||
const selectedYear = ref(now.getFullYear())
|
const selectedYear = ref(now.getFullYear())
|
||||||
@@ -170,43 +192,53 @@ const months = [
|
|||||||
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
||||||
|
|
||||||
|
|
||||||
|
// Infos de calendrier calculées.
|
||||||
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
||||||
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
|
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
|
||||||
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
|
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
|
||||||
|
|
||||||
|
// Largeur fixe de la colonne employés + une colonne par jour.
|
||||||
const gridStyle = computed(() => ({
|
const gridStyle = computed(() => ({
|
||||||
gridTemplateColumns: `160px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
gridTemplateColumns: `160px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Formulaire d'absence (AM/PM par défaut = journée complète).
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
employeeId: '' as number | '',
|
employeeId: '' as number | '',
|
||||||
typeId: '' as number | '',
|
typeId: '' as number | '',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
|
startHalf: 'AM' as HalfDay,
|
||||||
endDate: '',
|
endDate: '',
|
||||||
|
endHalf: 'PM' as HalfDay,
|
||||||
comment: ''
|
comment: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Formulaire d'impression (intervalle + sites).
|
||||||
const printForm = reactive({
|
const printForm = reactive({
|
||||||
from: '',
|
from: '',
|
||||||
to: '',
|
to: '',
|
||||||
siteIds: [] as number[]
|
siteIds: [] as number[]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Remet le formulaire à zéro.
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.employeeId = ''
|
form.employeeId = ''
|
||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
form.startDate = ''
|
form.startDate = ''
|
||||||
|
form.startHalf = 'AM'
|
||||||
form.endDate = ''
|
form.endDate = ''
|
||||||
|
form.endHalf = 'PM'
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ferme le drawer et nettoie l'état.
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ouvre l'impression avec la période du mois courant.
|
||||||
const openPrint = () => {
|
const openPrint = () => {
|
||||||
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
||||||
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
||||||
@@ -220,12 +252,36 @@ const closePrint = () => {
|
|||||||
isPrintOpen.value = false
|
isPrintOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseYmd = (value: string) => {
|
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
|
||||||
const [year, month, day] = value.split('-').map(Number)
|
const getHalfForDate = (
|
||||||
if (!year || !month || !day) return null
|
startDate: string,
|
||||||
return new Date(year, month - 1, day)
|
endDate: string,
|
||||||
|
startHalf: HalfDay,
|
||||||
|
endHalf: HalfDay,
|
||||||
|
date: string
|
||||||
|
) => {
|
||||||
|
if (startDate === endDate) {
|
||||||
|
return startHalf === endHalf ? startHalf : null
|
||||||
|
}
|
||||||
|
if (date === startDate && startHalf === 'PM') return 'PM'
|
||||||
|
if (date === endDate && endHalf === 'AM') return 'AM'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renvoie les segments occupés pour une date donnée (AM/PM).
|
||||||
|
const getSegmentsForDate = (
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
startHalf: HalfDay,
|
||||||
|
endHalf: HalfDay,
|
||||||
|
date: string
|
||||||
|
) => {
|
||||||
|
const half = getHalfForDate(startDate, endDate, startHalf, endHalf, date)
|
||||||
|
if (!half) return HALF_DAYS.map((item) => item.value) as HalfDay[]
|
||||||
|
return [half] as HalfDay[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajoute des mois tout en gardant un jour valide.
|
||||||
const addMonths = (date: Date, months: number) => {
|
const addMonths = (date: Date, months: number) => {
|
||||||
const next = new Date(date.getFullYear(), date.getMonth() + months, date.getDate())
|
const next = new Date(date.getFullYear(), date.getMonth() + months, date.getDate())
|
||||||
if (next.getMonth() !== (date.getMonth() + months) % 12) {
|
if (next.getMonth() !== (date.getMonth() + months) % 12) {
|
||||||
@@ -234,6 +290,7 @@ const addMonths = (date: Date, months: number) => {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limite l'intervalle d'impression à 2 mois max.
|
||||||
const enforcePrintRange = () => {
|
const enforcePrintRange = () => {
|
||||||
if (!printForm.from) return
|
if (!printForm.from) return
|
||||||
const start = parseYmd(printForm.from)
|
const start = parseYmd(printForm.from)
|
||||||
@@ -266,6 +323,7 @@ const enforcePrintRange = () => {
|
|||||||
watch(() => printForm.from, enforcePrintRange)
|
watch(() => printForm.from, enforcePrintRange)
|
||||||
watch(() => printForm.to, enforcePrintRange)
|
watch(() => printForm.to, enforcePrintRange)
|
||||||
|
|
||||||
|
// Chargements API.
|
||||||
const loadEmployees = async () => {
|
const loadEmployees = async () => {
|
||||||
employees.value = await listEmployees()
|
employees.value = await listEmployees()
|
||||||
}
|
}
|
||||||
@@ -302,15 +360,17 @@ watch(selectedYear, async () => {
|
|||||||
|
|
||||||
// Indexation des absences par cellule pour eviter un find() a chaque case.
|
// Indexation des absences par cellule pour eviter un find() a chaque case.
|
||||||
const cellAbsenceMap = computed(() => {
|
const cellAbsenceMap = computed(() => {
|
||||||
const map = new Map<string, { id: number; code: string; color: string; textColor?: string }>()
|
const map = new Map<string, { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string }>()
|
||||||
const monthStart = monthStartDate.value
|
const monthStart = monthStartDate.value
|
||||||
const monthEnd = monthEndDate.value
|
const monthEnd = monthEndDate.value
|
||||||
|
|
||||||
for (const absence of absences.value) {
|
for (const absence of absences.value) {
|
||||||
const employeeId = absence.employee?.id
|
const employeeId = absence.employee?.id
|
||||||
if (!employeeId) continue
|
if (!employeeId) continue
|
||||||
const start = parseYmd(normalizeDate(absence.startDate))
|
const startDate = normalizeDate(absence.startDate)
|
||||||
const end = parseYmd(normalizeDate(absence.endDate))
|
const endDate = normalizeDate(absence.endDate)
|
||||||
|
const start = parseYmd(startDate)
|
||||||
|
const end = parseYmd(endDate)
|
||||||
if (!start || !end) continue
|
if (!start || !end) continue
|
||||||
|
|
||||||
const rangeStart = start < monthStart ? monthStart : start
|
const rangeStart = start < monthStart ? monthStart : start
|
||||||
@@ -322,11 +382,20 @@ const cellAbsenceMap = computed(() => {
|
|||||||
currentDate <= rangeEnd;
|
currentDate <= rangeEnd;
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
) {
|
) {
|
||||||
const key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
|
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
|
||||||
|
const key = `${employeeId}-${dateKey}`
|
||||||
|
const halfLabel = getHalfForDate(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
absence.startHalf ?? 'AM',
|
||||||
|
absence.endHalf ?? 'PM',
|
||||||
|
dateKey
|
||||||
|
) ?? undefined
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
id: absence.id,
|
id: absence.id,
|
||||||
code: absence.type?.code ?? '',
|
code: absence.type?.code ?? '',
|
||||||
color: absence.type?.color ?? '#222783'
|
color: absence.type?.color ?? '#222783',
|
||||||
|
halfLabel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,15 +403,17 @@ const cellAbsenceMap = computed(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Jours fériés (interdit pour la création).
|
||||||
const isHolidayDate = (date: string) => {
|
const isHolidayDate = (date: string) => {
|
||||||
return Boolean(publicHolidays.value[date])
|
return Boolean(publicHolidays.value[date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renvoie l'absence effective pour une cellule (ou un "Férié").
|
||||||
const getCellAbsence = (employeeId: number, date: string) => {
|
const getCellAbsence = (employeeId: number, date: string) => {
|
||||||
if (isHolidayDate(date)) {
|
if (isHolidayDate(date)) {
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
code: 'F',
|
code: 'Férié',
|
||||||
color: '#b3e5fc',
|
color: '#b3e5fc',
|
||||||
textColor: '#0f172a'
|
textColor: '#0f172a'
|
||||||
}
|
}
|
||||||
@@ -352,20 +423,35 @@ const getCellAbsence = (employeeId: number, date: string) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Style de cellule (plein ou demi-journée).
|
||||||
const getCellStyle = (employeeId: number, date: string) => {
|
const getCellStyle = (employeeId: number, date: string) => {
|
||||||
const absence = getCellAbsence(employeeId, date)
|
const absence = getCellAbsence(employeeId, date)
|
||||||
if (!absence) return undefined
|
if (!absence) return undefined
|
||||||
|
|
||||||
|
if (absence.halfLabel) {
|
||||||
|
const color = absence.color
|
||||||
|
const textColor = absence.textColor ?? '#FFF'
|
||||||
|
const backgroundImage = absence.halfLabel === 'AM'
|
||||||
|
? `linear-gradient(180deg, ${color} 0 50%, transparent 50% 100%)`
|
||||||
|
: `linear-gradient(180deg, transparent 0 50%, ${color} 50% 100%)`
|
||||||
|
return {
|
||||||
|
backgroundImage,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: absence.color,
|
backgroundColor: absence.color,
|
||||||
color: absence.textColor ?? '#fff'
|
color: absence.textColor ?? '#fff'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCellCode = (employeeId: number, date: string) => {
|
const getCellInfo = (employeeId: number, date: string) => {
|
||||||
return getCellAbsence(employeeId, date)?.code ?? ''
|
return getCellAbsence(employeeId, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ouverture du drawer depuis une cellule.
|
||||||
const openCreate = (employee: Employee, date: string) => {
|
const openCreate = (employee: Employee, date: string) => {
|
||||||
if (isHolidayDate(date)) {
|
if (isHolidayDate(date)) {
|
||||||
window.alert("Impossible de creer une absence un jour ferie.")
|
window.alert("Impossible de creer une absence un jour ferie.")
|
||||||
@@ -384,12 +470,16 @@ const openCreate = (employee: Employee, date: string) => {
|
|||||||
form.typeId = existing.type.id
|
form.typeId = existing.type.id
|
||||||
form.startDate = normalizeDate(existing.startDate)
|
form.startDate = normalizeDate(existing.startDate)
|
||||||
form.endDate = normalizeDate(existing.endDate)
|
form.endDate = normalizeDate(existing.endDate)
|
||||||
|
form.startHalf = existing.startHalf ?? 'AM'
|
||||||
|
form.endHalf = existing.endHalf ?? 'PM'
|
||||||
form.comment = existing.comment ?? ''
|
form.comment = existing.comment ?? ''
|
||||||
} else {
|
} else {
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
form.employeeId = employee.id
|
form.employeeId = employee.id
|
||||||
form.startDate = date
|
form.startDate = date
|
||||||
form.endDate = date
|
form.endDate = date
|
||||||
|
form.startHalf = 'AM'
|
||||||
|
form.endHalf = 'PM'
|
||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
}
|
}
|
||||||
@@ -397,6 +487,7 @@ const openCreate = (employee: Employee, date: string) => {
|
|||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ouverture du drawer depuis le bouton "Ajouter".
|
||||||
const openCreateFromToday = () => {
|
const openCreateFromToday = () => {
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
form.employeeId = ''
|
form.employeeId = ''
|
||||||
@@ -409,10 +500,13 @@ const openCreateFromToday = () => {
|
|||||||
}
|
}
|
||||||
form.startDate = today
|
form.startDate = today
|
||||||
form.endDate = today
|
form.endDate = today
|
||||||
|
form.startHalf = 'AM'
|
||||||
|
form.endHalf = 'PM'
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifie la présence d'un férié dans l'intervalle.
|
||||||
const hasHolidayInRange = (startDate: string, endDate: string) => {
|
const hasHolidayInRange = (startDate: string, endDate: string) => {
|
||||||
const start = parseYmd(startDate)
|
const start = parseYmd(startDate)
|
||||||
const end = parseYmd(endDate)
|
const end = parseYmd(endDate)
|
||||||
@@ -430,6 +524,7 @@ const hasHolidayInRange = (startDate: string, endDate: string) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soumission du formulaire: validations + chevauchement + save.
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
@@ -437,6 +532,14 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
const start = normalizeDate(form.startDate)
|
const start = normalizeDate(form.startDate)
|
||||||
const end = normalizeDate(form.endDate)
|
const end = normalizeDate(form.endDate)
|
||||||
|
if (start > end) {
|
||||||
|
window.alert("La date de fin ne peut pas etre avant la date de debut.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (start === end && form.startHalf === 'PM' && form.endHalf === 'AM') {
|
||||||
|
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (hasHolidayInRange(start, end)) {
|
if (hasHolidayInRange(start, end)) {
|
||||||
window.alert("Impossible de creer une absence sur un jour ferie.")
|
window.alert("Impossible de creer une absence sur un jour ferie.")
|
||||||
return
|
return
|
||||||
@@ -446,7 +549,40 @@ const handleSubmit = async () => {
|
|||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
||||||
const aStart = normalizeDate(absence.startDate)
|
const aStart = normalizeDate(absence.startDate)
|
||||||
const aEnd = normalizeDate(absence.endDate)
|
const aEnd = normalizeDate(absence.endDate)
|
||||||
return start <= aEnd && end >= aStart
|
if (start > aEnd || end < aStart) return false
|
||||||
|
|
||||||
|
const overlapStart = start > aStart ? start : aStart
|
||||||
|
const overlapEnd = end < aEnd ? end : aEnd
|
||||||
|
const overlapStartDate = parseYmd(overlapStart)
|
||||||
|
const overlapEndDate = parseYmd(overlapEnd)
|
||||||
|
if (!overlapStartDate || !overlapEndDate) return false
|
||||||
|
|
||||||
|
for (
|
||||||
|
let currentDate = new Date(overlapStartDate.getTime());
|
||||||
|
currentDate <= overlapEndDate;
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
|
) {
|
||||||
|
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
|
||||||
|
const existingSegments = getSegmentsForDate(
|
||||||
|
aStart,
|
||||||
|
aEnd,
|
||||||
|
absence.startHalf ?? 'AM',
|
||||||
|
absence.endHalf ?? 'PM',
|
||||||
|
dateKey
|
||||||
|
)
|
||||||
|
const newSegments = getSegmentsForDate(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
form.startHalf,
|
||||||
|
form.endHalf,
|
||||||
|
dateKey
|
||||||
|
)
|
||||||
|
if (existingSegments.some((segment) => newSegments.includes(segment))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (overlaps.length > 0) {
|
if (overlaps.length > 0) {
|
||||||
@@ -466,7 +602,9 @@ const handleSubmit = async () => {
|
|||||||
employeeId: Number(form.employeeId),
|
employeeId: Number(form.employeeId),
|
||||||
typeId: Number(form.typeId),
|
typeId: Number(form.typeId),
|
||||||
startDate: form.startDate,
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
endDate: form.endDate,
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
comment: form.comment
|
comment: form.comment
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -474,7 +612,9 @@ const handleSubmit = async () => {
|
|||||||
employeeId: Number(form.employeeId),
|
employeeId: Number(form.employeeId),
|
||||||
typeId: Number(form.typeId),
|
typeId: Number(form.typeId),
|
||||||
startDate: form.startDate,
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
endDate: form.endDate,
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
comment: form.comment
|
comment: form.comment
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -486,6 +626,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppression de l'absence en cours d'édition.
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!editingAbsence.value) return
|
if (!editingAbsence.value) return
|
||||||
|
|
||||||
@@ -497,12 +638,14 @@ const handleDelete = async () => {
|
|||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Affiche "Prénom N.".
|
||||||
const formatEmployeeName = (employee: Employee) => {
|
const formatEmployeeName = (employee: Employee) => {
|
||||||
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
|
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
|
||||||
return `${employee.firstName} ${initial}`.trim()
|
return `${employee.firstName} ${initial}`.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { printPdf } = usePdfPrinter()
|
// Impression PDF de l'intervalle sélectionné.
|
||||||
|
const {printPdf} = usePdfPrinter()
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('from', printForm.from)
|
params.set('from', printForm.from)
|
||||||
@@ -513,4 +656,36 @@ const handlePrint = async () => {
|
|||||||
await printPdf(`/absences/print?${params.toString()}`)
|
await printPdf(`/absences/print?${params.toString()}`)
|
||||||
isPrintOpen.value = false
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,57 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
<div class="flex items-center justify-between pb-12">
|
<div class="shrink-0">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
<div class="flex items-center justify-between">
|
||||||
<button
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
type="button"
|
</div>
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="isDrawerOpen = true"
|
<div class="flex flex-col gap-3 py-6">
|
||||||
>
|
<div class="flex justify-between">
|
||||||
Ajouter un employé
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="isDrawerOpen = true"
|
||||||
|
>
|
||||||
|
Ajouter un employé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w-80">
|
||||||
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && 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"
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
>
|
>
|
||||||
Aucun employé pour le moment.
|
Aucun employé pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
<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">
|
<div class="h-full overflow-auto">
|
||||||
<span class="text-left">Prénom</span>
|
<div class="min-w-[900px]">
|
||||||
<span class="text-left">Nom</span>
|
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
|
||||||
<span class="text-left">Site</span>
|
<span class="text-left">Prénom</span>
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-left">Nom</span>
|
||||||
</div>
|
<span class="text-left">Site</span>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<span class="text-left">Contrat</span>
|
||||||
Chargement...
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
<div
|
Chargement...
|
||||||
v-for="employee in employees"
|
</div>
|
||||||
:key="employee.id"
|
<div v-else>
|
||||||
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"
|
<div
|
||||||
>
|
v-for="employee in filteredEmployees"
|
||||||
<span>{{ employee.firstName }}</span>
|
:key="employee.id"
|
||||||
<span>{{ employee.lastName }}</span>
|
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
<span>{{ employee.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)"
|
|
||||||
>
|
>
|
||||||
Modifier
|
<span>{{ employee.firstName }}</span>
|
||||||
</button>
|
<span>{{ employee.lastName }}</span>
|
||||||
<button
|
<span
|
||||||
type="button"
|
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
|
||||||
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
|
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
|
||||||
@click="confirmDelete(employee)"
|
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
|
||||||
>
|
>
|
||||||
Supprimer
|
{{ employee.site?.name ?? '-' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +128,24 @@
|
|||||||
Le site est obligatoire.
|
Le site est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
|
Contrat <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 contrat est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,14 +168,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import { listContracts } from '~/services/contracts'
|
||||||
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
||||||
import { listSites } from '~/services/sites'
|
import { listSites } from '~/services/sites'
|
||||||
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const sitesInitialized = ref(false)
|
||||||
const editingEmployee = ref<Employee | null>(null)
|
const editingEmployee = ref<Employee | null>(null)
|
||||||
const drawerTitle = computed(() =>
|
const drawerTitle = computed(() =>
|
||||||
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
||||||
@@ -142,24 +187,48 @@ const drawerTitle = computed(() =>
|
|||||||
|
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = ref<Site[]>([])
|
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 form = reactive({
|
const form = reactive({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
siteId: '' as number | ''
|
siteId: '' as number | '',
|
||||||
|
contractId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
firstName: false,
|
firstName: false,
|
||||||
lastName: false,
|
lastName: false,
|
||||||
siteId: false
|
siteId: false,
|
||||||
|
contractId: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
||||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||||
const isSiteValid = computed(() => form.siteId !== '')
|
const isSiteValid = computed(() => form.siteId !== '')
|
||||||
|
const isContractValid = computed(() => form.contractId !== '')
|
||||||
const isFormValid = computed(
|
const isFormValid = computed(
|
||||||
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
|
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const showFirstNameError = computed(
|
const showFirstNameError = computed(
|
||||||
@@ -171,9 +240,12 @@ const showLastNameError = computed(
|
|||||||
const showSiteError = computed(
|
const showSiteError = computed(
|
||||||
() => validationTouched.siteId && !isSiteValid.value
|
() => validationTouched.siteId && !isSiteValid.value
|
||||||
)
|
)
|
||||||
|
const showContractError = computed(
|
||||||
|
() => validationTouched.contractId && !isContractValid.value
|
||||||
|
)
|
||||||
|
|
||||||
const baseInputClass =
|
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(() => {
|
const firstNameFieldClass = computed(() => {
|
||||||
if (showFirstNameError.value) {
|
if (showFirstNameError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
@@ -194,6 +266,14 @@ const siteFieldClass = computed(() => {
|
|||||||
}
|
}
|
||||||
return `${baseSelectClass} border-neutral-300`
|
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 submitButtonClass = computed(() => {
|
const submitButtonClass = computed(() => {
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
if (isSubmitting.value || !isFormValid.value) {
|
||||||
@@ -215,15 +295,33 @@ const loadSites = async () => {
|
|||||||
sites.value = await listSites()
|
sites.value = await listSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadEmployees(), loadSites()])
|
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(sites, (nextSites) => {
|
||||||
|
const currentSiteIds = nextSites.map((site) => site.id)
|
||||||
|
|
||||||
|
if (!sitesInitialized.value) {
|
||||||
|
if (currentSiteIds.length === 0) return
|
||||||
|
selectedSiteIds.value = currentSiteIds
|
||||||
|
sitesInitialized.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
validationTouched.firstName = true
|
validationTouched.firstName = true
|
||||||
validationTouched.lastName = true
|
validationTouched.lastName = true
|
||||||
validationTouched.siteId = true
|
validationTouched.siteId = true
|
||||||
|
validationTouched.contractId = true
|
||||||
if (!isFormValid.value) return
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
@@ -232,19 +330,22 @@ const handleSubmit = async () => {
|
|||||||
await updateEmployee(editingEmployee.value.id, {
|
await updateEmployee(editingEmployee.value.id, {
|
||||||
firstName: form.firstName,
|
firstName: form.firstName,
|
||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: Number(form.contractId)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createEmployee({
|
await createEmployee({
|
||||||
firstName: form.firstName,
|
firstName: form.firstName,
|
||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: Number(form.contractId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
form.firstName = ''
|
form.firstName = ''
|
||||||
form.lastName = ''
|
form.lastName = ''
|
||||||
form.siteId = ''
|
form.siteId = ''
|
||||||
|
form.contractId = ''
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -258,6 +359,7 @@ watch(isDrawerOpen, (isOpen) => {
|
|||||||
validationTouched.firstName = false
|
validationTouched.firstName = false
|
||||||
validationTouched.lastName = false
|
validationTouched.lastName = false
|
||||||
validationTouched.siteId = false
|
validationTouched.siteId = false
|
||||||
|
validationTouched.contractId = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,6 +368,7 @@ const openEdit = (employee: Employee) => {
|
|||||||
form.firstName = employee.firstName
|
form.firstName = employee.firstName
|
||||||
form.lastName = employee.lastName
|
form.lastName = employee.lastName
|
||||||
form.siteId = employee.site?.id ?? ''
|
form.siteId = employee.site?.id ?? ''
|
||||||
|
form.contractId = employee.contract?.id ?? ''
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
frontend/pages/hours.vue
Normal file
162
frontend/pages/hours.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<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 flex-1 min-h-0 flex-col gap-4">
|
||||||
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
|
<HoursDayView
|
||||||
|
v-if="viewMode === 'day'"
|
||||||
|
v-model:rows="rows"
|
||||||
|
:employees="visibleEmployees"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:day-grid-cols="dayGridCols"
|
||||||
|
: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"
|
||||||
|
:is-validation-pending="isValidationPending"
|
||||||
|
:can-toggle-validation="canToggleValidation"
|
||||||
|
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||||
|
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||||
|
:on-toggle-validation="toggleValidation"
|
||||||
|
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||||
|
:get-row-metrics="getRowMetrics"
|
||||||
|
:get-row-absence-label="getRowAbsenceLabel"
|
||||||
|
:get-presence-day-value="getPresenceDayValue"
|
||||||
|
:on-absence-click="openAbsenceDrawer"
|
||||||
|
:format-minutes="formatMinutes"
|
||||||
|
class="max-h-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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-full"
|
||||||
|
/>
|
||||||
|
</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,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
rows,
|
||||||
|
absenceTypes,
|
||||||
|
absenceForm,
|
||||||
|
isAbsenceDrawerOpen,
|
||||||
|
isAbsenceSubmitting,
|
||||||
|
editingAbsence,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
weekShortcutButtonClass,
|
||||||
|
getWeekShortcutLabel,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
setThisWeek,
|
||||||
|
setPreviousWeek,
|
||||||
|
setNextWeek,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isTimeTracking,
|
||||||
|
isPresenceTracking,
|
||||||
|
isRowLocked,
|
||||||
|
isHalfLockedByAbsence,
|
||||||
|
isEveningLockedByAbsence,
|
||||||
|
isValidationPending,
|
||||||
|
canToggleValidation,
|
||||||
|
isBulkValidationChecked,
|
||||||
|
isBulkValidationIndeterminate,
|
||||||
|
toggleValidation,
|
||||||
|
toggleValidationBulk,
|
||||||
|
getRowMetrics,
|
||||||
|
getRowAbsenceLabel,
|
||||||
|
getPresenceDayValue,
|
||||||
|
openAbsenceDrawer,
|
||||||
|
submitAbsence,
|
||||||
|
deleteAbsenceFromDrawer,
|
||||||
|
closeAbsenceDrawer,
|
||||||
|
formatMinutes,
|
||||||
|
handleSave
|
||||||
|
} = useHoursPage()
|
||||||
|
</script>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@
|
|||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
Se connecter
|
Se connecter
|
||||||
@@ -63,7 +63,6 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
console.log(useRuntimeConfig().public.apiBase)
|
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
|
|||||||
@@ -32,8 +32,15 @@
|
|||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
: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"
|
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">
|
<div class="flex items-center gap-2 justify-start">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-3 w-3 rounded-full"
|
class="inline-block h-3 w-3 rounded-full"
|
||||||
@@ -114,11 +121,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isReordering = ref(false)
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const editingSite = ref<Site | null>(null)
|
const editingSite = ref<Site | null>(null)
|
||||||
@@ -142,7 +150,7 @@ const isFormValid = computed(() => isNameValid.value)
|
|||||||
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
||||||
|
|
||||||
const baseInputClass =
|
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(() => {
|
const nameFieldClass = computed(() => {
|
||||||
if (showNameError.value) {
|
if (showNameError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
@@ -207,7 +215,8 @@ const handleSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await createSite({
|
await createSite({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
color: form.color
|
color: form.color,
|
||||||
|
displayOrder: sites.value.length + 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,4 +240,52 @@ const confirmDelete = async (site: Site) => {
|
|||||||
await deleteSite(site.id)
|
await deleteSite(site.id)
|
||||||
await loadSites()
|
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>
|
</script>
|
||||||
|
|||||||
464
frontend/pages/users.vue
Normal file
464
frontend/pages/users.vue
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
<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'] })
|
||||||
|
|
||||||
|
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>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createAbsenceType = async (
|
export const createAbsenceType = async (
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<AbsenceType>('/absence_types', payload, {
|
return api.post<AbsenceType>('/absence_types', payload, {
|
||||||
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
|
|||||||
|
|
||||||
export const updateAbsenceType = async (
|
export const updateAbsenceType = async (
|
||||||
id: number,
|
id: number,
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Absence } from './dto/absence'
|
import type { Absence } from './dto/absence'
|
||||||
|
import type { HalfDay } from './dto/half-day'
|
||||||
import { extractItems } from '~/utils/api'
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
type ListAbsencesFilters = {
|
type ListAbsencesFilters = {
|
||||||
@@ -31,7 +32,9 @@ export const createAbsence = async (payload: {
|
|||||||
employeeId: number
|
employeeId: number
|
||||||
typeId: number
|
typeId: number
|
||||||
startDate: string
|
startDate: string
|
||||||
|
startHalf: HalfDay
|
||||||
endDate: string
|
endDate: string
|
||||||
|
endHalf: HalfDay
|
||||||
comment?: string
|
comment?: string
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -39,7 +42,9 @@ export const createAbsence = async (payload: {
|
|||||||
employee: `/api/employees/${payload.employeeId}`,
|
employee: `/api/employees/${payload.employeeId}`,
|
||||||
type: `/api/absence_types/${payload.typeId}`,
|
type: `/api/absence_types/${payload.typeId}`,
|
||||||
startDate: payload.startDate,
|
startDate: payload.startDate,
|
||||||
|
startHalf: payload.startHalf,
|
||||||
endDate: payload.endDate,
|
endDate: payload.endDate,
|
||||||
|
endHalf: payload.endHalf,
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.absence.create',
|
toastSuccessKey: 'success.absence.create',
|
||||||
@@ -52,7 +57,9 @@ export const updateAbsence = async (payload: {
|
|||||||
employeeId: number
|
employeeId: number
|
||||||
typeId: number
|
typeId: number
|
||||||
startDate: string
|
startDate: string
|
||||||
|
startHalf: HalfDay
|
||||||
endDate: string
|
endDate: string
|
||||||
|
endHalf: HalfDay
|
||||||
comment?: string
|
comment?: string
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -60,7 +67,9 @@ export const updateAbsence = async (payload: {
|
|||||||
employee: `/api/employees/${payload.employeeId}`,
|
employee: `/api/employees/${payload.employeeId}`,
|
||||||
type: `/api/absence_types/${payload.typeId}`,
|
type: `/api/absence_types/${payload.typeId}`,
|
||||||
startDate: payload.startDate,
|
startDate: payload.startDate,
|
||||||
|
startHalf: payload.startHalf,
|
||||||
endDate: payload.endDate,
|
endDate: payload.endDate,
|
||||||
|
endHalf: payload.endHalf,
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.absence.update',
|
toastSuccessKey: 'success.absence.update',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const getCurrentUser = () => {
|
|||||||
export const login = (username: string, password: string) => {
|
export const login = (username: string, password: string) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post('/login_check', { username, password }, {
|
return api.post('/login_check', { username, password }, {
|
||||||
|
toastOn401: true,
|
||||||
toastErrorKey: 'errors.auth.login'
|
toastErrorKey: 'errors.auth.login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/services/contracts.ts
Normal file
13
frontend/services/contracts.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ export type AbsenceType = {
|
|||||||
code: string
|
code: string
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
|
countAsWorkedHours: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Employee } from './employee'
|
import type { Employee } from './employee'
|
||||||
import type { AbsenceType } from './absence-type'
|
import type { AbsenceType } from './absence-type'
|
||||||
|
import type { HalfDay } from './half-day'
|
||||||
|
|
||||||
export type Absence = {
|
export type Absence = {
|
||||||
id: number
|
id: number
|
||||||
startDate: string
|
startDate: string
|
||||||
|
startHalf: HalfDay
|
||||||
endDate: string
|
endDate: string
|
||||||
|
endHalf: HalfDay
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
employee: Employee
|
employee: Employee
|
||||||
type: AbsenceType
|
type: AbsenceType
|
||||||
|
|||||||
25
frontend/services/dto/contract.ts
Normal file
25
frontend/services/dto/contract.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
|
import type { Contract } from './contract'
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
site: Site
|
site: Site
|
||||||
|
contract?: Contract | null
|
||||||
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
6
frontend/services/dto/half-day.ts
Normal file
6
frontend/services/dto/half-day.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type HalfDay = 'AM' | 'PM'
|
||||||
|
|
||||||
|
export const HALF_DAYS: { value: HalfDay; label: string }[] = [
|
||||||
|
{ value: 'AM', label: 'Matin' },
|
||||||
|
{ value: 'PM', label: 'Après-midi' }
|
||||||
|
]
|
||||||
@@ -2,4 +2,5 @@ export type Site = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
roles: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
8
frontend/services/dto/user.ts
Normal file
8
frontend/services/dto/user.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Employee } from './employee'
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
employee?: Employee | null
|
||||||
|
}
|
||||||
81
frontend/services/dto/work-hour.ts
Normal file
81
frontend/services/dto/work-hour.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
absenceLabel?: string | null
|
||||||
|
absenceHalf?: 'AM' | 'PM' | null
|
||||||
|
absentMorning: boolean
|
||||||
|
absentAfternoon: boolean
|
||||||
|
creditedMinutes: number
|
||||||
|
creditedPresenceUnits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkHourDayContext = {
|
||||||
|
workDate: string
|
||||||
|
rows: WorkHourDayContextRow[]
|
||||||
|
}
|
||||||
@@ -11,16 +11,28 @@ export const listEmployees = async () => {
|
|||||||
return extractItems<Employee>(data)
|
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: {
|
export const createEmployee = async (payload: {
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
|
contractId: number
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||||
|
contract: `/api/contracts/${payload.contractId}`
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -33,19 +45,35 @@ export const updateEmployee = async (
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
|
contractId: number
|
||||||
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Employee>(`/employees/${id}`, {
|
return api.patch<Employee>(`/employees/${id}`, {
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||||
|
contract: `/api/contracts/${payload.contractId}`,
|
||||||
|
displayOrder: payload.displayOrder
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
toastErrorKey: 'errors.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) => {
|
export const deleteEmployee = async (id: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.delete(`/employees/${id}`, {}, {
|
return api.delete(`/employees/${id}`, {}, {
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ export const listSites = async () => {
|
|||||||
{},
|
{},
|
||||||
{ toast: false }
|
{ 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()
|
const api = useApi()
|
||||||
return api.post<Site>('/sites', payload, {
|
return api.post<Site>('/sites', payload, {
|
||||||
toastSuccessKey: 'success.site.create',
|
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()
|
const api = useApi()
|
||||||
return api.patch<Site>(`/sites/${id}`, payload, {
|
return api.patch<Site>(`/sites/${id}`, payload, {
|
||||||
toastSuccessKey: 'success.site.update',
|
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) => {
|
export const deleteSite = async (id: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.delete(`/sites/${id}`, {}, {
|
return api.delete(`/sites/${id}`, {}, {
|
||||||
|
|||||||
38
frontend/services/user-site-roles.ts
Normal file
38
frontend/services/user-site-roles.ts
Normal 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 })
|
||||||
|
}
|
||||||
57
frontend/services/users.ts
Normal file
57
frontend/services/users.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
76
frontend/services/work-hours.ts
Normal file
76
frontend/services/work-hours.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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[]
|
||||||
|
}) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<{
|
||||||
|
processed: number
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
deleted: number
|
||||||
|
}>(
|
||||||
|
'/work-hours/bulk-upsert',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
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 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,115 @@ export const toYmd = (year: number, month: number, day: number) => {
|
|||||||
|
|
||||||
export const normalizeDate = (value: string) => value.slice(0, 10)
|
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) => {
|
export const getDaysInMonth = (year: number, month: number) => {
|
||||||
const total = new Date(year, month + 1, 0).getDate()
|
const total = new Date(year, month + 1, 0).getDate()
|
||||||
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||||
|
|||||||
31
frontend/utils/employee.ts
Normal file
31
frontend/utils/employee.ts
Normal 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)
|
||||||
|
}
|
||||||
28
migrations/Version20260210120000.php
Normal file
28
migrations/Version20260210120000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260210120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add start/end half-day fields to absences';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("ALTER TABLE absences ADD start_half VARCHAR(2) NOT NULL DEFAULT 'AM'");
|
||||||
|
$this->addSql("ALTER TABLE absences ADD end_half VARCHAR(2) NOT NULL DEFAULT 'PM'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE absences DROP COLUMN start_half');
|
||||||
|
$this->addSql('ALTER TABLE absences DROP COLUMN end_half');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260210121500.php
Normal file
26
migrations/Version20260210121500.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
migrations/Version20260210123000.php
Normal file
39
migrations/Version20260210123000.php
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20260211120000.php
Normal file
33
migrations/Version20260211120000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/Version20260212120000.php
Normal file
30
migrations/Version20260212120000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260216100000.php
Normal file
31
migrations/Version20260216100000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260216143000.php
Normal file
26
migrations/Version20260216143000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
migrations/Version20260216143100.php
Normal file
37
migrations/Version20260216143100.php
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
34
migrations/Version20260217161000.php
Normal file
34
migrations/Version20260217161000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
migrations/Version20260217162000.php
Normal file
28
migrations/Version20260217162000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
migrations/Version20260218120000.php
Normal file
54
migrations/Version20260218120000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/Version20260218183000.php
Normal file
30
migrations/Version20260218183000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260218190000.php
Normal file
26
migrations/Version20260218190000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
83
migrations/Version20260219180000.php
Normal file
83
migrations/Version20260219180000.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
migrations/Version20260220133000.php
Normal file
40
migrations/Version20260220133000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
83
scripts/deploy-release.sh
Normal file
83
scripts/deploy-release.sh
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage: ./scripts/deploy-release.sh v0.0.1
|
||||||
|
# Requires: curl, tar, (optional) rsync
|
||||||
|
#
|
||||||
|
# Auth token: set RELEASE_TOKEN env var or create /etc/sirh-release-token
|
||||||
|
umask 002
|
||||||
|
|
||||||
|
TAG="${1:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "Usage: $0 v0.0.1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_OWNER="MALIO-DEV"
|
||||||
|
REPO_NAME="SIRH"
|
||||||
|
GITEA_API="https://gitea.malio.fr/api/v1"
|
||||||
|
DEPLOY_DIR="/var/www/sirh"
|
||||||
|
|
||||||
|
if [ -f /etc/sirh-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
RELEASE_TOKEN="$(cat /etc/sirh-release-token)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
release_json="$tmp_dir/release.json"
|
||||||
|
curl_opts=(-sS)
|
||||||
|
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||||
|
fi
|
||||||
|
curl "${curl_opts[@]}" \
|
||||||
|
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
||||||
|
-o "$release_json"
|
||||||
|
|
||||||
|
asset_url="$(python3 - "$release_json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
data = json.load(open(sys.argv[1], 'r'))
|
||||||
|
assets = data.get("assets", [])
|
||||||
|
for a in assets:
|
||||||
|
name = a.get("name", "")
|
||||||
|
if name.startswith("sirh-") and name.endswith(".tar.gz"):
|
||||||
|
print(a.get("browser_download_url", ""))
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$asset_url" ]; then
|
||||||
|
echo "Release asset not found for tag ${TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive="$tmp_dir/artefact.tar.gz"
|
||||||
|
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
||||||
|
|
||||||
|
tar -xzf "$archive" -C "$tmp_dir"
|
||||||
|
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a --delete --no-perms --no-owner --no-group \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude ".env.local" \
|
||||||
|
--exclude "config/jwt" \
|
||||||
|
--exclude "var" \
|
||||||
|
"$tmp_dir"/ "$DEPLOY_DIR"/
|
||||||
|
else
|
||||||
|
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure Nginx can traverse the deploy path.
|
||||||
|
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
if [ -f "${DEPLOY_DIR}/.env" ]; then
|
||||||
|
echo "Running migrations (if any)..."
|
||||||
|
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
||||||
|
else
|
||||||
|
echo "Skip migrations: ${DEPLOY_DIR}/.env not found" >&2
|
||||||
|
fi
|
||||||
@@ -18,7 +18,8 @@ use App\State\AbsencePrintProvider;
|
|||||||
new QueryParameter(key: 'from', required: true),
|
new QueryParameter(key: 'from', required: true),
|
||||||
new QueryParameter(key: 'to', required: true),
|
new QueryParameter(key: 'to', required: true),
|
||||||
new QueryParameter(key: 'sites', required: false),
|
new QueryParameter(key: 'sites', required: false),
|
||||||
]
|
],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
|||||||
22
src/ApiResource/ScopedEmployee.php
Normal file
22
src/ApiResource/ScopedEmployee.php
Normal 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 {}
|
||||||
39
src/ApiResource/WorkHourBulkUpsert.php
Normal file
39
src/ApiResource/WorkHourBulkUpsert.php
Normal 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 = [];
|
||||||
|
}
|
||||||
13
src/ApiResource/WorkHourBulkUpsertResult.php
Normal file
13
src/ApiResource/WorkHourBulkUpsertResult.php
Normal 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;
|
||||||
|
}
|
||||||
37
src/ApiResource/WorkHourDayContext.php
Normal file
37
src/ApiResource/WorkHourDayContext.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\WorkHourDayContextProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/day-context',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: WorkHourDayContextProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class WorkHourDayContext
|
||||||
|
{
|
||||||
|
public string $workDate = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* employeeId:int,
|
||||||
|
* absenceLabel:?string,
|
||||||
|
* absenceHalf:?string,
|
||||||
|
* absentMorning:bool,
|
||||||
|
* absentAfternoon:bool,
|
||||||
|
* creditedMinutes:int,
|
||||||
|
* creditedPresenceUnits:float
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public array $rows = [];
|
||||||
|
}
|
||||||
32
src/ApiResource/WorkHourWeeklySummary.php
Normal file
32
src/ApiResource/WorkHourWeeklySummary.php
Normal 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 = [];
|
||||||
|
}
|
||||||
50
src/Doctrine/AbsenceCollectionExtension.php
Normal file
50
src/Doctrine/AbsenceCollectionExtension.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Doctrine/WorkHourCollectionExtension.php
Normal file
53
src/Doctrine/WorkHourCollectionExtension.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Dto/WorkHours/DayContextRow.php
Normal file
84
src/Dto/WorkHours/DayContextRow.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class DayContextRow
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $employeeId,
|
||||||
|
public ?string $absenceLabel = null,
|
||||||
|
public ?string $absenceHalf = null,
|
||||||
|
public bool $absentMorning = false,
|
||||||
|
public bool $absentAfternoon = false,
|
||||||
|
public int $creditedMinutes = 0,
|
||||||
|
public float $creditedPresenceUnits = 0.0,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function addAbsence(
|
||||||
|
?string $label,
|
||||||
|
bool $morning,
|
||||||
|
bool $afternoon,
|
||||||
|
int $creditedMinutes,
|
||||||
|
float $creditedPresenceUnits
|
||||||
|
): void {
|
||||||
|
// Fusionne plusieurs absences du même jour sur la ligne salarié.
|
||||||
|
$this->absentMorning = $this->absentMorning || $morning;
|
||||||
|
$this->absentAfternoon = $this->absentAfternoon || $afternoon;
|
||||||
|
|
||||||
|
// Garde un libellé lisible: unique si possible, sinon "Absences multiples".
|
||||||
|
if (null === $this->absenceLabel) {
|
||||||
|
$this->absenceLabel = $label;
|
||||||
|
} elseif ($label !== $this->absenceLabel) {
|
||||||
|
$this->absenceLabel = 'Absences multiples';
|
||||||
|
}
|
||||||
|
|
||||||
|
// AM/PM seulement pour les demi-journées, null pour journée complète.
|
||||||
|
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
|
||||||
|
// Cumule les minutes créditées par les absences "comptées comme travaillées".
|
||||||
|
$this->creditedMinutes += $creditedMinutes;
|
||||||
|
// Cumule les unités de présence créditées (0.5 par demi-journée).
|
||||||
|
$this->creditedPresenceUnits += $creditedPresenceUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* employeeId:int,
|
||||||
|
* absenceLabel:?string,
|
||||||
|
* absenceHalf:?string,
|
||||||
|
* absentMorning:bool,
|
||||||
|
* absentAfternoon:bool,
|
||||||
|
* creditedMinutes:int,
|
||||||
|
* creditedPresenceUnits:float
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'employeeId' => $this->employeeId,
|
||||||
|
'absenceLabel' => $this->absenceLabel,
|
||||||
|
'absenceHalf' => $this->absenceHalf,
|
||||||
|
'absentMorning' => $this->absentMorning,
|
||||||
|
'absentAfternoon' => $this->absentAfternoon,
|
||||||
|
'creditedMinutes' => $this->creditedMinutes,
|
||||||
|
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHalfLabel(bool $morning, bool $afternoon): ?string
|
||||||
|
{
|
||||||
|
// Matin + après-midi => journée complète, pas de libellé AM/PM.
|
||||||
|
if ($morning && $afternoon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($morning) {
|
||||||
|
return 'AM';
|
||||||
|
}
|
||||||
|
if ($afternoon) {
|
||||||
|
return 'PM';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
26
src/Dto/WorkHours/WorkMetrics.php
Normal file
26
src/Dto/WorkHours/WorkMetrics.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,22 +9,51 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Enum\HalfDay;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
|
use App\State\AbsenceWriteProcessor;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('ABSENCE_VIEW', object)"
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
securityPostDenormalize: "is_granted('ABSENCE_EDIT', object)",
|
||||||
|
processor: AbsenceWriteProcessor::class
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('ABSENCE_EDIT', object)",
|
||||||
|
processor: AbsenceWriteProcessor::class
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('ABSENCE_EDIT', object)",
|
||||||
|
processor: AbsenceWriteProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
normalizationContext: [
|
normalizationContext: [
|
||||||
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
|
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
|
||||||
'datetime_format' => 'Y-m-d',
|
'datetime_format' => 'Y-m-d',
|
||||||
],
|
],
|
||||||
denormalizationContext: [
|
denormalizationContext: [
|
||||||
'datetime_format' => 'Y-m-d',
|
'datetime_format' => 'Y-m-d',
|
||||||
]
|
],
|
||||||
|
paginationEnabled: false,
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||||
#[ORM\Table(name: 'absences')]
|
#[ORM\Table(name: 'absences')]
|
||||||
class Absence
|
class Absence
|
||||||
{
|
{
|
||||||
@@ -50,10 +79,18 @@ class Absence
|
|||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private DateTimeInterface $startDate;
|
private DateTimeInterface $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
|
||||||
|
#[Groups(['absence:read'])]
|
||||||
|
private HalfDay $startHalf = HalfDay::AM;
|
||||||
|
|
||||||
#[ORM\Column(type: 'date')]
|
#[ORM\Column(type: 'date')]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private DateTimeInterface $endDate;
|
private DateTimeInterface $endDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
|
||||||
|
#[Groups(['absence:read'])]
|
||||||
|
private HalfDay $endHalf = HalfDay::PM;
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
#[Groups(['absence:read'])]
|
#[Groups(['absence:read'])]
|
||||||
private ?string $comment = null;
|
private ?string $comment = null;
|
||||||
@@ -111,6 +148,30 @@ class Absence
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getStartHalf(): HalfDay
|
||||||
|
{
|
||||||
|
return $this->startHalf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartHalf(HalfDay $startHalf): self
|
||||||
|
{
|
||||||
|
$this->startHalf = $startHalf;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndHalf(): HalfDay
|
||||||
|
{
|
||||||
|
return $this->endHalf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndHalf(HalfDay $endHalf): self
|
||||||
|
{
|
||||||
|
$this->endHalf = $endHalf;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getComment(): ?string
|
public function getComment(): ?string
|
||||||
{
|
{
|
||||||
return $this->comment;
|
return $this->comment;
|
||||||
|
|||||||
@@ -5,10 +5,35 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(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\Entity]
|
||||||
#[ORM\Table(name: 'absence_types')]
|
#[ORM\Table(name: 'absence_types')]
|
||||||
class AbsenceType
|
class AbsenceType
|
||||||
@@ -31,6 +56,10 @@ class AbsenceType
|
|||||||
#[Groups(['absence:read', 'absence_type:read'])]
|
#[Groups(['absence:read', 'absence_type:read'])]
|
||||||
private string $color = '';
|
private string $color = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['absence:read', 'absence_type:read'])]
|
||||||
|
private bool $countAsWorkedHours = false;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -71,4 +100,21 @@ class AbsenceType
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isCountAsWorkedHours(): bool
|
||||||
|
{
|
||||||
|
return $this->countAsWorkedHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountAsWorkedHours(): bool
|
||||||
|
{
|
||||||
|
return $this->countAsWorkedHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCountAsWorkedHours(bool $countAsWorkedHours): self
|
||||||
|
{
|
||||||
|
$this->countAsWorkedHours = $countAsWorkedHours;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/Entity/Contract.php
Normal file
122
src/Entity/Contract.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,22 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\State\EmployeeWriteProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
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')]
|
#[ORM\Table(name: 'employees')]
|
||||||
class Employee
|
class Employee
|
||||||
{
|
{
|
||||||
@@ -31,12 +37,22 @@ class Employee
|
|||||||
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
||||||
private string $lastName = '';
|
private string $lastName = '';
|
||||||
|
|
||||||
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||||
#[ORM\JoinColumn(nullable: true)]
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private ?Site $site = null;
|
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')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -86,8 +102,32 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContract(): ?Contract
|
||||||
|
{
|
||||||
|
return $this->contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContract(?Contract $contract): self
|
||||||
|
{
|
||||||
|
$this->contract = $contract;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDisplayOrder(): int
|
||||||
|
{
|
||||||
|
return $this->displayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDisplayOrder(int $displayOrder): self
|
||||||
|
{
|
||||||
|
$this->displayOrder = $displayOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/Entity/EmployeeContractPeriod.php
Normal file
102
src/Entity/EmployeeContractPeriod.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_contract_periods')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'start_date'], name: 'idx_emp_contract_period_employee_start')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'end_date'], name: 'idx_emp_contract_period_employee_end')]
|
||||||
|
class EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Contract $contract = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
private DateTimeImmutable $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
private ?DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->startDate = new DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(?Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContract(): ?Contract
|
||||||
|
{
|
||||||
|
return $this->contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContract(?Contract $contract): self
|
||||||
|
{
|
||||||
|
$this->contract = $contract;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartDate(DateTimeImmutable $startDate): self
|
||||||
|
{
|
||||||
|
$this->startDate = $startDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?DateTimeImmutable $endDate): self
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(normalizationContext: ['groups' => ['site:read']])]
|
#[ApiResource(
|
||||||
|
normalizationContext: ['groups' => ['site:read']],
|
||||||
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table(name: 'sites')]
|
#[ORM\Table(name: 'sites')]
|
||||||
class Site
|
class Site
|
||||||
@@ -27,6 +31,10 @@ class Site
|
|||||||
#[Groups(['site:read', 'employee:read'])]
|
#[Groups(['site:read', 'employee:read'])]
|
||||||
private string $color = '';
|
private string $color = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||||
|
#[Groups(['site:read'])]
|
||||||
|
private int $displayOrder = 0;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -55,4 +63,16 @@ class Site
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDisplayOrder(): int
|
||||||
|
{
|
||||||
|
return $this->displayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDisplayOrder(int $displayOrder): self
|
||||||
|
{
|
||||||
|
$this->displayOrder = $displayOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user