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)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<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]">
|
||||
<p><span class="font-semibold uppercase">RTT à la date du jour :</span> {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
|
||||
<button class="bg-white rounded-md text-primary-500 font-bold px-6 py-1">
|
||||
<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" @click="openNewPayment">
|
||||
<Icon name="mdi:plus-thick" size="16"/>
|
||||
Payer les RTT
|
||||
</button>
|
||||
@@ -30,22 +30,70 @@
|
||||
</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-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">0h</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 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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
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 = [
|
||||
'Janvier',
|
||||
'Fevrier',
|
||||
@@ -61,6 +109,32 @@ const monthLabels = [
|
||||
'Decembre'
|
||||
] 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(() => {
|
||||
type DisplayWeek = {
|
||||
key: string
|
||||
@@ -115,12 +189,29 @@ const formatMinutes = (minutes: number) => {
|
||||
const hours = Math.floor(abs / 60)
|
||||
const rest = abs % 60
|
||||
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 days = minutes / 420
|
||||
const sign = days < 0 ? '-' : ''
|
||||
return `${sign}${Math.abs(days).toFixed(2)} j`
|
||||
const openNewPayment = () => {
|
||||
isEditMode.value = false
|
||||
paymentForm.month = 6
|
||||
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>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||
import { listAbsences } from '~/services/absences'
|
||||
import { listContracts } from '~/services/contracts'
|
||||
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 { listPublicHolidays } from '~/services/public-holidays'
|
||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||
@@ -307,6 +307,13 @@ export const useEmployeeDetailPage = () => {
|
||||
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) => {
|
||||
if (!required) {
|
||||
createContractForm.endDate = ''
|
||||
@@ -358,6 +365,7 @@ export const useEmployeeDetailPage = () => {
|
||||
setCreateContractDrawerOpen,
|
||||
submitContractUpdate,
|
||||
submitCreateContract,
|
||||
submitFractionedDays
|
||||
submitFractionedDays,
|
||||
submitRttPayment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,52 +9,64 @@
|
||||
<template v-if="isAdmin">
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-tertiary-500 text-primary-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 font-bold"
|
||||
>
|
||||
Tableau de bord
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-tertiary-500 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"
|
||||
:class="route.path.startsWith('/calendar')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
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"
|
||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/hours')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
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"
|
||||
active-class="bg-tertiary-500 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"
|
||||
:class="route.path.startsWith('/employees')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
Employés
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-tertiary-500 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"
|
||||
:class="route.path.startsWith('/sites')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-tertiary-500 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"
|
||||
:class="route.path.startsWith('/absence-types')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
Types d'absence
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-tertiary-500 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"
|
||||
:class="route.path.startsWith('/users')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
Utilisateurs
|
||||
</NuxtLink>
|
||||
@@ -80,9 +92,5 @@
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
:public-holidays="publicHolidays"
|
||||
@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>
|
||||
@@ -143,7 +143,8 @@ const {
|
||||
setCreateContractDrawerOpen,
|
||||
submitContractUpdate,
|
||||
submitCreateContract,
|
||||
submitFractionedDays
|
||||
submitFractionedDays,
|
||||
submitRttPayment
|
||||
} = useEmployeeDetailPage()
|
||||
|
||||
useHead(() => ({
|
||||
|
||||
@@ -6,11 +6,19 @@ export type EmployeeRttWeekSummary = {
|
||||
recoveryMinutes: number
|
||||
}
|
||||
|
||||
export type RttMonthPayment = {
|
||||
month: number
|
||||
paidMinutes25: number
|
||||
paidMinutes50: number
|
||||
}
|
||||
|
||||
export type EmployeeRttSummary = {
|
||||
year: number
|
||||
carryFromPreviousYearMinutes: number
|
||||
currentYearRecoveryMinutes: number
|
||||
totalPaidMinutes: number
|
||||
availableMinutes: number
|
||||
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 })
|
||||
}
|
||||
|
||||
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\Get;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\RttMonthPayment;
|
||||
use App\State\EmployeeRttSummaryProvider;
|
||||
|
||||
#[ApiResource(
|
||||
@@ -25,6 +26,10 @@ final class EmployeeRttSummary
|
||||
public int $carryFromPreviousYearMinutes = 0;
|
||||
public int $currentYearRecoveryMinutes = 0;
|
||||
public int $availableMinutes = 0;
|
||||
public int $totalPaidMinutes = 0;
|
||||
|
||||
/** @var list<RttMonthPayment> */
|
||||
public array $monthPayments = [];
|
||||
|
||||
/** @var list<EmployeeRttWeekSummary> */
|
||||
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 App\ApiResource\EmployeeRttSummary;
|
||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||
use App\Dto\Rtt\RttMonthPayment;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use DateTimeImmutable;
|
||||
@@ -29,6 +31,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private RttRecoveryComputationService $rttRecoveryService,
|
||||
) {}
|
||||
|
||||
@@ -92,6 +95,33 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user