Files
policy-ui/app/layouts/default.vue
2026-04-29 16:25:11 -05:00

509 lines
19 KiB
Vue

<script setup lang="ts">
const route = useRoute()
const STORAGE_KEY_BRANDING = 'policy-ui.branding'
const STORAGE_KEY_SUPERADMIN = 'policy-ui.superadmin'
const STORAGE_KEY_SIDEBAR_WORKSTATIONS = 'policy-ui.sidebar.workstations'
const STORAGE_KEY_SIDEBAR_AI_TOOLS = 'policy-ui.sidebar.ai-tools'
const STORAGE_KEY_SIDEBAR_LEADS_HUB = 'policy-ui.sidebar.leads-hub'
const STORAGE_KEY_SIDEBAR_COLLAPSED = 'policy-ui.sidebar.collapsed'
interface BrokerageBrandingState {
companyName: string
logoDataUrl: string | null
logoFileName: string
reportPageHeader: string
reportPageFooter: string
}
function loadBranding(): BrokerageBrandingState {
if (import.meta.client) {
const stored = localStorage.getItem(STORAGE_KEY_BRANDING)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return defaultBranding()
}
}
}
return defaultBranding()
}
function defaultBranding(): BrokerageBrandingState {
return {
companyName: '',
logoDataUrl: null,
logoFileName: '',
reportPageHeader: '',
reportPageFooter: ''
}
}
const branding = ref<BrokerageBrandingState>(loadBranding())
const sidebarTitle = computed(() => branding.value.companyName || 'Segur-OS')
const isSuperAdmin = computed(() => {
if (import.meta.client) {
const stored = localStorage.getItem(STORAGE_KEY_SUPERADMIN)
return stored !== '0'
}
return true
})
const sidebarCollapsed = computed({
get: () => {
if (import.meta.client) {
return localStorage.getItem(STORAGE_KEY_SIDEBAR_COLLAPSED) === 'true'
}
return false
},
set: (value: boolean) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY_SIDEBAR_COLLAPSED, String(value))
}
}
})
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const sidebarFeatures = reactive({
showWorkstations: computed({
get: () => {
if (import.meta.client) {
return localStorage.getItem(STORAGE_KEY_SIDEBAR_WORKSTATIONS) !== 'false'
}
return true
},
set: (value: boolean) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY_SIDEBAR_WORKSTATIONS, String(value))
}
}
}),
showAiTools: computed({
get: () => {
if (import.meta.client) {
return localStorage.getItem(STORAGE_KEY_SIDEBAR_AI_TOOLS) !== 'false'
}
return true
},
set: (value: boolean) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY_SIDEBAR_AI_TOOLS, String(value))
}
}
}),
showLeadsHub: computed({
get: () => {
if (import.meta.client) {
return localStorage.getItem(STORAGE_KEY_SIDEBAR_LEADS_HUB) !== 'false'
}
return true
},
set: (value: boolean) => {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY_SIDEBAR_LEADS_HUB, String(value))
}
}
})
})
const openGroups = ref({
quotes: false,
sales: false,
cartera: false,
customerService: false,
workstation: false,
aiTools: false,
backOffice: false
})
watch(() => route.path, (p) => {
if (p.startsWith('/quotes') && p !== '/quotes/new' && p !== '/quotes/compare') openGroups.value.quotes = true
if (p.startsWith('/onboarding') || (p.startsWith('/sales') && !p.startsWith('/sales/leads')) || p === '/quotes/new' || p === '/quotes/compare' || p.startsWith('/registration')) openGroups.value.sales = true
if (p.startsWith('/customers') || p.startsWith('/policies') || p.startsWith('/cartera')) openGroups.value.cartera = true
if (p.startsWith('/support') || p.startsWith('/claims') || p.startsWith('/collections') || p.startsWith('/renewals') || p.startsWith('/sales/leads')) openGroups.value.customerService = true
if (p.startsWith('/workstation')) openGroups.value.workstation = true
if (p.startsWith('/ai-tools')) openGroups.value.aiTools = true
if (p.startsWith('/back-office')) openGroups.value.backOffice = true
}, { immediate: true })
function toggleGroup(key: string) {
openGroups.value[key] = !openGroups.value[key]
}
function isActive(path: string, exact = false) {
const p = route.path
return exact ? p === path : p === path || p.startsWith(`${path}/`)
}
function linkClass(path: string, exact = false) {
const active = isActive(path, exact)
return [
'app-sidebar-link sidebar-parent-link flex w-full items-center',
active
? 'app-sidebar-link-active'
: 'sidebar-link-inactive'
]
}
function subLinkClass(path: string, exact = false) {
const active = isActive(path, exact)
return [
'app-sidebar-link sidebar-child-link flex w-full items-center',
active
? 'app-sidebar-child-active'
: 'sidebar-link-inactive'
]
}
const isSettingsRoute = computed(() => route.path.startsWith('/settings'))
function groupBtnClass(key: string) {
const hasActive =
(key === 'quotes' && ((route.path.startsWith('/quotes') && route.path !== '/quotes/new' && route.path !== '/quotes/compare'))) ||
(key === 'sales' && (route.path.startsWith('/onboarding') || (route.path.startsWith('/sales') && !route.path.startsWith('/sales/leads')) || route.path === '/quotes/new' || route.path === '/quotes/compare' || route.path.startsWith('/registration'))) ||
(key === 'cartera' &&
(route.path.startsWith('/customers') ||
route.path.startsWith('/policies') ||
route.path.startsWith('/cartera'))) ||
(key === 'customerService' &&
(route.path.startsWith('/support') ||
route.path.startsWith('/claims') ||
route.path.startsWith('/collections') ||
route.path.startsWith('/renewals') ||
route.path.startsWith('/sales/leads'))) ||
(key === 'workstation' && route.path.startsWith('/workstation')) ||
(key === 'aiTools' && route.path.startsWith('/ai-tools')) ||
(key === 'backOffice' && route.path.startsWith('/back-office'))
return [
'app-sidebar-link sidebar-parent-link flex w-full items-center text-left',
hasActive
? 'sidebar-parent-active'
: 'sidebar-link-inactive'
]
}
async function onTopRefresh() {
try {
await refreshNuxtData()
} catch {
if (import.meta.client) window.location.reload()
}
}
function onKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
e.preventDefault()
toggleSidebar()
}
}
onMounted(() => {
if (import.meta.client) window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
if (import.meta.client) window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div class="flex h-screen flex-col overflow-hidden" style="background: var(--page-bg); color: var(--text-primary);">
<LayoutAppTopBar
:sidebar-collapsed="sidebarCollapsed"
:brand-title="sidebarTitle"
@toggle-sidebar="toggleSidebar"
@refresh="onTopRefresh"
/>
<div class="flex min-h-0 flex-1">
<!-- Sidebar -->
<aside
class="app-sidebar hidden min-h-0 flex-col md:flex md:shrink-0"
:class="
sidebarCollapsed
? 'md:w-0 md:min-w-0 md:max-w-0 md:overflow-hidden md:opacity-0'
: 'sidebar-open'
"
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
>
<!-- Navigation -->
<nav class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden" style="padding: 8px 12px 4px;">
<!-- Dashboard standalone (shrink-0 prevents compression when groups expand) -->
<NuxtLink to="/" :class="[...linkClass('/', true), 'shrink-0']">
<UIcon name="i-heroicons-squares-2x2" class="sidebar-icon shrink-0" />
<span>My Dashboard</span>
</NuxtLink>
<NuxtLink to="/calendar" :class="[...linkClass('/calendar'), 'shrink-0']">
<UIcon name="i-heroicons-calendar-days" class="sidebar-icon shrink-0" />
<span>Calendar</span>
</NuxtLink>
<!-- BUSINESS section -->
<p class="app-sidebar-section-label">Sales</p>
<div class="flex flex-col">
<button type="button" :class="groupBtnClass('quotes')" @click="toggleGroup('quotes')">
<UIcon name="i-heroicons-calculator" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Quotes</span>
<UIcon
:name="openGroups.quotes ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.quotes" class="sidebar-children">
<NuxtLink to="/quotes/new?tab=car" :class="subLinkClass('/quotes/new', true)">Auto</NuxtLink>
<NuxtLink to="/quotes/new?tab=life" :class="subLinkClass('/quotes/new')">Life</NuxtLink>
<NuxtLink to="/quotes/new?tab=fire_structure" :class="subLinkClass('/quotes/new')">Fire Structure</NuxtLink>
<NuxtLink to="/quotes/new?tab=fire_contents" :class="subLinkClass('/quotes/new')">Fire Contents</NuxtLink>
</div>
<button type="button" :class="groupBtnClass('sales')" @click="toggleGroup('sales')">
<UIcon name="i-heroicons-funnel" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Sales</span>
<UIcon
:name="openGroups.sales ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.sales" class="sidebar-children">
<NuxtLink to="/onboarding" :class="subLinkClass('/onboarding', true)">Sales Pipeline</NuxtLink>
<NuxtLink to="/sales/quick-lead" :class="subLinkClass('/sales/quick-lead')">Quick Lead</NuxtLink>
<NuxtLink to="/customers/new" :class="subLinkClass('/customers/new', true)">New Customer</NuxtLink>
<NuxtLink to="/quotes/new" :class="subLinkClass('/quotes/new', true)">New Quote</NuxtLink>
</div>
</div>
<!-- OPERATIONS section -->
<p class="app-sidebar-section-label">Operations</p>
<div class="flex flex-col">
<button type="button" :class="groupBtnClass('cartera')" @click="toggleGroup('cartera')">
<UIcon name="i-heroicons-briefcase" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Cartera</span>
<UIcon
:name="openGroups.cartera ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.cartera" class="sidebar-children">
<NuxtLink to="/customers" :class="subLinkClass('/customers', true)">Customers</NuxtLink>
<NuxtLink to="/policies" :class="subLinkClass('/policies', true)">Policies</NuxtLink>
<NuxtLink to="/policies/groups" :class="subLinkClass('/policies/groups')">Collectivos</NuxtLink>
</div>
<button type="button" :class="groupBtnClass('customerService')" @click="toggleGroup('customerService')">
<UIcon name="i-heroicons-lifebuoy" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Customer Service</span>
<UIcon
:name="openGroups.customerService ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.customerService" class="sidebar-children">
<NuxtLink v-if="sidebarFeatures.showLeadsHub" to="/sales/leads" :class="subLinkClass('/sales/leads')">Incoming Leads</NuxtLink>
<NuxtLink to="/support" :class="subLinkClass('/support', true)">Incoming Support</NuxtLink>
<NuxtLink to="/claims" :class="subLinkClass('/claims')">Claims</NuxtLink>
<NuxtLink to="/collections" :class="subLinkClass('/collections')">Collections</NuxtLink>
<NuxtLink to="/renewals" :class="subLinkClass('/renewals')">Renewals</NuxtLink>
<NuxtLink to="/support/collectivos" :class="subLinkClass('/support/collectivos')">Collectivos</NuxtLink>
</div>
<NuxtLink to="/analysis" :class="linkClass('/analysis')">
<UIcon name="i-heroicons-chart-bar-square" class="sidebar-icon shrink-0" />
Reports & Analysis
</NuxtLink>
</div>
<!-- BACK OFFICE section -->
<p class="app-sidebar-section-label">Back Office</p>
<div class="flex flex-col">
<button type="button" :class="groupBtnClass('backOffice')" @click="toggleGroup('backOffice')">
<UIcon name="i-heroicons-inbox-stack" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Back Office</span>
<UIcon
:name="openGroups.backOffice ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.backOffice" class="sidebar-children">
<NuxtLink to="/back-office/workload" :class="subLinkClass('/back-office/workload', true)">Task List</NuxtLink>
<NuxtLink to="/back-office/workload/kanban" :class="subLinkClass('/back-office/workload/kanban')">Kanban Board</NuxtLink>
</div>
</div>
<!-- WORKSTATION section -->
<template v-if="sidebarFeatures.showWorkstations || sidebarFeatures.showAiTools">
<p class="app-sidebar-section-label">Workstations</p>
<div class="flex flex-col">
<template v-if="sidebarFeatures.showWorkstations">
<button type="button" :class="groupBtnClass('workstation')" @click="toggleGroup('workstation')">
<UIcon name="i-heroicons-inbox-stack" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">Workstations</span>
<UIcon
:name="openGroups.workstation ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.workstation" class="sidebar-children">
<NuxtLink to="/workstation/collectivos" :class="subLinkClass('/workstation/collectivos')">Collectivos</NuxtLink>
<NuxtLink to="/workstation/collections" :class="subLinkClass('/workstation/collections')">Collections</NuxtLink>
<NuxtLink to="/workstation/claims" :class="subLinkClass('/workstation/claims')">Claims</NuxtLink>
<NuxtLink to="/workstation/renewals" :class="subLinkClass('/workstation/renewals')">Renewals</NuxtLink>
<NuxtLink to="/workstation/customer-service" :class="subLinkClass('/workstation/customer-service')">Customer Service</NuxtLink>
<NuxtLink to="/workstation/facturacion" :class="subLinkClass('/workstation/facturacion')">Facturación y Comisiones</NuxtLink>
</div>
</template>
<template v-if="sidebarFeatures.showAiTools">
<button type="button" :class="groupBtnClass('aiTools')" @click="toggleGroup('aiTools')">
<UIcon name="i-heroicons-sparkles" class="sidebar-icon shrink-0" />
<span class="flex-1 text-left">AI Tools</span>
<UIcon
:name="openGroups.aiTools ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
class="sidebar-chevron shrink-0"
/>
</button>
<div v-if="openGroups.aiTools" class="sidebar-children">
<NuxtLink to="/ai-tools/sales-factory" :class="subLinkClass('/ai-tools/sales-factory')">Sales Factory</NuxtLink>
<NuxtLink to="/ai-tools/policy-comparator" :class="subLinkClass('/ai-tools/policy-comparator')">Policy Comparator</NuxtLink>
<NuxtLink to="/ai-tools/email-writer" :class="subLinkClass('/ai-tools/email-writer')">Email Writer</NuxtLink>
<NuxtLink to="/ai-tools/case-assistant" :class="subLinkClass('/ai-tools/case-assistant')">Case Assistant</NuxtLink>
</div>
</template>
</div>
</template>
<!-- Footer zone -->
<div class="mt-auto" style="padding-top: 8px;">
<div class="sidebar-footer-divider" />
<NuxtLink to="/settings" :class="linkClass('/settings')" class="sidebar-footer-link">
<UIcon name="i-heroicons-cog-6-tooth" class="sidebar-icon shrink-0" style="width: 16px; height: 16px;" />
<span>Settings</span>
</NuxtLink>
</div>
</nav>
<!-- Collapse sidebar pinned outside scrollable nav -->
<div style="padding: 4px 12px 8px;">
<button
type="button"
class="app-sidebar-link sidebar-footer-link flex w-full items-center sidebar-link-inactive"
title="Hide sidebar (Ctrl+B)"
@click="toggleSidebar"
>
<UIcon name="i-heroicons-chevron-double-left" class="sidebar-icon shrink-0" style="width: 16px; height: 16px;" />
<span>Hide sidebar</span>
</button>
</div>
</aside>
<!-- Main content -->
<main
class="flex min-h-0 min-w-0 flex-1 flex-col"
:data-app-surface="isSettingsRoute ? 'settings' : undefined"
>
<div class="flex-1 overflow-y-auto flex flex-col" style="padding: 16px 24px 32px;">
<NuxtPage :key="route.fullPath" />
</div>
</main>
</div>
</div>
</template>
<style scoped>
/* ── Sidebar shell ── */
.app-sidebar {
background: var(--sidebar-bg);
box-shadow: 1px 0 0 0 rgba(0, 0, 0, 0.04);
transition: width 200ms ease, opacity 200ms ease;
}
.sidebar-open {
width: 252px;
opacity: 1;
}
/* ── Parent link row: 40px height, 20px icon, 10px gap ── */
.sidebar-parent-link {
height: 40px;
min-height: 40px;
flex-shrink: 0;
padding: 0 8px;
gap: 10px;
font-size: 14px;
border-radius: 8px;
}
/* ── Parent icon: 20px, outline variant at 0.5 opacity ── */
.sidebar-icon {
width: 20px;
height: 20px;
opacity: 0.5;
}
/* Active parent icon: slightly elevated but not full */
.app-sidebar-link-active .sidebar-icon {
opacity: 0.7;
}
/* ── Chevron: 14px ── */
.sidebar-chevron {
width: 14px;
height: 14px;
color: #c0c0bc;
transition: transform 150ms ease;
}
/* ── Inactive link: text-secondary, no color change on hover ── */
.sidebar-link-inactive {
color: #6b6b68;
}
/* ── Parent with active child: text-primary, font-medium ── */
.sidebar-parent-active {
font-weight: 500;
color: var(--text-primary);
}
/* ── Child items: text only, no icons, indented 36px ── */
.sidebar-children {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 38px;
padding-top: 4px;
padding-bottom: 4px;
}
/* ── Child link: 32px row, 13px font ── */
.sidebar-child-link {
height: 32px;
padding: 0 8px;
font-size: 13px;
color: #6b6b68;
border-radius: 8px;
}
/* ── Footer divider ── */
.sidebar-footer-divider {
height: 1px;
background: rgba(0, 0, 0, 0.06);
margin: 0 16px 8px;
}
/* ── Footer links ── */
.sidebar-footer-link {
height: 36px;
padding: 0 8px;
gap: 10px;
font-size: 13px;
color: #6b6b68 !important;
}
.sidebar-footer-link .sidebar-icon {
opacity: 0.6;
}
</style>