feat : ajout d'un champ commentaire sur les contrats + correction de plusieurs bugs

This commit is contained in:
2026-03-06 16:58:29 +01:00
parent 4cf2608cdd
commit e794ad2514
28 changed files with 235 additions and 59 deletions

View File

@@ -5,7 +5,8 @@
"Bash(npx nuxi:*)", "Bash(npx nuxi:*)",
"Bash(php:*)", "Bash(php:*)",
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(make test:*)" "Bash(make test:*)",
"Bash(grep:*)"
] ]
} }
} }

View File

@@ -43,10 +43,17 @@ Documents complementaires:
- Saisie par salarié et par date: - Saisie par salarié et par date:
- matin / après-midi / soir - matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi - pour `PRESENCE`: demi-journées matin/après-midi
- Sélecteur de temps:
- créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
- saisie libre possible mais valeur vidée au blur si hors options
- Calculs affichés: - Calculs affichés:
- `Jour`, `Nuit`, `Total` - `Jour`, `Nuit`, `Total`
- Heures de nuit: - Heures de nuit:
- fenêtres `00:00-06:00` et `21:00-24:00` - fenêtres `00:00-06:00` et `21:00-24:00`
- Date de modification (`updatedAt`):
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement)
## 4) Absences ## 4) Absences
@@ -57,8 +64,9 @@ Documents complementaires:
- Colonne absence (vue jour): - Colonne absence (vue jour):
- affiche le libellé - affiche le libellé
- fond coloré selon le type d'absence - fond coloré selon le type d'absence
- Si plusieurs absences de couleurs différentes sur le même jour: - Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- fallback rouge - demi-journée: dégradé diagonal
- journée complète: fond plein
### Effet absence sur les heures ### Effet absence sur les heures
@@ -73,7 +81,7 @@ Documents complementaires:
- Si `countAsWorkedHours = true`: - Si `countAsWorkedHours = true`:
- `TIME`: crédit de minutes selon contrat actif du jour - `TIME`: crédit de minutes selon contrat actif du jour
- `PRESENCE`: crédit d'unités (0.5 / demi-journée) - `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)
## 5) Validations des lignes d'heures ## 5) Validations des lignes d'heures
@@ -112,6 +120,7 @@ Documents complementaires:
## 7) Fériés ## 7) Fériés
- Les jours fériés sont identifiés et affichés - Les jours fériés sont identifiés et affichés
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Règle courante: - Règle courante:
- absences bloquées sur jour férié - absences bloquées sur jour férié
- saisie d'heures autorisée - saisie d'heures autorisée

View File

