Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fe9ae54c | ||
| 6395ffbe1c | |||
|
|
b5e7395760 | ||
| 380c72c242 |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.15'
|
app.version: '0.1.17'
|
||||||
|
|||||||
77
frontend/components/PeriodStepperPicker.vue
Normal file
77
frontend/components/PeriodStepperPicker.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
|
||||||
|
<input
|
||||||
|
ref="nativeInput"
|
||||||
|
:value="pickerValue"
|
||||||
|
:type="pickerType"
|
||||||
|
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
@input="onPickerInput"
|
||||||
|
@change="onPickerInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||||
|
:aria-label="prevAriaLabel"
|
||||||
|
@click="emit('prev')"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||||
|
:aria-label="nextAriaLabel"
|
||||||
|
@click="emit('next')"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
label: string
|
||||||
|
pickerType: 'date' | 'week' | 'month'
|
||||||
|
pickerValue: string
|
||||||
|
widthClass?: string
|
||||||
|
prevAriaLabel?: string
|
||||||
|
nextAriaLabel?: string
|
||||||
|
}>(), {
|
||||||
|
widthClass: 'w-[320px]',
|
||||||
|
prevAriaLabel: 'Précédent',
|
||||||
|
nextAriaLabel: 'Suivant'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'prev'): void
|
||||||
|
(e: 'next'): void
|
||||||
|
(e: 'pick', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nativeInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
const input = nativeInput.value
|
||||||
|
if (!input) return
|
||||||
|
if (typeof input.showPicker === 'function') {
|
||||||
|
input.showPicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.focus()
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPickerInput = (event: Event) => {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
if (!value) return
|
||||||
|
emit('pick', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -64,41 +64,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
<PeriodStepperPicker
|
||||||
<input
|
width-class="w-[320px]"
|
||||||
ref="nativeDateInput"
|
:label="formattedSelectedDate"
|
||||||
:value="pickerValue"
|
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||||
:type="viewMode === 'week' ? 'week' : 'date'"
|
:picker-value="pickerValue"
|
||||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
prev-aria-label="Période précédente"
|
||||||
tabindex="-1"
|
next-aria-label="Période suivante"
|
||||||
aria-hidden="true"
|
@prev="emit('shift-date', -1)"
|
||||||
@input="onPickerInput"
|
@next="emit('shift-date', 1)"
|
||||||
@change="onPickerInput"
|
@pick="onPickerValue"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
aria-label="Période précédente"
|
|
||||||
@click="emit('shift-date', -1)"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
@click="openDatePicker"
|
|
||||||
>
|
|
||||||
{{ formattedSelectedDate }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
aria-label="Période suivante"
|
|
||||||
@click="emit('shift-date', 1)"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
@@ -145,6 +121,7 @@ import type { Site } from '~/services/dto/site'
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||||
|
|
||||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
@@ -172,7 +149,6 @@ const emit = defineEmits<{
|
|||||||
(e: 'shift-date', value: number): void
|
(e: 'shift-date', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const pickerValue = computed(() => {
|
const pickerValue = computed(() => {
|
||||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||||
return selectedDate.value
|
return selectedDate.value
|
||||||
@@ -186,19 +162,7 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
|
|||||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openDatePicker = () => {
|
const onPickerValue = (value: string) => {
|
||||||
const input = nativeDateInput.value
|
|
||||||
if (!input) return
|
|
||||||
if (typeof input.showPicker === 'function') {
|
|
||||||
input.showPicker()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.focus()
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPickerInput = (event: Event) => {
|
|
||||||
const value = (event.target as HTMLInputElement).value
|
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
if (viewMode.value === 'week') {
|
if (viewMode.value === 'week') {
|
||||||
|
|||||||
@@ -427,8 +427,11 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const getPresenceDayValue = (employeeId: number) => {
|
const getPresenceDayValue = (employeeId: number) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
|
const absentMorning = dayRow?.absentMorning ?? false
|
||||||
|
const absentAfternoon = dayRow?.absentAfternoon ?? false
|
||||||
|
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
|
||||||
|
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
|
||||||
const total = Math.min(1, basePresence + creditedPresence)
|
const total = Math.min(1, basePresence + creditedPresence)
|
||||||
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,22 +30,17 @@
|
|||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<PeriodStepperPicker
|
||||||
v-model="selectedMonth"
|
width-class="w-[260px]"
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
:label="selectedMonthLabel"
|
||||||
>
|
picker-type="month"
|
||||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
:picker-value="monthPickerValue"
|
||||||
{{ month.label }}
|
prev-aria-label="Mois précédent"
|
||||||
</option>
|
next-aria-label="Mois suivant"
|
||||||
</select>
|
@prev="shiftMonth(-1)"
|
||||||
<select
|
@next="shiftMonth(1)"
|
||||||
v-model="selectedYear"
|
@pick="onMonthPickerValue"
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
/>
|
||||||
>
|
|
||||||
<option v-for="year in years" :key="year" :value="year">
|
|
||||||
{{ year }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-6 py-2">
|
<div class="flex flex-wrap items-center gap-6 py-2">
|
||||||
@@ -111,6 +106,7 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
|
|||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
|
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@@ -195,8 +191,8 @@ const months = [
|
|||||||
{value: 11, label: 'Décembre'}
|
{value: 11, label: 'Décembre'}
|
||||||
]
|
]
|
||||||
|
|
||||||
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
|
||||||
|
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
|
||||||
|
|
||||||
// Infos de calendrier calculées.
|
// Infos de calendrier calculées.
|
||||||
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
||||||
@@ -316,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) => {
|
||||||
|
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
|
||||||
|
selectedYear.value = next.getFullYear()
|
||||||
|
selectedMonth.value = next.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMonthPickerValue = (value: string) => {
|
||||||
|
if (!value) return
|
||||||
|
const [yearStr, monthStr] = value.split('-')
|
||||||
|
const year = Number(yearStr)
|
||||||
|
const month = Number(monthStr)
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
|
||||||
|
selectedYear.value = year
|
||||||
|
selectedMonth.value = month - 1
|
||||||
|
}
|
||||||
|
|
||||||
// Limite l'intervalle d'impression à 2 mois max.
|
// Limite l'intervalle d'impression à 2 mois max.
|
||||||
const enforcePrintRange = () => {
|
const enforcePrintRange = () => {
|
||||||
if (!printForm.from) return
|
if (!printForm.from) return
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ final readonly class WorkedHoursCreditPolicy
|
|||||||
bool $absentMorning,
|
bool $absentMorning,
|
||||||
bool $absentAfternoon
|
bool $absentAfternoon
|
||||||
): float {
|
): float {
|
||||||
$type = $absence->getType();
|
|
||||||
if (!$type?->getCountAsWorkedHours()) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
if (null === $employee) {
|
if (null === $employee) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -74,9 +69,14 @@ final readonly class WorkedHoursCreditPolicy
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
// Règle forfait:
|
||||||
|
// - demi-journée d'absence => 0.5 travaillé
|
||||||
|
// - journée complète d'absence => 0 travaillé
|
||||||
|
if ($absentMorning xor $absentAfternoon) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
return $halfUnits * 0.5;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$creditedByEmployeeDate = [];
|
$creditedByEmployeeDate = [];
|
||||||
$creditedPresenceByEmployeeDate = [];
|
$creditedPresenceByEmployeeDate = [];
|
||||||
$absenceByEmployeeDate = [];
|
$absenceByEmployeeDate = [];
|
||||||
|
$absentMorningByEmployeeDate = [];
|
||||||
|
$absentAfternoonByEmployeeDate = [];
|
||||||
$absenceLabelByEmployeeDate = [];
|
$absenceLabelByEmployeeDate = [];
|
||||||
$absenceColorByEmployeeDate = [];
|
$absenceColorByEmployeeDate = [];
|
||||||
foreach ($absences as $absence) {
|
foreach ($absences as $absence) {
|
||||||
@@ -153,7 +155,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||||
if ($absentMorning || $absentAfternoon) {
|
if ($absentMorning || $absentAfternoon) {
|
||||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||||
|
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
|
||||||
|
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
|
||||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||||
}
|
}
|
||||||
@@ -202,8 +206,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
$present = null;
|
$present = null;
|
||||||
if ($isPresenceTracking) {
|
if ($isPresenceTracking) {
|
||||||
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
|
||||||
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
|
||||||
|
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||||
|
$afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||||
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
||||||
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
||||||
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
||||||
|
|
||||||
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
|
self::assertSame(0.0, $result->rows[1]->weeklyPresenceCount);
|
||||||
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
||||||
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
||||||
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
||||||
|
|||||||
Reference in New Issue
Block a user