Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ce523019 |
@@ -11,15 +11,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="showArchived"
|
||||||
|
:label="$t('users.showArchived')"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
empty-message="Aucun utilisateur trouvé."
|
empty-message="Aucun utilisateur trouvé."
|
||||||
deletable
|
|
||||||
@row-click="openEdit"
|
@row-click="openEdit"
|
||||||
@delete="(item) => handleDelete(item.id)"
|
|
||||||
>
|
>
|
||||||
|
<template #cell-username="{ item }">
|
||||||
|
<span :class="{ 'text-neutral-400 line-through': item.archived }">
|
||||||
|
{{ item.username }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.archived"
|
||||||
|
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
{{ $t('users.archivedBadge') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-roles="{ item }">
|
<template #cell-roles="{ item }">
|
||||||
<span
|
<span
|
||||||
v-for="role in item.roles"
|
v-for="role in item.roles"
|
||||||
@@ -29,6 +47,27 @@
|
|||||||
{{ role }}
|
{{ role }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ item }">
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="item.archived"
|
||||||
|
icon="mdi:restore"
|
||||||
|
:aria-label="$t('users.restore')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
button-class="text-neutral-400 hover:text-primary-500"
|
||||||
|
@click.stop="handleRestore(item)"
|
||||||
|
/>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-else-if="item.id !== currentUserId"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
:aria-label="$t('users.archive')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
button-class="text-neutral-400 hover:text-red-500"
|
||||||
|
@click.stop="openArchiveConfirm(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
@@ -36,12 +75,19 @@
|
|||||||
:item="selectedItem"
|
:item="selectedItem"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmArchiveUserModal
|
||||||
|
v-model="archiveConfirmOpen"
|
||||||
|
:username="userToArchive?.username ?? ''"
|
||||||
|
@confirm="confirmArchive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useAuthStore } from '~/shared/stores/auth'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'roles', label: 'Rôles' },
|
{ key: 'roles', label: 'Rôles' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const { getAll, remove } = useUserService()
|
const { getAll, getArchived, remove, restore } = useUserService()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentUserId = computed(() => authStore.user?.id)
|
||||||
|
|
||||||
const items = ref<UserData[]>([])
|
const items = ref<UserData[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedItem = ref<UserData | null>(null)
|
const selectedItem = ref<UserData | null>(null)
|
||||||
|
const showArchived = ref(false)
|
||||||
|
const archiveConfirmOpen = ref(false)
|
||||||
|
const userToArchive = ref<UserData | null>(null)
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
items.value = await getAll()
|
if (showArchived.value) {
|
||||||
|
const [active, archived] = await Promise.all([getAll(), getArchived()])
|
||||||
|
items.value = [...active, ...archived]
|
||||||
|
} else {
|
||||||
|
items.value = await getAll()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
|
|||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
function openArchiveConfirm(item: UserData) {
|
||||||
await remove(id)
|
userToArchive.value = item
|
||||||
|
archiveConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmArchive() {
|
||||||
|
if (!userToArchive.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await remove(userToArchive.value.id)
|
||||||
|
archiveConfirmOpen.value = false
|
||||||
|
userToArchive.value = null
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(item: UserData) {
|
||||||
|
await restore(item.id)
|
||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +156,10 @@ async function onSaved() {
|
|||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(showArchived, () => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadItems()
|
loadItems()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ $t('users.archiveConfirmMessage', { username }) }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="cancel"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
:label="$t('users.archive')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
username: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -194,8 +194,16 @@
|
|||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès.",
|
"deleted": "Utilisateur supprimé avec succès.",
|
||||||
|
"archived": "Utilisateur archivé avec succès.",
|
||||||
|
"restored": "Utilisateur restauré avec succès.",
|
||||||
"addUser": "Ajouter un utilisateur",
|
"addUser": "Ajouter un utilisateur",
|
||||||
"editUser": "Modifier un utilisateur"
|
"editUser": "Modifier un utilisateur",
|
||||||
|
"archivedBadge": "Archivé",
|
||||||
|
"showArchived": "Afficher les utilisateurs archivés",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"restore": "Restaurer",
|
||||||
|
"archiveConfirmTitle": "Archiver l'utilisateur",
|
||||||
|
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type UserData = {
|
|||||||
effectivePermissions?: string[]
|
effectivePermissions?: string[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: string | null
|
apiToken?: string | null
|
||||||
|
// Soft-delete flag: an archived user keeps its data but cannot log in
|
||||||
|
archived?: boolean
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export function useUserService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archived users are hidden from the default collection; an admin lists
|
||||||
|
// them explicitly via the `archived` filter (handled server-side).
|
||||||
|
async function getArchived(): Promise<UserData[]> {
|
||||||
|
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
async function getById(id: number): Promise<UserData> {
|
async function getById(id: number): Promise<UserData> {
|
||||||
return api.get<UserData>(`/users/${id}`)
|
return api.get<UserData>(`/users/${id}`)
|
||||||
}
|
}
|
||||||
@@ -26,11 +33,19 @@ export function useUserService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting a user is a soft delete server-side: the account is archived
|
||||||
|
// (kept for referential integrity) rather than removed.
|
||||||
async function remove(id: number): Promise<void> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/users/${id}`, {}, {
|
await api.delete(`/users/${id}`, {}, {
|
||||||
toastSuccessKey: 'users.deleted',
|
toastSuccessKey: 'users.archived',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, getById, create, update, remove }
|
async function restore(id: number): Promise<UserData> {
|
||||||
|
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
|
||||||
|
toastSuccessKey: 'users.restored',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getArchived, getById, create, update, remove, restore }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user