feat(absence) : extract Absence front into Nuxt module layer

LST-66 (2.3) front. Companion to the backend module migration.

- Move pages (absences, team-absences), 8 components, the absences service +
  DTO and the useAbsenceHelpers composable into frontend/modules/absence/
  (auto-detected layer; composable now auto-imported).
- Rewrite consumers: AdminAbsencePolicyTab and the time-tracking calendar
  (getPublicHolidays) point to ~/modules/absence/...
- Middlewares (employee/admin) and shared services (clients, users,
  user-data DTO) stay at the root. i18n stays global.
- Routes /absences and /team-absences preserved.

nuxt build passes; routes confirmed.
This commit is contained in:
Matthieu
2026-06-20 18:36:48 +02:00
parent 306cfd34cd
commit 163bf0891a
16 changed files with 21 additions and 27 deletions
@@ -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 '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/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>
@@ -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 '~/modules/absence/services/dto/absence'
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 {
// 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
// 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>
@@ -0,0 +1,142 @@
<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 '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
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>
@@ -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 '~/modules/absence/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>
@@ -0,0 +1,192 @@
<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 '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
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>
@@ -0,0 +1,68 @@
<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 '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
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>
@@ -0,0 +1,295 @@
<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 '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
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>
@@ -0,0 +1,143 @@
<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">
<!-- Dates en pleine largeur (1 par ligne) : le popover du calendrier
a besoin de toute la largeur pour s'afficher correctement. -->
<div class="sm:col-span-2">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
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
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
:options="contractOptions"
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"
/>
<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, 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 form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '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.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 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,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
})
emit('saved')
open.value = false
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,96 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
const STATUS_VARIANTS: Record<AbsenceStatus, BadgeVariant> = {
pending: 'warning',
approved: 'success',
rejected: 'danger',
cancelled: 'neutral',
}
const STATUS_ICONS: Record<AbsenceStatus, string> = {
pending: 'mdi:clock-outline',
approved: 'mdi:check-circle-outline',
rejected: 'mdi:close-circle-outline',
cancelled: 'mdi:cancel',
}
// Colours used for the calendar bars, keyed by absence type.
const TYPE_COLORS: Record<AbsenceType, string> = {
cp: '#4A90D9',
mariage_pacs: '#E91E63',
naissance: '#26A69A',
conge_parental: '#9C27B0',
deces: '#607D8B',
maladie: '#C62828',
}
export function useAbsenceHelpers() {
const { t } = useI18n()
function statusLabel(status: AbsenceStatus): string {
return t(`absences.status.${status}`)
}
function statusVariant(status: AbsenceStatus): BadgeVariant {
return STATUS_VARIANTS[status] ?? 'neutral'
}
function statusIcon(status: AbsenceStatus): string {
return STATUS_ICONS[status] ?? 'mdi:help-circle-outline'
}
function typeLabel(type: AbsenceType): string {
return t(`absences.types.${type}`)
}
function typeColor(type: AbsenceType): string {
return TYPE_COLORS[type] ?? '#9CA3AF'
}
function halfDayLabel(half: HalfDay): string {
return t(`absences.halfDay.${half}`)
}
function formatDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${day}/${month}/${d.getFullYear()}`
}
/** Human-readable period with half-day annotations. */
function formatRange(req: Pick<AbsenceRequest, 'startDate' | 'endDate' | 'startHalfDay' | 'endHalfDay'>): string {
const start = formatDate(req.startDate)
const end = formatDate(req.endDate)
const startSuffix = req.startHalfDay ? ` (${halfDayLabel(req.startHalfDay)})` : ''
const endSuffix = req.endHalfDay ? ` (${halfDayLabel(req.endHalfDay)})` : ''
if (start === end) {
return `${start}${startSuffix}`
}
return `${start}${startSuffix}${end}${endSuffix}`
}
function formatDays(days: number): string {
// Affiche la valeur réelle avec décimales (ex. 8,75) : un solde de CP se
// gère en demi/quart de journée, arrondir masquerait des droits réels.
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 {
statusLabel,
statusVariant,
statusIcon,
typeLabel,
typeColor,
halfDayLabel,
formatDate,
formatRange,
formatDays,
}
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
+169
View File
@@ -0,0 +1,169 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
<MalioButton
:label="$t('absences.newRequest')"
icon-name="mdi:plus"
icon-position="left"
@click="requestDrawerOpen = true"
/>
</div>
<AbsenceBalanceCards :balances="balances" />
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<MalioSelect
v-model="filters.status"
:label="$t('absences.table.status')"
:options="statusOptions"
:empty-option-label="$t('absences.filters.allStatuses')"
group-class="w-52"
/>
<MalioSelect
v-model="filters.type"
:label="$t('absences.table.type')"
:options="typeOptions"
:empty-option-label="$t('absences.filters.allTypes')"
group-class="w-52"
/>
<MalioSelect
v-model="filters.year"
:label="$t('absences.table.year')"
:options="yearOptions"
:empty-option-label="$t('absences.filters.allYears')"
group-class="w-40"
/>
</div>
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="rows.length"
:row-clickable="true"
:empty-message="$t('absences.noRequests')"
@row-click="openDetail"
>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as Row).status)"
:variant="statusVariant((item as Row).status)"
:icon="statusIcon((item as Row).status)"
/>
</template>
</MalioDataTable>
<AbsenceRequestDrawer
v-model="requestDrawerOpen"
:policies="policies"
@created="reload"
/>
<AbsenceDetailDrawer
v-model="detailDrawerOpen"
:request="selected"
:can-cancel="selected?.status === 'pending'"
@cancelled="reload"
/>
</div>
</template>
<script setup lang="ts">
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/modules/absence/services/dto/absence'
import { useAbsenceService, type AbsenceRequestFilters } from '~/modules/absence/services/absences'
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
definePageMeta({ middleware: ['employee'] })
const { t } = useI18n()
const service = useAbsenceService()
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
useHead({ title: t('absences.title') })
const balances = ref<AbsenceBalance[]>([])
const requests = ref<AbsenceRequest[]>([])
const policies = ref<AbsencePolicy[]>([])
const requestDrawerOpen = ref(false)
const detailDrawerOpen = ref(false)
const selected = ref<AbsenceRequest | null>(null)
// Empty option of MalioSelect has value null, so filters default to null.
const filters = reactive<{ status: AbsenceStatus | null; type: AbsenceType | null; year: number | null }>({
status: null,
type: null,
year: null,
})
const columns = [
{ key: 'typeLabelText', label: t('absences.table.type') },
{ key: 'periodText', label: t('absences.table.period') },
{ key: 'daysText', label: t('absences.table.days') },
{ key: 'status', label: t('absences.table.status') },
{ key: 'createdAtText', label: t('absences.table.requestedAt') },
]
const statusOptions = [
{ label: t('absences.status.pending'), value: 'pending' },
{ label: t('absences.status.approved'), value: 'approved' },
{ label: t('absences.status.rejected'), value: 'rejected' },
{ label: t('absences.status.cancelled'), value: 'cancelled' },
]
const typeOptions = computed(() => policies.value.map(p => ({ label: p.label, value: p.type })))
const yearOptions = computed(() => {
const current = new Date().getFullYear()
return [current + 1, current, current - 1, current - 2].map(y => ({ label: String(y), value: y }))
})
const rows = computed<Row[]>(() =>
requests.value.map(r => ({
...r,
typeLabelText: r.label,
periodText: formatRange(r),
daysText: formatDays(r.countedDays),
createdAtText: formatDate(r.createdAt),
})),
)
function openDetail(item: Record<string, unknown>) {
selected.value = item as Row
detailDrawerOpen.value = true
}
async function loadRequests() {
// Scope to the current user: the collection endpoint returns every user's
// requests for admins, which would leak the whole team into "Mes absences".
const userId = useAuthStore().user?.id
if (!userId) {
requests.value = []
return
}
const f: AbsenceRequestFilters = { user: userId }
if (filters.status) f.status = filters.status
if (filters.type) f.type = filters.type
if (filters.year) f.year = filters.year
requests.value = await service.getRequests(f)
}
async function reload() {
// Scope balances to the current user: the collection endpoint returns every
// user's balance for admins, which would pollute the personal "Mes absences" view.
const userId = useAuthStore().user?.id
const [bal] = await Promise.all([
userId ? service.getBalances({ user: userId }) : Promise.resolve([]),
loadRequests(),
])
balances.value = bal
}
watch(() => [filters.status, filters.type, filters.year], loadRequests)
onMounted(async () => {
policies.value = await service.getPolicies()
await reload()
})
</script>
@@ -0,0 +1,478 @@
<template>
<div class="flex flex-col gap-6">
<h1 class="text-2xl font-bold text-neutral-900">
{{ $t("absences.teamTitle") }}
</h1>
<!-- KPIs -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-neutral-200 bg-white p-4">
<p class="text-sm text-neutral-500">
{{ $t("absences.admin.kpis.pending") }}
</p>
<p class="mt-1 text-3xl font-bold text-amber-600">
{{ kpis.pending }}
</p>
</div>
<div class="rounded-lg border border-neutral-200 bg-white p-4">
<p class="text-sm text-neutral-500">
{{ $t("absences.admin.kpis.todayAbsent") }}
</p>
<p class="mt-1 text-3xl font-bold text-primary-500">
{{ kpis.today }}
</p>
</div>
<div class="rounded-lg border border-neutral-200 bg-white p-4">
<p class="text-sm text-neutral-500">
{{ $t("absences.admin.kpis.weekAbsent") }}
</p>
<p class="mt-1 text-3xl font-bold text-primary-500">
{{ kpis.week }}
</p>
</div>
</div>
<MalioTabList v-model="activeTab" :tabs="tabs">
<!-- Requests -->
<template #requests>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap gap-3">
<MalioSelect
v-model="filters.status"
:label="$t('absences.table.status')"
:options="statusOptions"
:empty-option-label="
$t('absences.filters.allStatuses')
"
group-class="w-48"
/>
<MalioSelect
v-model="filters.type"
:label="$t('absences.table.type')"
:options="typeOptions"
:empty-option-label="
$t('absences.filters.allTypes')
"
group-class="w-48"
/>
<MalioSelect
v-model="filters.user"
:label="$t('absences.table.employee')"
:options="employeeOptions"
:empty-option-label="
$t('absences.filters.allEmployees')
"
group-class="w-48"
/>
</div>
<MalioDataTable
:columns="requestColumns"
:items="requestRows"
:total-items="requestRows.length"
:empty-message="$t('absences.noRequests')"
@row-click="openDetail"
>
<template #cell-status="{ item }">
<StatusBadge
:label="
statusLabel((item as RequestRow).status)
"
:variant="
statusVariant((item as RequestRow).status)
"
:icon="statusIcon((item as RequestRow).status)"
/>
</template>
<template #cell-actions="{ item }">
<div
v-if="(item as RequestRow).status === 'pending'"
class="flex gap-1"
@click.stop
>
<MalioButtonIcon
icon="mdi:check"
:aria-label="$t('absences.review.approve')"
button-class="!bg-green-100 !text-green-700"
:icon-size="18"
@click="approve(item as RequestRow)"
/>
<MalioButtonIcon
icon="mdi:close"
:aria-label="$t('absences.review.reject')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="openReject(item as RequestRow)"
/>
</div>
<span v-else class="text-neutral-300"></span>
</template>
</MalioDataTable>
</div>
</template>
<!-- Calendar -->
<template #calendar>
<div class="min-h-[30rem] pt-10">
<AbsenceCalendar
:absences="calendarAbsences"
@range-change="loadCalendar"
/>
</div>
</template>
<!-- Balances -->
<template #balances>
<div class="min-h-[30rem] pt-10">
<MalioDataTable
:columns="balanceColumns"
:items="balanceRows"
:total-items="balanceRows.length"
:row-clickable="false"
:empty-message="$t('absences.noBalance')"
>
<template #cell-actions="{ item }">
<div class="flex justify-end">
<MalioButton
:label="
$t(
'absences.admin.balancesTable.adjust',
)
"
variant="secondary"
icon-name="mdi:pencil"
icon-position="left"
button-class="w-auto"
@click="openAdjust(item as BalanceRow)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
<!-- Employees -->
<template #employees>
<div class="min-h-[30rem] pt-10">
<MalioDataTable
:columns="employeeColumns"
:items="employeeRows"
:total-items="employeeRows.length"
:empty-message="$t('absences.admin.employees.empty')"
@row-click="openEmployee"
/>
</div>
</template>
</MalioTabList>
<AbsenceDetailDrawer
v-model="detailOpen"
:request="selectedRequest"
:can-cancel="
selectedRequest?.status === 'pending' ||
selectedRequest?.status === 'approved'
"
@cancelled="reloadRequests"
/>
<AbsenceRejectDrawer
v-model="rejectOpen"
:request="selectedRequest"
@rejected="reloadRequests"
/>
<AbsenceBalanceAdjustDrawer
v-model="adjustOpen"
:balance="selectedBalance"
@adjusted="loadBalances"
/>
<EmployeeDrawer
v-model="employeeDrawerOpen"
:user="selectedEmployee"
@saved="loadEmployees"
/>
</div>
</template>
<script setup lang="ts">
import type {
AbsenceBalance,
AbsenceRequest,
AbsenceStatus,
AbsenceType,
} from "~/modules/absence/services/dto/absence";
import {
useAbsenceService,
type AbsenceRequestFilters,
} from "~/modules/absence/services/absences";
import { useUserService } from "~/services/users";
import type { UserData } from "~/services/dto/user-data";
definePageMeta({ middleware: ["admin"] });
type RequestRow = AbsenceRequest & {
employeeText: string;
typeLabelText: string;
periodText: string;
daysText: string;
createdAtText: string;
};
type BalanceRow = AbsenceBalance & {
employeeText: string;
availableText: string;
};
type EmployeeRow = UserData & {
contractText: string;
cpTakenText: string;
cpRemainingText: string;
};
const { t } = useI18n();
const service = useAbsenceService();
const {
statusLabel,
statusVariant,
statusIcon,
formatRange,
formatDays,
formatDate,
} = useAbsenceHelpers();
useHead({ title: t("absences.teamTitle") });
const activeTab = ref("requests");
const tabs = [
{
key: "requests",
label: t("absences.admin.tabs.requests"),
icon: "mdi:format-list-bulleted",
},
{
key: "calendar",
label: t("absences.admin.tabs.calendar"),
icon: "mdi:calendar-month",
},
{
key: "balances",
label: t("absences.admin.tabs.balances"),
icon: "mdi:scale-balance",
},
{
key: "employees",
label: t("absences.admin.tabs.employees"),
icon: "mdi:account-group",
},
];
const requests = ref<AbsenceRequest[]>([]);
const balances = ref<AbsenceBalance[]>([]);
const calendarAbsences = ref<AbsenceRequest[]>([]);
const employees = ref<UserData[]>([]);
const employeeDrawerOpen = ref(false);
const selectedEmployee = ref<UserData | null>(null);
const detailOpen = ref(false);
const rejectOpen = ref(false);
const adjustOpen = ref(false);
const selectedRequest = ref<AbsenceRequest | null>(null);
const selectedBalance = ref<AbsenceBalance | null>(null);
// Empty option of MalioSelect has value null, so filters default to null.
const filters = reactive<{
status: AbsenceStatus | null;
type: AbsenceType | null;
user: number | null;
}>({
status: null,
type: null,
user: null,
});
const statusOptions = [
{ label: t("absences.status.pending"), value: "pending" },
{ label: t("absences.status.approved"), value: "approved" },
{ label: t("absences.status.rejected"), value: "rejected" },
{ label: t("absences.status.cancelled"), value: "cancelled" },
];
const typeOptions = [
{ label: t("absences.types.cp"), value: "cp" },
{ label: t("absences.types.mariage_pacs"), value: "mariage_pacs" },
{ label: t("absences.types.conge_parental"), value: "conge_parental" },
{ label: t("absences.types.deces"), value: "deces" },
{ label: t("absences.types.maladie"), value: "maladie" },
];
const employeeOptions = computed(() => {
const map = new Map<number, string>();
for (const r of requests.value) map.set(r.user.id, r.user.username);
for (const b of balances.value) map.set(b.user.id, b.user.username);
return [...map.entries()].map(([value, label]) => ({ value, label }));
});
const requestColumns = [
{ key: "employeeText", label: t("absences.table.employee") },
{ key: "typeLabelText", label: t("absences.table.type") },
{ key: "periodText", label: t("absences.table.period") },
{ key: "daysText", label: t("absences.table.days") },
{ key: "status", label: t("absences.table.status") },
{ key: "createdAtText", label: t("absences.table.requestedAt") },
{ key: "actions", label: t("absences.table.actions") },
];
const requestRows = computed<RequestRow[]>(() =>
requests.value.map((r) => ({
...r,
employeeText: r.user.username,
typeLabelText: r.label,
periodText: formatRange(r),
daysText: formatDays(r.countedDays),
createdAtText: formatDate(r.createdAt),
})),
);
const balanceColumns = [
{ key: "employeeText", label: t("absences.admin.balancesTable.employee") },
{ key: "label", label: t("absences.admin.balancesTable.type") },
{ key: "period", label: t("absences.admin.balancesTable.period") },
{ key: "acquired", label: t("absences.admin.balancesTable.acquired") },
{ key: "acquiring", label: t("absences.admin.balancesTable.acquiring") },
{ key: "taken", label: t("absences.admin.balancesTable.taken") },
{ key: "pending", label: t("absences.admin.balancesTable.pending") },
{
key: "availableText",
label: t("absences.admin.balancesTable.available"),
},
{ key: "actions", label: "" },
];
const balanceRows = computed<BalanceRow[]>(() =>
balances.value.map((b) => ({
...b,
employeeText: b.user.username,
availableText: formatDays(b.available),
})),
);
const employeeColumns = [
{ key: "username", label: t("absences.admin.employees.columns.name") },
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
];
const employeeRows = computed<EmployeeRow[]>(() => {
// Map user.id -> solde CP de la période courante.
const cpByUser = new Map<number, AbsenceBalance>();
for (const b of balances.value) {
if (b.type === "cp") cpByUser.set(b.user.id, b);
}
const dash = t("absences.admin.employees.noContract");
return employees.value.map((u) => {
const cp = cpByUser.get(u.id);
return {
...u,
contractText: u.contractType ?? dash,
cpTakenText: cp ? formatDays(cp.taken) : dash,
cpRemainingText: cp ? formatDays(cp.available) : dash,
};
});
});
const kpis = computed(() => {
const today = new Date().toISOString().slice(0, 10);
const now = new Date();
const day = (now.getDay() + 6) % 7;
const monday = new Date(now);
monday.setDate(now.getDate() - day);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const mondayStr = monday.toISOString().slice(0, 10);
const sundayStr = sunday.toISOString().slice(0, 10);
const approved = requests.value.filter((r) => r.status === "approved");
const todayUsers = new Set(
approved
.filter(
(r) =>
r.startDate.slice(0, 10) <= today &&
r.endDate.slice(0, 10) >= today,
)
.map((r) => r.user.id),
);
const weekUsers = new Set(
approved
.filter(
(r) =>
r.startDate.slice(0, 10) <= sundayStr &&
r.endDate.slice(0, 10) >= mondayStr,
)
.map((r) => r.user.id),
);
return {
pending: requests.value.filter((r) => r.status === "pending").length,
today: todayUsers.size,
week: weekUsers.size,
};
});
function openDetail(item: Record<string, unknown>) {
selectedRequest.value = item as RequestRow;
detailOpen.value = true;
}
function openReject(row: RequestRow) {
selectedRequest.value = row;
rejectOpen.value = true;
}
function openAdjust(row: BalanceRow) {
selectedBalance.value = row;
adjustOpen.value = true;
}
async function approve(row: RequestRow) {
await service.approve(row.id);
await reloadRequests();
}
async function reloadRequests() {
const f: AbsenceRequestFilters = {};
if (filters.status) f.status = filters.status;
if (filters.type) f.type = filters.type;
if (filters.user) f.user = filters.user;
requests.value = await service.getRequests(f);
}
async function loadBalances() {
balances.value = await service.getBalances();
}
async function loadEmployees() {
const all = await useUserService().getAll();
employees.value = all.filter((u) => u.isEmployee);
}
function openEmployee(item: Record<string, unknown>) {
selectedEmployee.value = item as EmployeeRow;
employeeDrawerOpen.value = true;
}
async function loadCalendar(from: string, to: string) {
calendarAbsences.value = await service.getCalendar(from, to);
}
watch(() => [filters.status, filters.type, filters.user], reloadRequests);
onMounted(async () => {
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
});
</script>
<style scoped>
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
:deep([role="tab"]) {
padding-top: 0.9rem;
padding-bottom: 0.25rem;
}
</style>
@@ -0,0 +1,137 @@
import type {
AbsenceBalance,
AbsencePolicy,
AbsencePolicyWrite,
AbsencePreviewPayload,
AbsencePreviewResult,
AbsenceRequest,
AbsenceRequestWrite,
AbsenceStatus,
AbsenceType,
} from './dto/absence'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type AbsenceRequestFilters = {
status?: AbsenceStatus
type?: AbsenceType
year?: number
user?: number
}
export function useAbsenceService() {
const api = useApi()
// --- Requests ---
async function getRequests(filters: AbsenceRequestFilters = {}): Promise<AbsenceRequest[]> {
const query: Record<string, unknown> = {}
if (filters.status) query.status = filters.status
if (filters.type) query.type = filters.type
if (filters.year) query.year = filters.year
if (filters.user) query.user = `/api/users/${filters.user}`
const data = await api.get<HydraCollection<AbsenceRequest>>('/absence_requests', query)
return extractHydraMembers(data)
}
async function getRequest(id: number): Promise<AbsenceRequest> {
return api.get<AbsenceRequest>(`/absence_requests/${id}`)
}
async function create(payload: AbsenceRequestWrite): Promise<AbsenceRequest> {
return api.post<AbsenceRequest>('/absence_requests', payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.created',
})
}
async function preview(payload: AbsencePreviewPayload): Promise<AbsencePreviewResult> {
return api.post<AbsencePreviewResult>('/absence_requests/preview', payload as Record<string, unknown>, {
toast: false,
})
}
async function approve(id: number): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/approve`, {}, {
toastSuccessKey: 'absences.toast.approved',
})
}
async function reject(id: number, rejectionReason: string): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/reject`, { rejectionReason }, {
toastSuccessKey: 'absences.toast.rejected',
})
}
async function cancel(id: number): Promise<AbsenceRequest> {
return api.patch<AbsenceRequest>(`/absence_requests/${id}/cancel`, {}, {
toastSuccessKey: 'absences.toast.cancelled',
})
}
async function uploadJustification(id: number, file: File): Promise<AbsenceRequest> {
const form = new FormData()
form.append('file', file)
return api.post<AbsenceRequest>(`/absence_requests/${id}/justificatif`, form as unknown as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.justificationUploaded',
})
}
// --- Balances ---
async function getBalances(filters: { user?: number; period?: string; type?: AbsenceType } = {}): Promise<AbsenceBalance[]> {
const query: Record<string, unknown> = {}
if (filters.user) query.user = `/api/users/${filters.user}`
if (filters.period) query.period = filters.period
if (filters.type) query.type = filters.type
const data = await api.get<HydraCollection<AbsenceBalance>>('/absence_balances', query)
return extractHydraMembers(data)
}
async function adjustBalance(id: number, payload: { acquired?: number; acquiring?: number; taken?: number }): Promise<AbsenceBalance> {
return api.patch<AbsenceBalance>(`/absence_balances/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.balanceAdjusted',
})
}
// --- Policies ---
async function getPolicies(): Promise<AbsencePolicy[]> {
const data = await api.get<HydraCollection<AbsencePolicy>>('/absence_policies')
return extractHydraMembers(data)
}
async function updatePolicy(id: number, payload: AbsencePolicyWrite): Promise<AbsencePolicy> {
return api.patch<AbsencePolicy>(`/absence_policies/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'absences.toast.policyUpdated',
})
}
// --- Admin calendar ---
async function getCalendar(from: string, to: string): Promise<AbsenceRequest[]> {
return api.get<AbsenceRequest[]>('/admin/absences/calendar', { from, to })
}
// --- Public holidays (computed server-side) ---
async function getPublicHolidays(from: string, to: string): Promise<Record<string, string>> {
return api.get<Record<string, string>>('/public_holidays', { from, to }, { toast: false })
}
return {
getRequests,
getRequest,
create,
preview,
approve,
reject,
cancel,
uploadJustification,
getBalances,
adjustBalance,
getPolicies,
updatePolicy,
getCalendar,
getPublicHolidays,
}
}
@@ -0,0 +1,93 @@
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
export type HalfDay = 'matin' | 'apres_midi'
export type AbsenceUserRef = {
'@id'?: string
id: number
username: string
avatarUrl: string | null
}
export type AbsenceRequest = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
startDate: string
endDate: string
startHalfDay: HalfDay | null
endHalfDay: HalfDay | null
countedDays: number
reason: string | null
justificationFileName: string | null
justificationUrl: string | null
status: AbsenceStatus
rejectionReason: string | null
createdAt: string
reviewedAt: string | null
reviewedBy: AbsenceUserRef | null
}
export type AbsenceRequestWrite = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
reason?: string | null
}
export type AbsenceBalance = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
period: string
acquired: number
acquiring: number
acquiredTotal: number
taken: number
pending: number
available: number
}
export type AbsencePolicy = {
'@id'?: string
id: number
type: AbsenceType
label: string
daysPerYear: number | null
daysPerEvent: number | null
justificationRequired: boolean
noticeDays: number
countWorkingDaysOnly: boolean
active: boolean
}
export type AbsencePolicyWrite = {
daysPerYear?: number | null
daysPerEvent?: number | null
justificationRequired?: boolean
noticeDays?: number
countWorkingDaysOnly?: boolean
active?: boolean
}
export type AbsencePreviewPayload = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
}
export type AbsencePreviewResult = {
countedDays: number
period: string | null
available: number | null
projectedAvailable: number | null
justificationRequired: boolean
}
@@ -151,7 +151,7 @@
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceService } from '~/modules/absence/services/absences'
const { t } = useI18n()
const absenceService = useAbsenceService()