@@ -86,6 +86,19 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire
</label>
<textarea
id="contract-comment"
v-model="contractForm.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Motif de la clôture..."
/>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3"> <div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled"> <label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
<input <input
@@ -191,6 +204,7 @@ type ContractForm = {
startDate: string startDate: string
endDate: string endDate: string
paidLeaveSettled: boolean paidLeaveSettled: boolean
comment: string
} }
type CreateContractForm = { type CreateContractForm = {

View File

@@ -41,9 +41,9 @@
> >
<div <div
class="h-6 w-6" class="h-6 w-6"
:class="getDayClass(day.leave)" :class="getDayClass(day)"
:style="getDayStyle(day.leave)" :style="getDayStyle(day)"
:title="getDayTitle(day.leave)" :title="getDayTitle(day)"
> >
{{ getDayText(day) }} {{ getDayText(day) }}
</div> </div>
@@ -65,13 +65,13 @@ type DayLeaveState = {
am: boolean am: boolean
pm: boolean pm: boolean
labels: string[] labels: string[]
hasCongeTypeC: boolean colors: string[]
hasOtherTypes: boolean
} }
const props = defineProps<{ const props = defineProps<{
absences: Absence[] absences: Absence[]
summary: EmployeeLeaveSummary | null summary: EmployeeLeaveSummary | null
publicHolidays: Record<string, string>
}>() }>()
const monthLabels = [ const monthLabels = [
@@ -124,8 +124,7 @@ const dayLeaveMap = computed(() => {
am: false, am: false,
pm: false, pm: false,
labels: [] as string[], labels: [] as string[],
hasCongeTypeC: false, colors: [] as string[]
hasOtherTypes: false
} }
const isStart = ymd === startYmd const isStart = ymd === startYmd
@@ -150,18 +149,21 @@ const dayLeaveMap = computed(() => {
} }
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence' const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
const typeCode = (absence.type?.code ?? '').toUpperCase() const typeColor = absence.type?.color ?? '#222783'
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '') const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
const hoverLabel = `${typeLabel}${halfSuffix}` const hoverLabel = `${typeLabel}${halfSuffix}`
const colors = existing.colors.includes(typeColor)
? existing.colors
: [...existing.colors, typeColor]
map.set(ymd, { map.set(ymd, {
am: existing.am || am, am: existing.am || am,
pm: existing.pm || pm, pm: existing.pm || pm,
labels: existing.labels.includes(hoverLabel) labels: existing.labels.includes(hoverLabel)
? existing.labels ? existing.labels
: [...existing.labels, hoverLabel], : [...existing.labels, hoverLabel],
hasCongeTypeC: existing.hasCongeTypeC || typeCode === 'C', colors
hasOtherTypes: existing.hasOtherTypes || typeCode !== 'C'
}) })
} }
} }
@@ -180,7 +182,7 @@ const months = computed(() => {
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate() const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
const mondayBasedFirstDay = (first.getDay() + 6) % 7 const mondayBasedFirstDay = (first.getDay() + 6) % 7
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null } | null> = [] const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null; isHoliday: boolean } | null> = []
for (let i = 0; i < mondayBasedFirstDay; i += 1) { for (let i = 0; i < mondayBasedFirstDay; i += 1) {
cells.push(null) cells.push(null)
@@ -191,7 +193,8 @@ const months = computed(() => {
cells.push({ cells.push({
ymd, ymd,
label: String(day), label: String(day),
leave: dayLeaveMap.value.get(ymd) ?? null leave: dayLeaveMap.value.get(ymd) ?? null,
isHoliday: ymd in props.publicHolidays
}) })
} }
@@ -206,37 +209,37 @@ const months = computed(() => {
}) })
}) })
const getDayClass = (leave: DayLeaveState | null) => { const getDayClass = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
if (!leave) return 'text-primary-500' if (day.leave) {
if (leave.am && leave.pm) { return 'rounded font-semibold text-white'
return leave.hasOtherTypes
? 'bg-red-600 text-white rounded font-semibold'
: 'bg-primary-500 text-white rounded font-semibold'
} }
return 'rounded text-primary-700 font-semibold text-white' if (day.isHoliday) return 'text-primary-500 rounded font-semibold'
return 'text-primary-500'
} }
const getDayStyle = (leave: DayLeaveState | null) => { const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
if (!leave || (leave.am && leave.pm)) return undefined if (day.leave) {
const color = day.leave.colors[0] ?? '#222783'
const color = leave.hasOtherTypes ? '#dc2626' : '#222783' if (day.leave.am && day.leave.pm) {
const backgroundImage = leave.am return { backgroundColor: color }
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)` }
: `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)` const backgroundImage = day.leave.am
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)`
return { : `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)`
backgroundImage, return { backgroundImage, backgroundColor: 'transparent' }
backgroundColor: 'transparent'
} }
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
return undefined
} }
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => { const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
return day.label return day.label
} }
const getDayTitle = (leave: DayLeaveState | null) => { const getDayTitle = (day: { leave: DayLeaveState | null; isHoliday: boolean; ymd: string }) => {
if (!leave || leave.labels.length === 0) return '' if (day.leave && day.leave.labels.length > 0) return day.leave.labels.join(' / ')
return leave.labels.join(' / ') if (day.isHoliday) return props.publicHolidays[day.ymd] ?? 'Jour férié'
return ''
} }
const formatCount = (value: number | null | undefined) => { const formatCount = (value: number | null | undefined) => {

View File

@@ -63,6 +63,9 @@
<Icon name="mdi:check"/> <Icon name="mdi:check"/>
</span> </span>
</p> </p>
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
Modifié le {{ getRowUpdatedAt(employee.id) }}
</p>
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5"> <div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p <p
@@ -216,6 +219,7 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string

View File

@@ -10,4 +10,5 @@ export type HourRow = {
isPresentAfternoon: boolean isPresentAfternoon: boolean
isSiteValid: boolean isSiteValid: boolean
isValid: boolean isValid: boolean
updatedAt: string | null
} }

View File

@@ -167,8 +167,9 @@ const closeMenu = () => {
const commitInput = () => { const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value) const normalized = normalizeTypedTime(inputValue.value)
if (normalized === null) { if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
inputValue.value = props.modelValue emit('update:modelValue', '')
inputValue.value = ''
closeMenu() closeMenu()
return return
} }

View File

@@ -9,6 +9,7 @@ import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary' import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary'
import { getEmployeeRttSummary } from '~/services/employee-rtt-summary' import { getEmployeeRttSummary } from '~/services/employee-rtt-summary'
import { getEmployee, updateEmployee } from '~/services/employees' import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract' import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
@@ -22,6 +23,7 @@ export const useEmployeeDetailPage = () => {
const employeeAbsences = ref<Absence[]>([]) const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null) const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const rttSummary = ref<EmployeeRttSummary | null>(null) const rttSummary = ref<EmployeeRttSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isContractDrawerOpen = ref(false) const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false) const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false) const isCreateContractDrawerOpen = ref(false)
@@ -34,7 +36,8 @@ export const useEmployeeDetailPage = () => {
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '', startDate: '',
endDate: '', endDate: '',
paidLeaveSettled: false paidLeaveSettled: false,
comment: ''
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -138,6 +141,7 @@ export const useEmployeeDetailPage = () => {
contractForm.startDate = active.startDate contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd() contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false contractForm.paidLeaveSettled = false
contractForm.comment = ''
} }
const openCloseContractDrawer = () => { const openCloseContractDrawer = () => {
@@ -198,7 +202,10 @@ export const useEmployeeDetailPage = () => {
const to = isForfait const to = isForfait
? `${leaveYear}-12-31` ? `${leaveYear}-12-31`
: `${leaveYear}-05-31` : `${leaveYear}-05-31`
const [absences, summary, rtt] = await Promise.all([ const holidayYears = isForfait
? [leaveYear]
: [leaveYear - 1, leaveYear]
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
listAbsences({ listAbsences({
from, from,
to, to,
@@ -207,11 +214,13 @@ export const useEmployeeDetailPage = () => {
showLeaveTab.value showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear) ? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null), : Promise.resolve(null),
getEmployeeRttSummary(loadedEmployee.id, rttYear) getEmployeeRttSummary(loadedEmployee.id, rttYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
]) ])
employeeAbsences.value = absences employeeAbsences.value = absences
leaveSummary.value = summary leaveSummary.value = summary
rttSummary.value = rtt rttSummary.value = rtt
publicHolidays.value = Object.assign({}, ...holidayResults)
if (!showLeaveTab.value && activeTab.value === 'leave') { if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract' activeTab.value = 'contract'
} }
@@ -242,7 +251,8 @@ export const useEmployeeDetailPage = () => {
siteId: employee.value.site?.id ?? null, siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId), contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null, contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
}) })
isContractDrawerOpen.value = false isContractDrawerOpen.value = false
@@ -309,6 +319,7 @@ export const useEmployeeDetailPage = () => {
employeeAbsences, employeeAbsences,
leaveSummary, leaveSummary,
rttSummary, rttSummary,
publicHolidays,
showLeaveTab, showLeaveTab,
contractHistory, contractHistory,
employeeContractWorkLabel, employeeContractWorkLabel,

View File

@@ -341,7 +341,8 @@ export const useHoursPage = () => {
isPresentMorning: false, isPresentMorning: false,
isPresentAfternoon: false, isPresentAfternoon: false,
isSiteValid: false, isSiteValid: false,
isValid: false isValid: false,
updatedAt: null
}) })
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
@@ -463,6 +464,14 @@ export const useHoursPage = () => {
return { backgroundColor: dayRow.absenceColor || '#dc2626' } return { backgroundColor: dayRow.absenceColor || '#dc2626' }
} }
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const getPresenceDayValue = (employeeId: number) => { const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId] const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
@@ -521,7 +530,8 @@ export const useHoursPage = () => {
isPresentMorning: workHour?.isPresentMorning ?? false, isPresentMorning: workHour?.isPresentMorning ?? false,
isPresentAfternoon: workHour?.isPresentAfternoon ?? false, isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
isSiteValid: workHour?.isSiteValid ?? false, isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false isValid: workHour?.isValid ?? false,
updatedAt: workHour?.updatedAt ?? null
} }
} }
@@ -1140,6 +1150,7 @@ export const useHoursPage = () => {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -93,6 +93,7 @@
class="h-full" class="h-full"
:absences="employeeAbsences" :absences="employeeAbsences"
:summary="leaveSummary" :summary="leaveSummary"
:public-holidays="publicHolidays"
/> />
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" /> <EmployeesRttTab v-else class="h-full" :summary="rttSummary" />
</div> </div>
@@ -109,6 +110,7 @@ const {
employeeAbsences, employeeAbsences,
leaveSummary, leaveSummary,
rttSummary, rttSummary,
publicHolidays,
showLeaveTab, showLeaveTab,
contractHistory, contractHistory,
employeeContractWorkLabel, employeeContractWorkLabel,

View File

@@ -66,6 +66,7 @@
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
@@ -173,6 +174,7 @@ const {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -8,6 +8,7 @@ export type ContractHistoryItem = {
contractNature: 'CDI' | 'CDD' | 'INTERIM' contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string startDate: string
endDate?: string | null endDate?: string | null
comment?: string | null
} }
export type Employee = { export type Employee = {

View File

@@ -15,6 +15,7 @@ export type WorkHour = {
isPresentAfternoon?: boolean isPresentAfternoon?: boolean
isSiteValid?: boolean isSiteValid?: boolean
isValid?: boolean isValid?: boolean
updatedAt?: string | null
} }
export type WorkHourEntryPayload = { export type WorkHourEntryPayload = {

View File

@@ -61,6 +61,7 @@ export const updateEmployee = async (
contractStartDate?: string contractStartDate?: string
contractEndDate?: string | null contractEndDate?: string | null
contractPaidLeaveSettled?: boolean contractPaidLeaveSettled?: boolean
contractComment?: string | null
displayOrder?: number displayOrder?: number
} }
) => { ) => {
@@ -87,6 +88,9 @@ export const updateEmployee = async (
if (payload.contractPaidLeaveSettled !== undefined) { if (payload.contractPaidLeaveSettled !== undefined) {
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
} }
if (payload.contractComment !== undefined) {
body.contractComment = payload.contractComment ?? null
}
return api.patch<Employee>(`/employees/${id}`, body, { return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update', toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260306140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add updated_at column to work_hours table.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD COLUMN updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN updated_at');
}
}

View File

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

View File

@@ -21,5 +21,7 @@ final class ContractHistoryItem
public string $startDate, public string $startDate,
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public ?string $endDate, public ?string $endDate,
#[Groups(['employee:read'])]
public ?string $comment = null,
) {} ) {}
} }

