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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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)"

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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')}`,
})
}

View File

@@ -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>

View 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>

View File

@@ -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