feat : ajout des primes
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

This commit is contained in:
2026-03-13 16:20:30 +01:00
parent 8b20632ab8
commit d455bb77a3
10 changed files with 574 additions and 3 deletions

View File

@@ -0,0 +1,207 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-3 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Montant </p>
<p>Commentaire</p>
</div>
<div v-if="bonuses.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune prime.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in bonuses"
:key="item.id"
class="grid grid-cols-3 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.amount }} </p>
<p>{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification prime' : 'Nouvelle prime'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="bonus-month"
v-model="form.month"
type="month"
class="capitalize 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>
<label class="text-md font-semibold text-neutral-700" for="bonus-amount">
Montant () <span class="text-red-600">*</span>
</label>
<input
id="bonus-amount"
v-model.number="form.amount"
type="number"
step="0.01"
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>
<label class="text-md font-semibold text-neutral-700" for="bonus-comment">
Commentaire
</label>
<textarea
id="bonus-comment"
v-model="form.comment"
rows="3"
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"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Bonus } from '~/services/dto/bonus'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
bonuses: Bonus[]
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; amount: number; comment?: string }): void
(event: 'update', id: number, data: { month: string; amount: number; comment?: string }): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Bonus | null>(null)
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
amount: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && form.amount > 0
})
const monthLabels: Record<number, string> = {
1: 'Janvier',
2: 'Février',
3: 'Mars',
4: 'Avril',
5: 'Mai',
6: 'Juin',
7: 'Juillet',
8: 'Août',
9: 'Septembre',
10: 'Octobre',
11: 'Novembre',
12: 'Décembre'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.amount = 0
form.comment = ''
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Bonus) => {
isEditing.value = true
editingItem.value = item
form.month = item.month.substring(0, 7)
form.amount = item.amount
form.comment = item.comment ?? ''
isDrawerOpen.value = true
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
amount: form.amount,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data)
} else {
emit('create', data)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette prime ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -0,0 +1,62 @@
import type { Ref } from 'vue'
import type { Bonus } from '~/services/dto/bonus'
import type { Employee } from '~/services/dto/employee'
import {
listBonuses,
createBonus,
updateBonus,
deleteBonus
} from '~/services/bonuses'
export const useEmployeeBonus = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const bonuses = ref<Bonus[]>([])
const isBonusLoading = ref(false)
const bonusDataLoaded = ref(false)
const loadBonusData = async () => {
if (!employee.value || isBonusLoading.value) return
isBonusLoading.value = true
try {
bonuses.value = await listBonuses(employee.value.id)
bonusDataLoaded.value = true
} finally {
isBonusLoading.value = false
}
}
const resetLoaded = () => {
bonusDataLoaded.value = false
}
const submitCreateBonus = async (data: { month: string; amount: number; comment?: string }) => {
if (!employee.value) return
await createBonus({
employeeId: employee.value.id,
month: data.month,
amount: data.amount,
comment: data.comment
})
await reloadEmployee()
}
const submitUpdateBonus = async (id: number, data: { month: string; amount: number; comment?: string }) => {
await updateBonus(id, data)
await reloadEmployee()
}
const submitDeleteBonus = async (id: number) => {
await deleteBonus(id)
await reloadEmployee()
}
return {
bonuses,
isBonusLoading,
bonusDataLoaded,
loadBonusData,
resetLoaded,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
}
}

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage'>('contract')
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -39,6 +39,7 @@ export const useEmployeeDetailPage = () => {
leave.resetLoaded()
rtt.resetLoaded()
mileage.resetLoaded()
bonus.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
@@ -46,6 +47,8 @@ export const useEmployeeDetailPage = () => {
await rtt.loadRttData()
} else if (activeTab.value === 'mileage') {
await mileage.loadMileageData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
}
} finally {
isLoading.value = false
@@ -56,6 +59,7 @@ export const useEmployeeDetailPage = () => {
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
@@ -64,6 +68,8 @@ export const useEmployeeDetailPage = () => {
rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
}
})
@@ -82,6 +88,7 @@ export const useEmployeeDetailPage = () => {
...contract,
...leave,
...rtt,
...mileage
...mileage,
...bonus
}
}

