Compare commits

..

14 Commits

Author SHA1 Message Date
gitea-actions 7e32e4c013 chore: bump version to v0.4.14
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-26 09:36:14 +00:00
Matthieu 8fb5b80d8d fix(absences) : afficher le solde de CP avec décimales (8,75) sans arrondir
Auto Tag Develop / tag (push) Successful in 8s
Le solde était arrondi à la demi-journée (Math.round(n*2)/2), affichant
9 au lieu de 8,75 : un salarié pouvait croire à un droit supérieur au
réel. Formatage via Intl.NumberFormat fr-FR (virgule, max 2 décimales,
zéros superflus retirés) dans formatDays et les cartes de solde.
2026-05-26 11:36:04 +02:00
gitea-actions 96e25c2390 chore: bump version to v0.4.13
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 48s
2026-05-26 09:33:27 +00:00
Matthieu 02ac151ac0 feat(users) : ajout prénom et nom sur l'utilisateur
Auto Tag Develop / tag (push) Successful in 7s
Deux colonnes nullable firstName/lastName sur User (groupes me:read,
user:list, user:write), éditables dans le drawer utilisateur (admin).
L'affichage reste basé sur le username pour l'instant. Migration +
valeurs de démo dans les fixtures.
2026-05-26 11:33:18 +02:00
gitea-actions 1991c43f8c chore: bump version to v0.4.12
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 57s
2026-05-26 09:08:30 +00:00
Matthieu e9ca00aeb2 fix(ui) : espace vertical dans le corps des drawers (champ focus rogné)
Auto Tag Develop / tag (push) Successful in 10s
Le body scrollable de MalioDrawer (overflow-y-auto) n'avait aucun
padding vertical : le label flottant du premier champ, qui remonte au
focus, dépassait le bord haut et se faisait rogner sous l'entête. Idem
pour le dernier champ en bas.