View File

@@ -78,6 +78,9 @@ class Employee
#[Groups(['employee:write'])] #[Groups(['employee:write'])]
private ?bool $contractPaidLeaveSettled = null; private ?bool $contractPaidLeaveSettled = null;
#[Groups(['employee:write'])]
private ?string $contractComment = null;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
@@ -202,6 +205,18 @@ class Employee
return $this; return $this;
} }
public function getContractComment(): ?string
{
return $this->contractComment;
}
public function setContractComment(?string $contractComment): self
{
$this->contractComment = $contractComment;
return $this;
}
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public function getCurrentContractNature(): string public function getCurrentContractNature(): string
{ {
@@ -243,6 +258,7 @@ class Employee
contractNature: $period->getContractNatureEnum()->value, contractNature: $period->getContractNatureEnum()->value,
startDate: $period->getStartDate()->format('Y-m-d'), startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'), endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
); );
}, },
$periods $periods

View File

@@ -40,6 +40,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'boolean', options: ['default' => false])] #[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false; private bool $paidLeaveSettled = false;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -136,4 +139,16 @@ class EmployeeContractPeriod
return $this; return $this;
} }
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
} }

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor; use App\State\WorkHourSiteValidationProcessor;
use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -106,6 +107,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:site_validate'])] #[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false; private bool $isSiteValid = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['work_hour:read'])]
private ?DateTimeImmutable $updatedAt = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -274,4 +279,16 @@ class WorkHour
return $this; return $this;
} }
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?DateTimeImmutable $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
} }

