feat: Implement unified sidebar with Nuxt UI across all dashboard pages

- Install @nuxt/ui and integrate with existing Vuetify
- Create new layouts/dashboard-unified.vue with modern sidebar design
- Features: clean white design, collapsible sidebar, role-based navigation
- Remove old layouts/dashboard.vue to eliminate dual-sidebar confusion
- Update all dashboard pages to use dashboard-unified layout
- Add demo page showcasing new sidebar features
- Fix auth error handler to ignore external service 401 errors
- Ensure consistent navigation experience across entire platform
This commit is contained in:
2025-07-11 16:33:11 -04:00
parent d71e2d348c
commit 61235b163d
12 changed files with 3740 additions and 580 deletions

View File

@@ -0,0 +1,298 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Main Dashboard Group -->
<UDashboardGroup>
<!-- Sidebar -->
<UDashboardSidebar
v-model:open="sidebarOpen"
:collapsible="true"
:resizable="false"
:ui="{
wrapper: 'bg-white border-r border-gray-200',
header: 'px-4 py-4 border-b border-gray-100',
body: 'px-3 py-4',
footer: 'px-4 py-4 border-t border-gray-100'
}"
>
<template #header="{ collapsed }">
<div class="flex items-center gap-3">
<img
src="/Port Nimara New Logo-Circular Frame.png"
class="h-8 w-8"
alt="Port Nimara"
>
<transition name="fade">
<div v-if="!collapsed" class="flex flex-col">
<span class="text-sm font-semibold text-gray-900">Port Nimara</span>
<span class="text-xs text-gray-500">Client Portal</span>
</div>
</transition>
</div>
</template>
<template #default="{ collapsed }">
<!-- Navigation Menu -->
<UNavigationMenu
:collapsed="collapsed"
:items="navigationItems"
orientation="vertical"
:ui="{
wrapper: 'space-y-1',
base: 'group flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-200',
active: 'bg-blue-50 text-blue-700',
inactive: 'text-gray-700 hover:bg-gray-100',
icon: {
base: 'w-5 h-5 flex-shrink-0',
active: 'text-blue-700',
inactive: 'text-gray-400 group-hover:text-gray-600'
},
label: 'truncate',
badge: 'ml-auto'
}"
/>
</template>
<template #footer="{ collapsed }">
<div class="space-y-2">
<!-- User Info -->
<div v-if="authState?.user" class="flex items-center gap-3 p-2">
<UAvatar
:src="`https://ui-avatars.com/api/?name=${encodeURIComponent(authState.user.name || authState.user.email)}&background=387bca&color=fff`"
:alt="authState.user.name || authState.user.email"
size="sm"
/>
<div v-if="!collapsed" class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ authState.user.name || authState.user.email }}
</p>
<p class="text-xs text-gray-500 truncate">
{{ authState.user.email }}
</p>
</div>
</div>
<!-- Role Badge -->
<div v-if="!collapsed && authState?.groups?.length" class="px-2">
<UBadge
v-if="authState.groups.includes('admin')"
color="orange"
variant="subtle"
size="sm"
class="w-full justify-center"
>
Admin
</UBadge>
<UBadge
v-else-if="authState.groups.includes('sales')"
color="green"
variant="subtle"
size="sm"
class="w-full justify-center"
>
Sales
</UBadge>
</div>
<!-- Logout Button -->
<UButton
@click="handleLogout"
:icon="collapsed ? 'i-heroicons-arrow-left-on-rectangle' : undefined"
:label="collapsed ? undefined : 'Logout'"
color="gray"
variant="ghost"
:block="!collapsed"
:square="collapsed"
class="w-full justify-start"
>
<template v-if="!collapsed" #leading>
<UIcon name="i-heroicons-arrow-left-on-rectangle" class="w-5 h-5" />
</template>
</UButton>
</div>
</template>
</UDashboardSidebar>
<!-- Main Panel -->
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" class="bg-white border-b border-gray-200">
<template #left>
<UDashboardSidebarToggle
v-if="mdAndDown"
variant="ghost"
color="gray"
/>
</template>
<template #right>
<!-- Additional navbar content can go here -->
</template>
</UDashboardNavbar>
</template>
<!-- Page Content -->
<div class="p-6">
<slot />
</div>
</UDashboardPanel>
</UDashboardGroup>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
// Define NavigationMenuItem type locally
interface NavigationMenuItem {
label: string;
icon?: string;
to?: string;
badge?: string | number;
click?: () => void;
defaultOpen?: boolean;
children?: NavigationMenuItem[];
}
const route = useRoute();
const router = useRouter();
const nuxtApp = useNuxtApp();
const { mdAndDown } = useDisplay();
// Sidebar state
const sidebarOpen = ref(true);
// Get auth state - with fallback to prevent errors
const authState = computed(() => {
const data = nuxtApp.payload?.data?.authState;
// Only return data if it's properly initialized
if (data && data.authenticated !== undefined) {
return data;
}
return null;
});
// Page title based on current route
const pageTitle = computed(() => {
const routeName = route.name as string;
const pageTitles: Record<string, string> = {
'dashboard': 'Dashboard',
'dashboard-expenses': 'Expense Tracking',
'dashboard-interest-list': 'Interest List',
'dashboard-berth-list': 'Berth List',
'dashboard-interest-status': 'Interest Status',
'dashboard-interest-emails': 'Interest Emails',
'dashboard-interest-berth-list': 'Interest Berth List',
'dashboard-interest-berth-status': 'Berth Status',
'dashboard-interest-analytics': 'Analytics',
'dashboard-file-browser': 'File Browser',
'dashboard-admin': 'Admin Console',
'dashboard-admin-audit-logs': 'Audit Logs',
'dashboard-admin-system-logs': 'System Logs',
'dashboard-admin-duplicates': 'Duplicate Management',
};
return pageTitles[routeName] || routeName?.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ') || 'Dashboard';
});
// Navigation items based on user role
const navigationItems = computed((): NavigationMenuItem[][] => {
const items: NavigationMenuItem[] = [
{
label: 'Dashboard',
icon: 'i-heroicons-home',
to: '/dashboard',
},
{
label: 'Analytics',
icon: 'i-heroicons-chart-bar',
to: '/dashboard/interest-analytics',
},
{
label: 'Berth List',
icon: 'i-heroicons-table-cells',
to: '/dashboard/interest-berth-list',
},
{
label: 'Berth Status',
icon: 'i-heroicons-map',
to: '/dashboard/interest-berth-status',
},
{
label: 'Interest List',
icon: 'i-heroicons-users',
to: '/dashboard/interest-list',
},
{
label: 'Interest Status',
icon: 'i-heroicons-clipboard-document-check',
to: '/dashboard/interest-status',
},
{
label: 'File Browser',
icon: 'i-heroicons-folder-open',
to: '/dashboard/file-browser',
},
];
// Add sales/admin specific items
if (authState.value?.groups?.includes('sales') || authState.value?.groups?.includes('admin')) {
items.push(
{
label: 'Expenses',
icon: 'i-heroicons-receipt-percent',
to: '/dashboard/expenses',
},
{
label: 'Interest Emails',
icon: 'i-heroicons-envelope',
to: '/dashboard/interest-emails',
}
);
}
// Add admin-only items
if (authState.value?.groups?.includes('admin')) {
items.push({
label: 'Admin Console',
icon: 'i-heroicons-shield-check',
to: '/dashboard/admin',
});
}
// Return as nested array for proper spacing
return [items];
});
// Logout handler
const handleLogout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
await router.push('/login');
} catch (error) {
console.error('Logout error:', error);
// Even if logout fails, redirect to login
await router.push('/login');
}
};
// Initialize sidebar state based on screen size
onMounted(() => {
if (mdAndDown.value) {
sidebarOpen.value = false;
}
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,190 +0,0 @@
<template>
<v-app>
<!-- Navigation Drawer -->
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
color="grey-darken-4"
>
<v-list>
<v-list-item
prepend-avatar="/Port Nimara New Logo-Circular Frame.png"
:title="rail ? '' : 'Port Nimara'"
:subtitle="rail ? '' : 'Client Portal'"
nav
>
<template v-slot:append>
<v-btn
v-if="!rail"
icon="mdi-chevron-left"
variant="text"
@click.stop="rail = !rail"
size="small"
/>
</template>
</v-list-item>
</v-list>
<v-divider />
<v-list density="compact" nav>
<v-list-item
v-for="item in navigationItems"
:key="item.title"
:to="item.to"
:prepend-icon="item.icon"
:title="item.title"
:value="item.title"
color="primary"
/>
</v-list>
<template v-slot:append>
<v-list density="compact" nav>
<v-list-item
@click="handleLogout"
prepend-icon="mdi-logout"
title="Logout"
value="logout"
color="primary"
/>
</v-list>
</template>
</v-navigation-drawer>
<!-- App Bar -->
<v-app-bar
color="primary"
density="compact"
elevation="0"
>
<v-app-bar-nav-icon
@click="rail = !rail"
v-if="rail"
/>
<v-toolbar-title>{{ pageTitle }}</v-toolbar-title>
<v-spacer />
<v-chip
v-if="authState?.user"
class="mr-2"
color="white"
variant="tonal"
>
<v-icon start>mdi-account</v-icon>
{{ authState.user.name || authState.user.email }}
</v-chip>
<v-chip
v-if="authState?.groups?.includes('admin')"
color="orange"
variant="tonal"
class="mr-2"
>
Admin
</v-chip>
<v-chip
v-else-if="authState?.groups?.includes('sales')"
color="green"
variant="tonal"
class="mr-2"
>
Sales
</v-chip>
</v-app-bar>
<!-- Main Content -->
<v-main>
<slot />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const route = useRoute();
const router = useRouter();
const nuxtApp = useNuxtApp();
// Navigation state
const drawer = ref(true);
const rail = ref(false);
// Get auth state - with fallback to prevent errors
const authState = computed(() => {
const data = nuxtApp.payload?.data?.authState;
// Only return data if it's properly initialized
if (data && data.authenticated !== undefined) {
return data;
}
return null;
});
// Page title based on current route
const pageTitle = computed(() => {
const routeName = route.name as string;
if (routeName === 'dashboard') return 'Dashboard';
if (routeName === 'dashboard-expenses') return 'Expense Tracking';
if (routeName === 'dashboard-interest-list') return 'Interest List';
if (routeName === 'dashboard-berth-list') return 'Berth List';
if (routeName === 'dashboard-admin-audit-logs') return 'Audit Logs';
if (routeName === 'dashboard-admin-system-logs') return 'System Logs';
if (routeName === 'dashboard-admin-duplicates') return 'Duplicate Management';
// Default: capitalize route name
return routeName?.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ') || 'Dashboard';
});
// Navigation items based on user role
const navigationItems = computed(() => {
const items = [
{ title: 'Dashboard', icon: 'mdi-view-dashboard', to: '/dashboard' },
{ title: 'Interest List', icon: 'mdi-account-group', to: '/dashboard/interest-list' },
{ title: 'Berth List', icon: 'mdi-sailboat', to: '/dashboard/berth-list' },
];
// Add sales/admin specific items
if (authState.value?.groups?.includes('sales') || authState.value?.groups?.includes('admin')) {
items.push(
{ title: 'Expenses', icon: 'mdi-receipt', to: '/dashboard/expenses' },
{ title: 'Interest Status', icon: 'mdi-chart-timeline', to: '/dashboard/interest-status' },
{ title: 'Interest Emails', icon: 'mdi-email-multiple', to: '/dashboard/interest-emails' },
);
}
// Add admin-only items
if (authState.value?.groups?.includes('admin')) {
items.push(
{ title: 'Admin', icon: 'mdi-shield-crown', to: '/dashboard/admin' },
{ title: 'File Browser', icon: 'mdi-folder-open', to: '/dashboard/file-browser' },
);
}
return items;
});
// Logout handler
const handleLogout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
await router.push('/login');
} catch (error) {
console.error('Logout error:', error);
// Even if logout fails, redirect to login
await router.push('/login');
}
};
</script>
<style scoped>
.v-navigation-drawer {
top: 0 !important;
}
</style>