Consolidate create and edit pages into single create pages with edit mode support. Remove obsolete catalog pages, history composables, and fix remaining code review issues. Include migration to relink orphaned custom fields. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
454 lines
16 KiB
Vue
454 lines
16 KiB
Vue
<template>
|
|
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
|
|
<div class="navbar-start">
|
|
<!-- Mobile hamburger menu -->
|
|
<div class="dropdown">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
|
|
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
|
</div>
|
|
<ul
|
|
tabindex="0"
|
|
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
|
>
|
|
<li class="pt-1 pb-2 lg:hidden">
|
|
<button
|
|
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
|
@click="toggleDarkMode"
|
|
>
|
|
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
|
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
|
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
|
|
</button>
|
|
</li>
|
|
<li class="pt-1 pb-2 lg:hidden">
|
|
<button
|
|
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
|
@click="$emit('open-settings')"
|
|
>
|
|
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
|
Paramètres d'affichage
|
|
</button>
|
|
</li>
|
|
|
|
<!-- Mobile: simple links -->
|
|
<li v-for="link in simpleLinks" :key="link.to">
|
|
<NuxtLink
|
|
:to="link.to"
|
|
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
|
|
:class="linkClass(link)"
|
|
>
|
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
|
{{ link.label }}
|
|
</NuxtLink>
|
|
</li>
|
|
|
|
<!-- Mobile: dropdown groups -->
|
|
<li
|
|
v-for="group in visibleGroups"
|
|
:key="group.id + '-mobile'"
|
|
class="mt-1 border-t border-base-200 pt-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
|
|
:class="groupClass(group)"
|
|
:aria-expanded="openDropdown === group.id + '-mobile'"
|
|
@click="toggleDropdown(group.id + '-mobile')"
|
|
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
|
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
|
>
|
|
<span class="flex items-center gap-2">
|
|
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
|
{{ group.label }}
|
|
</span>
|
|
<IconLucideChevronRight
|
|
class="h-3.5 w-3.5 transition-transform duration-200"
|
|
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
<Transition name="nav-dropdown-mobile">
|
|
<ul
|
|
v-if="openDropdown === group.id + '-mobile'"
|
|
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
|
|
>
|
|
<li v-for="child in group.children" :key="child.to">
|
|
<NuxtLink
|
|
:to="child.to"
|
|
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
|
|
:class="childLinkClass(child)"
|
|
>
|
|
{{ child.label }}
|
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
|
{{ unresolvedCount }}
|
|
</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</Transition>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Logo -->
|
|
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
|
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
|
|
<img
|
|
:src="logoSrc"
|
|
alt="Logo Malio"
|
|
class="h-full w-full object-contain"
|
|
/>
|
|
</div>
|
|
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
|
|
Inventory
|
|
</span>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Desktop navbar -->
|
|
<div class="navbar-center hidden lg:flex">
|
|
<ul class="menu menu-horizontal gap-0.5 px-1">
|
|
<!-- Desktop: simple links -->
|
|
<li v-for="link in simpleLinks" :key="link.to">
|
|
<NuxtLink
|
|
:to="link.to"
|
|
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
|
|
:class="linkClass(link)"
|
|
>
|
|
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
|
{{ link.label }}
|
|
</NuxtLink>
|
|
</li>
|
|
|
|
<!-- Desktop: dropdown groups -->
|
|
<li
|
|
v-for="group in visibleGroups"
|
|
:key="group.id + '-desktop'"
|
|
class="relative"
|
|
@mouseenter="setDropdown(group.id + '-desktop')"
|
|
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
|
|
@focusin="setDropdown(group.id + '-desktop')"
|
|
@focusout="scheduleDropdownClose(group.id + '-desktop')"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
|
|
:class="groupClass(group)"
|
|
:aria-expanded="openDropdown === group.id + '-desktop'"
|
|
@click="toggleDropdown(group.id + '-desktop')"
|
|
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
|
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
|
>
|
|
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
|
{{ group.label }}
|
|
<IconLucideChevronDown
|
|
class="h-3.5 w-3.5 transition-transform duration-200"
|
|
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
<Transition name="nav-dropdown-desktop">
|
|
<ul
|
|
v-if="openDropdown === group.id + '-desktop'"
|
|
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
|
|
>
|
|
<li v-for="child in group.children" :key="child.to">
|
|
<NuxtLink
|
|
:to="child.to"
|
|
class="block rounded-lg px-3 py-2 transition-all text-sm"
|
|
:class="childLinkClass(child)"
|
|
>
|
|
{{ child.label }}
|
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
|
{{ unresolvedCount }}
|
|
</span>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</Transition>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Navbar end -->
|
|
<div class="navbar-end">
|
|
<div class="flex items-center gap-1.5">
|
|
<button
|
|
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
|
:title="isDark ? 'Mode clair' : 'Mode sombre'"
|
|
@click="toggleDarkMode"
|
|
>
|
|
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
|
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
|
title="Paramètres d'affichage"
|
|
@click="$emit('open-settings')"
|
|
>
|
|
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
|
|
<ClientOnly>
|
|
<div v-if="activeProfile" class="dropdown dropdown-end">
|
|
<div
|
|
tabindex="0"
|
|
role="button"
|
|
class="indicator cursor-pointer"
|
|
>
|
|
<span
|
|
v-if="unresolvedCount > 0"
|
|
class="indicator-item badge badge-warning badge-xs"
|
|
>
|
|
{{ unresolvedCount }}
|
|
</span>
|
|
<div
|
|
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
|
|
>
|
|
<span class="text-xs font-semibold">
|
|
{{ activeProfileInitials }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<ul
|
|
tabindex="0"
|
|
class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
|
>
|
|
<li class="px-3 py-2">
|
|
<div class="flex flex-col gap-1 pointer-events-none">
|
|
<span class="text-xs text-base-content/50">Connecté en tant que</span>
|
|
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
|
|
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
|
</div>
|
|
</li>
|
|
<div class="divider my-0.5 px-2" />
|
|
<li v-if="isAdmin">
|
|
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
|
|
Administration
|
|
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
|
</NuxtLink>
|
|
</li>
|
|
<li>
|
|
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
|
|
Commentaires
|
|
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
|
{{ unresolvedCount }}
|
|
</span>
|
|
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
|
</NuxtLink>
|
|
</li>
|
|
<div class="divider my-0.5 px-2" />
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
|
|
@click="$emit('logout')"
|
|
>
|
|
Déconnexion
|
|
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</ClientOnly>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
|
import { useRoute } from '#imports'
|
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
|
import { usePermissions } from '~/composables/usePermissions'
|
|
import { useProfileSession } from '~/composables/useProfileSession'
|
|
import { useComments } from '~/composables/useComments'
|
|
import IconLucideMenu from '~icons/lucide/menu'
|
|
import IconLucideSettings from '~icons/lucide/settings'
|
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
|
import IconLucideLogOut from '~icons/lucide/log-out'
|
|
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
|
import IconLucideFactory from '~icons/lucide/factory'
|
|
import IconLucideBookOpen from '~icons/lucide/book-open'
|
|
|
|
import IconLucidePackage from '~icons/lucide/package'
|
|
import IconLucideSun from '~icons/lucide/sun'
|
|
import IconLucideMoon from '~icons/lucide/moon'
|
|
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
|
|
|
defineEmits<{
|
|
(e: 'open-settings'): void
|
|
(e: 'logout'): void
|
|
}>()
|
|
|
|
interface NavLink {
|
|
to: string
|
|
label: string
|
|
icon?: Component
|
|
}
|
|
|
|
interface NavGroup {
|
|
id: string
|
|
label: string
|
|
icon?: Component
|
|
activePaths: string[]
|
|
children: NavLink[]
|
|
requiresEdit?: boolean
|
|
}
|
|
|
|
const simpleLinks: NavLink[] = [
|
|
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
|
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
|
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
|
|
]
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
id: 'catalogues',
|
|
label: 'Catalogues',
|
|
icon: IconLucidePackage,
|
|
activePaths: ['/catalogues', '/component', '/piece', '/product'],
|
|
children: [
|
|
{ to: '/catalogues/composants', label: 'Composants' },
|
|
{ to: '/catalogues/pieces', label: 'Pièces' },
|
|
{ to: '/catalogues/produits', label: 'Produits' },
|
|
],
|
|
},
|
|
{
|
|
id: 'admin',
|
|
label: 'Administration',
|
|
icon: IconLucideSettings,
|
|
activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
|
|
requiresEdit: true,
|
|
children: [
|
|
{ to: '/sites', label: 'Sites' },
|
|
{ to: '/documents', label: 'Documents' },
|
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
|
{ to: '/comments', label: 'Commentaires' },
|
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
|
{ to: '/admin', label: 'Profils' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const route = useRoute()
|
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
|
const { activeProfile } = useProfileSession()
|
|
const { isAdmin, canEdit } = usePermissions()
|
|
|
|
const visibleGroups = computed(() =>
|
|
navGroups.filter(g => !g.requiresEdit || canEdit.value)
|
|
)
|
|
const { fetchUnresolvedCount } = useComments()
|
|
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
|
|
|
const unresolvedCount = ref(0)
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
const refreshUnresolvedCount = async () => {
|
|
if (!activeProfile.value) return
|
|
unresolvedCount.value = await fetchUnresolvedCount()
|
|
}
|
|
|
|
onMounted(() => {
|
|
initDarkMode()
|
|
refreshUnresolvedCount()
|
|
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (pollInterval) clearInterval(pollInterval)
|
|
})
|
|
|
|
const isActive = (path: string) => {
|
|
if (path === '/') {
|
|
return route.path === '/'
|
|
}
|
|
return route.path.startsWith(path)
|
|
}
|
|
|
|
const isGroupActive = (group: NavGroup) => {
|
|
return group.activePaths.some((path) => isActive(path))
|
|
}
|
|
|
|
const linkClass = (link: NavLink) => {
|
|
return isActive(link.to)
|
|
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
|
}
|
|
|
|
const groupClass = (group: NavGroup) => {
|
|
return isGroupActive(group)
|
|
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
|
}
|
|
|
|
const childLinkClass = (child: NavLink) => {
|
|
return isActive(child.to)
|
|
? 'bg-primary/10 text-primary font-semibold'
|
|
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
|
}
|
|
|
|
const roleLabel = computed(() => {
|
|
if (isAdmin.value) return 'Admin'
|
|
if (canEdit.value) return 'Gestionnaire'
|
|
return 'Lecteur'
|
|
})
|
|
|
|
const roleBadgeClass = computed(() => {
|
|
if (isAdmin.value) return 'badge-error'
|
|
if (canEdit.value) return 'badge-warning'
|
|
return 'badge-info'
|
|
})
|
|
|
|
const activeProfileLabel = computed(() => {
|
|
if (!activeProfile.value) {
|
|
return 'Profil inconnu'
|
|
}
|
|
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
|
|
})
|
|
|
|
const activeProfileInitials = computed(() => {
|
|
if (!activeProfile.value) {
|
|
return '??'
|
|
}
|
|
const { firstName = '', lastName = '' } = activeProfile.value
|
|
return (
|
|
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
|
|
)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.nav-dropdown-desktop-enter-active,
|
|
.nav-dropdown-desktop-leave-active {
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.nav-dropdown-desktop-enter-from,
|
|
.nav-dropdown-desktop-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(4px) scale(0.98);
|
|
}
|
|
.nav-dropdown-desktop-enter-to,
|
|
.nav-dropdown-desktop-leave-from {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
|
|
.nav-dropdown-mobile-enter-active,
|
|
.nav-dropdown-mobile-leave-active {
|
|
transition: max-height 0.2s ease, opacity 0.2s ease;
|
|
}
|
|
.nav-dropdown-mobile-enter-from,
|
|
.nav-dropdown-mobile-leave-to {
|
|
max-height: 0;
|
|
opacity: 0;
|
|
}
|
|
.nav-dropdown-mobile-enter-to,
|
|
.nav-dropdown-mobile-leave-from {
|
|
max-height: 12rem;
|
|
opacity: 1;
|
|
}
|
|
</style>
|