View File

@@ -41,6 +41,11 @@
"create": "Impossible de créer le frais kilométrique.",
"update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer le frais kilométrique."
},
"bonus": {
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
"delete": "Impossible de supprimer la prime."
}
},
"success": {
@@ -77,6 +82,11 @@
"create": "Frais kilométrique créé.",
"update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé."
},
"bonus": {
"create": "Prime créée.",
"update": "Prime mise à jour.",
"delete": "Prime supprimée."
}
}
}

View File

@@ -65,6 +65,16 @@
<Icon name="mdi:car-outline" size="24" class="align-self"/>
Frais Kms
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'bonus'"
>
<Icon name="mdi:money-100" size="24" class="align-self"/>
Prime
</button>
</div>
</div>
<div class="min-h-0 flex-1">
@@ -141,6 +151,19 @@
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesBonusTab
v-else
class="h-full"
:bonuses="bonuses"
@create="submitCreateBonus"
@update="submitUpdateBonus"
@delete="submitDeleteBonus"
/>
</div>
</div>
</div>
</div>
@@ -203,7 +226,12 @@ const {
mileageApiBase,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage
submitDeleteMileage,
bonuses,
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
} = useEmployeeDetailPage()
useHead(() => ({

View File

@@ -0,0 +1,54 @@
import type { Bonus } from './dto/bonus'
import { extractItems } from '~/utils/api'
export const listBonuses = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Bonus[] | { 'hydra:member'?: Bonus[] }>(
'/bonuses',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Bonus>(data)
}
export const createBonus = async (data: {
employeeId: number
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.post<Bonus>('/bonuses', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.create',
toastErrorKey: 'errors.bonus.create'
})
}
export const updateBonus = async (id: number, data: {
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.patch<Bonus>(`/bonuses/${id}`, {
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.update',
toastErrorKey: 'errors.bonus.update'
})
}
export const deleteBonus = async (id: number) => {
const api = useApi()
return api.delete(`/bonuses/${id}`, {}, {
toastSuccessKey: 'success.bonus.delete',
toastErrorKey: 'errors.bonus.delete'
})
}

View File

@@ -0,0 +1,7 @@
export type Bonus = {
id: number
month: string
amount: number
comment: string | null
createdAt: string
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313151220 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create bonuses table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE bonuses (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, amount DOUBLE PRECISION NOT NULL, comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_8535CFD28C03F15C ON bonuses (employee_id)');
$this->addSql('COMMENT ON COLUMN bonuses.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN bonuses.month IS \'(DC2Type:date_immutable)\'');
$this->addSql('ALTER TABLE bonuses ADD CONSTRAINT FK_8535CFD28C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bonuses DROP CONSTRAINT FK_8535CFD28C03F15C');
$this->addSql('DROP TABLE bonuses');
}
}

145
src/Entity/Bonus.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\BonusRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_USER')"
),
new GetCollection(
security: "is_granted('ROLE_USER')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
],
normalizationContext: [
'groups' => ['bonus:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['bonus:write'],
'datetime_format' => 'Y-m-d',
],
order: ['month' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['month'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: BonusRepository::class)]
#[ORM\Table(name: 'bonuses')]
class Bonus
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['bonus:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['bonus:read', 'bonus:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['bonus:read', 'bonus:write'])]
private ?DateTimeImmutable $month = null;
#[ORM\Column(type: 'float')]
#[Groups(['bonus:read', 'bonus:write'])]
private float $amount = 0;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['bonus:read', 'bonus:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['bonus:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
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 getMonth(): ?DateTimeImmutable
{
return $this->month;
}
public function setMonth(?DateTimeImmutable $month): self
{
$this->month = $month;
return $this;
}
public function getAmount(): float
{
return $this->amount;
}
public function setAmount(float $amount): self
{
$this->amount = $amount;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bonus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Bonus>
*/
final class BonusRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bonus::class);
}
}