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>
|
||||
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal file
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900">{{ $t('absences.policies.title') }}</h2>
|
||||
<p class="text-sm text-neutral-500">{{ $t('absences.policies.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-left text-neutral-500">
|
||||
<th class="py-2 pr-3">{{ $t('absences.policies.type') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.daysPerYear') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.daysPerEvent') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.noticeDays') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.justificationRequired') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.countWorkingDaysOnly') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.active') }}</th>
|
||||
<th class="py-2 pl-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-neutral-100">
|
||||
<td class="py-2 pr-3 font-medium text-neutral-800">{{ row.label }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.daysPerYear" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.daysPerEvent" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.noticeDays" type="number" class="w-16 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.justificationRequired" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.countWorkingDaysOnly" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.active" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
<MalioButton :label="$t('absences.policies.save')" variant="secondary" @click="save(row)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
async function load() {
|
||||
rows.value = await service.getPolicies()
|
||||
}
|
||||
|
||||
async function save(row: AbsencePolicy) {
|
||||
await service.updatePolicy(row.id, {
|
||||
daysPerYear: row.daysPerYear === null || Number.isNaN(row.daysPerYear) ? null : Number(row.daysPerYear),
|
||||
daysPerEvent: row.daysPerEvent === null || Number.isNaN(row.daysPerEvent) ? null : Number(row.daysPerEvent),
|
||||
noticeDays: Number(row.noticeDays),
|
||||
justificationRequired: row.justificationRequired,
|
||||
countWorkingDaysOnly: row.countWorkingDaysOnly,
|
||||
active: row.active,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,382 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('clientTicket.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket list -->
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
|
||||
<th class="px-3 py-3">#</th>
|
||||
<th class="px-3 py-3">Type</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
|
||||
<th class="px-3 py-3">Statut</th>
|
||||
<th class="px-3 py-3">Projet</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
|
||||
<th class="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
v-if="getSubmitterUser(ticket.submittedBy)"
|
||||
:user="getSubmitterUser(ticket.submittedBy)!"
|
||||
size="sm"
|
||||
/>
|
||||
{{ getSubmitterName(ticket.submittedBy) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||
@click.stop="openDeleteConfirm(ticket)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm modal -->
|
||||
<Teleport v-if="deleteModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="deleteModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="deleteModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Ticket detail modal (read-only) -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="detailTicket"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
let result = tickets.value
|
||||
if (filterProjectId.value) {
|
||||
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(t => t.status === filterStatus.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Status change modal
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
// Delete modal
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTarget = ref<ClientTicket | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Detail modal
|
||||
const detailOpen = ref(false)
|
||||
const detailTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterName(iri: string | null): string {
|
||||
if (!iri) return '-'
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return users.value.find(u => u.id === id)?.username ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||
if (!iri) return undefined
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return undefined
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
detailTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
function openDeleteConfirm(ticket: ClientTicket) {
|
||||
deleteTarget.value = ticket
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await clientTicketService.remove(deleteTarget.value.id)
|
||||
deleteModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
|
||||
clientTicketService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
tickets.value = ticketsResult
|
||||
projects.value = projectsResult
|
||||
users.value = usersResult
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-modal-enter-active,
|
||||
.status-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.status-modal-enter-from,
|
||||
.status-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow') }}</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ticket-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-if="ticket"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
CT-{{ String(ticket.number).padStart(3, '0') }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ $t('portal.ticketDetail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||
<MalioButton
|
||||
v-if="canEdit && !isEditing"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
:label="$t('common.edit')"
|
||||
@click="startEdit"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-if="isEditing">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.type === 'bug'" class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.url"
|
||||
type="url"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- View mode -->
|
||||
<template v-else>
|
||||
<!-- Title -->
|
||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
group-class="mt-1"
|
||||
/>
|
||||
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||
</div>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
<div v-if="ticket.url" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status comment -->
|
||||
<div v-if="ticket.statusComment" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentList
|
||||
v-if="localDocuments.length"
|
||||
:documents="localDocuments"
|
||||
:is-admin="canEdit"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
/>
|
||||
|
||||
<!-- Document preview -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<TaskDocumentUpload
|
||||
v-if="ticket"
|
||||
:client-ticket-id="ticket.id"
|
||||
@uploaded="refreshDocuments"
|
||||
/>
|
||||
|
||||
<!-- Date -->
|
||||
<p class="mt-6 text-xs text-neutral-400">
|
||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
ticket: ClientTicket | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
isEditing.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
// Edit mode
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (!props.ticket) return false
|
||||
if (isAdmin.value) return true
|
||||
const status = props.ticket.status
|
||||
if (status === 'done' || status === 'rejected') return false
|
||||
const userId = auth.user?.id
|
||||
if (!userId) return false
|
||||
const sub = props.ticket.submittedBy
|
||||
if (!sub) return false
|
||||
// submittedBy can be an IRI string or an embedded object
|
||||
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
|
||||
return false
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.ticket) return
|
||||
editForm.title = props.ticket.title
|
||||
editForm.description = props.ticket.description
|
||||
editForm.url = props.ticket.url ?? ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!props.ticket) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
title: editForm.title,
|
||||
description: editForm.description,
|
||||
}
|
||||
if (props.ticket.type === 'bug') {
|
||||
data.url = editForm.url || null
|
||||
}
|
||||
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
|
||||
isEditing.value = false
|
||||
emit('refresh')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset edit mode when ticket changes
|
||||
watch(() => props.ticket?.id, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
|
||||
async function handleDeleteDocument(doc: TaskDocument) {
|
||||
await removeDocument(doc.id)
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.ticket) return
|
||||
localDocuments.value = await getByTicket(props.ticket.id)
|
||||
}
|
||||
|
||||
// Document list (local copy to allow refresh)
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
|
||||
watch(() => props.ticket?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
// Document preview
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
previewDoc.value = doc
|
||||
}
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-modal-enter-active,
|
||||
.ticket-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-active > div:last-child,
|
||||
.ticket-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from,
|
||||
.ticket-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,333 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger button -->
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:ticket-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||
@click="open"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||
<span
|
||||
v-if="totalCount > 0"
|
||||
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</MalioButton>
|
||||
|
||||
<!-- Panel -->
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ct-panel" appear>
|
||||
<div class="fixed inset-0 z-50 flex justify-end">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Slide panel -->
|
||||
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<!-- Ticket row -->
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="18"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="ct-modal" appear>
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
projectName: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const totalCount = computed(() =>
|
||||
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
async function loadTickets() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
expandedId.value = null
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ct-panel-enter-active,
|
||||
.ct-panel-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ct-panel-enter-active > div:last-child,
|
||||
.ct-panel-leave-active > div:last-child {
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ct-panel-enter-from,
|
||||
.ct-panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ct-panel-enter-from > div:last-child,
|
||||
.ct-panel-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.ct-modal-enter-active,
|
||||
.ct-modal-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ct-modal-enter-from,
|
||||
.ct-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
|
||||
@@ -119,20 +119,20 @@ async function handleSubmit(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" min-width="w-full" :disabled="loadingGroups" />
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ async function handleSubmit(): Promise<void> {
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
|
||||
@@ -106,19 +106,7 @@ function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="codeProxy"
|
||||
@@ -27,7 +30,7 @@
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
@@ -39,7 +42,7 @@
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +52,7 @@
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:options="targetOptions"
|
||||
:label="$t('workflows.switchTargetLabel')"
|
||||
empty-option-label="—"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
|
||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
@@ -39,7 +39,7 @@
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
@@ -50,7 +50,7 @@
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
@@ -61,7 +61,7 @@
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
@@ -73,7 +73,7 @@
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
|
||||
@@ -20,12 +20,6 @@
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
|
||||
@@ -50,14 +50,13 @@ import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
clientTicketId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -110,9 +109,7 @@ async function processFiles(files: File[]) {
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.clientTicketId) {
|
||||
await uploadForTicket(props.clientTicketId, file)
|
||||
} else if (props.taskId) {
|
||||
if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
|
||||
@@ -35,23 +35,6 @@
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client ticket link -->
|
||||
<div
|
||||
v-if="isEditing && task?.clientTicket"
|
||||
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
||||
<span class="text-sm font-medium text-blue-700">
|
||||
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="ticketStatusClass(task.clientTicket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -91,7 +74,7 @@
|
||||
:options="projectOptions"
|
||||
label="Projet *"
|
||||
empty-option-label="Sélectionner un projet"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||
Le projet est requis
|
||||
@@ -105,43 +88,35 @@
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="clientTicketOptions.length"
|
||||
v-model="form.clientTicketId"
|
||||
:options="clientTicketOptions"
|
||||
label="Ticket client"
|
||||
empty-option-label="Aucun ticket client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -549,10 +524,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
@@ -627,7 +600,6 @@ const form = reactive({
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
scheduledStart: '',
|
||||
scheduledEnd: '',
|
||||
@@ -757,7 +729,6 @@ function populateForm(task: Task | null) {
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||
@@ -804,7 +775,6 @@ function populateForm(task: Task | null) {
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
form.scheduledStart = ''
|
||||
form.scheduledEnd = ''
|
||||
@@ -833,16 +803,6 @@ watch(() => props.modelValue, async (open) => {
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
try {
|
||||
const settings = await getGiteaSettings()
|
||||
@@ -862,48 +822,26 @@ watch(() => props.task, (task) => {
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const clientTickets = ref<ClientTicket[]>([])
|
||||
const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// Reset group and reload client tickets when project changes in create mode
|
||||
watch(() => form.projectId, async (pid) => {
|
||||
// Reset group when project changes in create mode
|
||||
watch(() => form.projectId, () => {
|
||||
if (!showProjectSelect.value) return
|
||||
form.groupId = null
|
||||
form.clientTicketId = null
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
const isMailUser = computed(() => !isClientOnly.value)
|
||||
|
||||
const availableTabs = computed(() => {
|
||||
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||
if (isEditing.value && isMailUser.value) base.push('mails')
|
||||
if (isEditing.value) base.push('mails')
|
||||
return base
|
||||
})
|
||||
|
||||
async function loadLinkedMails(): Promise<void> {
|
||||
if (!props.task || !isMailUser.value) return
|
||||
if (!props.task) return
|
||||
mailsLoading.value = true
|
||||
try {
|
||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||
@@ -928,16 +866,6 @@ function formatMailDate(iso: string | null): string {
|
||||
})
|
||||
}
|
||||
|
||||
function ticketStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
@@ -1057,7 +985,6 @@ async function handleSubmit() {
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
scheduledStart: form.scheduledStart || null,
|
||||
scheduledEnd: form.scheduledEnd || null,
|
||||
deadline: form.deadline || null,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
|
||||
</template>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -58,7 +61,7 @@
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
@@ -66,7 +69,7 @@
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
@@ -16,6 +17,14 @@
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="day.holiday"
|
||||
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
|
||||
:title="day.holiday"
|
||||
>
|
||||
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ day.holiday }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,6 +49,7 @@
|
||||
:key="day.dateStr"
|
||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
@click="onClickGrid($event, day)"
|
||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||
>
|
||||
@@ -141,8 +151,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
@@ -209,6 +221,23 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- Public holidays (computed server-side, shared with the absence calendar) ---
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
async function loadHolidays() {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
const start = new Date(props.startDate)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + count - 1)
|
||||
try {
|
||||
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
|
||||
|
||||
// --- Days computation ---
|
||||
const days = computed(() => {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
@@ -231,6 +260,7 @@ const days = computed(() => {
|
||||
dateStr,
|
||||
dayNum: d.getDate(),
|
||||
label: dayLabels[d.getDay()],
|
||||
holiday: holidays.value[dateStr] ?? null,
|
||||
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
|
||||
<MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Period presets -->
|
||||
<div>
|
||||
@@ -52,7 +55,7 @@
|
||||
:label="$t('timeEntries.exportUsers')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +66,7 @@
|
||||
:options="clientOptions"
|
||||
:label="$t('timeEntries.exportClient')"
|
||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +78,7 @@
|
||||
:label="$t('timeEntries.exportProjects')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +90,7 @@
|
||||
:label="$t('timeEntries.exportTags')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
32
frontend/components/ui/StatusBadge.vue
Normal file
32
frontend/components/ui/StatusBadge.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap"
|
||||
:class="variantClass"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="14" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Variant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
variant?: Variant
|
||||
icon?: string
|
||||
}>(), {
|
||||
variant: 'neutral',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
neutral: 'bg-neutral-100 text-neutral-700',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-amber-100 text-amber-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const variantClass = computed(() => VARIANT_CLASSES[props.variant])
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('users.editUser') : $t('users.addUser') }}</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -35,37 +38,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
placeholder="Aucun client"
|
||||
class="w-full"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.clientId !== null" class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowedProjectIds"
|
||||
type="checkbox"
|
||||
:value="project.id"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
{{ project.name }}
|
||||
</label>
|
||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
||||
Aucun projet pour ce client.
|
||||
</span>
|
||||
</div>
|
||||
<!-- RH / Absences -->
|
||||
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
|
||||
Les informations RH (contrat, dates, CP…) se gèrent dans Absences équipe → onglet Employés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
@@ -83,12 +61,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -105,32 +77,16 @@ const isOpen = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: t('common.noClient'), value: null as number | null },
|
||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
||||
])
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (form.clientId === null) return []
|
||||
return allProjects.value.filter(
|
||||
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||
)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
isEmployee: false,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -138,45 +94,21 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
function onClientChange(value: number | null) {
|
||||
form.clientId = value
|
||||
form.allowedProjectIds = []
|
||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.roles, (roles) => {
|
||||
if (!roles.includes('ROLE_CLIENT')) {
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.clientId = props.item.client?.id ?? null
|
||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
form.isEmployee = false
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
const [loadedClients, loadedProjects] = await Promise.all([
|
||||
useClientService().getAll(),
|
||||
useProjectService().getAll({ archived: false }),
|
||||
])
|
||||
clients.value = loadedClients
|
||||
allProjects.value = loadedProjects
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,10 +125,7 @@ async function handleSubmit() {
|
||||
const payload: UserWrite = {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.clientId !== null
|
||||
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
|
||||
: [],
|
||||
isEmployee: form.isEmployee,
|
||||
}
|
||||
if (form.password) {
|
||||
payload.plainPassword = form.password
|
||||
|
||||
93
frontend/composables/useAbsenceHelpers.ts
Normal file
93
frontend/composables/useAbsenceHelpers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/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',
|
||||
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 {
|
||||
const rounded = Math.round(days * 2) / 2
|
||||
const unit = rounded > 1 ? t('absences.daysPlural') : t('absences.daySingular')
|
||||
return `${rounded} ${unit}`
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
statusIcon,
|
||||
typeLabel,
|
||||
typeColor,
|
||||
halfDayLabel,
|
||||
formatDate,
|
||||
formatRange,
|
||||
formatDays,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
|
||||
export function useClientTicketHelpers() {
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'bug': return 'bg-red-500'
|
||||
case 'improvement': return 'bg-blue-500'
|
||||
default: return 'bg-neutral-500'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAvailableStatusTransitions(
|
||||
current: ClientTicketStatus,
|
||||
t: (key: string) => string,
|
||||
): { label: string; value: ClientTicketStatus }[] {
|
||||
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
||||
{ label: t('clientTicket.status.new'), value: 'new' },
|
||||
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
||||
{ label: t('clientTicket.status.done'), value: 'done' },
|
||||
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
||||
]
|
||||
return allStatuses.filter(s => {
|
||||
if (s.value === current) return false
|
||||
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
Lesstime est un outil de **gestion de projets** qui combine plusieurs grandes capacités :
|
||||
|
||||
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||
|
||||
## Comprendre les rôles
|
||||
|
||||
@@ -13,7 +12,6 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités
|
||||
|---|---|
|
||||
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||
|
||||
## Vues principales
|
||||
|
||||
@@ -22,6 +20,5 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités
|
||||
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||
- **Time tracking** : timer, time entries, vue mois
|
||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||
|
||||
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||
|
||||
@@ -51,10 +51,6 @@ Si le projet a un repo Gitea lié, tu peux :
|
||||
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||
- **Voir les PRs** liées (état CI inclus)
|
||||
|
||||
## Liaison ticket client
|
||||
|
||||
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||
|
||||
## Commentaires & notifications
|
||||
|
||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
@@ -40,12 +40,9 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
|
||||
@@ -4,7 +4,7 @@ Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directemen
|
||||
|
||||
> 📥 La messagerie est accessible depuis l'entrée **Messagerie** de la barre latérale (icône enveloppe). Un **badge** y affiche le nombre de mails non lus, toutes boîtes confondues.
|
||||
|
||||
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**. Les utilisateurs *client* sont redirigés vers leur portail.
|
||||
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**.
|
||||
|
||||
## L'interface
|
||||
|
||||
|
||||
@@ -351,63 +351,6 @@
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail client",
|
||||
"projects": "Vos projets",
|
||||
"noProjects": "Aucun projet disponible.",
|
||||
"openTickets": "tickets ouverts",
|
||||
"newTicket": "Nouveau ticket",
|
||||
"ticketDetail": "Détail du ticket",
|
||||
"backToProject": "Retour au projet",
|
||||
"submitTicket": "Soumettre le ticket",
|
||||
"ticketCreated": "Ticket soumis avec succès."
|
||||
},
|
||||
"clientTicket": {
|
||||
"title": "Tickets",
|
||||
"new": "Nouveau ticket",
|
||||
"created": "Ticket créé avec succès.",
|
||||
"deleted": "Ticket supprimé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
"statusUpdated": "Statut du ticket mis à jour.",
|
||||
"type": {
|
||||
"bug": "Bug",
|
||||
"improvement": "Amélioration",
|
||||
"other": "Autre"
|
||||
},
|
||||
"status": {
|
||||
"new": "Nouveau",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminé",
|
||||
"rejected": "Rejeté"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
"url": "URL de la page",
|
||||
"urlPlaceholder": "https://example.com/page-concernee",
|
||||
"type": "Type",
|
||||
"project": "Projet"
|
||||
},
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
|
||||
"rejectComment": "Commentaire de rejet",
|
||||
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
|
||||
"linkedTicket": "Lié au ticket client CT-{number}",
|
||||
"description": "Description",
|
||||
"url": "URL (page concernée)",
|
||||
"statusComment": "Commentaire de statut",
|
||||
"statusChanged": "Statut mis à jour",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"linkedTooltip": "Lié au ticket client {number}",
|
||||
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
||||
"noTickets": "Aucun ticket.",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allProjects": "Tous les projets",
|
||||
"submittedBy": "Soumis par",
|
||||
"createdAt": "Créé le",
|
||||
"adminTab": "Tickets client",
|
||||
"selectType": "Type de ticket",
|
||||
"changeStatus": "Changer le statut"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
@@ -614,5 +557,195 @@
|
||||
"hasAttachments": "Pièces jointes",
|
||||
"unread": "non lu | non lus",
|
||||
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
||||
},
|
||||
"absences": {
|
||||
"title": "Mes absences",
|
||||
"teamTitle": "Absences de l'équipe",
|
||||
"newRequest": "Nouvelle demande",
|
||||
"daySingular": "jour",
|
||||
"daysPlural": "jours",
|
||||
"noBalance": "Aucun solde à afficher.",
|
||||
"noRequests": "Aucune demande.",
|
||||
"remaining": "restants",
|
||||
"acquired": "acquis",
|
||||
"acquiredN1": "Acquis (N-1)",
|
||||
"acquiringN": "En cours d'acquisition (N)",
|
||||
"acquiringHint": "posables par anticipation",
|
||||
"taken": "pris",
|
||||
"pending": "en attente",
|
||||
"available": "disponible",
|
||||
"types": {
|
||||
"cp": "Congés payés",
|
||||
"mariage_pacs": "Mariage / PACS",
|
||||
"conge_parental": "Congé parental",
|
||||
"deces": "Décès proche",
|
||||
"maladie": "Arrêt maladie"
|
||||
},
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"approved": "Approuvée",
|
||||
"rejected": "Refusée",
|
||||
"cancelled": "Annulée"
|
||||
},
|
||||
"halfDay": {
|
||||
"matin": "Matin",
|
||||
"apres_midi": "Après-midi"
|
||||
},
|
||||
"table": {
|
||||
"type": "Type",
|
||||
"period": "Période",
|
||||
"days": "Jours",
|
||||
"status": "Statut",
|
||||
"employee": "Salarié",
|
||||
"year": "Année",
|
||||
"requestedAt": "Demandé le",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"filters": {
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allTypes": "Tous les types",
|
||||
"allYears": "Toutes les années",
|
||||
"allEmployees": "Tous les salariés"
|
||||
},
|
||||
"form": {
|
||||
"type": "Type d'absence",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"startHalfDay": "Demi-journée (début)",
|
||||
"endHalfDay": "Demi-journée (fin)",
|
||||
"halfDayCheckbox": "Demi-journée",
|
||||
"reason": "Motif",
|
||||
"reasonPlaceholder": "Précisez le motif si nécessaire…",
|
||||
"justification": "Justificatif",
|
||||
"computed": "{days} décompté(s)",
|
||||
"balanceAfter": "Solde restant après cette demande : {value}",
|
||||
"negativeWarning": "Cette demande dépasse votre solde disponible.",
|
||||
"noticeWarning": "Le délai de prévenance ({days} jours) n'est pas respecté.",
|
||||
"submit": "Soumettre la demande",
|
||||
"justificationRequired": "Un justificatif est requis pour ce type d'absence.",
|
||||
"fullDay": "Journée entière",
|
||||
"balanceAt": "Solde au {date}",
|
||||
"balanceAfterValidation": "Solde après validation",
|
||||
"duration": "Durée de la demande",
|
||||
"commentPlaceholder": "Écrire un commentaire…",
|
||||
"serverError": "La demande n'a pas pu être enregistrée.",
|
||||
"errors": {
|
||||
"typeRequired": "Veuillez choisir un type d'absence.",
|
||||
"startRequired": "Veuillez indiquer une date de début.",
|
||||
"endRequired": "Veuillez indiquer une date de fin.",
|
||||
"endBeforeStart": "La date de fin doit être après la date de début.",
|
||||
"zeroDays": "La période sélectionnée ne décompte aucun jour.",
|
||||
"justificationRequired": "Un justificatif est obligatoire pour ce type d'absence."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"title": "Détail de la demande",
|
||||
"timeline": "Historique",
|
||||
"created": "Demande créée",
|
||||
"reviewed": "Traitée par {name}",
|
||||
"rejectionReason": "Motif du refus",
|
||||
"downloadJustification": "Télécharger le justificatif",
|
||||
"cancel": "Annuler ma demande",
|
||||
"cancelConfirm": "Annuler cette demande ?"
|
||||
},
|
||||
"review": {
|
||||
"approve": "Valider",
|
||||
"reject": "Refuser",
|
||||
"rejectTitle": "Refuser la demande",
|
||||
"rejectReasonLabel": "Motif du refus",
|
||||
"rejectReasonPlaceholder": "Expliquez la raison du refus…",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"admin": {
|
||||
"tabs": {
|
||||
"requests": "Demandes",
|
||||
"calendar": "Calendrier",
|
||||
"balances": "Soldes",
|
||||
"employees": "Employés"
|
||||
},
|
||||
"kpis": {
|
||||
"pending": "En attente",
|
||||
"todayAbsent": "Absents aujourd'hui",
|
||||
"weekAbsent": "Absents cette semaine"
|
||||
},
|
||||
"balancesTable": {
|
||||
"employee": "Salarié",
|
||||
"type": "Type",
|
||||
"period": "Période",
|
||||
"acquired": "Acquis (N-1)",
|
||||
"acquiring": "En cours (N)",
|
||||
"taken": "Pris",
|
||||
"pending": "En attente",
|
||||
"available": "Disponible",
|
||||
"adjust": "Ajuster"
|
||||
},
|
||||
"adjust": {
|
||||
"title": "Ajuster le solde",
|
||||
"acquired": "Acquis (N-1)",
|
||||
"acquiring": "En cours d'acquisition (N)",
|
||||
"taken": "Pris",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"employees": {
|
||||
"columns": {
|
||||
"name": "Nom",
|
||||
"contract": "Contrat",
|
||||
"cpTaken": "CP pris",
|
||||
"cpRemaining": "CP restants"
|
||||
},
|
||||
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
|
||||
"noContract": "—",
|
||||
"drawer": {
|
||||
"title": "Informations employé",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"fields": {
|
||||
"hireDate": "Date d'embauche",
|
||||
"endDate": "Date de sortie",
|
||||
"contractType": "Type de contrat",
|
||||
"familySituation": "Situation familiale",
|
||||
"workTimeRatio": "Temps de travail (ex : 1.0)",
|
||||
"annualLeaveDays": "CP annuels (jours)",
|
||||
"referencePeriodStart": "Début période réf. (MM-DD)",
|
||||
"initialLeaveBalance": "Solde CP initial",
|
||||
"nbChildren": "Nombre d'enfants"
|
||||
},
|
||||
"contract": {
|
||||
"cdi": "CDI",
|
||||
"cdd": "CDD",
|
||||
"stage": "Stage",
|
||||
"alternance": "Alternance",
|
||||
"autre": "Autre"
|
||||
},
|
||||
"family": {
|
||||
"celibataire": "Célibataire",
|
||||
"marie": "Marié(e)",
|
||||
"pacse": "Pacsé(e)",
|
||||
"divorce": "Divorcé(e)",
|
||||
"veuf": "Veuf(ve)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "Politiques d'absence",
|
||||
"subtitle": "Réglez les défauts par type d'absence (convention collective).",
|
||||
"type": "Type",
|
||||
"daysPerYear": "Jours / an",
|
||||
"daysPerEvent": "Jours / événement",
|
||||
"justificationRequired": "Justificatif requis",
|
||||
"noticeDays": "Délai prévenance (j)",
|
||||
"countWorkingDaysOnly": "Jours ouvrés",
|
||||
"active": "Actif",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Demande d'absence créée.",
|
||||
"approved": "Demande validée.",
|
||||
"rejected": "Demande refusée.",
|
||||
"cancelled": "Demande annulée.",
|
||||
"justificationUploaded": "Justificatif ajouté.",
|
||||
"balanceAdjusted": "Solde ajusté.",
|
||||
"policyUpdated": "Politique mise à jour."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Gestion de projet -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Gestion de projet
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
@@ -53,23 +59,6 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
@@ -103,14 +92,6 @@
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||
icon="mdi:ticket-outline"
|
||||
label="Tickets client"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
@@ -119,13 +100,59 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Section : Absences -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Absences
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
to="/absences"
|
||||
icon="mdi:umbrella-beach-outline"
|
||||
label="Mes absences"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/team-absences"
|
||||
icon="mdi:calendar-account-outline"
|
||||
label="Absences équipe"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Administration (admin only) -->
|
||||
<template v-if="isAdmin">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Administration
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3">
|
||||
@@ -183,12 +210,11 @@ const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||
&& !roles.includes('ROLE_ADMIN')
|
||||
&& !roles.includes('ROLE_USER')
|
||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto" />
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<SidebarLink
|
||||
to="/portal"
|
||||
icon="mdi:folder-outline"
|
||||
label="Mes projets"
|
||||
:collapsed="false"
|
||||
class="border-t border-secondary-500 pt-6"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/"
|
||||
icon="mdi:shield-crown-outline"
|
||||
label="Administration"
|
||||
:collapsed="false"
|
||||
class="mt-2"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const { version } = useAppVersion()
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,16 +10,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
const isClientOnly = auth.isAuthenticated
|
||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
const isProfileRoute = to.path === '/profile'
|
||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||
return navigateTo('/portal')
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2210,9 +2210,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.8/layer-ui-1.4.8.tgz",
|
||||
"integrity": "sha512-ABQmfMqJqKGGnx6kf5KK/XVuKAPWSpRHmLpS9XMg6pUH8kww8o3JoywlrlFkk9xA30zNFaehAtzV7S19E4JTlg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
168
frontend/pages/absences.vue
Normal file
168
frontend/pages/absences.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<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 '~/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
|
||||
|
||||
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>
|
||||
@@ -31,6 +31,7 @@
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +51,7 @@ const tabs = [
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
{ key: 'absences', label: 'Absences' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -20,7 +20,6 @@ const META: Record<string, { title: string, icon: string, accent: string, roles:
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
@@ -40,7 +39,6 @@ const auth = useAuthStore()
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ const lineOptions = {
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
min-width="!w-48"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -524,7 +524,7 @@ const lineOptions = {
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -533,7 +533,7 @@ const lineOptions = {
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
@@ -54,9 +54,7 @@ async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await navigateTo(isClient ? '/portal' : '/')
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||
// en SPA car la session peut être hydratée après le rendu initial.
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
|
||||
if (isClientOnly.value) {
|
||||
await navigateTo('/portal')
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
@@ -40,11 +25,6 @@ const {
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isClientOnly.value) {
|
||||
router.replace('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ onMounted(async () => {
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -381,7 +381,7 @@ onMounted(async () => {
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -390,7 +390,7 @@ onMounted(async () => {
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -399,7 +399,7 @@ onMounted(async () => {
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -408,7 +408,7 @@ onMounted(async () => {
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -417,7 +417,7 @@ onMounted(async () => {
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -426,7 +426,7 @@ onMounted(async () => {
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('portal.noProjects') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:to="`/portal/projects/${project.id}`"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
const projectId = extractIdFromIri(ticket.project)
|
||||
if (projectId) {
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
} else {
|
||||
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||
}
|
||||
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,282 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
to="/portal"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="isClient"
|
||||
:to="`/portal/projects/${projectId}/new-ticket`"
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
>
|
||||
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||
>
|
||||
<div class="mb-3 flex shrink-0 items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||
{{ col.tickets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div
|
||||
v-for="ticket in col.tickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
|
||||
:draggable="isAdmin"
|
||||
@dragstart="onDragStart(ticket)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="col.tickets.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket detail modal -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="selectedTicket"
|
||||
@refresh="loadTickets"
|
||||
/>
|
||||
|
||||
<!-- Reject comment modal -->
|
||||
<Teleport v-if="rejectModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
|
||||
<textarea
|
||||
v-model="rejectComment"
|
||||
rows="3"
|
||||
class="mt-3 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
:placeholder="$t('clientTicket.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelReject"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('clientTicket.status.rejected')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projectName = ref('')
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-500'
|
||||
case 'in_progress': return 'bg-yellow-500'
|
||||
case 'done': return 'bg-green-500'
|
||||
case 'rejected': return 'bg-red-500'
|
||||
default: return 'bg-neutral-400'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => allStatuses.map(status => ({
|
||||
status,
|
||||
label: t(`clientTicket.status.${status}`),
|
||||
dotClass: statusDotClass(status),
|
||||
tickets: tickets.value.filter(tk => tk.status === status),
|
||||
})))
|
||||
|
||||
// Drag & drop (admin only)
|
||||
const draggedTicket = ref<ClientTicket | null>(null)
|
||||
const dragOverStatus = ref<ClientTicketStatus | null>(null)
|
||||
|
||||
function onDragStart(ticket: ClientTicket) {
|
||||
draggedTicket.value = ticket
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTicket.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: ClientTicketStatus) {
|
||||
if (!draggedTicket.value) return
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(newStatus: ClientTicketStatus) {
|
||||
dragOverStatus.value = null
|
||||
const ticket = draggedTicket.value
|
||||
draggedTicket.value = null
|
||||
|
||||
if (!ticket || ticket.status === newStatus) return
|
||||
|
||||
// Rejected requires a comment
|
||||
if (newStatus === 'rejected') {
|
||||
pendingRejectTicket.value = ticket
|
||||
rejectComment.value = ''
|
||||
rejectModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = newStatus
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const rejectModalOpen = ref(false)
|
||||
const rejectComment = ref('')
|
||||
const pendingRejectTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
function cancelReject() {
|
||||
rejectModalOpen.value = false
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const ticket = pendingRejectTicket.value
|
||||
if (!ticket || !rejectComment.value.trim()) return
|
||||
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = 'rejected'
|
||||
rejectModalOpen.value = false
|
||||
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, {
|
||||
status: 'rejected',
|
||||
statusComment: rejectComment.value.trim(),
|
||||
})
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketList, project] = await Promise.all([
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
projectService.getById(projectId.value),
|
||||
])
|
||||
tickets.value = ticketList
|
||||
projectName.value = project.name
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
|
||||
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
|
||||
<option value="other">{{ $t('clientTicket.type.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
:label="$t('clientTicket.title')"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL (only for bug type) -->
|
||||
<div v-if="form.type === 'bug'" class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('clientTicket.url')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document upload (only after ticket is created) -->
|
||||
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
|
||||
Les documents pourront être ajoutés après la soumission du ticket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<MalioButton
|
||||
:label="$t('portal.submitTicket')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketType } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.newTicket') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
|
||||
const form = reactive({
|
||||
type: 'bug' as ClientTicketType | string,
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
if (!form.description.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await clientTicketService.create({
|
||||
type: form.type as ClientTicketType,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
||||
project: `/api/projects/${projectId.value}`,
|
||||
})
|
||||
await navigateTo(`/portal/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// Toast already shown by useApi
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||
<NuxtLayout name="default">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
@@ -14,17 +14,18 @@
|
||||
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<label
|
||||
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('profile.changeAvatar')"
|
||||
@click="avatarInput?.click()"
|
||||
/>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
{{ $t('profile.changeAvatar') }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
@@ -39,7 +40,6 @@
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
v-if="!isClientOnly"
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
@@ -134,10 +134,6 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
@@ -145,6 +141,7 @@ const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
Tickets client
|
||||
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option :value="null">Tous les statuts</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="onDelete(ticket)"
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Tickets client' })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(ticket: ClientTicket) {
|
||||
await clientTicketService.remove(ticket.id)
|
||||
await loadTickets()
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
])
|
||||
project.value = p
|
||||
tickets.value = t
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -47,7 +47,7 @@
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -56,7 +56,7 @@
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -75,7 +75,7 @@
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -84,7 +84,7 @@
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
479
frontend/pages/team-absences.vue
Normal file
479
frontend/pages/team-absences.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<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 "~/services/dto/absence";
|
||||
import {
|
||||
useAbsenceService,
|
||||
type AbsenceRequestFilters,
|
||||
} from "~/services/absences";
|
||||
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
|
||||
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>
|
||||
@@ -52,7 +52,7 @@
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
137
frontend/services/absences.ts
Normal file
137
frontend/services/absences.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useClientTicketService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (params?.project) query.project = `/api/projects/${params.project}`
|
||||
if (params?.status) query.status = params.status
|
||||
if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}`
|
||||
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<ClientTicket> {
|
||||
return api.get<ClientTicket>(`/client_tickets/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
|
||||
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'portal.ticketCreated',
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.statusUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, updateStatus, remove }
|
||||
}
|
||||
93
frontend/services/dto/absence.ts
Normal file
93
frontend/services/dto/absence.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type AbsenceType = 'cp' | 'mariage_pacs' | '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
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type ClientTicketType = 'bug' | 'improvement' | 'other'
|
||||
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
|
||||
|
||||
export type ClientTicket = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
number: number
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url: string | null
|
||||
status: ClientTicketStatus
|
||||
statusComment: string | null
|
||||
project: string
|
||||
submittedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: TaskDocument[]
|
||||
}
|
||||
|
||||
export type ClientTicketWrite = {
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url?: string | null
|
||||
project: string
|
||||
}
|
||||
|
||||
export type ClientTicketStatusUpdate = {
|
||||
status: ClientTicketStatus
|
||||
statusComment?: string | null
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export type Notification = {
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
relatedTicket: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ export type Task = {
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
clientTicket: {
|
||||
id: number
|
||||
number: number
|
||||
type: string
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
@@ -61,7 +54,6 @@ export type TaskWrite = {
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
clientTicket?: string | null
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import type { Project } from './project'
|
||||
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
||||
export type FamilySituation = 'CELIBATAIRE' | 'MARIE' | 'PACSE' | 'DIVORCE' | 'VEUF'
|
||||
|
||||
export type UserData = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
username: string
|
||||
roles: string[]
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
familySituation?: FamilySituation | null
|
||||
nbChildren?: number
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
username: string
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
client?: string | null
|
||||
allowedProjects?: string[]
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
familySituation?: FamilySituation | null
|
||||
nbChildren?: number
|
||||
}
|
||||
|
||||
@@ -31,17 +31,6 @@ export function useTaskDocumentService() {
|
||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||
}
|
||||
|
||||
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
|
||||
}
|
||||
|
||||
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
@@ -52,5 +41,5 @@ export function useTaskDocumentService() {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
|
||||
return { getByTask, upload, remove, getDownloadUrl }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user