feat : ajout des RTT payés

This commit is contained in:
2026-03-09 16:19:21 +01:00
parent 232bdb8ec4
commit 701dd9faf3
17 changed files with 589 additions and 32 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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(() => ({

View File

@@ -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[]
}

View File

@@ -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)
}

View 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');
}
}

View 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');
}
}

View 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;
}

View File

@@ -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 = [];

View 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,
) {}
}

View 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;
}
}

View 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()
;
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -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;
}