Compare commits
7 Commits
v0.1.72
...
fc2b184c50
| Author | SHA1 | Date | |
|---|---|---|---|
| fc2b184c50 | |||
| 0b01e7772c | |||
| 584cb2ed16 | |||
| 7a3d01d77f | |||
| e0f2a84f2c | |||
| 5aac94ad0e | |||
| ea06059c0b |
@@ -13,6 +13,13 @@ Arborescence clé:
|
|||||||
- `tests/`: TU backend (PHPUnit)
|
- `tests/`: TU backend (PHPUnit)
|
||||||
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||||
- `migrations/`: migrations Doctrine
|
- `migrations/`: migrations Doctrine
|
||||||
|
- `doc/`: documentation fonctionnelle et règles métier de référence
|
||||||
|
|
||||||
|
## 1.1) Référentiel Fonctionnel (obligatoire)
|
||||||
|
|
||||||
|
- Référence principale des règles métier: `doc/functional-rules.md`
|
||||||
|
- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
|
||||||
|
- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
|
||||||
|
|
||||||
## 2) Commandes utiles
|
## 2) Commandes utiles
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Règles Fonctionnelles SIRH
|
||||||
|
|
||||||
|
Ce document centralise les règles métier actuellement implémentées dans l'application.
|
||||||
|
|
||||||
|
## 1) Utilisateurs et accès
|
||||||
|
|
||||||
|
- `ROLE_ADMIN`
|
||||||
|
- accès complet aux écrans d'administration
|
||||||
|
- vue semaine des heures
|
||||||
|
- validation RH des lignes d'heures
|
||||||
|
- `ROLE_SELF`
|
||||||
|
- accès limité à son périmètre personnel
|
||||||
|
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
|
||||||
|
- accès au périmètre des sites autorisés
|
||||||
|
- validation site des lignes d'heures
|
||||||
|
|
||||||
|
## 2) Contrats
|
||||||
|
|
||||||
|
- Le profil de temps de travail est porté par `Contract`:
|
||||||
|
- `trackingMode`: `TIME` ou `PRESENCE`
|
||||||
|
- `weeklyHours` (ex: 35, 39, 4, etc.)
|
||||||
|
- La nature RH est portée par période employé:
|
||||||
|
- `CDI`, `CDD`, `INTERIM`
|
||||||
|
- Historique des contrats employé:
|
||||||
|
- table `employee_contract_periods`
|
||||||
|
- un employé peut avoir plusieurs périodes
|
||||||
|
|
||||||
|
### Règles de période
|
||||||
|
|
||||||
|
- `CDI`:
|
||||||
|
- à la création d'une période: `endDate` doit être vide
|
||||||
|
- en clôture d'un contrat en cours: `endDate` peut être renseignée
|
||||||
|
- `CDD` / `INTERIM`:
|
||||||
|
- `endDate` obligatoire
|
||||||
|
- `endDate` ne peut pas être antérieure à `startDate`
|
||||||
|
|
||||||
|
## 3) Heures (vue jour)
|
||||||
|
|
||||||
|
- Saisie par salarié et par date:
|
||||||
|
- matin / après-midi / soir
|
||||||
|
- pour `PRESENCE`: demi-journées matin/après-midi
|
||||||
|
- Calculs affichés:
|
||||||
|
- `Jour`, `Nuit`, `Total`
|
||||||
|
- Heures de nuit:
|
||||||
|
- fenêtres `00:00-06:00` et `21:00-24:00`
|
||||||
|
|
||||||
|
## 4) Absences
|
||||||
|
|
||||||
|
- Les absences sont stockées par jour (découpage lors de l'écriture)
|
||||||
|
- Une absence peut être:
|
||||||
|
- journée complète
|
||||||
|
- demi-journée `AM` ou `PM`
|
||||||
|
- Colonne absence (vue jour):
|
||||||
|
- affiche le libellé
|
||||||
|
- fond coloré selon le type d'absence
|
||||||
|
- Si plusieurs absences de couleurs différentes sur le même jour:
|
||||||
|
- fallback rouge
|
||||||
|
|
||||||
|
### Effet absence sur les heures
|
||||||
|
|
||||||
|
- Absence `AM`:
|
||||||
|
- efface les heures du matin
|
||||||
|
- Absence `PM`:
|
||||||
|
- efface les heures d'après-midi et du soir
|
||||||
|
- Absence journée:
|
||||||
|
- efface toutes les plages horaires
|
||||||
|
|
||||||
|
### Absences "comptées comme travaillées"
|
||||||
|
|
||||||
|
- Si `countAsWorkedHours = true`:
|
||||||
|
- `TIME`: crédit de minutes selon contrat actif du jour
|
||||||
|
- `PRESENCE`: crédit d'unités (0.5 / demi-journée)
|
||||||
|
|
||||||
|
## 5) Validations des lignes d'heures
|
||||||
|
|
||||||
|
- Validation RH (`isValid`)
|
||||||
|
- action admin
|
||||||
|
- Validation site (`isSiteValid`)
|
||||||
|
- action chef de site
|
||||||
|
|
||||||
|
### Verrouillage
|
||||||
|
|
||||||
|
- Ligne validée RH:
|
||||||
|
- verrouillée pour modifications heures/absences
|
||||||
|
- Ligne validée site:
|
||||||
|
- verrouillée pour non-admin
|
||||||
|
- admin peut corriger
|
||||||
|
- Toute vraie modification d'une ligne:
|
||||||
|
- remet `isSiteValid = false`
|
||||||
|
- remet `isValid = false`
|
||||||
|
- Si aucun changement réel à l'enregistrement:
|
||||||
|
- les validations existantes ne sont pas altérées
|
||||||
|
|
||||||
|
## 6) Heures supplémentaires (vue semaine)
|
||||||
|
|
||||||
|
- Base de calcul:
|
||||||
|
- dépend du contrat actif par jour
|
||||||
|
- Tranche 25%:
|
||||||
|
- contrats <= 35h: de 35h à 43h
|
||||||
|
- contrats >= 39h: de 39h à 43h
|
||||||
|
- Tranche 50%:
|
||||||
|
- au-delà de 43h
|
||||||
|
- Nature `INTERIM`:
|
||||||
|
- pas de bonus 25%
|
||||||
|
- pas de bonus 50%
|
||||||
|
- pas de total récup
|
||||||
|
|
||||||
|
## 7) Fériés
|
||||||
|
|
||||||
|
- Les jours fériés sont identifiés et affichés
|
||||||
|
- Règle courante:
|
||||||
|
- absences bloquées sur jour férié
|
||||||
|
- saisie d'heures autorisée
|
||||||
|
|
||||||
|
## 8) Impression absences (PDF)
|
||||||
|
|
||||||
|
Filtres disponibles:
|
||||||
|
- période `from` / `to`
|
||||||
|
- sites
|
||||||
|
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
|
||||||
|
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
|
||||||
|
|
||||||
|
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||||
|
|
||||||
|
## 9) Employés
|
||||||
|
|
||||||
|
- Création employé:
|
||||||
|
- prénom, nom, site
|
||||||
|
- type de contrat (nature RH)
|
||||||
|
- temps de travail
|
||||||
|
- dates début/fin (selon règles nature)
|
||||||
|
- Modification employé:
|
||||||
|
- uniquement prénom, nom, site
|
||||||
|
- pas de modification de contrat depuis ce drawer
|
||||||
|
- Détail employé:
|
||||||
|
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
||||||
|
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
||||||
|
- action `Clôturer`:
|
||||||
|
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
|
||||||
|
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
|
||||||
|
- seule la date de fin est saisissable (préremplie à aujourd'hui)
|
||||||
|
- backend: en mode clôture, seule `contractEndDate` est acceptée
|
||||||
|
- action `Ajouter`:
|
||||||
|
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
|
||||||
|
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
|
||||||
|
|
||||||
|
## 10) Notifications
|
||||||
|
|
||||||
|
- Icône cloche en topbar:
|
||||||
|
- badge = nombre de notifications non lues
|
||||||
|
- ouverture panneau = liste des non lues
|
||||||
|
- fermeture panneau = marquage "lu" en masse
|
||||||
|
|
||||||
|
### Règle métier de déclenchement
|
||||||
|
|
||||||
|
- Les notifications de validation site ne sont pas envoyées ligne par ligne.
|
||||||
|
- Une notification est créée uniquement quand un chef de site termine la validation complète:
|
||||||
|
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||||
|
- destinataires: utilisateurs `ROLE_ADMIN`
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||||
|
<div class="flex h-full items-center justify-end">
|
||||||
|
<div class="flex gap-12 text-xl text-white">
|
||||||
|
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
||||||
|
<button type="button" class="relative self-center cursor-pointer" @click="toggleNotifications">
|
||||||
|
<Icon name="mdi:bell-plus" size="36" />
|
||||||
|
<span
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{{ unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isNotificationsOpen"
|
||||||
|
class="absolute right-0 top-full z-30 mt-2 w-80 rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="border-b border-neutral-200 px-3 py-2 text-sm font-semibold">
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="notifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
|
||||||
|
Aucune notification.
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-h-80 overflow-auto">
|
||||||
|
<div
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
class="border-b border-neutral-100 px-3 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-semibold text-neutral-900">{{ notification.title }}</p>
|
||||||
|
<p class="text-xs text-neutral-600">{{ notification.message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group relative flex gap-4">
|
||||||
|
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||||
|
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
||||||
|
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
Mon profil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { User } from '~/services/dto/user'
|
||||||
|
import type { NotificationItem } from '~/services/dto/notification'
|
||||||
|
import { listUnreadNotifications, markAllNotificationsRead } from '~/services/notifications'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
user?: User
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const bellRoot = ref<HTMLElement | null>(null)
|
||||||
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const isNotificationsOpen = ref(false)
|
||||||
|
const isLoadingNotifications = ref(false)
|
||||||
|
const unreadCount = computed(() => notifications.value.length)
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await auth.logout()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
isLoadingNotifications.value = true
|
||||||
|
try {
|
||||||
|
notifications.value = await listUnreadNotifications()
|
||||||
|
} finally {
|
||||||
|
isLoadingNotifications.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotifications = async () => {
|
||||||
|
if (!isNotificationsOpen.value) return
|
||||||
|
isNotificationsOpen.value = false
|
||||||
|
if (notifications.value.length > 0) {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
notifications.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNotifications = async () => {
|
||||||
|
if (isNotificationsOpen.value) {
|
||||||
|
await closeNotifications()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isNotificationsOpen.value = true
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickOutside = async (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!target || !bellRoot.value) return
|
||||||
|
if (!bellRoot.value.contains(target)) {
|
||||||
|
await closeNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
async () => {
|
||||||
|
if (!isAdmin.value) return
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<div class="relative w-full max-w-[340px]">
|
||||||
v-model="model"
|
<input
|
||||||
type="text"
|
id="employee-search"
|
||||||
:placeholder="placeholder"
|
v-model="model"
|
||||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
type="text"
|
||||||
/>
|
:placeholder="placeholder"
|
||||||
|
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="mdi:magnify"
|
||||||
|
size="18"
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const model = defineModel<string>({ required: true })
|
const model = defineModel<string>({required: true})
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
placeholder: 'Chercher un employé (nom ou prénom)'
|
placeholder: "Recherche d'un employé"
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,25 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
|
<div ref="root" class="relative inline-block w-fit max-w-full">
|
||||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
<button
|
||||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
|
type="button"
|
||||||
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
class="inline-flex w-[280px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
|
||||||
<input
|
@click="isOpen = !isOpen"
|
||||||
:id="`site-${site.id}`"
|
>
|
||||||
v-model="selectedSiteIds"
|
<span>Sites</span>
|
||||||
:value="site.id"
|
<span class="inline-flex items-center gap-2">
|
||||||
type="checkbox"
|
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
|
||||||
class="h-4 w-4"
|
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
|
||||||
/>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:for="`site-${site.id}`"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`site-${site.id}`"
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:value="site.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-md text-neutral-800">{{ site.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
const selectedSiteIds = defineModel<number[]>({ required: true })
|
const selectedSiteIds = defineModel<number[]>({ required: true })
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedSiteIds.value.length)
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!root.value || !target) return
|
||||||
|
if (!root.value.contains(target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
|
<p>Contrat</p>
|
||||||
|
<p>Heures</p>
|
||||||
|
<p>Date de début</p>
|
||||||
|
<p>Date de fin</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
|
||||||
|
Aucun historique de contrat.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="item in contractHistory"
|
||||||
|
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
||||||
|
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
|
||||||
|
>
|
||||||
|
<p>{{ contractNatureLabel(item.contractNature) }}</p>
|
||||||
|
<p>{{ contractHistoryLabel(item) }}</p>
|
||||||
|
<p>{{ formatDate(item.startDate) }}</p>
|
||||||
|
<p>{{ formatDate(item.endDate) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center gap-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
||||||
|
@click="onOpenCloseContractDrawer"
|
||||||
|
>
|
||||||
|
Clôturer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
|
||||||
|
@click="onOpenCreateContractDrawer"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus-thick" size="16" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
||||||
|
Type de contrat
|
||||||
|
</label>
|
||||||
|
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
|
Temps de travail
|
||||||
|
</label>
|
||||||
|
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
||||||
|
Début contrat
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-start-date"
|
||||||
|
:value="contractForm.startDate"
|
||||||
|
type="date"
|
||||||
|
:class="readonlyFieldClass"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
||||||
|
Fin contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-end-date"
|
||||||
|
v-model="contractForm.endDate"
|
||||||
|
type="date"
|
||||||
|
:class="contractEndDateFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</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"
|
||||||
|
:disabled="isContractSubmitting"
|
||||||
|
@click="onUpdateContractDrawerOpen(false)"
|
||||||
|
>
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
|
||||||
|
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
||||||
|
Type de contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
|
||||||
|
<option value="CDI">CDI</option>
|
||||||
|
<option value="CDD">CDD</option>
|
||||||
|
<option value="INTERIM">Intérim</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
||||||
|
Temps de travail <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
|
||||||
|
<option value="">Sélectionner un contrat</option>
|
||||||
|
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
||||||
|
{{ contract.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
|
||||||
|
Début contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="requiresCreateContractEndDate">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
|
||||||
|
Fin contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||||
|
</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"
|
||||||
|
:disabled="isCreateContractSubmitting"
|
||||||
|
@click="onUpdateCreateContractDrawerOpen(false)"
|
||||||
|
>
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
|
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||||
|
|
||||||
|
type ContractForm = {
|
||||||
|
contractId: number | ''
|
||||||
|
contractName: string
|
||||||
|
weeklyHours: number | null
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateContractForm = {
|
||||||
|
contractId: number | ''
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
contractHistory: ContractHistoryItem[]
|
||||||
|
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
||||||
|
contractHistoryLabel: (item: ContractHistoryItem) => string
|
||||||
|
formatDate: (value?: string | null) => string
|
||||||
|
isContractSubmitting: boolean
|
||||||
|
canCloseCurrentContract: boolean
|
||||||
|
isCreateContractSubmitting: boolean
|
||||||
|
contracts: Contract[]
|
||||||
|
canCreateContract: boolean
|
||||||
|
isContractDrawerOpen: boolean
|
||||||
|
contractForm: ContractForm
|
||||||
|
readonlyFieldClass: string
|
||||||
|
closeContractWorkedHoursLabel: string
|
||||||
|
contractEndDateFieldClass: string
|
||||||
|
showContractEndDateError: boolean
|
||||||
|
isContractEndDateValid: boolean
|
||||||
|
isCreateContractDrawerOpen: boolean
|
||||||
|
createContractForm: CreateContractForm
|
||||||
|
createContractNatureFieldClass: string
|
||||||
|
createContractFieldClass: string
|
||||||
|
createContractStartDateFieldClass: string
|
||||||
|
requiresCreateContractEndDate: boolean
|
||||||
|
createContractEndDateFieldClass: string
|
||||||
|
isCreateContractFormValid: boolean
|
||||||
|
onOpenCloseContractDrawer: () => void
|
||||||
|
onOpenCreateContractDrawer: () => void
|
||||||
|
onUpdateContractDrawerOpen: (open: boolean) => void
|
||||||
|
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
||||||
|
onSubmitCloseContract: () => void
|
||||||
|
onSubmitCreateContract: () => void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Bloc Congé (à implémenter)
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Bloc RTT (à implémenter)
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
|
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
||||||
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
|
import { listContracts } from '~/services/contracts'
|
||||||
|
import { getEmployee, updateEmployee } from '~/services/employees'
|
||||||
|
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||||
|
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
|
||||||
|
|
||||||
|
export const useEmployeeDetailPage = () => {
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
const employee = ref<Employee | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
||||||
|
const contracts = ref<Contract[]>([])
|
||||||
|
const isContractDrawerOpen = ref(false)
|
||||||
|
const isContractSubmitting = ref(false)
|
||||||
|
const isCreateContractDrawerOpen = ref(false)
|
||||||
|
const isCreateContractSubmitting = ref(false)
|
||||||
|
|
||||||
|
const contractForm = reactive({
|
||||||
|
contractId: '' as number | '',
|
||||||
|
contractName: '',
|
||||||
|
weeklyHours: null as number | null,
|
||||||
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const validationTouched = reactive({
|
||||||
|
endDate: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const createContractForm = reactive({
|
||||||
|
contractId: '' as number | '',
|
||||||
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const createValidationTouched = reactive({
|
||||||
|
contractId: false,
|
||||||
|
contractNature: false,
|
||||||
|
startDate: false,
|
||||||
|
endDate: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
||||||
|
const employeeContractWorkLabel = computed(() => {
|
||||||
|
const contract = employee.value?.contract
|
||||||
|
if (!contract) return '-'
|
||||||
|
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
|
||||||
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
|
||||||
|
return contract.name || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
||||||
|
|
||||||
|
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
||||||
|
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
||||||
|
return `${item.weeklyHours} heures`
|
||||||
|
}
|
||||||
|
return item.contractName ?? '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentActiveContractPeriod = computed(() => {
|
||||||
|
const today = getTodayYmd()
|
||||||
|
const history = employee.value?.contractHistory ?? []
|
||||||
|
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCloseCurrentContract = computed(() => {
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!active) return false
|
||||||
|
if (!active.endDate) return true
|
||||||
|
return active.endDate > getTodayYmd()
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCreateContract = computed(() => {
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!active) return true
|
||||||
|
return !!active.endDate
|
||||||
|
})
|
||||||
|
|
||||||
|
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
|
||||||
|
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
|
||||||
|
|
||||||
|
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
|
||||||
|
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
|
||||||
|
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
||||||
|
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
||||||
|
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
||||||
|
const isCreateContractFormValid = computed(() =>
|
||||||
|
isCreateContractValid.value &&
|
||||||
|
isCreateContractNatureValid.value &&
|
||||||
|
isCreateContractStartDateValid.value &&
|
||||||
|
isCreateContractEndDateValid.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseInputClass =
|
||||||
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
|
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
||||||
|
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||||
|
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||||
|
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const closeContractWorkedHoursLabel = computed(() => {
|
||||||
|
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
||||||
|
return contractForm.contractName || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetContractValidation = () => {
|
||||||
|
validationTouched.endDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateContractFormFromCurrent = () => {
|
||||||
|
const current = employee.value
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!current || !active) return
|
||||||
|
|
||||||
|
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
||||||
|
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
||||||
|
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
||||||
|
contractForm.contractNature = active.contractNature
|
||||||
|
contractForm.startDate = active.startDate
|
||||||
|
contractForm.endDate = getTodayYmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCloseContractDrawer = () => {
|
||||||
|
if (!employee.value || !canCloseCurrentContract.value) return
|
||||||
|
hydrateContractFormFromCurrent()
|
||||||
|
resetContractValidation()
|
||||||
|
isContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setContractDrawerOpen = (open: boolean) => {
|
||||||
|
isContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreateValidation = () => {
|
||||||
|
createValidationTouched.contractId = false
|
||||||
|
createValidationTouched.contractNature = false
|
||||||
|
createValidationTouched.startDate = false
|
||||||
|
createValidationTouched.endDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateContractDrawer = () => {
|
||||||
|
if (!employee.value || !canCreateContract.value) return
|
||||||
|
createContractForm.contractId = ''
|
||||||
|
createContractForm.contractNature = 'CDI'
|
||||||
|
createContractForm.endDate = ''
|
||||||
|
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
||||||
|
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
||||||
|
: getTodayYmd()
|
||||||
|
resetCreateValidation()
|
||||||
|
isCreateContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCreateContractDrawerOpen = (open: boolean) => {
|
||||||
|
isCreateContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEmployee = async () => {
|
||||||
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
const employeeId = Number(idParam)
|
||||||
|
if (!Number.isInteger(employeeId) || employeeId <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
employee.value = await getEmployee(employeeId)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitContractUpdate = async () => {
|
||||||
|
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
||||||
|
|
||||||
|
validationTouched.endDate = true
|
||||||
|
if (!isContractEndDateValid.value) return
|
||||||
|
|
||||||
|
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isContractSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await updateEmployee(employee.value.id, {
|
||||||
|
firstName: employee.value.firstName,
|
||||||
|
lastName: employee.value.lastName,
|
||||||
|
siteId: employee.value.site?.id ?? null,
|
||||||
|
contractId: Number(contractForm.contractId),
|
||||||
|
contractEndDate: contractForm.endDate || null
|
||||||
|
})
|
||||||
|
|
||||||
|
isContractDrawerOpen.value = false
|
||||||
|
await loadEmployee()
|
||||||
|
} finally {
|
||||||
|
isContractSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreateContract = async () => {
|
||||||
|
if (!employee.value || isCreateContractSubmitting.value) return
|
||||||
|
|
||||||
|
createValidationTouched.contractId = true
|
||||||
|
createValidationTouched.contractNature = true
|
||||||
|
createValidationTouched.startDate = true
|
||||||
|
createValidationTouched.endDate = true
|
||||||
|
if (!isCreateContractFormValid.value) return
|
||||||
|
|
||||||
|
if (currentActiveContractPeriod.value?.endDate) {
|
||||||
|
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
||||||
|
if (createContractForm.startDate < minStartDate) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreateContractSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await updateEmployee(employee.value.id, {
|
||||||
|
firstName: employee.value.firstName,
|
||||||
|
lastName: employee.value.lastName,
|
||||||
|
siteId: employee.value.site?.id ?? null,
|
||||||
|
contractId: Number(createContractForm.contractId),
|
||||||
|
contractNature: createContractForm.contractNature,
|
||||||
|
contractStartDate: createContractForm.startDate,
|
||||||
|
contractEndDate: createContractForm.endDate || null
|
||||||
|
})
|
||||||
|
isCreateContractDrawerOpen.value = false
|
||||||
|
await loadEmployee()
|
||||||
|
} finally {
|
||||||
|
isCreateContractSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(requiresCreateContractEndDate, (required) => {
|
||||||
|
if (!required) {
|
||||||
|
createContractForm.endDate = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
await loadEmployee()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee,
|
||||||
|
isLoading,
|
||||||
|
activeTab,
|
||||||
|
contracts,
|
||||||
|
contractHistory,
|
||||||
|
employeeContractWorkLabel,
|
||||||
|
contractForm,
|
||||||
|
createContractForm,
|
||||||
|
isContractDrawerOpen,
|
||||||
|
isContractSubmitting,
|
||||||
|
isCreateContractDrawerOpen,
|
||||||
|
isCreateContractSubmitting,
|
||||||
|
canCloseCurrentContract,
|
||||||
|
canCreateContract,
|
||||||
|
readonlyFieldClass,
|
||||||
|
closeContractWorkedHoursLabel,
|
||||||
|
contractEndDateFieldClass,
|
||||||
|
showContractEndDateError,
|
||||||
|
isContractEndDateValid,
|
||||||
|
createContractNatureFieldClass,
|
||||||
|
createContractFieldClass,
|
||||||
|
createContractStartDateFieldClass,
|
||||||
|
requiresCreateContractEndDate,
|
||||||
|
createContractEndDateFieldClass,
|
||||||
|
isCreateContractFormValid,
|
||||||
|
contractNatureLabel,
|
||||||
|
contractHistoryLabel,
|
||||||
|
formatDate,
|
||||||
|
openCloseContractDrawer,
|
||||||
|
openCreateContractDrawer,
|
||||||
|
setContractDrawerOpen,
|
||||||
|
setCreateContractDrawerOpen,
|
||||||
|
submitContractUpdate,
|
||||||
|
submitCreateContract
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,20 +62,16 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 items-center p-4">
|
<div class="flex flex-col gap-2 items-center p-4">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
Déconnexion
|
|
||||||
</button>
|
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
|
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
||||||
<slot/>
|
<AppTopNav :user="auth.user" />
|
||||||
</main>
|
<main class="flex-1 overflow-y-auto px-8 py-12">
|
||||||
|
<slot/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default defineNuxtConfig({
|
|||||||
devServer: {port: 3001},
|
devServer: {port: 3001},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
timeout: 10000,
|
timeout: 2000,
|
||||||
closeOnClick: true,
|
closeOnClick: true,
|
||||||
progressBar: false
|
progressBar: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-auto">
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!employee"
|
||||||
|
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Employé introuvable.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
||||||
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-12 border-b border-primary-500">
|
||||||
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'contract'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'contract'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||||
|
Suivi contrat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'leave'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'leave'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||||
|
Congé
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'rtt'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'rtt'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:magnify" size="24" class="align-self"/>
|
||||||
|
RTT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmployeesContractTab
|
||||||
|
v-if="activeTab === 'contract'"
|
||||||
|
:contract-history="contractHistory"
|
||||||
|
:contract-nature-label="contractNatureLabel"
|
||||||
|
:contract-history-label="contractHistoryLabel"
|
||||||
|
:format-date="formatDate"
|
||||||
|
:is-contract-submitting="isContractSubmitting"
|
||||||
|
:can-close-current-contract="canCloseCurrentContract"
|
||||||
|
:is-create-contract-submitting="isCreateContractSubmitting"
|
||||||
|
:contracts="contracts"
|
||||||
|
:can-create-contract="canCreateContract"
|
||||||
|
:is-contract-drawer-open="isContractDrawerOpen"
|
||||||
|
:contract-form="contractForm"
|
||||||
|
:readonly-field-class="readonlyFieldClass"
|
||||||
|
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
|
||||||
|
:contract-end-date-field-class="contractEndDateFieldClass"
|
||||||
|
:show-contract-end-date-error="showContractEndDateError"
|
||||||
|
:is-contract-end-date-valid="isContractEndDateValid"
|
||||||
|
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
|
||||||
|
:create-contract-form="createContractForm"
|
||||||
|
:create-contract-nature-field-class="createContractNatureFieldClass"
|
||||||
|
:create-contract-field-class="createContractFieldClass"
|
||||||
|
:create-contract-start-date-field-class="createContractStartDateFieldClass"
|
||||||
|
:requires-create-contract-end-date="requiresCreateContractEndDate"
|
||||||
|
:create-contract-end-date-field-class="createContractEndDateFieldClass"
|
||||||
|
:is-create-contract-form-valid="isCreateContractFormValid"
|
||||||
|
:on-open-close-contract-drawer="openCloseContractDrawer"
|
||||||
|
:on-open-create-contract-drawer="openCreateContractDrawer"
|
||||||
|
:on-update-contract-drawer-open="setContractDrawerOpen"
|
||||||
|
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
|
||||||
|
:on-submit-close-contract="submitContractUpdate"
|
||||||
|
:on-submit-create-contract="submitCreateContract"
|
||||||
|
/>
|
||||||
|
<EmployeesLeaveTab v-else-if="activeTab === 'leave'" />
|
||||||
|
<EmployeesRttTab v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
employee,
|
||||||
|
isLoading,
|
||||||
|
activeTab,
|
||||||
|
contracts,
|
||||||
|
contractHistory,
|
||||||
|
employeeContractWorkLabel,
|
||||||
|
contractForm,
|
||||||
|
createContractForm,
|
||||||
|
isContractDrawerOpen,
|
||||||
|
isContractSubmitting,
|
||||||
|
isCreateContractDrawerOpen,
|
||||||
|
isCreateContractSubmitting,
|
||||||
|
canCloseCurrentContract,
|
||||||
|
canCreateContract,
|
||||||
|
readonlyFieldClass,
|
||||||
|
closeContractWorkedHoursLabel,
|
||||||
|
contractEndDateFieldClass,
|
||||||
|
showContractEndDateError,
|
||||||
|
isContractEndDateValid,
|
||||||
|
createContractNatureFieldClass,
|
||||||
|
createContractFieldClass,
|
||||||
|
createContractStartDateFieldClass,
|
||||||
|
requiresCreateContractEndDate,
|
||||||
|
createContractEndDateFieldClass,
|
||||||
|
isCreateContractFormValid,
|
||||||
|
contractNatureLabel,
|
||||||
|
contractHistoryLabel,
|
||||||
|
formatDate,
|
||||||
|
openCloseContractDrawer,
|
||||||
|
openCreateContractDrawer,
|
||||||
|
setContractDrawerOpen,
|
||||||
|
setCreateContractDrawerOpen,
|
||||||
|
submitContractUpdate,
|
||||||
|
submitCreateContract
|
||||||
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: employee.value
|
||||||
|
? `${employee.value.firstName} ${employee.value.lastName}`
|
||||||
|
: 'Détail employé'
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
<div class="flex-col">
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:plus-thick" size="16" class="text-white"/>
|
||||||
|
Ajouter un employé
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-10 py-7">
|
||||||
<div class="flex flex-col gap-3 py-6">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
Ajouter un employé
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,57 +27,33 @@
|
|||||||
Aucun employé pour le moment.
|
Aucun employé pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div v-else class="grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
|
||||||
<div class="h-full overflow-auto">
|
<NuxtLink
|
||||||
<div class="min-w-[900px]">
|
v-for="employee in filteredEmployees"
|
||||||
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
|
:key="employee.id"
|
||||||
<span class="text-left">Prénom</span>
|
:to="`/employees/${employee.id}`"
|
||||||
<span class="text-left">Nom</span>
|
target="_blank"
|
||||||
<span class="text-left">Site</span>
|
rel="noopener noreferrer"
|
||||||
<span class="text-left">Nature</span>
|
class="group relative min-h-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
|
||||||
<span class="text-left">Contrat</span>
|
>
|
||||||
<span class="text-right">Actions</span>
|
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
|
||||||
</div>
|
<div class="rounded-full bg-neutral-300 h-[175px] w-[175px]"></div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div class="text-center text-[20px]">
|
||||||
Chargement...
|
<p class="text-primary-500 font-bold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
||||||
</div>
|
<p>Nom du poste occupé</p>
|
||||||
<div v-else>
|
<p>Site ({{ employee.site?.name ?? '-' }})</p>
|
||||||
<div
|
|
||||||
v-for="employee in filteredEmployees"
|
|
||||||
:key="employee.id"
|
|
||||||
class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span>{{ employee.firstName }}</span>
|
|
||||||
<span>{{ employee.lastName }}</span>
|
|
||||||
<span
|
|
||||||
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
|
|
||||||
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
|
|
||||||
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
|
|
||||||
>
|
|
||||||
{{ employee.site?.name ?? '-' }}
|
|
||||||
</span>
|
|
||||||
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
|
|
||||||
<span>{{ employee.contract?.name ?? '-' }}</span>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="openEdit(employee)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
|
|
||||||
@click="confirmDelete(employee)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
||||||
|
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
||||||
|
<p>Type: {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
||||||
|
<p>Temps de travail: {{ employee.contract?.name ?? '-' }}</p>
|
||||||
|
<p>Site: {{ employee.site?.name ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
@@ -180,10 +154,10 @@
|
|||||||
La date de début est obligatoire.
|
La date de début est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="requiresContractEndDate">
|
<div v-if="requiresContractEndDateComputed">
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
||||||
Fin contrat
|
Fin contrat
|
||||||
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
|
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="contract-end-date"
|
id="contract-end-date"
|
||||||
@@ -225,6 +199,7 @@ 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'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Employés'
|
title: 'Employés'
|
||||||
})
|
})
|
||||||
@@ -244,7 +219,7 @@ const contracts = ref<Contract[]>([])
|
|||||||
const employeeFilter = ref('')
|
const employeeFilter = ref('')
|
||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
const filteredEmployees = computed(() => {
|
const filteredEmployees = computed<Employee[]>(() => {
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
@@ -262,12 +237,6 @@ const filteredEmployees = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
|
|
||||||
if (value === 'CDD') return 'CDD'
|
|
||||||
if (value === 'INTERIM') return 'Intérim'
|
|
||||||
return 'CDI'
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@@ -292,11 +261,11 @@ 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 isContractValid = computed(() => form.contractId !== '')
|
||||||
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
|
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
|
||||||
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
||||||
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
|
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
|
||||||
const isContractEndDateValid = computed(() => {
|
const isContractEndDateValid = computed(() => {
|
||||||
if (!requiresContractEndDate.value) return true
|
if (!requiresContractEndDateComputed.value) return true
|
||||||
return form.contractEndDate !== ''
|
return form.contractEndDate !== ''
|
||||||
})
|
})
|
||||||
const isFormValid = computed(
|
const isFormValid = computed(
|
||||||
@@ -459,7 +428,7 @@ const handleSubmit = async () => {
|
|||||||
contractId: Number(form.contractId),
|
contractId: Number(form.contractId),
|
||||||
contractNature: form.contractNature,
|
contractNature: form.contractNature,
|
||||||
contractStartDate: form.contractStartDate,
|
contractStartDate: form.contractStartDate,
|
||||||
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
|
contractEndDate: requiresContractEndDateComputed.value ? form.contractEndDate : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +459,7 @@ watch(isDrawerOpen, (isOpen) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(requiresContractEndDate, (required) => {
|
watch(requiresContractEndDateComputed, (required) => {
|
||||||
if (!required) {
|
if (!required) {
|
||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
import type { Contract } from './contract'
|
||||||
|
|
||||||
|
export type ContractHistoryItem = {
|
||||||
|
contractId?: number | null
|
||||||
|
contractName?: string | null
|
||||||
|
weeklyHours?: number | null
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -10,5 +19,6 @@ export type Employee = {
|
|||||||
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
currentContractStartDate?: string | null
|
currentContractStartDate?: string | null
|
||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
|
contractHistory?: ContractHistoryItem[]
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type NotificationItem = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
isRead: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ export const listScopedEmployees = async () => {
|
|||||||
return extractItems<Employee>(data)
|
return extractItems<Employee>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getEmployee = async (id: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
|
||||||
export const createEmployee = async (payload: {
|
export const createEmployee = async (payload: {
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
@@ -51,7 +56,7 @@ export const updateEmployee = async (
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
contractId: number
|
contractId?: number
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
contractStartDate?: string
|
contractStartDate?: string
|
||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
@@ -59,16 +64,27 @@ export const updateEmployee = async (
|
|||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Employee>(`/employees/${id}`, {
|
const body: Record<string, unknown> = {
|
||||||
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}`,
|
|
||||||
contractNature: payload.contractNature,
|
|
||||||
contractStartDate: payload.contractStartDate,
|
|
||||||
contractEndDate: payload.contractEndDate ?? null,
|
|
||||||
displayOrder: payload.displayOrder
|
displayOrder: payload.displayOrder
|
||||||
}, {
|
}
|
||||||
|
|
||||||
|
if (payload.contractId !== undefined) {
|
||||||
|
body.contract = `/api/contracts/${payload.contractId}`
|
||||||
|
}
|
||||||
|
if (payload.contractNature !== undefined) {
|
||||||
|
body.contractNature = payload.contractNature
|
||||||
|
}
|
||||||
|
if (payload.contractStartDate !== undefined) {
|
||||||
|
body.contractStartDate = payload.contractStartDate
|
||||||
|
}
|
||||||
|
if (payload.contractEndDate !== undefined) {
|
||||||
|
body.contractEndDate = payload.contractEndDate ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
toastErrorKey: 'errors.employee.update'
|
toastErrorKey: 'errors.employee.update'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NotificationItem } from './dto/notification'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const listUnreadNotifications = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
||||||
|
'/notifications/unread',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<NotificationItem>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markAllNotificationsRead = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post('/notifications/mark-all-read', {}, { toast: false })
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ export default <Partial<Config>>{
|
|||||||
},
|
},
|
||||||
tertiary: {
|
tertiary: {
|
||||||
500: '#F3F4F8'
|
500: '#F3F4F8'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
500: '#056CF2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
|
||||||
|
|
||||||
|
export type ContractNature = (typeof CONTRACT_NATURES)[number]
|
||||||
|
|
||||||
|
export const contractNatureLabel = (value?: ContractNature) => {
|
||||||
|
if (value === 'CDD') return 'CDD'
|
||||||
|
if (value === 'INTERIM') return 'Intérim'
|
||||||
|
return 'CDI'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requiresContractEndDate = (nature: ContractNature) => {
|
||||||
|
return nature === 'CDD' || nature === 'INTERIM'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isContractNature = (value: string): value is ContractNature => {
|
||||||
|
return (CONTRACT_NATURES as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@ 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 formatYmdToFr = (value: string) => {
|
||||||
|
const [year, month, day] = value.split('-')
|
||||||
|
if (!year || !month || !day) return value
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
|
||||||
|
if (!value) return fallback
|
||||||
|
return formatYmdToFr(value)
|
||||||
|
}
|
||||||
|
|
||||||
export const parseYmd = (value: string) => {
|
export const parseYmd = (value: string) => {
|
||||||
const [year, month, day] = value.split('-').map(Number)
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
if (!year || !month || !day) return null
|
if (!year || !month || !day) return null
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260302110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add notifications table for user notification center';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)');
|
||||||
|
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78');
|
||||||
|
$this->addSql('DROP TABLE notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Employees;
|
||||||
|
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
final class ContractHistoryItem
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?int $contractId,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $contractName,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?float $weeklyHours,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public string $contractNature,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public string $startDate,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $endDate,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Dto\Employees\ContractHistoryItem;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\State\EmployeeWriteProcessor;
|
use App\State\EmployeeWriteProcessor;
|
||||||
@@ -204,6 +205,35 @@ class Employee
|
|||||||
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractHistoryItem>
|
||||||
|
*/
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getContractHistory(): array
|
||||||
|
{
|
||||||
|
$periods = $this->contractPeriods->toArray();
|
||||||
|
usort(
|
||||||
|
$periods,
|
||||||
|
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $b->getStartDate() <=> $a->getStartDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static function (EmployeeContractPeriod $period): ContractHistoryItem {
|
||||||
|
$contract = $period->getContract();
|
||||||
|
|
||||||
|
return new ContractHistoryItem(
|
||||||
|
contractId: $contract?->getId(),
|
||||||
|
contractName: $contract?->getName(),
|
||||||
|
weeklyHours: $contract?->getWeeklyHours(),
|
||||||
|
contractNature: $period->getContractNatureEnum()->value,
|
||||||
|
startDate: $period->getStartDate()->format('Y-m-d'),
|
||||||
|
endDate: $period->getEndDate()?->format('Y-m-d'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
$periods
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\State\MarkAllNotificationsReadProcessor;
|
||||||
|
use App\State\UnreadNotificationsProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/notifications/unread',
|
||||||
|
normalizationContext: ['groups' => ['notification:read']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: UnreadNotificationsProvider::class,
|
||||||
|
paginationEnabled: false
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/notifications/mark-all-read',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
input: false,
|
||||||
|
output: false,
|
||||||
|
read: false,
|
||||||
|
processor: MarkAllNotificationsReadProcessor::class
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
|
||||||
|
#[ORM\Table(name: 'notifications')]
|
||||||
|
#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
|
||||||
|
class Notification
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?User $recipient = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 120)]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private string $title = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private string $message = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private bool $isRead = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecipient(): ?User
|
||||||
|
{
|
||||||
|
return $this->recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRecipient(?User $recipient): self
|
||||||
|
{
|
||||||
|
$this->recipient = $recipient;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(string $title): self
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMessage(string $message): self
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRead(): bool
|
||||||
|
{
|
||||||
|
return $this->isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsRead(): bool
|
||||||
|
{
|
||||||
|
return $this->isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsRead(bool $isRead): self
|
||||||
|
{
|
||||||
|
$this->isRead = $isRead;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
use App\State\CurrentUserProvider;
|
use App\State\CurrentUserProvider;
|
||||||
use App\State\UserPasswordHasherProcessor;
|
use App\State\UserPasswordHasherProcessor;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -52,7 +53,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: 'users')]
|
#[ORM\Table(name: 'users')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
|
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Notification;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Notification>
|
||||||
|
*/
|
||||||
|
final class NotificationRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Notification::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Notification>
|
||||||
|
*/
|
||||||
|
public function findUnreadByRecipient(User $recipient): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('n')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.isRead = :isRead')
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->setParameter('isRead', false)
|
||||||
|
->orderBy('n.createdAt', 'DESC')
|
||||||
|
->setMaxResults(50)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllReadByRecipient(User $recipient): int
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('n')
|
||||||
|
->update()
|
||||||
|
->set('n.isRead', ':isRead')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.isRead = :current')
|
||||||
|
->setParameter('isRead', true)
|
||||||
|
->setParameter('current', false)
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<User>
|
||||||
|
*/
|
||||||
|
final class UserRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<User>
|
||||||
|
*/
|
||||||
|
public function findAllAdmins(): array
|
||||||
|
{
|
||||||
|
/** @var list<User> $users */
|
||||||
|
$users = $this->createQueryBuilder('u')
|
||||||
|
->orderBy('u.id', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$users,
|
||||||
|
static fn (User $user): bool => in_array('ROLE_ADMIN', $user->getRoles(), true)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,4 +137,23 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
// @var null|WorkHour $workHour
|
// @var null|WorkHour $workHour
|
||||||
return $qb->getQuery()->getOneOrNullResult();
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
|
||||||
|
{
|
||||||
|
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('w')
|
||||||
|
->select('COUNT(w.id)')
|
||||||
|
->leftJoin('w.employee', 'e')
|
||||||
|
->leftJoin('e.site', 's')
|
||||||
|
->andWhere('s.id = :siteId')
|
||||||
|
->andWhere('w.workDate = :workDate')
|
||||||
|
->andWhere('w.isSiteValid = :isSiteValid')
|
||||||
|
->setParameter('siteId', $siteId)
|
||||||
|
->setParameter('workDate', $workDate)
|
||||||
|
->setParameter('isSiteValid', false)
|
||||||
|
;
|
||||||
|
|
||||||
|
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,26 +70,53 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$startDate = $requestedStartDate ?? $today;
|
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
$currentPeriodContract = $todayPeriod?->getContract();
|
||||||
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
$contractChanged = $currentPeriodContract instanceof Contract
|
||||||
$endDate = $requestedEndDate;
|
? $currentPeriodContract->getId() !== $currentContract->getId()
|
||||||
$this->assertPeriodDates($startDate, $endDate, $nature);
|
: true;
|
||||||
|
$isCloseOnlyRequest = !$contractChanged
|
||||||
|
&& null === $requestedStartDate
|
||||||
|
&& null === $requestedContractNature
|
||||||
|
&& null !== $requestedEndDate;
|
||||||
|
|
||||||
if (
|
if ($isCloseOnlyRequest) {
|
||||||
null !== $todayPeriod
|
if (null === $todayPeriod) {
|
||||||
&& null === $todayPeriod->getEndDate()
|
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
||||||
&& $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
|
}
|
||||||
) {
|
|
||||||
$todayPeriod->setContract($currentContract);
|
$currentNature = $todayPeriod->getContractNatureEnum();
|
||||||
$todayPeriod->setContractNature($nature);
|
$this->assertPeriodDates($todayPeriod->getStartDate(), $requestedEndDate, $currentNature, true);
|
||||||
$todayPeriod->setEndDate($endDate);
|
|
||||||
|
$currentEndDate = $todayPeriod->getEndDate();
|
||||||
|
if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
|
||||||
|
throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayPeriod->setEndDate($requestedEndDate);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
|
$startDate = $requestedStartDate ?? $today;
|
||||||
|
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||||
|
$endDate = $requestedEndDate;
|
||||||
|
$this->assertPeriodDates($startDate, $endDate, $nature);
|
||||||
|
|
||||||
|
if (null !== $todayPeriod) {
|
||||||
|
$currentEndDate = $todayPeriod->getEndDate();
|
||||||
|
if (null === $currentEndDate) {
|
||||||
|
if ($startDate <= $todayPeriod->getStartDate()) {
|
||||||
|
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayPeriod->setEndDate($startDate->modify('-1 day'));
|
||||||
|
} elseif ($startDate <= $currentEndDate) {
|
||||||
|
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
|
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
@@ -179,7 +206,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
private function assertPeriodDates(
|
private function assertPeriodDates(
|
||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature
|
ContractNature $nature,
|
||||||
|
bool $allowCdiEndDate = false
|
||||||
): void {
|
): void {
|
||||||
if (null !== $endDate && $endDate < $startDate) {
|
if (null !== $endDate && $endDate < $startDate) {
|
||||||
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
|
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
|
||||||
@@ -189,7 +217,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
|
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ContractNature::CDI === $nature && null !== $endDate) {
|
if (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
|
||||||
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
|
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
|
final readonly class MarkAllNotificationsReadProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notificationRepository->markAllReadByRecipient($user);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
|
final readonly class UnreadNotificationsProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->notificationRepository->findUnreadByRecipient($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Notification;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -18,6 +21,8 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private EmployeeScopeService $employeeScopeService,
|
private EmployeeScopeService $employeeScopeService,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -47,8 +52,38 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
|||||||
throw new AccessDeniedHttpException('Employee is outside your site scope.');
|
throw new AccessDeniedHttpException('Employee is outside your site scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$uow = $this->entityManager->getUnitOfWork();
|
||||||
|
$uow->computeChangeSets();
|
||||||
|
$changeSet = $uow->getEntityChangeSet($data);
|
||||||
|
$isSiteValidationChangedToTrue = isset($changeSet['isSiteValid'])
|
||||||
|
&& false === $changeSet['isSiteValid'][0]
|
||||||
|
&& true === $changeSet['isSiteValid'][1];
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Notification uniquement quand la dernière ligne du site est validée pour la date.
|
||||||
|
if ($isSiteValidationChangedToTrue) {
|
||||||
|
$workDate = $data->getWorkDate();
|
||||||
|
$hasPending = $this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate);
|
||||||
|
if (!$hasPending) {
|
||||||
|
$siteName = $data->getEmployee()?->getSite()?->getName() ?? 'Site';
|
||||||
|
$dateLabel = $workDate->format('d/m/Y');
|
||||||
|
$title = sprintf('%s validé', $siteName);
|
||||||
|
$message = sprintf('Le site %s a terminé la validation du %s.', $siteName, $dateLabel);
|
||||||
|
|
||||||
|
foreach ($this->userRepository->findAllAdmins() as $admin) {
|
||||||
|
$notification = new Notification()
|
||||||
|
->setRecipient($admin)
|
||||||
|
->setTitle($title)
|
||||||
|
->setMessage($message)
|
||||||
|
;
|
||||||
|
$this->entityManager->persist($notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user