Files
SIRH/frontend/pages/users.vue
tristan 6df37d15c1 feat : version mobile écran Utilisateurs
Tableau desktop masqué sous 1024px, remplacé par des cards
cliquables avec username, badge statut, employé lié, accès et sites.
Titre et bouton ajouter adaptés pour mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:11:11 +02:00

534 lines
17 KiB
Vue

<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
@click="openCreate"
>
+ Ajouter
</button>
</div>
<div
v-if="!isLoading && users.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun utilisateur pour le moment.
</div>
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
<span class="text-left">Accès</span>
<span class="text-left">Sites</span>
<span class="text-left">Statut</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="user in users"
:key="user.id"
class="grid grid-cols-5 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="openEdit(user)"
>
<span>{{ user.username }}</span>
<span>
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
</span>
<span>{{ getAccessLabel(user) }}</span>
<span>{{ getSiteLabels(user) }}</span>
<span>
<span
v-if="user.isLocked"
class="inline-block rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"
>Actif</span>
</span>
</div>
</div>
</div>
<!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<AppDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="username">
Nom d'utilisateur <span class="text-red-600">*</span>
</label>
<input
id="username"
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="password">
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
v-model="form.password"
type="password"
:class="passwordFieldClass"
/>
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div>
<div>
<p class="text-md font-semibold text-neutral-700">Accès</p>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('admin')"
>
Admin
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('self')"
>
Accès personnel
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('sites')"
>
Sites
</button>
</div>
<p class="mt-2 text-sm text-neutral-500">
{{
form.accessMode === 'admin'
? 'Donne accès à tout.'
: form.accessMode === 'self'
? "Donne accès uniquement à ses propres données."
: 'Donne accès aux employés des sites sélectionnés.'
}}
</p>
</div>
<div v-if="form.accessMode === 'self'">
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé lié
</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucun</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
</div>
<div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
>
<input
type="checkbox"
class="cursor-pointer"
:checked="form.siteIds.includes(site.id)"
@change="toggleSite(site.id)"
/>
<span>{{ site.name }}</span>
</label>
</div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site.
</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.isLocked"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Un compte verrouillé ne peut plus se connecter.
</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.hasLeaveRecapAccess"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
</button>
</div>
</form>
</AppDrawer>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import type { User } from '~/services/dto/user'
import type { UserSiteRole } from '~/services/user-site-roles'
import { listEmployees } from '~/services/employees'
import { listSites } from '~/services/sites'
import { createUser, listUsers, updateUser } from '~/services/users'
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
definePageMeta({ middleware: ['admin'] })
useHead({
title: 'Utilisateurs'
})
const users = ref<User[]>([])
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const userSiteRoles = ref<UserSiteRole[]>([])
const isLoading = ref(false)
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const editingUser = ref<User | null>(null)
const form = reactive({
username: '',
password: '',
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[],
isLocked: false,
hasLeaveRecapAccess: false
})
const validationTouched = reactive({
username: false,
password: false,
sites: false,
selfEmployee: false
})
const isUsernameValid = computed(() => form.username.trim() !== '')
const isPasswordValid = computed(() =>
editingUser.value ? true : form.password.trim() !== ''
)
const isFormValid = computed(() => isUsernameValid.value && isPasswordValid.value)
const isSitesValid = computed(() => form.siteIds.length > 0)
const isSelfEmployeeValid = computed(() => form.employeeId !== '')
const showUsernameError = computed(
() => validationTouched.username && !isUsernameValid.value
)
const showPasswordError = computed(
() => validationTouched.password && !isPasswordValid.value
)
const showSitesError = computed(
() => validationTouched.sites && form.accessMode === 'sites' && !isSitesValid.value
)
const showSelfEmployeeError = computed(
() =>
validationTouched.selfEmployee &&
form.accessMode === 'self' &&
!isSelfEmployeeValid.value
)
const userAccessById = computed(() => {
const rolesByUser = new Map<number, UserSiteRole[]>()
for (const role of userSiteRoles.value) {
const userId = role.user?.id
if (!userId) continue
const list = rolesByUser.get(userId) ?? []
list.push(role)
rolesByUser.set(userId, list)
}
return rolesByUser
})
const getAccessLabel = (user: User) => {
if (user.roles.includes('ROLE_ADMIN')) return 'Admin'
if (user.roles.includes('ROLE_SELF')) return 'Self'
const siteRoles = userAccessById.value.get(user.id) ?? []
return siteRoles.length > 0 ? 'Sites' : 'Aucun'
}
const getSiteLabels = (user: User) => {
const siteRoles = userAccessById.value.get(user.id) ?? []
if (siteRoles.length === 0) return '-'
const names = siteRoles
.map((role) => role.site?.name)
.filter((name): name is string => Boolean(name))
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
}
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadData = async () => {
isLoading.value = true
try {
const [usersData, employeesData, sitesData, userSiteRolesData] = await Promise.all([
listUsers(),
listEmployees(),
listSites(),
listUserSiteRoles()
])
users.value = usersData
employees.value = employeesData
sites.value = sitesData
userSiteRoles.value = userSiteRolesData
} finally {
isLoading.value = false
}
}
onMounted(loadData)
const resetForm = () => {
form.username = ''
form.password = ''
form.employeeId = ''
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
validationTouched.sites = false
validationTouched.selfEmployee = false
}
const openCreate = () => {
resetForm()
isDrawerOpen.value = true
}
const openEdit = (user: User) => {
resetForm()
editingUser.value = user
form.username = user.username
form.password = ''
if (user.roles.includes('ROLE_ADMIN')) {
selectAccessMode('admin')
} else if (user.roles.includes('ROLE_SELF')) {
selectAccessMode('self')
} else {
selectAccessMode('sites')
}
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
isDrawerOpen.value = true
}
const closeDrawer = () => {
isDrawerOpen.value = false
resetForm()
}
const selectAccessMode = (mode: 'admin' | 'self' | 'sites') => {
form.accessMode = mode
if (mode !== 'sites') {
form.siteIds = []
}
}
const toggleSite = (siteId: number) => {
if (form.siteIds.includes(siteId)) {
form.siteIds = form.siteIds.filter((existing) => existing !== siteId)
} else {
form.siteIds = [...form.siteIds, siteId]
}
}
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.username = true
validationTouched.password = true
validationTouched.sites = true
validationTouched.selfEmployee = true
if (!isFormValid.value) return
if (form.accessMode === 'sites' && !isSitesValid.value) return
if (form.accessMode === 'self' && !isSelfEmployeeValid.value) return
isSubmitting.value = true
try {
const roles =
form.accessMode === 'admin'
? ['ROLE_ADMIN']
: form.accessMode === 'self'
? ['ROLE_SELF']
: []
const employeeId =
form.accessMode === 'self' ? (form.employeeId === '' ? null : Number(form.employeeId)) : null
if (editingUser.value) {
await updateUser(editingUser.value.id, {
username: form.username,
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId,
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
if (existingSiteRoles.length > 0) {
await Promise.all(existingSiteRoles.map((role) => deleteUserSiteRole(role.id)))
}
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
await Promise.all(
form.siteIds.map((siteId) =>
createUserSiteRole({
userId: editingUser.value!.id,
siteId,
role: 'SITE_ACCESS'
})
)
)
}
} else {
const created = await createUser({
username: form.username,
plainPassword: form.password,
roles,
employeeId,
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
await Promise.all(
form.siteIds.map((siteId) =>
createUserSiteRole({
userId: created.id,
siteId,
role: 'SITE_ACCESS'
})
)
)
}
}
closeDrawer()
await loadData()
} finally {
isSubmitting.value = false
}
}
</script>