View File

@@ -14,6 +14,7 @@ final readonly class EmployeeContractChangeRequest
public ?DateTimeImmutable $contractStartDate, public ?DateTimeImmutable $contractStartDate,
public ?DateTimeImmutable $contractEndDate, public ?DateTimeImmutable $contractEndDate,
public ?bool $contractPaidLeaveSettled, public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
) {} ) {}
public function hasPeriodChangeRequest(): bool public function hasPeriodChangeRequest(): bool
@@ -21,7 +22,8 @@ final readonly class EmployeeContractChangeRequest
return null !== $this->contractNature return null !== $this->contractNature
|| null !== $this->contractStartDate || null !== $this->contractStartDate
|| null !== $this->contractEndDate || null !== $this->contractEndDate
|| null !== $this->contractPaidLeaveSettled; || null !== $this->contractPaidLeaveSettled
|| null !== $this->contractComment;
} }
public function isCloseOnlyRequest(bool $contractChanged): bool public function isCloseOnlyRequest(bool $contractChanged): bool

View File

@@ -18,6 +18,7 @@ final class EmployeeContractChangeRequestFactory
contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'), contractStartDate: $this->parseOptionalYmd($employee->getContractStartDate(), 'contractStartDate'),
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'), contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(), contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
); );
} }

