fix : affichage des absences dans les heures vue semaine + refacto
This commit is contained in:
105
AGENTS.md
Normal file
105
AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# AGENTS.md
|
||||
|
||||
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions.
|
||||
|
||||
## 1) Stack et structure
|
||||
|
||||
- Backend: Symfony + API Platform + Doctrine ORM
|
||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
|
||||
- Exécution locale: Docker via `makefile`
|
||||
|
||||
Arborescence clé:
|
||||
- `src/`: domaine, API resources, state providers/processors, services
|
||||
- `tests/`: TU backend (PHPUnit)
|
||||
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||
- `migrations/`: migrations Doctrine
|
||||
|
||||
## 2) Commandes utiles
|
||||
|
||||
- Démarrer stack: `make start`
|
||||
- Tests backend: `make test`
|
||||
- Build frontend: `cd frontend && npm run build`
|
||||
- Dev frontend: `make dev-nuxt`
|
||||
|
||||
## 3) Domaine métier (résumé)
|
||||
|
||||
### Contrats
|
||||
- Entité: `Contract`
|
||||
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
|
||||
- `trackingMode`:
|
||||
- `TIME`: suivi par heures
|
||||
- `PRESENCE`: suivi présence demi-journées/journées
|
||||
- Enums backend:
|
||||
- `App\Enum\TrackingMode`
|
||||
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
|
||||
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
|
||||
|
||||
### Heures / absences
|
||||
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
|
||||
- Une ligne d’heures validée est verrouillée côté métier.
|
||||
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
|
||||
- contrats présence: crédit en unités de présence
|
||||
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
|
||||
|
||||
## 4) Écrans principaux
|
||||
|
||||
### Page Heures (`frontend/pages/hours.vue`)
|
||||
- Vue Jour + Vue Semaine (semaine réservée admin)
|
||||
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
|
||||
- Vue jour: `frontend/components/hours/HoursDayView.vue`
|
||||
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
|
||||
- Logique page: `frontend/composables/useHoursPage.ts`
|
||||
|
||||
### Points UX déjà en place
|
||||
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
|
||||
- Légende absences affichée dans la toolbar (admin + vue semaine)
|
||||
- Cellules semaine avec absence: couleur du type d’absence (plus rouge fixe)
|
||||
- Pour user non-admin: restrictions d’édition selon validations/absences
|
||||
|
||||
## 5) API / calculs hebdo
|
||||
|
||||
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
|
||||
- DTOs:
|
||||
- `src/Dto/WorkHours/WeeklySummaryRow.php`
|
||||
- `src/Dto/WorkHours/WeeklyDaySummary.php`
|
||||
- Le résumé hebdo renvoie notamment:
|
||||
- `trackingMode`
|
||||
- `contractName`
|
||||
- `contractType`
|
||||
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
|
||||
|
||||
### Heures supp
|
||||
- Règles métier:
|
||||
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
|
||||
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
|
||||
- contrats `INTERIM`: pas de bonus 25/50 ni récup
|
||||
|
||||
## 6) Conventions techniques
|
||||
|
||||
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
|
||||
- Utiliser les interfaces repository dans providers/processors testés.
|
||||
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
|
||||
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
|
||||
|
||||
## 7) Tests et qualité
|
||||
|
||||
- Les TU backend passent actuellement via `make test`.
|
||||
- Le build frontend passe via `npm run build`.
|
||||
- À chaque évolution métier:
|
||||
- mettre à jour les tests provider/processor/service impactés
|
||||
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
|
||||
|
||||
## 8) Fichiers sensibles (à lire avant modif)
|
||||
|
||||
- `src/State/WorkHourWeeklySummaryProvider.php`
|
||||
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
|
||||
- `src/State/AbsenceWriteProcessor.php`
|
||||
- `src/State/WorkHourBulkUpsertProcessor.php`
|
||||
- `frontend/composables/useHoursPage.ts`
|
||||
- `frontend/components/hours/HoursWeekView.vue`
|
||||
|
||||
## 9) Décisions de conception actuelles
|
||||
|
||||
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
|
||||
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
|
||||
- Le front reste centré sur l’affichage/interaction et réutilise les données enrichies de l’API.
|
||||
@@ -126,11 +126,23 @@
|
||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||
class="flex flex-wrap items-center gap-6"
|
||||
>
|
||||
<p class="font-bold">Légende :</p>
|
||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
||||
<p>{{ type.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||
@@ -143,6 +155,7 @@ const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
||||
defineProps<{
|
||||
isAdmin: boolean
|
||||
sites: Site[]
|
||||
absenceTypes: AbsenceType[]
|
||||
formattedSelectedDate: string
|
||||
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4">
|
||||
<div
|
||||
v-for="daily in row.daily"
|
||||
:key="daily.date"
|
||||
class="text-left leading-4 rounded-md px-2 py-1"
|
||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="daily.absenceLabel ?? ''"
|
||||
>
|
||||
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
||||
<template v-else>
|
||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||
@@ -52,13 +59,13 @@
|
||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +74,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
||||
|
||||
const isInterimContract = (contractType?: ContractType | null) => {
|
||||
return contractType === CONTRACT_TYPES.INTERIM
|
||||
}
|
||||
|
||||
const getDailyCellStyle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceColor?: string | null
|
||||
}) => {
|
||||
if (!daily.hasAbsence) return undefined
|
||||
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isWeekLoading: boolean
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/serv
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import type { HalfDay } from '~/services/dto/half-day'
|
||||
import { CONTRACT_TYPES, TRACKING_MODES } from '~/services/dto/contract'
|
||||
import type { HourRow } from '~/components/hours/types'
|
||||
import { listScopedEmployees } from '~/services/employees'
|
||||
import { listAbsenceTypes } from '~/services/absence-types'
|
||||
@@ -275,14 +276,17 @@ export const useHoursPage = () => {
|
||||
isValid: false
|
||||
})
|
||||
|
||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE'
|
||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
||||
|
||||
const contractLabel = (employee: Employee) => {
|
||||
const contract = employee.contract
|
||||
if (!contract) return '-'
|
||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') {
|
||||
if (contract.type === CONTRACT_TYPES.INTERIM) {
|
||||
return contract.name
|
||||
}
|
||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
|
||||
return `${contract.weeklyHours}h`
|
||||
}
|
||||
return contract.name
|
||||
@@ -392,7 +396,7 @@ export const useHoursPage = () => {
|
||||
const isEveningLockedByAbsence = (employeeId: number) => {
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
if (!dayRow) return false
|
||||
return dayRow.absentMorning && dayRow.absentAfternoon
|
||||
return dayRow.absentAfternoon
|
||||
}
|
||||
|
||||
const formatMinutes = (minutes: number) => {
|
||||
@@ -475,6 +479,42 @@ export const useHoursPage = () => {
|
||||
isAbsenceDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
|
||||
const row = rows.value[employeeId]
|
||||
if (!row) return
|
||||
|
||||
if (startHalf === 'AM' && endHalf === 'AM') {
|
||||
row.morningFrom = ''
|
||||
row.morningTo = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (startHalf === 'PM' && endHalf === 'PM') {
|
||||
row.afternoonFrom = ''
|
||||
row.afternoonTo = ''
|
||||
row.eveningFrom = ''
|
||||
row.eveningTo = ''
|
||||
return
|
||||
}
|
||||
|
||||
row.morningFrom = ''
|
||||
row.morningTo = ''
|
||||
row.afternoonFrom = ''
|
||||
row.afternoonTo = ''
|
||||
row.eveningFrom = ''
|
||||
row.eveningTo = ''
|
||||
}
|
||||
|
||||
const refreshAfterAbsenceChange = async () => {
|
||||
if (isAdmin.value) {
|
||||
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||
return
|
||||
}
|
||||
|
||||
weeklySummary.value = null
|
||||
await Promise.all([loadDayContext(), loadAbsences()])
|
||||
}
|
||||
|
||||
const submitAbsence = async () => {
|
||||
const form = absenceForm.value
|
||||
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
||||
@@ -504,9 +544,9 @@ export const useHoursPage = () => {
|
||||
})
|
||||
}
|
||||
|
||||
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
|
||||
closeAbsenceDrawer()
|
||||
await refreshByDate()
|
||||
await loadAbsences()
|
||||
await refreshAfterAbsenceChange()
|
||||
} finally {
|
||||
isAbsenceSubmitting.value = false
|
||||
}
|
||||
@@ -519,8 +559,7 @@ export const useHoursPage = () => {
|
||||
try {
|
||||
await deleteAbsence(editingAbsence.value.id)
|
||||
closeAbsenceDrawer()
|
||||
await refreshByDate()
|
||||
await loadAbsences()
|
||||
await refreshAfterAbsenceChange()
|
||||
} finally {
|
||||
isAbsenceSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -6,17 +6,10 @@
|
||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<NuxtLink
|
||||
to="/hours"
|
||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
Heures
|
||||
</NuxtLink>
|
||||
<template v-if="isAdmin">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
Tableau de bord
|
||||
@@ -28,6 +21,15 @@
|
||||
>
|
||||
Calendrier
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
to="/hours"
|
||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||
active-class="bg-tertiary-500 text-primary-500"
|
||||
>
|
||||
Heures
|
||||
</NuxtLink>
|
||||
<template v-if="isAdmin">
|
||||
<NuxtLink
|
||||
to="/employees"
|
||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
v-model:employee-filter="employeeFilter"
|
||||
:is-admin="isAdmin"
|
||||
:sites="sites"
|
||||
:absence-types="absenceTypes"
|
||||
:formatted-selected-date="formattedSelectedDate"
|
||||
:shortcut-button-class="shortcutButtonClass"
|
||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
export const TRACKING_MODES = {
|
||||
TIME: 'TIME',
|
||||
PRESENCE: 'PRESENCE'
|
||||
} as const
|
||||
|
||||
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
|
||||
|
||||
export const CONTRACT_TYPES = {
|
||||
FORFAIT: 'FORFAIT',
|
||||
H35: '35H',
|
||||
H39: '39H',
|
||||
INTERIM: 'INTERIM',
|
||||
CUSTOM: 'CUSTOM'
|
||||
} as const
|
||||
|
||||
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
|
||||
|
||||
export type Contract = {
|
||||
id: number
|
||||
name: string
|
||||
trackingMode: 'TIME' | 'PRESENCE'
|
||||
trackingMode: TrackingMode
|
||||
type: ContractType
|
||||
weeklyHours?: number | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Employee } from './employee'
|
||||
import type { ContractType, TrackingMode } from './contract'
|
||||
|
||||
export type WorkHour = {
|
||||
id: number
|
||||
@@ -33,6 +34,9 @@ export type WeeklyWorkHourDailySummary = {
|
||||
nightMinutes: number
|
||||
totalMinutes: number
|
||||
present?: number | null
|
||||
hasAbsence?: boolean
|
||||
absenceLabel?: string | null
|
||||
absenceColor?: string | null
|
||||
}
|
||||
|
||||
export type WeeklyWorkHourRowSummary = {
|
||||
@@ -41,7 +45,8 @@ export type WeeklyWorkHourRowSummary = {
|
||||
lastName: string
|
||||
siteName?: string | null
|
||||
contractName?: string | null
|
||||
trackingMode?: 'TIME' | 'PRESENCE' | null
|
||||
contractType?: ContractType | null
|
||||
trackingMode?: TrackingMode | null
|
||||
daily: WeeklyWorkHourDailySummary[]
|
||||
weeklyDayMinutes: number
|
||||
weeklyNightMinutes: number
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||
use App\State\WorkHourWeeklySummaryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
@@ -26,30 +27,6 @@ final class WorkHourWeeklySummary
|
||||
/** @var list<string> */
|
||||
public array $days = [];
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* employeeId:int,
|
||||
* firstName:string,
|
||||
* lastName:string,
|
||||
* siteName:?string,
|
||||
* contractName:?string,
|
||||
* trackingMode:?string,
|
||||
* daily:list<array{
|
||||
* date:string,
|
||||
* dayMinutes:int,
|
||||
* nightMinutes:int,
|
||||
* totalMinutes:int,
|
||||
* present:?float
|
||||
* }>,
|
||||
* weeklyDayMinutes:int,
|
||||
* weeklyNightMinutes:int,
|
||||
* weeklyTotalMinutes:int,
|
||||
* weeklyPresenceCount:float,
|
||||
* weeklyOvertimeTotalMinutes:int,
|
||||
* weeklyOvertime25Minutes:int,
|
||||
* weeklyOvertime50Minutes:int,
|
||||
* weeklyRecoveryMinutes:int
|
||||
* }>
|
||||
*/
|
||||
/** @var list<WeeklySummaryRow> */
|
||||
public array $rows = [];
|
||||
}
|
||||
|
||||
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class WeeklyDaySummary
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public int $dayMinutes,
|
||||
public int $nightMinutes,
|
||||
public int $totalMinutes,
|
||||
public ?float $present = null,
|
||||
public bool $hasAbsence = false,
|
||||
public ?string $absenceLabel = null,
|
||||
public ?string $absenceColor = null,
|
||||
) {}
|
||||
}
|
||||
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\WorkHours;
|
||||
|
||||
final class WeeklySummaryRow
|
||||
{
|
||||
/**
|
||||
* @param list<WeeklyDaySummary> $daily
|
||||
*/
|
||||
public function __construct(
|
||||
public int $employeeId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $siteName,
|
||||
public ?string $contractName,
|
||||
public ?string $contractType,
|
||||
public ?string $trackingMode,
|
||||
public array $daily,
|
||||
public int $weeklyDayMinutes,
|
||||
public int $weeklyNightMinutes,
|
||||
public int $weeklyTotalMinutes,
|
||||
public float $weeklyPresenceCount,
|
||||
public int $weeklyOvertimeTotalMinutes,
|
||||
public int $weeklyOvertime25Minutes,
|
||||
public int $weeklyOvertime50Minutes,
|
||||
public int $weeklyRecoveryMinutes,
|
||||
) {}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
@@ -18,8 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Table(name: 'contracts')]
|
||||
class Contract
|
||||
{
|
||||
public const string TRACKING_TIME = 'TIME';
|
||||
public const string TRACKING_PRESENCE = 'PRESENCE';
|
||||
public const string TRACKING_TIME = TrackingMode::TIME->value;
|
||||
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -65,13 +68,29 @@ class Contract
|
||||
return $this->trackingMode;
|
||||
}
|
||||
|
||||
public function setTrackingMode(string $trackingMode): self
|
||||
public function getTrackingModeEnum(): TrackingMode
|
||||
{
|
||||
$this->trackingMode = $trackingMode;
|
||||
return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
|
||||
}
|
||||
|
||||
public function setTrackingMode(string|TrackingMode $trackingMode): self
|
||||
{
|
||||
$value = $trackingMode instanceof TrackingMode ? $trackingMode->value : $trackingMode;
|
||||
if (null === TrackingMode::tryFrom($value)) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid tracking mode "%s".', $value));
|
||||
}
|
||||
|
||||
$this->trackingMode = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['contract:read', 'employee:read'])]
|
||||
public function getType(): ContractType
|
||||
{
|
||||
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
|
||||
}
|
||||
|
||||
public function getWeeklyHours(): ?int
|
||||
{
|
||||
return $this->weeklyHours;
|
||||
|
||||
47
src/Enum/ContractType.php
Normal file
47
src/Enum/ContractType.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum ContractType: string
|
||||
{
|
||||
case FORFAIT = 'FORFAIT';
|
||||
case H35 = '35H';
|
||||
case H39 = '39H';
|
||||
case INTERIM = 'INTERIM';
|
||||
case CUSTOM = 'CUSTOM';
|
||||
|
||||
public static function resolve(?string $name, ?string $trackingMode, ?int $weeklyHours): self
|
||||
{
|
||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||
return self::FORFAIT;
|
||||
}
|
||||
|
||||
$normalizedName = self::normalize($name);
|
||||
if ('interim' === $normalizedName) {
|
||||
return self::INTERIM;
|
||||
}
|
||||
|
||||
if (35 === $weeklyHours) {
|
||||
return self::H35;
|
||||
}
|
||||
|
||||
if (39 === $weeklyHours) {
|
||||
return self::H39;
|
||||
}
|
||||
|
||||
return self::CUSTOM;
|
||||
}
|
||||
|
||||
private static function normalize(?string $value): string
|
||||
{
|
||||
if (null === $value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalized = mb_strtolower(trim($value));
|
||||
|
||||
return str_replace('é', 'e', $normalized);
|
||||
}
|
||||
}
|
||||
11
src/Enum/TrackingMode.php
Normal file
11
src/Enum/TrackingMode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum TrackingMode: string
|
||||
{
|
||||
case TIME = 'TIME';
|
||||
case PRESENCE = 'PRESENCE';
|
||||
}
|
||||
@@ -18,5 +18,7 @@ interface WorkHourReadRepositoryInterface
|
||||
*/
|
||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
}
|
||||
|
||||
@@ -101,4 +101,20 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
|
||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||
}
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
||||
{
|
||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||
|
||||
$qb = $this->createQueryBuilder('w')
|
||||
->andWhere('w.employee = :employee')
|
||||
->andWhere('w.workDate = :workDate')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('workDate', $workDate)
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
/** @var null|WorkHour $workHour */
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service\WorkHours;
|
||||
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Contract;
|
||||
use App\Enum\TrackingMode;
|
||||
use DateMalformedStringException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
@@ -24,7 +24,7 @@ final class WorkedHoursCreditPolicy
|
||||
|
||||
$employee = $absence->getEmployee();
|
||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
||||
if (Contract::TRACKING_TIME !== $employee?->getContract()?->getTrackingMode()) {
|
||||
if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ final class WorkedHoursCreditPolicy
|
||||
}
|
||||
|
||||
$employee = $absence->getEmployee();
|
||||
if (Contract::TRACKING_PRESENCE !== $employee?->getContract()?->getTrackingMode()) {
|
||||
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
@@ -81,6 +82,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setStartHalf($first['startHalf'])
|
||||
->setEndHalf($first['endHalf'])
|
||||
;
|
||||
$this->clearWorkHoursForSegment($employee, $first);
|
||||
$this->entityManager->persist($data);
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
@@ -94,6 +96,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setEndHalf($segment['endHalf'])
|
||||
;
|
||||
|
||||
$this->clearWorkHoursForSegment($employee, $segment);
|
||||
$this->entityManager->persist($absence);
|
||||
}
|
||||
|
||||
@@ -174,4 +177,47 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
{
|
||||
return DateTime::createFromImmutable($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
||||
*/
|
||||
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
|
||||
{
|
||||
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
|
||||
if (null === $workHour) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Demi-journée matin: on efface uniquement la plage du matin.
|
||||
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
|
||||
$workHour
|
||||
->setMorningFrom(null)
|
||||
->setMorningTo(null)
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Demi-journée après-midi: on efface après-midi + soirée.
|
||||
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
|
||||
$workHour
|
||||
->setAfternoonFrom(null)
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Journée complète: on efface toutes les plages horaires.
|
||||
$workHour
|
||||
->setMorningFrom(null)
|
||||
->setMorningTo(null)
|
||||
->setAfternoonFrom(null)
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\WorkHourBulkUpsert;
|
||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use DateTimeImmutable;
|
||||
@@ -75,7 +75,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||
}
|
||||
|
||||
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ namespace App\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\WorkHourWeeklySummary;
|
||||
use App\Dto\WorkHours\WeeklyDaySummary;
|
||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
@@ -102,23 +106,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
* @param list<Absence> $absences
|
||||
* @param list<string> $days
|
||||
*
|
||||
* @return list<array{
|
||||
* employeeId:int,
|
||||
* firstName:string,
|
||||
* lastName:string,
|
||||
* siteName:?string,
|
||||
* contractName:?string,
|
||||
* trackingMode:?string,
|
||||
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
|
||||
* weeklyDayMinutes:int,
|
||||
* weeklyNightMinutes:int,
|
||||
* weeklyTotalMinutes:int,
|
||||
* weeklyPresenceCount:float,
|
||||
* weeklyOvertimeTotalMinutes:int,
|
||||
* weeklyOvertime25Minutes:int,
|
||||
* weeklyOvertime50Minutes:int,
|
||||
* weeklyRecoveryMinutes:int
|
||||
* }>
|
||||
* @return list<WeeklySummaryRow>
|
||||
*/
|
||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
||||
{
|
||||
@@ -140,6 +128,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
|
||||
$creditedByEmployeeDate = [];
|
||||
$creditedPresenceByEmployeeDate = [];
|
||||
$absenceByEmployeeDate = [];
|
||||
$absenceLabelByEmployeeDate = [];
|
||||
$absenceColorByEmployeeDate = [];
|
||||
foreach ($absences as $absence) {
|
||||
$employeeId = $absence->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
@@ -154,7 +145,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||
if ($absentMorning || $absentAfternoon) {
|
||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||
}
|
||||
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
|
||||
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
|
||||
}
|
||||
}
|
||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||
@@ -175,7 +175,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$weeklyPresenceCount = 0.0;
|
||||
$daily = [];
|
||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
|
||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||
|
||||
foreach ($days as $date) {
|
||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||
@@ -198,46 +198,51 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$weeklyPresenceCount += $present;
|
||||
}
|
||||
|
||||
$daily[] = [
|
||||
'date' => $date,
|
||||
'dayMinutes' => $metrics->dayMinutes,
|
||||
'nightMinutes' => $metrics->nightMinutes,
|
||||
'totalMinutes' => $metrics->totalMinutes,
|
||||
'present' => $present,
|
||||
];
|
||||
$daily[] = new WeeklyDaySummary(
|
||||
date: $date,
|
||||
dayMinutes: $metrics->dayMinutes,
|
||||
nightMinutes: $metrics->nightMinutes,
|
||||
totalMinutes: $metrics->totalMinutes,
|
||||
present: $present,
|
||||
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
||||
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
||||
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
||||
? 0
|
||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime25Minutes = $isPresenceTracking
|
||||
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||
$weeklyOvertime50Minutes = $isPresenceTracking
|
||||
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||
$weeklyRecoveryMinutes = $isPresenceTracking
|
||||
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||
|
||||
$rows[] = [
|
||||
'employeeId' => $employeeId,
|
||||
'firstName' => $employee->getFirstName(),
|
||||
'lastName' => $employee->getLastName(),
|
||||
'siteName' => $employee->getSite()?->getName(),
|
||||
'contractName' => $employee->getContract()?->getName(),
|
||||
'trackingMode' => $employee->getContract()?->getTrackingMode(),
|
||||
'daily' => $daily,
|
||||
'weeklyDayMinutes' => $weeklyDayMinutes,
|
||||
'weeklyNightMinutes' => $weeklyNightMinutes,
|
||||
'weeklyTotalMinutes' => $weeklyTotalMinutes,
|
||||
'weeklyPresenceCount' => $weeklyPresenceCount,
|
||||
'weeklyOvertimeTotalMinutes' => $weeklyOvertimeTotalMinutes,
|
||||
'weeklyOvertime25Minutes' => $weeklyOvertime25Minutes,
|
||||
'weeklyOvertime50Minutes' => $weeklyOvertime50Minutes,
|
||||
'weeklyRecoveryMinutes' => $weeklyRecoveryMinutes,
|
||||
];
|
||||
$rows[] = new WeeklySummaryRow(
|
||||
employeeId: $employeeId,
|
||||
firstName: $employee->getFirstName(),
|
||||
lastName: $employee->getLastName(),
|
||||
siteName: $employee->getSite()?->getName(),
|
||||
contractName: $employee->getContract()?->getName(),
|
||||
contractType: $employee->getContract()?->getType()->value,
|
||||
trackingMode: $employee->getContract()?->getTrackingMode(),
|
||||
daily: $daily,
|
||||
weeklyDayMinutes: $weeklyDayMinutes,
|
||||
weeklyNightMinutes: $weeklyNightMinutes,
|
||||
weeklyTotalMinutes: $weeklyTotalMinutes,
|
||||
weeklyPresenceCount: $weeklyPresenceCount,
|
||||
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
||||
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
||||
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
||||
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
@@ -369,4 +374,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
|
||||
return (int) round($trancheMinutes * 0.5);
|
||||
}
|
||||
|
||||
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
||||
{
|
||||
$contract = $employee->getContract();
|
||||
$type = ContractType::resolve(
|
||||
$contract?->getName(),
|
||||
$contract?->getTrackingMode(),
|
||||
$contract?->getWeeklyHours()
|
||||
);
|
||||
|
||||
return ContractType::INTERIM === $type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
$user = new User();
|
||||
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
|
||||
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
|
||||
$employees = [$timeEmployee, $presenceEmployee];
|
||||
$interimEmployee = $this->buildEmployee(3, 'TIME', 35, 'Charly', 'Interim');
|
||||
$employees = [$timeEmployee, $presenceEmployee, $interimEmployee];
|
||||
|
||||
$workHours = [];
|
||||
foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) {
|
||||
@@ -80,6 +81,12 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
->setMorningFrom('09:00')
|
||||
->setMorningTo('19:00')
|
||||
;
|
||||
$workHours[] = new WorkHour()
|
||||
->setEmployee($interimEmployee)
|
||||
->setWorkDate(new DateTimeImmutable($date))
|
||||
->setMorningFrom('09:00')
|
||||
->setMorningTo('19:00')
|
||||
;
|
||||
}
|
||||
|
||||
$absenceType = new AbsenceType()
|
||||
@@ -117,22 +124,29 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
|
||||
self::assertSame('2026-02-16', $result->weekStart);
|
||||
self::assertSame('2026-02-22', $result->weekEnd);
|
||||
self::assertCount(2, $result->rows);
|
||||
self::assertCount(3, $result->rows);
|
||||
|
||||
self::assertSame(3000, $result->rows[0]['weeklyTotalMinutes']);
|
||||
self::assertSame(900, $result->rows[0]['weeklyOvertimeTotalMinutes']);
|
||||
self::assertSame(120, $result->rows[0]['weeklyOvertime25Minutes']);
|
||||
self::assertSame(210, $result->rows[0]['weeklyOvertime50Minutes']);
|
||||
self::assertSame(1230, $result->rows[0]['weeklyRecoveryMinutes']);
|
||||
self::assertSame(3000, $result->rows[0]->weeklyTotalMinutes);
|
||||
self::assertSame(900, $result->rows[0]->weeklyOvertimeTotalMinutes);
|
||||
self::assertSame(120, $result->rows[0]->weeklyOvertime25Minutes);
|
||||
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
||||
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
||||
|
||||
self::assertSame(1.0, $result->rows[1]['weeklyPresenceCount']);
|
||||
self::assertSame(0, $result->rows[1]['weeklyOvertimeTotalMinutes']);
|
||||
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
|
||||
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
||||
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
||||
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
||||
self::assertSame(0, $result->rows[1]->weeklyOvertimeTotalMinutes);
|
||||
self::assertSame(0, $result->rows[2]->weeklyOvertime25Minutes);
|
||||
self::assertSame(0, $result->rows[2]->weeklyOvertime50Minutes);
|
||||
self::assertSame(0, $result->rows[2]->weeklyRecoveryMinutes);
|
||||
self::assertSame(900, $result->rows[2]->weeklyOvertimeTotalMinutes);
|
||||
}
|
||||
|
||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName): Employee
|
||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName, ?string $contractName = null): Employee
|
||||
{
|
||||
$contract = new Contract()
|
||||
->setName($trackingMode)
|
||||
->setName($contractName ?? $trackingMode)
|
||||
->setTrackingMode($trackingMode)
|
||||
->setWeeklyHours($weeklyHours)
|
||||
;
|
||||
|
||||
Reference in New Issue
Block a user