Ajoute une feuille app.css (hors thème sombre) qui donne 1rem de padding
haut/bas au corps de tous les drawers, via l'API data-test stable de
@malio/layer-ui (sans modifier la lib ni chaque drawer).
2026-05-26 11:08:19 +02:00
gitea-actions b13ba41674 chore: bump version to v0.4.11
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-05-26 09:02:57 +00:00
Matthieu ac615875f3 fix(absences) : dates du drawer employé en pleine largeur (1 par ligne)
Auto Tag Develop / tag (push) Successful in 7s
Dans le drawer « Informations employé », les champs Date d'embauche et
Date de sortie étaient sur une grille 2 colonnes : le popover du
calendrier MalioDate, coincé en demi-largeur, chevauchait le champ
voisin. Passage en pleine largeur (sm:col-span-2) pour laisser le
calendrier s'afficher correctement.
2026-05-26 11:02:50 +02:00
gitea-actions f8322f8b1e chore: bump version to v0.4.10
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-26 08:49:58 +00:00
Matthieu cd474d5089 feat(absences) : réserve « Mes absences » aux employés
Auto Tag Develop / tag (push) Successful in 6s
Un utilisateur non coché « Employé » (isEmployee = false) ne voit plus
l'entrée « Mes absences » dans le menu et ne peut plus accéder à la page
/absences (nouveau middleware employee qui redirige vers l'accueil).
L'entête de section « Absences » est masquée si l'utilisateur n'est ni
employé ni admin.
2026-05-26 10:49:52 +02:00
gitea-actions 2feba57cb6 chore: bump version to v0.4.9
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-05-26 08:42:59 +00:00
Matthieu f74f0e1ad3 fix(absences) : seed des politiques par défaut en prod via migration
Auto Tag Develop / tag (push) Successful in 7s
Les 6 AbsencePolicy par défaut (barème Syntec / IDCC 1486) n'étaient
créées que par les fixtures, jamais en prod : l'écran « Politiques
d'absence » était vide en production.

Ajoute une migration de données idempotente (INSERT ... ON CONFLICT
(type) DO NOTHING sur l'index unique uniq_absence_policy_type) qui sème
les mêmes valeurs que les fixtures. Sans effet en dev (lignes déjà
présentes), peuple la prod au déploiement.
2026-05-26 10:42:27 +02:00
gitea-actions 6db6d13e76 chore: bump version to v0.4.8
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m21s
2026-05-26 08:08:06 +00:00
malio 26fae393cf Merge pull request 'feat(absences) : module de gestion des absences + MCP + conformité RGPD/légale' (#7) from feat/absence-management into develop
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #7
2026-05-26 08:07:54 +00:00
15 changed files with 222 additions and 20 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.7' app.version: '0.4.14'
+21
View File
@@ -0,0 +1,21 @@
/*
* App-level layout fixes (not theme-related).
*/
/*
* MalioDrawer : donne au corps scrollable un peu d'espace vertical.
*
* Le body du drawer est `overflow-y-auto` sans padding vertical. Or le label
* flottant d'un champ Malio remonte (-1.25rem) au focus/remplissage : pour le
* PREMIER champ, collé en haut du body, ce label dépasse le bord supérieur et
* se fait rogner (il « grossit et passe sous l'entête »). Le dernier champ
* (popover de date, hint) souffre du même rognage en bas.
*
* On ajoute donc un padding vertical au body de TOUS les drawers via l'API de
* test stable de la lib (@malio/layer-ui), sans la modifier ni toucher chaque
* drawer un par un. Le sélecteur reste limité au panneau du drawer.
*/
[data-test="panel"] > [data-test="body"] {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -102,7 +102,8 @@ const others = computed<AbsenceBalance[]>(() =>
) )
function formatNumber(n: number): string { function formatNumber(n: number): string {
return (Math.round(n * 2) / 2).toString() // Valeur réelle avec décimales (ex. 8,75) : pas d'arrondi qui gonflerait le solde.
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }).format(n)
} }
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the // Total entitlement = acquired (N-1) + in-progress (N); falls back to the
+16 -10
View File
@@ -7,16 +7,22 @@
</div> </div>
</template> </template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save"> <form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate <!-- Dates en pleine largeur (1 par ligne) : le popover du calendrier
v-model="form.hireDate" a besoin de toute la largeur pour s'afficher correctement. -->
:label="$t('absences.admin.employees.fields.hireDate')" <div class="sm:col-span-2">
group-class="w-full" <MalioDate
/> v-model="form.hireDate"
<MalioDate :label="$t('absences.admin.employees.fields.hireDate')"
v-model="form.endDate" group-class="w-full"
:label="$t('absences.admin.employees.fields.endDate')" />
group-class="w-full" </div>
/> <div class="sm:col-span-2">
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
</div>
<MalioSelect <MalioSelect
v-model="form.contractType" v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')" :label="$t('absences.admin.employees.fields.contractType')"
+18
View File
@@ -11,6 +11,16 @@
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''" :error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true" @blur="touched.username = true"
/> />
<MalioInputText
v-model="form.firstName"
label="Prénom"
input-class="w-full"
/>
<MalioInputText
v-model="form.lastName"
label="Nom"
input-class="w-full"
/>
<MalioInputPassword <MalioInputPassword
v-model="form.password" v-model="form.password"
label="Mot de passe" label="Mot de passe"
@@ -84,6 +94,8 @@ const isSubmitting = ref(false)
const form = reactive({ const form = reactive({
username: '', username: '',
firstName: '',
lastName: '',
password: '', password: '',
roles: [] as string[], roles: [] as string[],
isEmployee: false, isEmployee: false,
@@ -98,11 +110,15 @@ watch(() => props.modelValue, (open) => {
if (open) { if (open) {
if (props.item) { if (props.item) {
form.username = props.item.username ?? '' form.username = props.item.username ?? ''
form.firstName = props.item.firstName ?? ''
form.lastName = props.item.lastName ?? ''
form.password = '' form.password = ''
form.roles = [...props.item.roles] form.roles = [...props.item.roles]
form.isEmployee = props.item.isEmployee ?? false form.isEmployee = props.item.isEmployee ?? false
} else { } else {
form.username = '' form.username = ''
form.firstName = ''
form.lastName = ''
form.password = '' form.password = ''
form.roles = ['ROLE_USER'] form.roles = ['ROLE_USER']
form.isEmployee = false form.isEmployee = false
@@ -124,6 +140,8 @@ async function handleSubmit() {
try { try {
const payload: UserWrite = { const payload: UserWrite = {
username: form.username.trim(), username: form.username.trim(),
firstName: form.firstName.trim() || null,
lastName: form.lastName.trim() || null,
roles: form.roles, roles: form.roles,
isEmployee: form.isEmployee, isEmployee: form.isEmployee,
} }
+5 -3
View File
@@ -75,9 +75,11 @@ export function useAbsenceHelpers() {
} }
function formatDays(days: number): string { function formatDays(days: number): string {
const rounded = Math.round(days * 2) / 2 // Affiche la valeur réelle avec décimales (ex. 8,75) : un solde de CP se
const unit = rounded > 1 ? t('absences.daysPlural') : t('absences.daySingular') // gère en demi/quart de journée, arrondir masquerait des droits réels.
return `${rounded} ${unit}` const value = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }).format(days)
const unit = days >= 2 ? t('absences.daysPlural') : t('absences.daySingular')
return `${value} ${unit}`
} }
return { return {
+9 -4
View File
@@ -119,11 +119,14 @@
</div> </div>
<!-- Section : Absences --> <!-- Section : Absences -->
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"> <template v-if="isAbsenceSectionVisible">
Absences <p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
</p> Absences
<div v-else class="mx-2 my-3 border-t border-secondary-500" /> </p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
</template>
<SidebarLink <SidebarLink
v-if="isEmployee"
to="/absences" to="/absences"
icon="mdi:umbrella-beach-outline" icon="mdi:umbrella-beach-outline"
label="Mes absences" label="Mes absences"
@@ -211,6 +214,8 @@ const {version} = useAppVersion()
const route = useRoute() const route = useRoute()
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN')) const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isAbsenceSectionVisible = computed(() => isEmployee.value || isAdmin.value)
const isMailVisible = computed(() => { const isMailVisible = computed(() => {
const roles: string[] = auth.user?.roles ?? [] const roles: string[] = auth.user?.roles ?? []
+9
View File
@@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
// "Mes absences" is reserved for users flagged as employees (subject to the
// absence management). Non-employees are redirected to the home page.
if (!auth.isAuthenticated || !auth.user?.isEmployee) {
return navigateTo('/')
}
})
+1 -1
View File
@@ -2,7 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: {enabled: false}, devtools: {enabled: false},
ssr: false, ssr: false,
css: ['~/assets/css/dark.css'], css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
app: { app: {
baseURL: process.env.NODE_ENV === 'production' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/') ? (process.env.NUXT_PUBLIC_APP_BASE || '/')
+2
View File
@@ -75,6 +75,8 @@ import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string } type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
definePageMeta({ middleware: ['employee'] })
const { t } = useI18n() const { t } = useI18n()
const service = useAbsenceService() const service = useAbsenceService()
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers() const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
+4
View File
@@ -4,6 +4,8 @@ export type UserData = {
id: number id: number
'@id'?: string '@id'?: string
username: string username: string
firstName?: string | null
lastName?: string | null
roles: string[] roles: string[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null apiToken?: string | null
@@ -20,6 +22,8 @@ export type UserData = {
export type UserWrite = { export type UserWrite = {
username: string username: string
firstName?: string | null
lastName?: string | null
plainPassword?: string plainPassword?: string
roles: string[] roles: string[]
// HR / absence management // HR / absence management
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Seed the default absence policies (Syntec / IDCC 1486 legal baseline).
*
* These rows were previously created only by AppFixtures, so they existed in
* dev but never in production: the "Politiques d'absence" admin screen was
* empty in prod. This data migration seeds the same 6 policies idempotently
* (ON CONFLICT on the unique `type` index), so prod gets populated and dev,
* where fixtures already created them, is left untouched.
*
* Values mirror AppFixtures and the legal compliance design
* (docs/superpowers/specs/2026-05-22-absence-legal-compliance-fixes-design.md):
* - CP: 25 jours ouvrés/an, préavis 30 j, pas de justificatif.
* - Mariage/PACS: 4 j/événement ; Naissance: 3 j/événement.
* - Décès / Congé parental / Maladie: pas de forfait codé en dur
* (montant selon lien de parenté ou suspension), justificatif requis.
*/
final class Version20260526100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Seed default absence policies (Syntec IDCC 1486) idempotently for prod';
}
public function up(Schema $schema): void
{
// [type, days_per_year, days_per_event, justification_required, notice_days, count_working_days_only]
$policies = [
['cp', '25', null, 'false', 30, 'true'],
['mariage_pacs', null, '4', 'true', 0, 'true'],
['naissance', null, '3', 'true', 0, 'true'],
['conge_parental', null, null, 'true', 30, 'true'],
['deces', null, null, 'true', 0, 'true'],
['maladie', null, null, 'true', 0, 'true'],
];
foreach ($policies as [$type, $daysPerYear, $daysPerEvent, $justif, $notice, $workingOnly]) {
$daysPerYear = null === $daysPerYear ? 'NULL' : $daysPerYear;
$daysPerEvent = null === $daysPerEvent ? 'NULL' : $daysPerEvent;
$this->addSql(<<<SQL
INSERT INTO absence_policy
(type, days_per_year, days_per_event, justification_required, notice_days, count_working_days_only, active)
VALUES
('{$type}', {$daysPerYear}, {$daysPerEvent}, {$justif}, {$notice}, {$workingOnly}, true)
ON CONFLICT (type) DO NOTHING
SQL);
}
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM absence_policy WHERE type IN ('cp', 'mariage_pacs', 'naissance', 'conge_parental', 'deces', 'maladie')");
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add optional first name / last name to users.
*/
final class Version20260526120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user.first_name and user.last_name (nullable)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD first_name VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD last_name VARCHAR(100) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS first_name');
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS last_name');
}
}
+8
View File
@@ -43,6 +43,8 @@ class AppFixtures extends Fixture
// Users // Users
$admin = new User(); $admin = new User();
$admin->setUsername('admin'); $admin->setUsername('admin');
$admin->setFirstName('Alex');
$admin->setLastName('Martin');
$admin->setRoles(['ROLE_ADMIN']); $admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production'); $admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
@@ -50,18 +52,24 @@ class AppFixtures extends Fixture
$userAlice = new User(); $userAlice = new User();
$userAlice->setUsername('alice'); $userAlice->setUsername('alice');
$userAlice->setFirstName('Alice');
$userAlice->setLastName('Dupont');
$userAlice->setRoles(['ROLE_USER']); $userAlice->setRoles(['ROLE_USER']);
$userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice')); $userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice'));
$manager->persist($userAlice); $manager->persist($userAlice);
$userBob = new User(); $userBob = new User();
$userBob->setUsername('bob'); $userBob->setUsername('bob');
$userBob->setFirstName('Bob');
$userBob->setLastName('Leroy');
$userBob->setRoles(['ROLE_USER']); $userBob->setRoles(['ROLE_USER']);
$userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob')); $userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
$manager->persist($userBob); $manager->persist($userBob);
$userCharlie = new User(); $userCharlie = new User();
$userCharlie->setUsername('charlie'); $userCharlie->setUsername('charlie');
$userCharlie->setFirstName('Charlie');
$userCharlie->setLastName('Moreau');
$userCharlie->setRoles(['ROLE_USER']); $userCharlie->setRoles(['ROLE_USER']);
$userCharlie->setPassword($this->passwordHasher->hashPassword($userCharlie, 'charlie')); $userCharlie->setPassword($this->passwordHasher->hashPassword($userCharlie, 'charlie'));
$manager->persist($userCharlie); $manager->persist($userCharlie);
+32
View File
@@ -55,6 +55,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])] #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
private ?string $username = null; private ?string $username = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $lastName = null;
/** @var list<string> */ /** @var list<string> */
#[ORM\Column] #[ORM\Column]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")] #[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
@@ -147,6 +155,30 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getUserIdentifier(): string public function getUserIdentifier(): string
{ {
return (string) $this->username; return (string) $this->username;