c766e76624
Auto Tag Develop / tag (push) Successful in 8s
## Objectif Remplacer la sidebar maison par le composant `MalioSidebar` de `@malio/layer-ui` (alignement avec Starseed). ## Changements - **Backend** : `config/sidebar.php` re-catégorisé en **3 groupes** (Général / Outils / Administration). Tous les gates permission/rôle/module **préservés côté serveur** (rien déplacé côté client). - **Frontend** : `app/layouts/default.vue` migré vers `<MalioSidebar>`. Un computed `mergedSections` mappe les sections backend et y fusionne les items contextuels (Kanban/Groupes/Archives sous « Projets », Mes absences, Messagerie avec compteur `(N)`, Documents). - **Footer** : timer (`SidebarTimer`) + version de l'app (masquée en mode replié). - **Logo** : logos Malio repris de Starseed (`LOGO_MALIO.png` / `LOGO_MALIO_COLLAPSED.png`). - **Mobile** : `MalioSidebar` étant toujours visible (pas de tiroir off-canvas), le hamburger pilote désormais le repli ; suppression du code de tiroir mobile mort (`sidebarOpen`/`openMobileSidebar`/`closeMobileSidebar`). - **Nettoyage** : suppression de `SidebarLink.vue` et `LOGO_CARRE.png` (obsolètes). `malio.png` conservé (utilisé par la page login). - **i18n** : nouvelles clés `sidebar.tools.section`, `sidebar.general.myAbsences`, `sidebar.project.kanban|groups|archives` ; `sidebar.general.section` → « Général ». ## Compromis (limites du composant, lib non modifiée) - Pas d'icône par item (uniquement icône de section) — design malioUI, comme Starseed. - Badge mail → suffixe `(N)` dans le libellé. ## Vérifications - Build Nuxt OK (`✨ Build complete!`, exit 0). - Revue par task + revue finale whole-branch : aucun Critical/Important. - Sécurité : filtrage des permissions inchangé (côté serveur). Specs/plan : `docs/superpowers/specs/2026-06-25-malio-sidebar-migration-design.md`, `docs/superpowers/plans/2026-06-25-malio-sidebar-migration.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #26 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
268 lines
8.8 KiB
Vue
268 lines
8.8 KiB
Vue
<template>
|
|
<div class="h-screen overflow-hidden">
|
|
<div class="flex h-full">
|
|
<MalioSidebar
|
|
v-model="ui.sidebarCollapsed"
|
|
:sections="mergedSections"
|
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
|
>
|
|
<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" />
|
|
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-6 lg:px-12 xl:px-11">
|
|
<slot/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<TimeEntryDrawer
|
|
v-model="completeDrawerOpen"
|
|
:entry="timerStore.pendingCompleteEntry"
|
|
:users="refData.users"
|
|
:projects="refData.projects"
|
|
:tags="refData.tags"
|
|
@saved="onCompleteSaved"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { UserData } from '~/services/dto/user-data'
|
|
import type { Project } from '~/modules/project-management/services/dto/project'
|
|
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
|
import { useAppVersion } from '~/composables/useAppVersion'
|
|
import type { HydraCollection } from '~/utils/api'
|
|
import { extractHydraMembers } from '~/utils/api'
|
|
|
|
const auth = useAuthStore()
|
|
const ui = useUiStore()
|
|
const mailStore = useMailStore()
|
|
const {version} = useAppVersion()
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const { sections } = useSidebar()
|
|
|
|
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
|
|
|
const isMailVisible = computed(() => {
|
|
const roles: string[] = auth.user?.roles ?? []
|
|
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
|
})
|
|
|
|
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
|
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
|
|
|
const currentProjectId = computed(() => {
|
|
const match = route.path.match(/^\/projects\/(\d+)/)
|
|
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',
|
|
}
|
|
|
|
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
|
|
// (sinon ajouté en fin de section).
|
|
type ClientItem = MalioItem & { after?: string }
|
|
|
|
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
|
function clientItemsFor(key: string): ClientItem[] {
|
|
if (key === 'sidebar.general.section') {
|
|
const items: ClientItem[] = []
|
|
if (currentProjectId.value) {
|
|
const id = currentProjectId.value
|
|
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
|
|
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
|
|
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
|
|
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
|
|
}
|
|
if (isEmployee.value) {
|
|
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
|
}
|
|
return items
|
|
}
|
|
if (key === 'sidebar.tools.section') {
|
|
const items: ClientItem[] = []
|
|
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 []
|
|
}
|
|
|
|
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
|
|
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
|
|
const result = [...base]
|
|
for (const { after, ...item } of extra) {
|
|
const idx = after ? result.findIndex((i) => i.to === after) : -1
|
|
if (idx !== -1) {
|
|
result.splice(idx + 1, 0, item)
|
|
} else {
|
|
result.push(item)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
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 = mergeClientItems(base.items, extra)
|
|
if (base.items.length > 0) {
|
|
result.push(base)
|
|
}
|
|
} else if (extra.length > 0) {
|
|
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], 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')
|
|
|
|
useHead({
|
|
titleTemplate: (title) => {
|
|
baseTitle.value = title || 'Lesstime'
|
|
return title || 'Lesstime'
|
|
},
|
|
})
|
|
|
|
watch(
|
|
[() => timerStore.elapsedFormatted, () => timerStore.isRunning, () => timerStore.activeEntry?.title],
|
|
([elapsed, running, label]) => {
|
|
if (import.meta.server) return
|
|
const base = baseTitle.value
|
|
if (running) {
|
|
document.title = label ? `${base} | ${elapsed} · ${label}` : `${base} | ${elapsed}`
|
|
} else {
|
|
document.title = base
|
|
}
|
|
},
|
|
)
|
|
|
|
onMounted(() => {
|
|
timerStore.fetchActive()
|
|
if (isMailVisible.value) {
|
|
mailStore.startPolling()
|
|
}
|
|
ensureShareStatus()
|
|
})
|
|
|
|
watch(() => auth.user, (user) => {
|
|
if (!user) {
|
|
mailStore.stopPolling()
|
|
} else {
|
|
if (isMailVisible.value) {
|
|
mailStore.startPolling()
|
|
}
|
|
ensureShareStatus()
|
|
}
|
|
})
|
|
|
|
const completeDrawerOpen = ref(false)
|
|
const refData = reactive({
|
|
users: [] as UserData[],
|
|
projects: [] as Project[],
|
|
tags: [] as TaskTag[],
|
|
loaded: false,
|
|
})
|
|
|
|
async function loadRefData() {
|
|
if (refData.loaded) return
|
|
const api = useApi()
|
|
const [usersData, projectsData, typesData] = await Promise.all([
|
|
api.get<HydraCollection<UserData>>('/users'),
|
|
api.get<HydraCollection<Project>>('/projects'),
|
|
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
|
])
|
|
refData.users = extractHydraMembers(usersData)
|
|
refData.projects = extractHydraMembers(projectsData)
|
|
refData.tags = extractHydraMembers(typesData)
|
|
refData.loaded = true
|
|
}
|
|
|
|
watch(() => timerStore.pendingCompleteEntry, async (entry) => {
|
|
if (entry) {
|
|
await loadRefData()
|
|
completeDrawerOpen.value = true
|
|
}
|
|
})
|
|
|
|
watch(completeDrawerOpen, (open) => {
|
|
if (!open) {
|
|
nextTick(() => {
|
|
timerStore.clearPendingEntry()
|
|
})
|
|
}
|
|
})
|
|
|
|
function onCompleteSaved() {
|
|
completeDrawerOpen.value = false
|
|
nextTick(() => {
|
|
timerStore.clearPendingEntry()
|
|
})
|
|
}
|
|
</script>
|