feat(absences) : avancement module absences + suppression du portail client

Deux lots regroupés sur la branche feat/absence-management.

Suppression complète du portail client :
- retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER
- supprime l'entité ClientTicket (+ repo, states, relations), User.client et
  User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc
  ROLE_CLIENT de MailAccessChecker
- front : pages /portal, layout portal, composants client-ticket/,
  AdminClientTicketTab, services/dto/i18n/docs associés
- fixtures : retire les users client-liot / client-acme
- migration Version20260522110000 (drop client_ticket, user_allowed_projects,
  colonnes liées ; task_document.task_id -> NOT NULL)
- tests : retire les cas obsolètes testant le blocage des clients sur le mail

Module gestion des absences (WIP) :
- entités / migrations (Version20260521160000, Version20260522090000)
- pages absences.vue / team-absences.vue, composants frontend/components/absence/
- services front, AccrueLeaveCommand, PublicHolidayController

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 11:31:31 +02:00
parent de98924fd3
commit 2a0b202d32
109 changed files with 3918 additions and 3656 deletions

View File

@@ -0,0 +1,69 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-md">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.admin.adjust.title') }}</h2>
</template>
<div v-if="balance" class="flex flex-col gap-4">
<p class="text-sm text-neutral-600">
{{ balance.user.username }} · {{ balance.label }} · {{ balance.period }}
</p>
<MalioInputNumber v-model="acquired" :label="$t('absences.admin.adjust.acquired')" />
<MalioInputNumber v-model="acquiring" :label="$t('absences.admin.adjust.acquiring')" />
<MalioInputNumber v-model="taken" :label="$t('absences.admin.adjust.taken')" />
<div class="flex justify-end gap-2 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton :label="$t('absences.admin.adjust.save')" :disabled="submitting" @click="submit" />
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const props = defineProps<{
modelValue: boolean
balance: AbsenceBalance | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'adjusted': []
}>()
const service = useAbsenceService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
// MalioInputNumber works with string values (v-model is string | null).
const acquired = ref('0')
const acquiring = ref('0')
const taken = ref('0')
const submitting = ref(false)
watch(() => props.balance, (b) => {
acquired.value = String(b?.acquired ?? 0)
acquiring.value = String(b?.acquiring ?? 0)
taken.value = String(b?.taken ?? 0)
}, { immediate: true })
async function submit() {
if (!props.balance) return
submitting.value = true
try {
await service.adjustBalance(props.balance.id, {
acquired: Number(acquired.value) || 0,
acquiring: Number(acquiring.value) || 0,
taken: Number(taken.value) || 0,
})
emit('adjusted')
open.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div v-if="balances.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
{{ $t('absences.noBalance') }}
</div>
<div v-else class="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
<!-- Primary balance, highlighted -->
<div v-if="primary" class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-neutral-500">{{ primary.label }}</p>
<p class="mt-1 text-3xl font-bold text-neutral-900">
{{ formatNumber(primary.available) }}
<span class="text-lg font-normal text-neutral-400">/ {{ formatNumber(acquiredTotal(primary)) }}</span>
</p>
<p class="text-xs text-neutral-400">{{ $t('absences.remaining') }}</p>
</div>
<span
class="rounded-full px-2.5 py-1 text-xs font-medium"
:style="{ backgroundColor: typeColor(primary.type) + '22', color: typeColor(primary.type) }"
>
{{ primary.period }}
</span>
</div>
<div v-if="primary" class="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div
class="h-full rounded-full transition-all"
:style="{ width: progress(primary) + '%', backgroundColor: typeColor(primary.type) }"
/>
</div>
<!-- Acquired (N-1) vs in-progress (N), as on a French payslip -->
<div v-if="primary && primary.type === 'cp'" class="mt-3 grid grid-cols-2 gap-2">
<div class="rounded-lg bg-neutral-50 px-3 py-2">
<p class="text-xs text-neutral-400">{{ $t('absences.acquiredN1') }}</p>
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquired) }}</p>
</div>
<div class="rounded-lg bg-neutral-50 px-3 py-2">
<p class="text-xs text-neutral-400">{{ $t('absences.acquiringN') }}</p>
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquiring) }}</p>
<p class="text-[10px] leading-tight text-neutral-400">{{ $t('absences.acquiringHint') }}</p>
</div>
</div>
<div v-if="primary" class="mt-2 flex justify-between text-xs text-neutral-500">
<span>{{ formatNumber(primary.taken) }} {{ $t('absences.taken') }}</span>
<span v-if="primary.pending > 0" class="text-amber-600">
{{ formatNumber(primary.pending) }} {{ $t('absences.pending') }}
</span>
</div>
<!-- Other balances, compact rows -->
<div v-if="others.length" class="mt-4 flex flex-col divide-y divide-neutral-100 border-t border-neutral-100 pt-1">
<div
v-for="balance in others"
:key="balance.id"
class="flex items-center justify-between py-2 text-sm"
>
<span class="flex items-center gap-2 text-neutral-600">
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: typeColor(balance.type) }" />
{{ balance.label }}
<span class="text-xs text-neutral-400">{{ balance.period }}</span>
</span>
<span class="text-neutral-900">
<span class="font-semibold">{{ formatNumber(balance.available) }}</span>
<span class="text-neutral-400"> / {{ formatNumber(acquiredTotal(balance)) }}</span>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
balances: AbsenceBalance[]
}>()
const { typeColor } = useAbsenceHelpers()
// Current paid-leave reference period, mirroring AbsenceBalanceService::periodFor.
const currentCpPeriod = computed<string>(() => {
const start = useAuthStore().user?.referencePeriodStart ?? '06-01'
const now = new Date()
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const startYear = md >= start ? now.getFullYear() : now.getFullYear() - 1
return `${startYear}-${startYear + 1}`
})
// The current "congés payés" balance is the headline; fall back to any CP, then any balance.
const primary = computed<AbsenceBalance | null>(() => {
const cps = props.balances.filter(b => b.type === 'cp')
return cps.find(b => b.period === currentCpPeriod.value) ?? cps[0] ?? props.balances[0] ?? null
})
const others = computed<AbsenceBalance[]>(() =>
props.balances.filter(b => b.id !== primary.value?.id),
)
function formatNumber(n: number): string {
return (Math.round(n * 2) / 2).toString()
}
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
// backend-computed field when present.
function acquiredTotal(balance: AbsenceBalance): number {
return balance.acquiredTotal ?? balance.acquired + balance.acquiring
}
function progress(balance: AbsenceBalance): number {
const total = acquiredTotal(balance)
if (total <= 0) return 0
return Math.min(100, Math.max(0, (balance.taken / total) * 100))
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(-1)">
<Icon name="mdi:chevron-left" size="22" />
</button>
<p class="text-lg font-semibold capitalize text-neutral-900">{{ monthLabel }}</p>
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(1)">
<Icon name="mdi:chevron-right" size="22" />
</button>
</div>
<!-- Weekday headers -->
<div class="grid grid-cols-7 border-b border-neutral-100 text-center text-xs font-medium text-neutral-400">
<div v-for="d in weekdays" :key="d" class="py-2">{{ d }}</div>
</div>
<!-- Grid -->
<div class="grid grid-cols-7">
<div
v-for="cell in cells"
:key="cell.key"
class="min-h-[92px] border-b border-r border-neutral-100 p-1.5"
:class="cell.holiday ? 'bg-amber-50' : (cell.inMonth ? 'bg-white' : 'bg-neutral-50')"
:title="cell.holiday ?? undefined"
>
<div class="mb-1 flex items-center gap-1">
<span v-if="cell.holiday" class="flex min-w-0 flex-1 items-center gap-1 text-[10px] font-medium text-amber-700">
<Icon name="mdi:star-four-points-outline" size="11" class="flex-shrink-0" />
<span class="truncate">{{ cell.holiday }}</span>
</span>
<span v-else class="flex-1" />
<span class="flex-shrink-0 text-xs" :class="cell.isToday ? 'font-bold text-orange-500' : 'text-neutral-400'">
{{ cell.day }}
</span>
</div>
<div class="flex flex-col gap-1">
<span
v-for="abs in cell.absences"
:key="abs.id"
class="truncate rounded px-1 py-0.5 text-[11px] font-medium text-white"
:style="{ backgroundColor: abs.status === 'pending' ? typeColor(abs.type) + 'aa' : typeColor(abs.type) }"
:title="`${abs.user.username} · ${abs.label} (${statusLabel(abs.status)})`"
>
{{ abs.user.username }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
absences: AbsenceRequest[]
}>()
const emit = defineEmits<{
'range-change': [from: string, to: string]
}>()
const service = useAbsenceService()
const { typeColor, statusLabel } = useAbsenceHelpers()
const holidays = ref<Record<string, string>>({})
const weekdays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const cursor = ref(startOfMonth(new Date()))
const monthLabel = computed(() =>
cursor.value.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
)
type Cell = { key: string; day: number; date: Date; inMonth: boolean; isToday: boolean; holiday: string | null; absences: AbsenceRequest[] }
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
function ymd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// First Monday on/before the 1st of the month
function gridStart(month: Date): Date {
const first = startOfMonth(month)
const dow = (first.getDay() + 6) % 7 // 0 = Monday
const start = new Date(first)
start.setDate(first.getDate() - dow)
return start
}
const visibleRange = computed(() => {
const start = gridStart(cursor.value)
const end = new Date(start)
end.setDate(start.getDate() + 41) // 6 weeks grid
return { start, end }
})
const cells = computed<Cell[]>(() => {
const { start } = visibleRange.value
const today = ymd(new Date())
const result: Cell[] = []
for (let i = 0; i < 42; i++) {
const date = new Date(start)
date.setDate(start.getDate() + i)
const key = ymd(date)
result.push({
key,
day: date.getDate(),
date,
inMonth: date.getMonth() === cursor.value.getMonth(),
isToday: key === today,
holiday: holidays.value[key] ?? null,
absences: props.absences.filter(a => key >= a.startDate.slice(0, 10) && key <= a.endDate.slice(0, 10)),
})
}
return result
})
async function emitRange() {
const { start, end } = visibleRange.value
emit('range-change', ymd(start), ymd(end))
try {
holidays.value = await service.getPublicHolidays(ymd(start), ymd(end))
} catch {
holidays.value = {}
}
}
function shiftMonth(delta: number) {
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
emitRange()
}
onMounted(emitRange)
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="absence-date-field">
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ label }}</label>
<MalioDate
:model-value="modelValue"
:min="min ?? undefined"
:max="max ?? undefined"
:error="error"
:clearable="true"
group-class="w-full"
@update:model-value="$emit('update:modelValue', $event)"
/>
<div v-if="showPills" class="mt-2 flex flex-wrap gap-2">
<button
v-for="opt in pillOptions"
:key="String(opt.value)"
type="button"
class="rounded-full border px-4 py-1.5 text-sm font-medium transition"
:class="half === opt.value
? 'border-primary-500 bg-primary-50 text-primary-600'
: 'border-neutral-300 text-neutral-600 hover:border-neutral-400'"
@click="$emit('update:half', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */
modelValue: string | null
half: HalfDay | null
label: string
/** 'start' shows full/morning/afternoon, 'end' shows full/morning only. */
mode?: 'start' | 'end'
error?: string
/** ISO date string "YYYY-MM-DD" or null. */
min?: string | null
max?: string | null
showPills?: boolean
}>(), {
mode: 'start',
error: '',
min: null,
max: null,
showPills: true,
})
defineEmits<{
'update:modelValue': [value: string | null]
'update:half': [value: HalfDay | null]
}>()
const { t } = useI18n()
type PillOption = { label: string; value: HalfDay | null }
const pillOptions = computed<PillOption[]>(() => {
const base: PillOption[] = [
{ label: t('absences.form.fullDay'), value: null },
{ label: t('absences.halfDay.matin'), value: 'matin' },
]
if (props.mode === 'start') {
base.push({ label: t('absences.halfDay.apres_midi'), value: 'apres_midi' })
}
return base
})
</script>

