feat(absences) : avancement module absences + suppression du portail client
Deux lots regroupés sur la branche feat/absence-management. Suppression complète du portail client : - retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER - supprime l'entité ClientTicket (+ repo, states, relations), User.client et User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc ROLE_CLIENT de MailAccessChecker - front : pages /portal, layout portal, composants client-ticket/, AdminClientTicketTab, services/dto/i18n/docs associés - fixtures : retire les users client-liot / client-acme - migration Version20260522110000 (drop client_ticket, user_allowed_projects, colonnes liées ; task_document.task_id -> NOT NULL) - tests : retire les cas obsolètes testant le blocage des clients sur le mail Module gestion des absences (WIP) : - entités / migrations (Version20260521160000, Version20260522090000) - pages absences.vue / team-absences.vue, composants frontend/components/absence/ - services front, AccrueLeaveCommand, PublicHolidayController Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal file
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.adjust.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="balance" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ balance.user.username }} · {{ balance.label }} · {{ balance.period }}
|
||||
</p>
|
||||
<MalioInputNumber v-model="acquired" :label="$t('absences.admin.adjust.acquired')" />
|
||||
<MalioInputNumber v-model="acquiring" :label="$t('absences.admin.adjust.acquiring')" />
|
||||
<MalioInputNumber v-model="taken" :label="$t('absences.admin.adjust.taken')" />
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton :label="$t('absences.admin.adjust.save')" :disabled="submitting" @click="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
balance: AbsenceBalance | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'adjusted': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
// MalioInputNumber works with string values (v-model is string | null).
|
||||
const acquired = ref('0')
|
||||
const acquiring = ref('0')
|
||||
const taken = ref('0')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(() => props.balance, (b) => {
|
||||
acquired.value = String(b?.acquired ?? 0)
|
||||
acquiring.value = String(b?.acquiring ?? 0)
|
||||
taken.value = String(b?.taken ?? 0)
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
if (!props.balance) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.adjustBalance(props.balance.id, {
|
||||
acquired: Number(acquired.value) || 0,
|
||||
acquiring: Number(acquiring.value) || 0,
|
||||
taken: Number(taken.value) || 0,
|
||||
})
|
||||
emit('adjusted')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal file
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="balances.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
|
||||
{{ $t('absences.noBalance') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||
<!-- Primary balance, highlighted -->
|
||||
<div v-if="primary" class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-500">{{ primary.label }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-neutral-900">
|
||||
{{ formatNumber(primary.available) }}
|
||||
<span class="text-lg font-normal text-neutral-400">/ {{ formatNumber(acquiredTotal(primary)) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.remaining') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:style="{ backgroundColor: typeColor(primary.type) + '22', color: typeColor(primary.type) }"
|
||||
>
|
||||
{{ primary.period }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{ width: progress(primary) + '%', backgroundColor: typeColor(primary.type) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Acquired (N-1) vs in-progress (N), as on a French payslip -->
|
||||
<div v-if="primary && primary.type === 'cp'" class="mt-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiredN1') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquired) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiringN') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquiring) }}</p>
|
||||
<p class="text-[10px] leading-tight text-neutral-400">{{ $t('absences.acquiringHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-2 flex justify-between text-xs text-neutral-500">
|
||||
<span>{{ formatNumber(primary.taken) }} {{ $t('absences.taken') }}</span>
|
||||
<span v-if="primary.pending > 0" class="text-amber-600">
|
||||
{{ formatNumber(primary.pending) }} {{ $t('absences.pending') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other balances, compact rows -->
|
||||
<div v-if="others.length" class="mt-4 flex flex-col divide-y divide-neutral-100 border-t border-neutral-100 pt-1">
|
||||
<div
|
||||
v-for="balance in others"
|
||||
:key="balance.id"
|
||||
class="flex items-center justify-between py-2 text-sm"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-neutral-600">
|
||||
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: typeColor(balance.type) }" />
|
||||
{{ balance.label }}
|
||||
<span class="text-xs text-neutral-400">{{ balance.period }}</span>
|
||||
</span>
|
||||
<span class="text-neutral-900">
|
||||
<span class="font-semibold">{{ formatNumber(balance.available) }}</span>
|
||||
<span class="text-neutral-400"> / {{ formatNumber(acquiredTotal(balance)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
balances: AbsenceBalance[]
|
||||
}>()
|
||||
|
||||
const { typeColor } = useAbsenceHelpers()
|
||||
|
||||
// Current paid-leave reference period, mirroring AbsenceBalanceService::periodFor.
|
||||
const currentCpPeriod = computed<string>(() => {
|
||||
const start = useAuthStore().user?.referencePeriodStart ?? '06-01'
|
||||
const now = new Date()
|
||||
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
const startYear = md >= start ? now.getFullYear() : now.getFullYear() - 1
|
||||
return `${startYear}-${startYear + 1}`
|
||||
})
|
||||
|
||||
// The current "congés payés" balance is the headline; fall back to any CP, then any balance.
|
||||
const primary = computed<AbsenceBalance | null>(() => {
|
||||
const cps = props.balances.filter(b => b.type === 'cp')
|
||||
return cps.find(b => b.period === currentCpPeriod.value) ?? cps[0] ?? props.balances[0] ?? null
|
||||
})
|
||||
|
||||
const others = computed<AbsenceBalance[]>(() =>
|
||||
props.balances.filter(b => b.id !== primary.value?.id),
|
||||
)
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return (Math.round(n * 2) / 2).toString()
|
||||
}
|
||||
|
||||
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
|
||||
// backend-computed field when present.
|
||||
function acquiredTotal(balance: AbsenceBalance): number {
|
||||
return balance.acquiredTotal ?? balance.acquired + balance.acquiring
|
||||
}
|
||||
|
||||
function progress(balance: AbsenceBalance): number {
|
||||
const total = acquiredTotal(balance)
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (balance.taken / total) * 100))
|
||||
}
|
||||
</script>
|
||||
143
frontend/components/absence/AbsenceCalendar.vue
Normal file
143
frontend/components/absence/AbsenceCalendar.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(-1)">
|
||||
<Icon name="mdi:chevron-left" size="22" />
|
||||
</button>
|
||||
<p class="text-lg font-semibold capitalize text-neutral-900">{{ monthLabel }}</p>
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(1)">
|
||||
<Icon name="mdi:chevron-right" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday headers -->
|
||||
<div class="grid grid-cols-7 border-b border-neutral-100 text-center text-xs font-medium text-neutral-400">
|
||||
<div v-for="d in weekdays" :key="d" class="py-2">{{ d }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-7">
|
||||
<div
|
||||
v-for="cell in cells"
|
||||
:key="cell.key"
|
||||
class="min-h-[92px] border-b border-r border-neutral-100 p-1.5"
|
||||
:class="cell.holiday ? 'bg-amber-50' : (cell.inMonth ? 'bg-white' : 'bg-neutral-50')"
|
||||
:title="cell.holiday ?? undefined"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<span v-if="cell.holiday" class="flex min-w-0 flex-1 items-center gap-1 text-[10px] font-medium text-amber-700">
|
||||
<Icon name="mdi:star-four-points-outline" size="11" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ cell.holiday }}</span>
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<span class="flex-shrink-0 text-xs" :class="cell.isToday ? 'font-bold text-orange-500' : 'text-neutral-400'">
|
||||
{{ cell.day }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-for="abs in cell.absences"
|
||||
:key="abs.id"
|
||||
class="truncate rounded px-1 py-0.5 text-[11px] font-medium text-white"
|
||||
:style="{ backgroundColor: abs.status === 'pending' ? typeColor(abs.type) + 'aa' : typeColor(abs.type) }"
|
||||
:title="`${abs.user.username} · ${abs.label} (${statusLabel(abs.status)})`"
|
||||
>
|
||||
{{ abs.user.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-change': [from: string, to: string]
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { typeColor, statusLabel } = useAbsenceHelpers()
|
||||
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
const weekdays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
|
||||
const cursor = ref(startOfMonth(new Date()))
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
cursor.value.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
|
||||
)
|
||||
|
||||
type Cell = { key: string; day: number; date: Date; inMonth: boolean; isToday: boolean; holiday: string | null; absences: AbsenceRequest[] }
|
||||
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// First Monday on/before the 1st of the month
|
||||
function gridStart(month: Date): Date {
|
||||
const first = startOfMonth(month)
|
||||
const dow = (first.getDay() + 6) % 7 // 0 = Monday
|
||||
const start = new Date(first)
|
||||
start.setDate(first.getDate() - dow)
|
||||
return start
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const start = gridStart(cursor.value)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 41) // 6 weeks grid
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const cells = computed<Cell[]>(() => {
|
||||
const { start } = visibleRange.value
|
||||
const today = ymd(new Date())
|
||||
const result: Cell[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(start)
|
||||
date.setDate(start.getDate() + i)
|
||||
const key = ymd(date)
|
||||
result.push({
|
||||
key,
|
||||
day: date.getDate(),
|
||||
date,
|
||||
inMonth: date.getMonth() === cursor.value.getMonth(),
|
||||
isToday: key === today,
|
||||
holiday: holidays.value[key] ?? null,
|
||||
absences: props.absences.filter(a => key >= a.startDate.slice(0, 10) && key <= a.endDate.slice(0, 10)),
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function emitRange() {
|
||||
const { start, end } = visibleRange.value
|
||||
emit('range-change', ymd(start), ymd(end))
|
||||
try {
|
||||
holidays.value = await service.getPublicHolidays(ymd(start), ymd(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
|
||||
emitRange()
|
||||
}
|
||||
|
||||
onMounted(emitRange)
|
||||
</script>
|
||||
73
frontend/components/absence/AbsenceDateField.vue
Normal file
73
frontend/components/absence/AbsenceDateField.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="absence-date-field">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ label }}</label>
|
||||
<MalioDate
|
||||
:model-value="modelValue"
|
||||
:min="min ?? undefined"
|
||||
:max="max ?? undefined"
|
||||
:error="error"
|
||||
:clearable="true"
|
||||
group-class="w-full"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<div v-if="showPills" class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in pillOptions"
|
||||
:key="String(opt.value)"
|
||||
type="button"
|
||||
class="rounded-full border px-4 py-1.5 text-sm font-medium transition"
|
||||
:class="half === opt.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-600'
|
||||
: 'border-neutral-300 text-neutral-600 hover:border-neutral-400'"
|
||||
@click="$emit('update:half', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
modelValue: string | null
|
||||
half: HalfDay | null
|
||||
label: string
|
||||
/** 'start' shows full/morning/afternoon, 'end' shows full/morning only. */
|
||||
mode?: 'start' | 'end'
|
||||
error?: string
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
showPills?: boolean
|
||||
}>(), {
|
||||
mode: 'start',
|
||||
error: '',
|
||||
min: null,
|
||||
max: null,
|
||||
showPills: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
'update:half': [value: HalfDay | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type PillOption = { label: string; value: HalfDay | null }
|
||||
|
||||
const pillOptions = computed<PillOption[]>(() => {
|
||||
const base: PillOption[] = [
|
||||
{ label: t('absences.form.fullDay'), value: null },
|
||||
{ label: t('absences.halfDay.matin'), value: 'matin' },
|
||||
]
|
||||
if (props.mode === 'start') {
|
||||
base.push({ label: t('absences.halfDay.apres_midi'), value: 'apres_midi' })
|
||||
}
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal file
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.detail.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-5">
|
||||
<!-- Hero -->
|
||||
<div class="overflow-hidden rounded-xl border border-neutral-200 shadow-sm">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4"
|
||||
:style="{ borderLeft: `4px solid ${typeColor(request.type)}` }"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: tint(request.type), color: typeColor(request.type) }"
|
||||
>
|
||||
<Icon name="mdi:calendar-account" size="22" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-lg font-semibold text-neutral-900">{{ request.label }}</p>
|
||||
<p class="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
|
||||
<Icon name="mdi:calendar-range" size="15" />
|
||||
{{ formatRange(request) }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
class="flex-shrink-0"
|
||||
:label="statusLabel(request.status)"
|
||||
:variant="statusVariant(request.status)"
|
||||
:icon="statusIcon(request.status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 divide-x divide-neutral-200 border-t border-neutral-200 bg-neutral-50">
|
||||
<div class="flex items-center gap-2.5 p-3">
|
||||
<span
|
||||
v-if="request.user.avatarUrl"
|
||||
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded-full"
|
||||
>
|
||||
<img :src="request.user.avatarUrl" alt="" class="h-full w-full object-cover">
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.employee') }}</dt>
|
||||
<dd class="truncate text-sm font-medium text-neutral-800">{{ request.user.username }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center p-3">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.days') }}</dt>
|
||||
<dd class="text-sm font-semibold text-neutral-900">{{ formatDays(request.countedDays) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="request.reason" class="rounded-lg border border-neutral-200 p-3">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
<Icon name="mdi:comment-text-outline" size="14" />
|
||||
{{ $t('absences.form.reason') }}
|
||||
</p>
|
||||
<p class="whitespace-pre-line text-sm text-neutral-800">{{ request.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Rejection -->
|
||||
<div
|
||||
v-if="request.status === 'rejected' && request.rejectionReason"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<Icon name="mdi:close-circle-outline" size="14" />
|
||||
{{ $t('absences.detail.rejectionReason') }}
|
||||
</p>
|
||||
<p class="text-sm text-red-700">{{ request.rejectionReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Justification -->
|
||||
<a
|
||||
v-if="request.justificationUrl"
|
||||
:href="request.justificationUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-200 p-3 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50"
|
||||
>
|
||||
<Icon name="mdi:file-document-outline" size="20" class="text-primary-500" />
|
||||
<span class="flex-1 truncate">{{ request.justificationFileName || $t('absences.detail.downloadJustification') }}</span>
|
||||
<Icon name="mdi:download" size="16" class="text-neutral-400" />
|
||||
</a>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{{ $t('absences.detail.timeline') }}
|
||||
</p>
|
||||
<ol class="relative ml-1 border-l border-neutral-200 pl-5">
|
||||
<li class="mb-4 last:mb-0">
|
||||
<span class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white bg-primary-500 ring-1 ring-primary-200" />
|
||||
<p class="text-sm font-medium text-neutral-800">{{ $t('absences.detail.created') }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.createdAt) }}</p>
|
||||
</li>
|
||||
<li v-if="request.reviewedAt" class="last:mb-0">
|
||||
<span
|
||||
class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white ring-1"
|
||||
:class="request.status === 'rejected'
|
||||
? 'bg-red-500 ring-red-200'
|
||||
: 'bg-green-500 ring-green-200'"
|
||||
/>
|
||||
<p class="text-sm font-medium text-neutral-800">
|
||||
{{ statusLabel(request.status) }}
|
||||
<span v-if="request.reviewedBy" class="font-normal text-neutral-500">
|
||||
· {{ $t('absences.detail.reviewed', { name: request.reviewedBy.username }) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.reviewedAt) }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="canCancel" class="flex justify-end border-t border-neutral-100 pt-4">
|
||||
<MalioButton
|
||||
:label="$t('absences.detail.cancel')"
|
||||
variant="danger"
|
||||
icon-name="mdi:cancel"
|
||||
icon-position="left"
|
||||
:disabled="cancelling"
|
||||
@click="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
canCancel?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'cancelled': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, typeColor } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const cancelling = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = props.request?.user.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
/** Type colour at ~12% opacity for soft backgrounds. */
|
||||
function tint(type: AbsenceRequest['type']): string {
|
||||
return `${typeColor(type)}1f`
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!props.request) return
|
||||
if (!confirm(t('absences.detail.cancelConfirm'))) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await service.cancel(props.request.id)
|
||||
emit('cancelled')
|
||||
open.value = false
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal file
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.review.rejectTitle') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ request.user.username }} · {{ request.label }} · {{ formatRange(request) }}
|
||||
</p>
|
||||
<MalioInputTextArea
|
||||
v-model="reason"
|
||||
:label="$t('absences.review.rejectReasonLabel')"
|
||||
:placeholder="$t('absences.review.rejectReasonPlaceholder')"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.review.confirm')"
|
||||
variant="danger"
|
||||
:disabled="!reason.trim() || submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'rejected': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { formatRange } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const reason = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) reason.value = ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!props.request || !reason.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.reject(props.request.id, reason.value.trim())
|
||||
emit('rejected')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal file
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-xl">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.newRequest') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Server-side error banner -->
|
||||
<div v-if="serverError" class="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
<Icon name="mdi:alert-circle-outline" size="18" class="mt-0.5 flex-shrink-0" />
|
||||
<span>{{ serverError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 — type (always visible) -->
|
||||
<MalioSelect
|
||||
v-model="form.type"
|
||||
:label="$t('absences.form.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
:error="errors.type"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Step 2 — start date (revealed once a type is chosen) -->
|
||||
<AbsenceDateField
|
||||
v-if="showDates"
|
||||
v-model="form.startDate"
|
||||
v-model:half="form.startHalf"
|
||||
:label="$t('absences.form.startDate')"
|
||||
mode="start"
|
||||
:error="errors.startDate"
|
||||
:max="form.endDate"
|
||||
/>
|
||||
|
||||
<!-- Balance at start date -->
|
||||
<div v-if="preview && preview.available !== null" class="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAt', { date: startDateLabel }) }}</span>
|
||||
<span class="text-neutral-900">{{ formatDays(preview.available) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 — end date (revealed once a start date is set) -->
|
||||
<AbsenceDateField
|
||||
v-if="showEnd"
|
||||
v-model="form.endDate"
|
||||
v-model:half="form.endHalf"
|
||||
:label="$t('absences.form.endDate')"
|
||||
mode="end"
|
||||
:error="errors.endDate"
|
||||
:min="form.startDate"
|
||||
:show-pills="!isSingleDay"
|
||||
/>
|
||||
|
||||
<!-- Duration & projected balance -->
|
||||
<div v-if="preview" class="flex flex-col gap-1 rounded-lg bg-neutral-50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-neutral-600">{{ $t('absences.form.duration') }}</span>
|
||||
<span class="font-semibold text-neutral-900">{{ formatDays(preview.countedDays) }}</span>
|
||||
</div>
|
||||
<div v-if="preview.projectedAvailable !== null" class="flex items-center justify-between border-t border-neutral-200 pt-1 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAfterValidation') }}</span>
|
||||
<span :class="preview.projectedAvailable < 0 ? 'font-semibold text-amber-600' : 'text-neutral-900'">
|
||||
{{ formatDays(preview.projectedAvailable) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="preview && preview.projectedAvailable !== null && preview.projectedAvailable < 0"
|
||||
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('absences.form.negativeWarning') }}
|
||||
</div>
|
||||
|
||||
<!-- Step 4 — justification (only when the policy requires it) -->
|
||||
<MalioInputUpload
|
||||
v-if="showJustification"
|
||||
:model-value="form.file?.name ?? null"
|
||||
:label="`${$t('absences.form.justification')} *`"
|
||||
accept="application/pdf,image/png,image/jpeg,image/webp"
|
||||
:error="errors.justification"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
|
||||
<!-- Comment (optional) -->
|
||||
<div v-if="showComment" class="flex items-start gap-2">
|
||||
<span class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600">
|
||||
{{ initials }}
|
||||
</span>
|
||||
<MalioInputTextArea
|
||||
v-model="form.reason"
|
||||
group-class="flex-1"
|
||||
:placeholder="$t('absences.form.commentPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
policies: AbsencePolicy[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatDays, formatDate } = useAbsenceHelpers()
|
||||
const service = useAbsenceService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
type: AbsenceType | null
|
||||
// ISO date strings "YYYY-MM-DD" (lexicographic order == chronological order).
|
||||
startDate: string | null
|
||||
startHalf: HalfDay | null
|
||||
endDate: string | null
|
||||
endHalf: HalfDay | null
|
||||
reason: string
|
||||
file: File | null
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
type: null,
|
||||
startDate: null,
|
||||
startHalf: null,
|
||||
endDate: null,
|
||||
endHalf: null,
|
||||
reason: '',
|
||||
file: null,
|
||||
})
|
||||
|
||||
const errors = reactive<{ type: string; startDate: string; endDate: string; justification: string }>({
|
||||
type: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
justification: '',
|
||||
})
|
||||
|
||||
const serverError = ref('')
|
||||
const preview = ref<AbsencePreviewResult | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
props.policies
|
||||
.filter(p => p.active)
|
||||
.map(p => ({ label: p.label, value: p.type })),
|
||||
)
|
||||
|
||||
const selectedPolicy = computed(() => props.policies.find(p => p.type === form.type) ?? null)
|
||||
const justificationRequired = computed(() => selectedPolicy.value?.justificationRequired ?? false)
|
||||
|
||||
const showDates = computed(() => form.type !== null)
|
||||
const showEnd = computed(() => form.startDate !== null)
|
||||
const showJustification = computed(() => form.type !== null && justificationRequired.value)
|
||||
const showComment = computed(() => form.startDate !== null)
|
||||
|
||||
const isSingleDay = computed(() =>
|
||||
form.startDate !== null
|
||||
&& form.endDate !== null
|
||||
&& form.startDate === form.endDate,
|
||||
)
|
||||
|
||||
const startDateLabel = computed(() => formatDate(form.startDate))
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
form.file = file
|
||||
errors.justification = ''
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
// On a single-day request the end half-day mirrors the start.
|
||||
const endHalf = isSingleDay.value ? form.startHalf : form.endHalf
|
||||
return {
|
||||
type: form.type as AbsenceType,
|
||||
startDate: form.startDate as string,
|
||||
endDate: form.endDate as string,
|
||||
startHalfDay: form.startHalf,
|
||||
endHalfDay: endHalf,
|
||||
reason: form.reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
errors.type = form.type ? '' : t('absences.form.errors.typeRequired')
|
||||
errors.startDate = form.startDate ? '' : t('absences.form.errors.startRequired')
|
||||
|
||||
if (form.endDate === null) {
|
||||
errors.endDate = t('absences.form.errors.endRequired')
|
||||
} else if (form.startDate && form.endDate < form.startDate) {
|
||||
errors.endDate = t('absences.form.errors.endBeforeStart')
|
||||
} else if (form.type && form.startDate && (preview.value?.countedDays ?? 0) <= 0) {
|
||||
errors.endDate = t('absences.form.errors.zeroDays')
|
||||
} else {
|
||||
errors.endDate = ''
|
||||
}
|
||||
|
||||
errors.justification = justificationRequired.value && !form.file
|
||||
? t('absences.form.errors.justificationRequired')
|
||||
: ''
|
||||
|
||||
return !errors.type && !errors.startDate && !errors.endDate && !errors.justification
|
||||
}
|
||||
|
||||
// Clear field errors as soon as the user corrects them.
|
||||
watch(() => form.type, (v) => { if (v) errors.type = '' })
|
||||
watch(() => form.startDate, (v) => { if (v) errors.startDate = '' })
|
||||
watch(() => [form.endDate, form.startDate], () => {
|
||||
if (form.endDate && (!form.startDate || form.endDate >= form.startDate)) errors.endDate = ''
|
||||
})
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
() => [form.type, form.startDate, form.endDate, form.startHalf, form.endHalf],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!form.type || !form.startDate || !form.endDate) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview.value = await service.preview(buildPayload())
|
||||
} catch {
|
||||
preview.value = null
|
||||
}
|
||||
}, 300)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await service.create(buildPayload())
|
||||
if (form.file) {
|
||||
await service.uploadJustification(created.id, form.file)
|
||||
}
|
||||
emit('created')
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
serverError.value = (e instanceof Error && e.message) ? e.message : t('absences.form.serverError')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.type = null
|
||||
form.startDate = null
|
||||
form.startHalf = null
|
||||
form.endDate = null
|
||||
form.endHalf = null
|
||||
form.reason = ''
|
||||
form.file = null
|
||||
errors.type = ''
|
||||
errors.startDate = ''
|
||||
errors.endDate = ''
|
||||
errors.justification = ''
|
||||
serverError.value = ''
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) resetForm()
|
||||
})
|
||||
</script>
|
||||
163
frontend/components/absence/EmployeeDrawer.vue
Normal file
163
frontend/components/absence/EmployeeDrawer.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
|
||||
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
|
||||
<MalioDate
|
||||
v-model="form.hireDate"
|
||||
:label="$t('absences.admin.employees.fields.hireDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="form.endDate"
|
||||
:label="$t('absences.admin.employees.fields.endDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.contractType"
|
||||
:label="$t('absences.admin.employees.fields.contractType')"
|
||||
:options="contractOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.familySituation"
|
||||
:label="$t('absences.admin.employees.fields.familySituation')"
|
||||
:options="familyOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.workTimeRatio"
|
||||
:label="$t('absences.admin.employees.fields.workTimeRatio')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.annualLeaveDays"
|
||||
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.referencePeriodStart"
|
||||
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.initialLeaveBalance"
|
||||
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.nbChildren"
|
||||
:label="$t('absences.admin.employees.fields.nbChildren')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="col-span-full mt-2 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('absences.admin.employees.drawer.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="submitting"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { update } = useUserService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const contractOptions = [
|
||||
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
|
||||
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
|
||||
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
|
||||
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
|
||||
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
|
||||
]
|
||||
|
||||
const familyOptions = [
|
||||
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
|
||||
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
|
||||
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
|
||||
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
|
||||
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
hireDate: null as string | null,
|
||||
endDate: null as string | null,
|
||||
contractType: null as ContractType | null,
|
||||
familySituation: null as FamilySituation | null,
|
||||
workTimeRatio: '1.0',
|
||||
annualLeaveDays: '25',
|
||||
referencePeriodStart: '06-01',
|
||||
initialLeaveBalance: '0',
|
||||
nbChildren: '0',
|
||||
})
|
||||
|
||||
function hydrate(u: UserData | null) {
|
||||
if (!u) return
|
||||
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
|
||||
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
|
||||
form.contractType = u.contractType ?? null
|
||||
form.familySituation = u.familySituation ?? null
|
||||
form.workTimeRatio = String(u.workTimeRatio ?? 1)
|
||||
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
|
||||
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
|
||||
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
|
||||
form.nbChildren = String(u.nbChildren ?? 0)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) hydrate(props.user)
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!props.user) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await update(props.user.id, {
|
||||
isEmployee: true,
|
||||
hireDate: form.hireDate || null,
|
||||
endDate: form.endDate || null,
|
||||
contractType: form.contractType,
|
||||
familySituation: form.familySituation,
|
||||
workTimeRatio: Number(form.workTimeRatio) || 1,
|
||||
annualLeaveDays: Number(form.annualLeaveDays) || 0,
|
||||
referencePeriodStart: form.referencePeriodStart || '06-01',
|
||||
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
|
||||
nbChildren: Number(form.nbChildren) || 0,
|
||||
})
|
||||
emit('saved')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user