feat : ajout des RTT payés
This commit is contained in:
@@ -231,6 +231,14 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- s'exécute le `1er juin` (même cron que le rollover congés)
|
- s'exécute le `1er juin` (même cron que le rollover congés)
|
||||||
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
|
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
|
||||||
- idempotent (ne recrée pas si la ligne existe)
|
- idempotent (ne recrée pas si la ligne existe)
|
||||||
|
- paiement RTT:
|
||||||
|
- saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
|
||||||
|
- stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
|
||||||
|
- `rate`: taux de majoration, valeurs `25` ou `50`
|
||||||
|
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
||||||
|
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
||||||
|
- affichage:
|
||||||
|
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
||||||
|
|
||||||
## 10) Notifications
|
## 10) Notifications
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||||
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5 text-[20px]">
|
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5 text-[20px]">
|
||||||
<p><span class="font-semibold uppercase">RTT à la date du jour :</span> {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
|
<p><span class="font-semibold uppercase">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
||||||
<button class="bg-white rounded-md text-primary-500 font-bold px-6 py-1">
|
<button class="bg-white rounded-md text-primary-500 font-bold px-6 py-1" @click="openNewPayment">
|
||||||
<Icon name="mdi:plus-thick" size="16"/>
|
<Icon name="mdi:plus-thick" size="16"/>
|
||||||
Payer les RTT
|
Payer les RTT
|
||||||
</button>
|
</button>
|
||||||
@@ -30,22 +30,70 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
|
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
|
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
|
||||||
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée</div>
|
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
|
||||||
<div class="py-[6px] pl-3">0h</div>
|
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
||||||
|
@click="openEditPayment(month.month, '25')"
|
||||||
|
title="Modifier les heures payées"
|
||||||
|
>
|
||||||
|
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
|
||||||
|
<Icon name="mdi:pencil" size="16" class="self-center"/>
|
||||||
|
</div>
|
||||||
|
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
||||||
|
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
||||||
|
@click="openEditPayment(month.month, '50')"
|
||||||
|
title="Modifier les heures payées"
|
||||||
|
>
|
||||||
|
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
|
||||||
|
<Icon name="mdi:pencil" size="16" class="self-center"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
|
||||||
|
<form @submit.prevent="onSubmitPayment">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
||||||
|
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
|
||||||
|
<input v-model.number="paymentForm.hours" type="number" step="0.5" min="0" 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" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Taux</label>
|
||||||
|
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<option value="25">25%</option>
|
||||||
|
<option value="50">50%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
|
||||||
|
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
summary: EmployeeRttSummary | null
|
summary: EmployeeRttSummary | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isPaymentDrawerOpen = ref(false)
|
||||||
|
const isEditMode = ref(false)
|
||||||
|
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
||||||
|
|
||||||
const monthLabels = [
|
const monthLabels = [
|
||||||
'Janvier',
|
'Janvier',
|
||||||
'Fevrier',
|
'Fevrier',
|
||||||
@@ -61,6 +109,32 @@ const monthLabels = [
|
|||||||
'Decembre'
|
'Decembre'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const orderedMonthOptions = [
|
||||||
|
{ value: 6, label: 'Juin' },
|
||||||
|
{ value: 7, label: 'Juillet' },
|
||||||
|
{ value: 8, label: 'Aout' },
|
||||||
|
{ value: 9, label: 'Septembre' },
|
||||||
|
{ value: 10, label: 'Octobre' },
|
||||||
|
{ value: 11, label: 'Novembre' },
|
||||||
|
{ value: 12, label: 'Decembre' },
|
||||||
|
{ value: 1, label: 'Janvier' },
|
||||||
|
{ value: 2, label: 'Fevrier' },
|
||||||
|
{ value: 3, label: 'Mars' },
|
||||||
|
{ value: 4, label: 'Avril' },
|
||||||
|
{ value: 5, label: 'Mai' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const paymentsByMonth = computed(() => {
|
||||||
|
const map = new Map<number, { paid25: number; paid50: number }>()
|
||||||
|
for (const mp of props.summary?.monthPayments ?? []) {
|
||||||
|
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
||||||
|
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
||||||
|
|
||||||
const months = computed(() => {
|
const months = computed(() => {
|
||||||
type DisplayWeek = {
|
type DisplayWeek = {
|
||||||
key: string
|
key: string
|
||||||
@@ -115,12 +189,29 @@ const formatMinutes = (minutes: number) => {
|
|||||||
const hours = Math.floor(abs / 60)
|
const hours = Math.floor(abs / 60)
|
||||||
const rest = abs % 60
|
const rest = abs % 60
|
||||||
const sign = minutes < 0 ? '-' : ''
|
const sign = minutes < 0 ? '-' : ''
|
||||||
return `${sign}${hours}h${rest.toString().padStart(2, '0')}`
|
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDays = (minutes: number) => {
|
const openNewPayment = () => {
|
||||||
const days = minutes / 420
|
isEditMode.value = false
|
||||||
const sign = days < 0 ? '-' : ''
|
paymentForm.month = 6
|
||||||
return `${sign}${Math.abs(days).toFixed(2)} j`
|
paymentForm.hours = 0
|
||||||
|
paymentForm.rate = '25'
|
||||||
|
isPaymentDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditPayment = (month: number, rate: '25' | '50') => {
|
||||||
|
isEditMode.value = true
|
||||||
|
paymentForm.month = month
|
||||||
|
paymentForm.rate = rate
|
||||||
|
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
||||||
|
paymentForm.hours = currentMinutes / 60
|
||||||
|
isPaymentDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmitPayment = () => {
|
||||||
|
const minutes = Math.round(paymentForm.hours * 60)
|
||||||
|
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
||||||
|
isPaymentDrawerOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { CONTRACT_TYPES } from '~/services/dto/contract'
|
|||||||
import { listAbsences } from '~/services/absences'
|
import { listAbsences } from '~/services/absences'
|
||||||
import { listContracts } from '~/services/contracts'
|
import { listContracts } from '~/services/contracts'
|
||||||
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
||||||
import { getEmployeeRttSummary } from '~/services/employee-rtt-summary'
|
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||||
import { getEmployee, updateEmployee } from '~/services/employees'
|
import { getEmployee, updateEmployee } from '~/services/employees'
|
||||||
import { listPublicHolidays } from '~/services/public-holidays'
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||||
@@ -307,6 +307,13 @@ export const useEmployeeDetailPage = () => {
|
|||||||
await loadEmployee()
|
await loadEmployee()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
|
||||||
|
if (!employee.value) return
|
||||||
|
const year = rttSummary.value?.year ?? undefined
|
||||||
|
await createRttPayment(employee.value.id, month, minutes, rate, year)
|
||||||
|
await loadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
watch(requiresCreateContractEndDate, (required) => {
|
watch(requiresCreateContractEndDate, (required) => {
|
||||||
if (!required) {
|
if (!required) {
|
||||||
createContractForm.endDate = ''
|
createContractForm.endDate = ''
|
||||||
@@ -358,6 +365,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
setCreateContractDrawerOpen,
|
setCreateContractDrawerOpen,
|
||||||
submitContractUpdate,
|
submitContractUpdate,
|
||||||
submitCreateContract,
|
submitCreateContract,
|
||||||
submitFractionedDays
|
submitFractionedDays,
|
||||||
|
submitRttPayment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,52 +9,64 @@
|
|||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
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"
|
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
active-class="bg-tertiary-500 text-primary-500 font-bold"
|
||||||
>
|
>
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/calendar"
|
to="/calendar"
|
||||||
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 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/calendar')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Calendrier
|
Calendrier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/hours"
|
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"
|
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/hours')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Heures
|
Heures
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
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"
|
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/employees')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Employés
|
Employés
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
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 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/sites')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Sites
|
Sites
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/absence-types"
|
to="/absence-types"
|
||||||
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 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/absence-types')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Types d'absence
|
Types d'absence
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/users"
|
to="/users"
|
||||||
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 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/users')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Utilisateurs
|
Utilisateurs
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -80,9 +92,5 @@
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
const route = useRoute()
|
||||||
const handleLogout = async () => {
|
|
||||||
await auth.logout()
|
|
||||||
await navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
:public-holidays="publicHolidays"
|
:public-holidays="publicHolidays"
|
||||||
@update-fractioned-days="submitFractionedDays"
|
@update-fractioned-days="submitFractionedDays"
|
||||||
/>
|
/>
|
||||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" />
|
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +143,8 @@ const {
|
|||||||
setCreateContractDrawerOpen,
|
setCreateContractDrawerOpen,
|
||||||
submitContractUpdate,
|
submitContractUpdate,
|
||||||
submitCreateContract,
|
submitCreateContract,
|
||||||
submitFractionedDays
|
submitFractionedDays,
|
||||||
|
submitRttPayment
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ export type EmployeeRttWeekSummary = {
|
|||||||
recoveryMinutes: number
|
recoveryMinutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RttMonthPayment = {
|
||||||
|
month: number
|
||||||
|
paidMinutes25: number
|
||||||
|
paidMinutes50: number
|
||||||
|
}
|
||||||
|
|
||||||
export type EmployeeRttSummary = {
|
export type EmployeeRttSummary = {
|
||||||
year: number
|
year: number
|
||||||
carryFromPreviousYearMinutes: number
|
carryFromPreviousYearMinutes: number
|
||||||
currentYearRecoveryMinutes: number
|
currentYearRecoveryMinutes: number
|
||||||
|
totalPaidMinutes: number
|
||||||
availableMinutes: number
|
availableMinutes: number
|
||||||
weeks: EmployeeRttWeekSummary[]
|
weeks: EmployeeRttWeekSummary[]
|
||||||
|
monthPayments: RttMonthPayment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,10 @@ export const getEmployeeRttSummary = async (employeeId: number, year?: number) =
|
|||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { month, minutes, rate }
|
||||||
|
if (year) body.year = year
|
||||||
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
41
migrations/Version20260309140000.php
Normal file
41
migrations/Version20260309140000.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE employee_rtt_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
minutes INT NOT NULL,
|
||||||
|
rate VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_rtt_payments');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260309160000.php
Normal file
26
migrations/Version20260309160000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add unique constraint on employee_rtt_payments (employee_id, year, month, rate).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX uniq_rtt_payment_employee_year_month_rate');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ApiResource/EmployeeRttPaymentInput.php
Normal file
29
src/ApiResource/EmployeeRttPaymentInput.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use App\State\EmployeeRttPaymentProcessor;
|
||||||
|
use App\State\EmployeeRttPaymentProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/employees/{id}/rtt-payments',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeRttPaymentProvider::class,
|
||||||
|
processor: EmployeeRttPaymentProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
public int $month = 0;
|
||||||
|
public int $minutes = 0;
|
||||||
|
public string $rate = '25';
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\ApiResource;
|
|||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
use App\State\EmployeeRttSummaryProvider;
|
use App\State\EmployeeRttSummaryProvider;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -25,6 +26,10 @@ final class EmployeeRttSummary
|
|||||||
public int $carryFromPreviousYearMinutes = 0;
|
public int $carryFromPreviousYearMinutes = 0;
|
||||||
public int $currentYearRecoveryMinutes = 0;
|
public int $currentYearRecoveryMinutes = 0;
|
||||||
public int $availableMinutes = 0;
|
public int $availableMinutes = 0;
|
||||||
|
public int $totalPaidMinutes = 0;
|
||||||
|
|
||||||
|
/** @var list<RttMonthPayment> */
|
||||||
|
public array $monthPayments = [];
|
||||||
|
|
||||||
/** @var list<EmployeeRttWeekSummary> */
|
/** @var list<EmployeeRttWeekSummary> */
|
||||||
public array $weeks = [];
|
public array $weeks = [];
|
||||||
|
|||||||
14
src/Dto/Rtt/RttMonthPayment.php
Normal file
14
src/Dto/Rtt/RttMonthPayment.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Rtt;
|
||||||
|
|
||||||
|
final class RttMonthPayment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $paidMinutes25 = 0,
|
||||||
|
public int $paidMinutes50 = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
131
src/Entity/EmployeeRttPayment.php
Normal file
131
src/Entity/EmployeeRttPayment.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_rtt_payments', options: ['comment' => 'Paiements RTT par employe, mois et exercice.'])]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
|
||||||
|
class EmployeeRttPayment
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice.'])]
|
||||||
|
private int $year = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
||||||
|
private int $month = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
|
||||||
|
private int $minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
|
||||||
|
private string $rate = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYear(): int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(int $year): self
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonth(): int
|
||||||
|
{
|
||||||
|
return $this->month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMonth(int $month): self
|
||||||
|
{
|
||||||
|
$this->month = $month;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMinutes(int $minutes): self
|
||||||
|
{
|
||||||
|
$this->minutes = $minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRate(): string
|
||||||
|
{
|
||||||
|
return $this->rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRate(string $rate): self
|
||||||
|
{
|
||||||
|
$this->rate = $rate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(): self
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Repository/EmployeeRttPaymentRepository.php
Normal file
47
src/Repository/EmployeeRttPaymentRepository.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeRttPayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
|
||||||
|
{
|
||||||
|
return $this->findOneBy([
|
||||||
|
'employee' => $employee,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'rate' => $rate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EmployeeRttPayment[]
|
||||||
|
*/
|
||||||
|
public function findByEmployeeAndYear(Employee $employee, int $year): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->addOrderBy('p.month', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/State/EmployeeRttPaymentProcessor.php
Normal file
86
src/State/EmployeeRttPaymentProcessor.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\ApiResource\EmployeeRttPaymentInput;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
if (!$data instanceof EmployeeRttPaymentInput) {
|
||||||
|
throw new UnprocessableEntityHttpException('Invalid payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||||
|
if ($employeeId <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $this->employeeRepository->find($employeeId);
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
throw new NotFoundHttpException('Employee not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($data->rate, ['25', '50'], true)) {
|
||||||
|
throw new UnprocessableEntityHttpException('rate must be "25" or "50".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data->month < 1 || $data->month > 12) {
|
||||||
|
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data->minutes < 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('minutes must be >= 0.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||||
|
|
||||||
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate);
|
||||||
|
|
||||||
|
if (null === $payment) {
|
||||||
|
$payment = new EmployeeRttPayment();
|
||||||
|
$payment->setEmployee($employee);
|
||||||
|
$payment->setYear($year);
|
||||||
|
$payment->setMonth($data->month);
|
||||||
|
$payment->setRate($data->rate);
|
||||||
|
$this->entityManager->persist($payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment->setMinutes($data->minutes);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$data->year = $year;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentExerciseYear(): int
|
||||||
|
{
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$year = (int) $today->format('Y');
|
||||||
|
$month = (int) $today->format('n');
|
||||||
|
|
||||||
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/State/EmployeeRttPaymentProvider.php
Normal file
17
src/State/EmployeeRttPaymentProvider.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\EmployeeRttPaymentInput;
|
||||||
|
|
||||||
|
final readonly class EmployeeRttPaymentProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
return new EmployeeRttPaymentInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeRttSummary;
|
use App\ApiResource\EmployeeRttSummary;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttBalanceRepository;
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -29,6 +31,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private EmployeeScopeService $employeeScopeService,
|
private EmployeeScopeService $employeeScopeService,
|
||||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private RttRecoveryComputationService $rttRecoveryService,
|
private RttRecoveryComputationService $rttRecoveryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -92,6 +95,33 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$weekRanges
|
$weekRanges
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
|
||||||
|
$monthBuckets = [];
|
||||||
|
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$m = $payment->getMonth();
|
||||||
|
if (!isset($monthBuckets[$m])) {
|
||||||
|
$monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
|
||||||
|
}
|
||||||
|
if ('25' === $payment->getRate()) {
|
||||||
|
$monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
|
||||||
|
} else {
|
||||||
|
$monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthPayments = [];
|
||||||
|
$totalPaidMinutes = 0;
|
||||||
|
|
||||||
|
foreach ($monthBuckets as $m => $bucket) {
|
||||||
|
$monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
|
||||||
|
$totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary->totalPaidMinutes = $totalPaidMinutes;
|
||||||
|
$summary->monthPayments = $monthPayments;
|
||||||
|
$summary->availableMinutes -= $totalPaidMinutes;
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user