View File

@@ -43,7 +43,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
public function closeCurrentPeriod( public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate, DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled bool $paidLeaveSettled,
?string $comment = null
): void { ): void {
if (null === $todayPeriod) { if (null === $todayPeriod) {
throw new UnprocessableEntityHttpException('No active contract period to close.'); throw new UnprocessableEntityHttpException('No active contract period to close.');
@@ -58,6 +59,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
$todayPeriod->setEndDate($requestedEndDate); $todayPeriod->setEndDate($requestedEndDate);
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled); $todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
$todayPeriod->setComment($comment);
$this->entityManager->flush(); $this->entityManager->flush();
} }

View File

@@ -23,7 +23,8 @@ interface EmployeeContractPeriodManagerInterface
public function closeCurrentPeriod( public function closeCurrentPeriod(
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
DateTimeImmutable $requestedEndDate, DateTimeImmutable $requestedEndDate,
bool $paidLeaveSettled bool $paidLeaveSettled,
?string $comment = null
): void; ): void;
public function createNextPeriod( public function createNextPeriod(

View File

@@ -69,13 +69,8 @@ final readonly class WorkedHoursCreditPolicy
return 0.0; return 0.0;
} }
// Règle forfait: // Règle forfait: les absences ne créditent jamais de présence.
// - demi-journée d'absence => 0.5 travaillé // Seules les checkboxes cochées par l'employé comptent.
// - journée complète d'absence => 0 travaillé
if ($absentMorning xor $absentAfternoon) {
return 0.5;
}
return 0.0; return 0.0;
} }

View File

@@ -90,7 +90,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
$this->periodManager->closeCurrentPeriod( $this->periodManager->closeCurrentPeriod(
$todayPeriod, $todayPeriod,
$requestedEndDate, $requestedEndDate,
$changeRequest->contractPaidLeaveSettled ?? false $changeRequest->contractPaidLeaveSettled ?? false,
$changeRequest->contractComment
); );
return $result; return $result;

View File

@@ -98,6 +98,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking); $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null; $existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true); $isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
if ($existing?->isValid()) { if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) { if (!$this->isSameAsExisting($existing, $normalized)) {
@@ -145,6 +146,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setWorkDate($workDate) ->setWorkDate($workDate)
; ;
$this->hydrateWorkHour($workHour, $normalized); $this->hydrateWorkHour($workHour, $normalized);
if ($isSelf) {
$workHour->setUpdatedAt(new DateTimeImmutable());
}
$this->entityManager->persist($workHour); $this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour; $existingByEmployeeId[$employeeId] = $workHour;
++$result->created; ++$result->created;
@@ -169,6 +173,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
} }
$this->hydrateWorkHour($workHour, $normalized); $this->hydrateWorkHour($workHour, $normalized);
if (!$isAdmin) {
$workHour->setUpdatedAt(new DateTimeImmutable());
}
++$result->processed; ++$result->processed;
} }

View File

@@ -43,9 +43,9 @@ final class WorkedHoursCreditPolicyTest extends TestCase
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
$units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false); // Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent.
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false));
self::assertSame(0.5, $units); self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
} }
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void public function testNoCreditWhenAbsenceTypeDoesNotCount(): void