feat(sidebar) : migration vers MalioSidebar — 3 groupes, footer timer+version, logo (LST-71) #26

Merged
tristan merged 7 commits from feature/LST-71-mettre-en-place-la-sidebar-malio-ui into develop 2026-06-25 15:30:24 +00:00
Showing only changes of commit 07375b7202 - Show all commits
+106 -134
View File
@@ -1,107 +1,27 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="[
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
<MalioSidebar
v-model="ui.sidebarCollapsed"
:sections="mergedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
>
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img
v-if="!sidebarIsCollapsed"
src="/malio.png"
alt="Logo"
class="w-auto"
/>
<img
v-else
src="/LOGO_CARRE.png"
alt="Logo"
class="w-[46px] h-[55px]"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{{ section.label }}
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink
v-for="item in section.items"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
<template v-if="sIndex === 0">
<!-- Contextuel projet -->
<template v-if="currentProjectId">
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
</template>
<!-- Feature-flag : Documents -->
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<!-- Feature-flag : Mail + badge -->
<div v-if="isMailVisible" class="relative">
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<span
v-if="mailStore.globalUnreadCount > 0"
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
>
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span>
</div>
<!-- User-flag : Mes absences (isEmployee non couvert par le gate rôle) -->
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
</template>
</template>
</nav>
<div class="px-4 py-3">
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
</div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/>
</template>
<template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template>
<template #footer>
<div class="flex flex-col gap-2">
<SidebarTimer :collapsed="false" />
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
</div>
</template>
<template #footer-collapsed>
<SidebarTimer :collapsed="true" />
</template>
</MalioSidebar>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
@@ -138,23 +58,6 @@ const route = useRoute()
const { t } = useI18n()
const { sections } = useSidebar()
// `/mail` est déclaré dans config/sidebar.php pour le gating module (disabledRoutes),
// mais son rendu visuel + badge non-lus est géré manuellement ci-dessous (feature-flag Mail).
// On le filtre des sections dynamiques pour éviter un doublon dans la nav.
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isMailVisible = computed(() => {
@@ -165,12 +68,6 @@ const isMailVisible = computed(() => {
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true)
// On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false
return ui.sidebarCollapsed
})
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
@@ -181,6 +78,92 @@ const currentProjectId = computed(() => {
return match ? match[1] : null
})
type MalioItem = { label: string; to: string; exact?: boolean }
type MalioSection = { label: string; icon: string; items: MalioItem[] }
// Ordre d'affichage canonique des sections.
const SECTION_ORDER = [
'sidebar.general.section',
'sidebar.tools.section',
'sidebar.admin.section',
] as const
// Icônes de secours pour les sections créées côté client (absentes du backend,
// ex. module mail off mais partage actif → section Outils à recréer).
const SECTION_ICON: Record<string, string> = {
'sidebar.general.section': 'mdi:view-dashboard-outline',
'sidebar.tools.section': 'mdi:tools',
'sidebar.admin.section': 'mdi:cog-outline',
}
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
function clientItemsFor(key: string): MalioItem[] {
if (key === 'sidebar.general.section') {
const items: MalioItem[] = []
if (currentProjectId.value) {
const id = currentProjectId.value
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true })
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups` })
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives` })
}
if (isEmployee.value) {
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
}
return items
}
if (key === 'sidebar.tools.section') {
const items: MalioItem[] = []
if (isMailVisible.value) {
const n = mailStore.globalUnreadCount
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
}
if (isDocumentsVisible.value) {
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
}
return items
}
return []
}
const mergedSections = computed<MalioSection[]>(() => {
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
const backend = new Map<string, MalioSection>()
for (const section of sections.value) {
backend.set(section.label, {
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({ label: t(item.label), to: item.to })),
})
}
// 2. Fusion dans l'ordre canonique.
const result: MalioSection[] = []
for (const key of SECTION_ORDER) {
const base = backend.get(key)
const extra = clientItemsFor(key)
if (base) {
base.items.push(...extra)
if (base.items.length > 0) {
result.push(base)
}
} else if (extra.length > 0) {
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: extra })
}
}
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
for (const [key, section] of backend) {
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
result.push(section)
}
}
return result
})
const timerStore = useTimerStore()
const baseTitle = ref('Lesstime')
@@ -268,14 +251,3 @@ function onCompleteSaved() {
})
}
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>