Compare commits

..

4 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:47:14 +02:00
gitea-actions
9e411be3c3 chore: bump version to v0.1.89
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-04-17 09:05:24 +00:00
90e63a463e feat : autoriser la création d'absences sur les jours fériés depuis le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:04:57 +02:00
30 changed files with 291 additions and 43 deletions

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.88'
app.version: '0.1.90'

View File

@@ -130,6 +130,7 @@ Documents complementaires:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
## 6bis) Heures Conducteurs
@@ -166,7 +167,7 @@ Documents complementaires:
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Règle courante:
- absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:class="getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation"
:disabled="getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -80,9 +80,7 @@
<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>

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-2">Statut</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>

View File

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

View File

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

View File

@@ -207,10 +207,10 @@ export const documentationSections: DocSection[] = [
},
{
id: 'gestion-types-absence',
title: 'Gestion des types d\'absence',
title: 'Gestion des types de statut',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
],
@@ -258,7 +258,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
],
},
{
@@ -387,7 +387,8 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
],
},
],

View File

@@ -82,7 +82,7 @@
: ''"
>
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p>
<p>Types de statut</p>
</NuxtLink>
<NuxtLink
to="/users"

View File

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

View File

@@ -490,14 +490,15 @@ const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés (interdit pour la création).
// Jours fériés.
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
// Renvoie l'absence effective pour une cellule (ou un "Férié").
// Renvoie l'absence effective pour une cellule (ou un "Férié" si pas d'absence).
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return {
id: 0,
code: 'Férié',
@@ -505,7 +506,6 @@ const getCellAbsence = (employeeId: number, date: string) => {
textColor: '#0f172a'
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
@@ -549,11 +549,6 @@ const getCellInfo = (employeeId: number, date: string) => {
// Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -590,10 +585,6 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today
form.endDate = today
form.startHalf = 'AM'

View File

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

View File

@@ -72,7 +72,7 @@
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
@@ -147,6 +147,21 @@
Le type de contrat est obligatoire.
</p>
</div>
<div v-if="form.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="interim-agency">
Agence d'intérim
</label>
<select
id="interim-agency"
v-model="form.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
@@ -191,7 +206,7 @@
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD.
La date de fin est obligatoire pour un CDD ou un Intérim.
</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
@@ -246,6 +261,7 @@ import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
@@ -269,6 +285,7 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const selectedSiteIds = ref<number[]>([])
@@ -300,7 +317,8 @@ const form = reactive({
contractStartDate: '',
contractEndDate: '',
isDriver: false,
workDaysHours: null as Record<number, number> | null
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const validationTouched = reactive({
@@ -451,8 +469,12 @@ const loadContracts = async () => {
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
@@ -503,7 +525,8 @@ const handleSubmit = async () => {
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
})
}
@@ -516,6 +539,7 @@ const handleSubmit = async () => {
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -542,6 +566,12 @@ watch(showsContractEndDateComputed, (shows) => {
}
})
watch(() => form.contractNature, (nature) => {
if (nature !== 'INTERIM') {
form.interimAgencyId = ''
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
@@ -567,6 +597,7 @@ const openCreate = () => {
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
isDrawerOpen.value = true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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