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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user