View File

@@ -0,0 +1,193 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.detail.title') }}</h2>
</template>
<div v-if="request" class="flex flex-col gap-5">
<!-- Hero -->
<div class="overflow-hidden rounded-xl border border-neutral-200 shadow-sm">
<div
class="flex items-start gap-3 p-4"
:style="{ borderLeft: `4px solid ${typeColor(request.type)}` }"
>
<span
class="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: tint(request.type), color: typeColor(request.type) }"
>
<Icon name="mdi:calendar-account" size="22" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-lg font-semibold text-neutral-900">{{ request.label }}</p>
<p class="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
<Icon name="mdi:calendar-range" size="15" />
{{ formatRange(request) }}
</p>
</div>
<StatusBadge
class="flex-shrink-0"
:label="statusLabel(request.status)"
:variant="statusVariant(request.status)"
:icon="statusIcon(request.status)"
/>
</div>
<dl class="grid grid-cols-2 divide-x divide-neutral-200 border-t border-neutral-200 bg-neutral-50">
<div class="flex items-center gap-2.5 p-3">
<span
v-if="request.user.avatarUrl"
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded-full"
>
<img :src="request.user.avatarUrl" alt="" class="h-full w-full object-cover">
</span>
<span
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600"
>
{{ initials }}
</span>
<div class="min-w-0">
<dt class="text-xs text-neutral-400">{{ $t('absences.table.employee') }}</dt>
<dd class="truncate text-sm font-medium text-neutral-800">{{ request.user.username }}</dd>
</div>
</div>
<div class="flex flex-col justify-center p-3">
<dt class="text-xs text-neutral-400">{{ $t('absences.table.days') }}</dt>
<dd class="text-sm font-semibold text-neutral-900">{{ formatDays(request.countedDays) }}</dd>
</div>
</dl>
</div>
<!-- Reason -->
<div v-if="request.reason" class="rounded-lg border border-neutral-200 p-3">
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-neutral-400">
<Icon name="mdi:comment-text-outline" size="14" />
{{ $t('absences.form.reason') }}
</p>
<p class="whitespace-pre-line text-sm text-neutral-800">{{ request.reason }}</p>
</div>
<!-- Rejection -->
<div
v-if="request.status === 'rejected' && request.rejectionReason"
class="rounded-lg border border-red-200 bg-red-50 p-3"
>
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
<Icon name="mdi:close-circle-outline" size="14" />
{{ $t('absences.detail.rejectionReason') }}
</p>
<p class="text-sm text-red-700">{{ request.rejectionReason }}</p>
</div>
<!-- Justification -->
<a
v-if="request.justificationUrl"
:href="request.justificationUrl"
target="_blank"
rel="noopener"
class="flex items-center gap-2 rounded-lg border border-neutral-200 p-3 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50"
>
<Icon name="mdi:file-document-outline" size="20" class="text-primary-500" />
<span class="flex-1 truncate">{{ request.justificationFileName || $t('absences.detail.downloadJustification') }}</span>
<Icon name="mdi:download" size="16" class="text-neutral-400" />
</a>
<!-- Timeline -->
<div>
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-neutral-400">
{{ $t('absences.detail.timeline') }}
</p>
<ol class="relative ml-1 border-l border-neutral-200 pl-5">
<li class="mb-4 last:mb-0">
<span class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white bg-primary-500 ring-1 ring-primary-200" />
<p class="text-sm font-medium text-neutral-800">{{ $t('absences.detail.created') }}</p>
<p class="text-xs text-neutral-400">{{ formatDateTime(request.createdAt) }}</p>
</li>
<li v-if="request.reviewedAt" class="last:mb-0">
<span
class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white ring-1"
:class="request.status === 'rejected'
? 'bg-red-500 ring-red-200'
: 'bg-green-500 ring-green-200'"
/>
<p class="text-sm font-medium text-neutral-800">
{{ statusLabel(request.status) }}
<span v-if="request.reviewedBy" class="font-normal text-neutral-500">
· {{ $t('absences.detail.reviewed', { name: request.reviewedBy.username }) }}
</span>
</p>
<p class="text-xs text-neutral-400">{{ formatDateTime(request.reviewedAt) }}</p>
</li>
</ol>
</div>
<div v-if="canCancel" class="flex justify-end border-t border-neutral-100 pt-4">
<MalioButton
:label="$t('absences.detail.cancel')"
variant="danger"
icon-name="mdi:cancel"
icon-position="left"
:disabled="cancelling"
@click="onCancel"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
request: AbsenceRequest | null
canCancel?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'cancelled': []
}>()
const { t } = useI18n()
const service = useAbsenceService()
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, typeColor } = useAbsenceHelpers()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const cancelling = ref(false)
const initials = computed(() => {
const name = props.request?.user.username ?? ''
return name.slice(0, 2).toUpperCase() || '?'
})
/** Type colour at ~12% opacity for soft backgrounds. */
function tint(type: AbsenceRequest['type']): string {
return `${typeColor(type)}1f`
}
function formatDateTime(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
async function onCancel() {
if (!props.request) return
if (!confirm(t('absences.detail.cancelConfirm'))) return
cancelling.value = true
try {
await service.cancel(props.request.id)
emit('cancelled')
open.value = false
} finally {
cancelling.value = false
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-md">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.review.rejectTitle') }}</h2>
</template>
<div v-if="request" class="flex flex-col gap-4">
<p class="text-sm text-neutral-600">
{{ request.user.username }} · {{ request.label }} · {{ formatRange(request) }}
</p>
<MalioInputTextArea
v-model="reason"
:label="$t('absences.review.rejectReasonLabel')"
:placeholder="$t('absences.review.rejectReasonPlaceholder')"
/>
<div class="flex justify-end gap-2 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton
:label="$t('absences.review.confirm')"
variant="danger"
:disabled="!reason.trim() || submitting"
@click="submit"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
request: AbsenceRequest | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'rejected': []
}>()
const service = useAbsenceService()
const { formatRange } = useAbsenceHelpers()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const reason = ref('')
const submitting = ref(false)
watch(open, (v) => {
if (v) reason.value = ''
})
async function submit() {
if (!props.request || !reason.value.trim()) return
submitting.value = true
try {
await service.reject(props.request.id, reason.value.trim())
emit('rejected')
open.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,296 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-xl">
<template #header>
<h2 class="text-xl font-bold">{{ $t('absences.newRequest') }}</h2>
</template>
<div class="flex flex-col gap-5">
<!-- Server-side error banner -->
<div v-if="serverError" class="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
<Icon name="mdi:alert-circle-outline" size="18" class="mt-0.5 flex-shrink-0" />
<span>{{ serverError }}</span>
</div>
<!-- Step 1 type (always visible) -->
<MalioSelect
v-model="form.type"
:label="$t('absences.form.type')"
:options="typeOptions"
:empty-option-label="$t('absences.filters.allTypes')"
:error="errors.type"
group-class="w-full"
/>
<!-- Step 2 start date (revealed once a type is chosen) -->
<AbsenceDateField
v-if="showDates"
v-model="form.startDate"
v-model:half="form.startHalf"
:label="$t('absences.form.startDate')"
mode="start"
:error="errors.startDate"
:max="form.endDate"
/>
<!-- Balance at start date -->
<div v-if="preview && preview.available !== null" class="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAt', { date: startDateLabel }) }}</span>
<span class="text-neutral-900">{{ formatDays(preview.available) }}</span>
</div>
<!-- Step 3 end date (revealed once a start date is set) -->
<AbsenceDateField
v-if="showEnd"
v-model="form.endDate"
v-model:half="form.endHalf"
:label="$t('absences.form.endDate')"
mode="end"
:error="errors.endDate"
:min="form.startDate"
:show-pills="!isSingleDay"
/>
<!-- Duration & projected balance -->
<div v-if="preview" class="flex flex-col gap-1 rounded-lg bg-neutral-50 p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-neutral-600">{{ $t('absences.form.duration') }}</span>
<span class="font-semibold text-neutral-900">{{ formatDays(preview.countedDays) }}</span>
</div>
<div v-if="preview.projectedAvailable !== null" class="flex items-center justify-between border-t border-neutral-200 pt-1 text-sm">
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAfterValidation') }}</span>
<span :class="preview.projectedAvailable < 0 ? 'font-semibold text-amber-600' : 'text-neutral-900'">
{{ formatDays(preview.projectedAvailable) }}
</span>
</div>
</div>
<div
v-if="preview && preview.projectedAvailable !== null && preview.projectedAvailable < 0"
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-700"
>
{{ $t('absences.form.negativeWarning') }}
</div>
<!-- Step 4 justification (only when the policy requires it) -->
<MalioInputUpload
v-if="showJustification"
:model-value="form.file?.name ?? null"
:label="`${$t('absences.form.justification')} *`"
accept="application/pdf,image/png,image/jpeg,image/webp"
:error="errors.justification"
@file-selected="onFileSelected"
/>
<!-- Comment (optional) -->
<div v-if="showComment" class="flex items-start gap-2">
<span class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600">
{{ initials }}
</span>
<MalioInputTextArea
v-model="form.reason"
group-class="flex-1"
:placeholder="$t('absences.form.commentPlaceholder')"
/>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 pt-2">
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
<MalioButton
:label="$t('absences.form.submit')"
:disabled="submitting"
@click="submit"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
policies: AbsencePolicy[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': []
}>()
const { t } = useI18n()
const { formatDays, formatDate } = useAbsenceHelpers()
const service = useAbsenceService()
const auth = useAuthStore()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
type FormState = {
type: AbsenceType | null
// ISO date strings "YYYY-MM-DD" (lexicographic order == chronological order).
startDate: string | null
startHalf: HalfDay | null
endDate: string | null
endHalf: HalfDay | null
reason: string
file: File | null
}
const form = reactive<FormState>({
type: null,
startDate: null,
startHalf: null,
endDate: null,
endHalf: null,
reason: '',
file: null,
})
const errors = reactive<{ type: string; startDate: string; endDate: string; justification: string }>({
type: '',
startDate: '',
endDate: '',
justification: '',
})
const serverError = ref('')
const preview = ref<AbsencePreviewResult | null>(null)
const submitting = ref(false)
const typeOptions = computed(() =>
props.policies
.filter(p => p.active)
.map(p => ({ label: p.label, value: p.type })),
)
const selectedPolicy = computed(() => props.policies.find(p => p.type === form.type) ?? null)
const justificationRequired = computed(() => selectedPolicy.value?.justificationRequired ?? false)
const showDates = computed(() => form.type !== null)
const showEnd = computed(() => form.startDate !== null)
const showJustification = computed(() => form.type !== null && justificationRequired.value)
const showComment = computed(() => form.startDate !== null)
const isSingleDay = computed(() =>
form.startDate !== null
&& form.endDate !== null
&& form.startDate === form.endDate,
)
const startDateLabel = computed(() => formatDate(form.startDate))
const initials = computed(() => {
const name = auth.user?.username ?? ''
return name.slice(0, 2).toUpperCase() || '?'
})
function onFileSelected(file: File) {
form.file = file
errors.justification = ''
}
function buildPayload() {
// On a single-day request the end half-day mirrors the start.
const endHalf = isSingleDay.value ? form.startHalf : form.endHalf
return {
type: form.type as AbsenceType,
startDate: form.startDate as string,
endDate: form.endDate as string,
startHalfDay: form.startHalf,
endHalfDay: endHalf,
reason: form.reason || null,
}
}
function validate(): boolean {
errors.type = form.type ? '' : t('absences.form.errors.typeRequired')
errors.startDate = form.startDate ? '' : t('absences.form.errors.startRequired')
if (form.endDate === null) {
errors.endDate = t('absences.form.errors.endRequired')
} else if (form.startDate && form.endDate < form.startDate) {
errors.endDate = t('absences.form.errors.endBeforeStart')
} else if (form.type && form.startDate && (preview.value?.countedDays ?? 0) <= 0) {
errors.endDate = t('absences.form.errors.zeroDays')
} else {
errors.endDate = ''
}
errors.justification = justificationRequired.value && !form.file
? t('absences.form.errors.justificationRequired')
: ''
return !errors.type && !errors.startDate && !errors.endDate && !errors.justification
}
// Clear field errors as soon as the user corrects them.
watch(() => form.type, (v) => { if (v) errors.type = '' })
watch(() => form.startDate, (v) => { if (v) errors.startDate = '' })
watch(() => [form.endDate, form.startDate], () => {
if (form.endDate && (!form.startDate || form.endDate >= form.startDate)) errors.endDate = ''
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => [form.type, form.startDate, form.endDate, form.startHalf, form.endHalf],
() => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!form.type || !form.startDate || !form.endDate) {
preview.value = null
return
}
debounceTimer = setTimeout(async () => {
try {
preview.value = await service.preview(buildPayload())
} catch {
preview.value = null
}
}, 300)
},
{ deep: true },
)
async function submit() {
serverError.value = ''
if (!validate()) return
submitting.value = true
try {
const created = await service.create(buildPayload())
if (form.file) {
await service.uploadJustification(created.id, form.file)
}
emit('created')
open.value = false
resetForm()
} catch (e) {
serverError.value = (e instanceof Error && e.message) ? e.message : t('absences.form.serverError')
} finally {
submitting.value = false
}
}
function resetForm() {
form.type = null
form.startDate = null
form.startHalf = null
form.endDate = null
form.endHalf = null
form.reason = ''
form.file = null
errors.type = ''
errors.startDate = ''
errors.endDate = ''
errors.justification = ''
serverError.value = ''
preview.value = null
}
watch(open, (v) => {
if (v) resetForm()
})
</script>

View File

@@ -0,0 +1,163 @@
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<div>
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
</div>
</template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
<MalioSelect
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
:options="contractOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioSelect
v-model="form.familySituation"
:label="$t('absences.admin.employees.fields.familySituation')"
:options="familyOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioInputText
v-model="form.workTimeRatio"
:label="$t('absences.admin.employees.fields.workTimeRatio')"
input-class="w-full"
/>
<MalioInputText
v-model="form.annualLeaveDays"
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
input-class="w-full"
/>
<MalioInputText
v-model="form.referencePeriodStart"
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
input-class="w-full"
/>
<MalioInputText
v-model="form.initialLeaveBalance"
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
input-class="w-full"
/>
<MalioInputText
v-model="form.nbChildren"
:label="$t('absences.admin.employees.fields.nbChildren')"
input-class="w-full"
/>
<div class="col-span-full mt-2 flex justify-end">
<MalioButton
:label="$t('absences.admin.employees.drawer.save')"
button-class="w-auto px-6"
:disabled="submitting"
@click="save"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
const props = defineProps<{
modelValue: boolean
user: UserData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const { t } = useI18n()
const { update } = useUserService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const submitting = ref(false)
const contractOptions = [
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
]
const familyOptions = [
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
]
const form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
familySituation: null as FamilySituation | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '0',
nbChildren: '0',
})
function hydrate(u: UserData | null) {
if (!u) return
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
form.contractType = u.contractType ?? null
form.familySituation = u.familySituation ?? null
form.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
form.nbChildren = String(u.nbChildren ?? 0)
}
watch(() => props.modelValue, (isOpen) => {
if (isOpen) hydrate(props.user)
})
async function save() {
if (!props.user) return
submitting.value = true
try {
await update(props.user.id, {
isEmployee: true,
hireDate: form.hireDate || null,
endDate: form.endDate || null,
contractType: form.contractType,
familySituation: form.familySituation,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
nbChildren: Number(form.nbChildren) || 0,
})
emit('saved')
open.value = false
} finally {
submitting.value = false
}
}
</script>