499 lines
17 KiB
Vue
499 lines
17 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({ ssr: false })
|
|
usePageTitle('Profile Layouts · Settings')
|
|
|
|
interface ProfileSection {
|
|
id: string
|
|
label: string
|
|
visible: boolean
|
|
order: number
|
|
}
|
|
|
|
interface ProfileLayout {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
icon: string
|
|
isCustom: boolean
|
|
defaultTab: string
|
|
sections: ProfileSection[]
|
|
}
|
|
|
|
const STORAGE_KEY = 'policy-ui.profile-layouts'
|
|
|
|
function loadLayouts(): ProfileLayout[] {
|
|
if (import.meta.client) {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
try {
|
|
return JSON.parse(stored)
|
|
} catch {
|
|
return defaultLayouts()
|
|
}
|
|
}
|
|
}
|
|
return defaultLayouts()
|
|
}
|
|
|
|
function defaultLayouts(): ProfileLayout[] {
|
|
return [
|
|
{
|
|
id: 'agent',
|
|
name: 'Agent',
|
|
description: 'For producers and account executives',
|
|
icon: 'i-heroicons-user',
|
|
isCustom: false,
|
|
defaultTab: 'policies',
|
|
sections: [
|
|
{ id: 'overview', label: 'Overview', visible: true, order: 1 },
|
|
{ id: 'policies', label: 'Policies', visible: true, order: 2 },
|
|
{ id: 'quotes', label: 'Quotes', visible: true, order: 3 },
|
|
{ id: 'claims', label: 'Claims', visible: true, order: 4 },
|
|
{ id: 'billing', label: 'Billing', visible: true, order: 5 },
|
|
{ id: 'documents', label: 'Documents', visible: true, order: 6 },
|
|
]
|
|
},
|
|
{
|
|
id: 'manager',
|
|
name: 'Manager',
|
|
description: 'For team leads and supervisors',
|
|
icon: 'i-heroicons-users',
|
|
isCustom: false,
|
|
defaultTab: 'team',
|
|
sections: [
|
|
{ id: 'overview', label: 'Overview', visible: true, order: 1 },
|
|
{ id: 'team', label: 'Team', visible: true, order: 2 },
|
|
{ id: 'performance', label: 'Performance', visible: true, order: 3 },
|
|
{ id: 'policies', label: 'Policies', visible: true, order: 4 },
|
|
{ id: 'reports', label: 'Reports', visible: true, order: 5 },
|
|
]
|
|
},
|
|
{
|
|
id: 'admin',
|
|
name: 'Admin',
|
|
description: 'For administrators and operations',
|
|
icon: 'i-heroicons-shield-check',
|
|
isCustom: false,
|
|
defaultTab: 'overview',
|
|
sections: [
|
|
{ id: 'overview', label: 'Overview', visible: true, order: 1 },
|
|
{ id: 'users', label: 'Users', visible: true, order: 2 },
|
|
{ id: 'settings', label: 'Settings', visible: true, order: 3 },
|
|
{ id: 'audit', label: 'Audit Log', visible: true, order: 4 },
|
|
{ id: 'integrations', label: 'Integrations', visible: true, order: 5 },
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
const layouts = ref<ProfileLayout[]>(loadLayouts())
|
|
const activeLayoutId = ref('agent')
|
|
|
|
function saveLayouts() {
|
|
if (import.meta.client) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts.value))
|
|
}
|
|
}
|
|
|
|
const activeLayout = computed(() => layouts.value.find(l => l.id === activeLayoutId.value) || layouts.value[0])
|
|
|
|
const sortedSections = computed(() => {
|
|
if (!activeLayout.value) return []
|
|
return [...activeLayout.value.sections].sort((a, b) => a.order - b.order)
|
|
})
|
|
|
|
function setActiveLayout(id: string) {
|
|
activeLayoutId.value = id
|
|
}
|
|
|
|
function updateLayout(id: string, updates: Partial<ProfileLayout>) {
|
|
const idx = layouts.value.findIndex(l => l.id === id)
|
|
if (idx !== -1) {
|
|
layouts.value[idx] = { ...layouts.value[idx], ...updates }
|
|
saveLayouts()
|
|
}
|
|
}
|
|
|
|
function removeCustomLayout(id: string) {
|
|
layouts.value = layouts.value.filter(l => l.id !== id)
|
|
saveLayouts()
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
layouts.value = defaultLayouts()
|
|
activeLayoutId.value = 'agent'
|
|
saveLayouts()
|
|
}
|
|
|
|
const builtInLayouts = computed(() => layouts.value.filter(l => !l.isCustom))
|
|
const customLayouts = computed(() => layouts.value.filter(l => l.isCustom))
|
|
|
|
function toggleSectionVisibility(sectionId: string) {
|
|
if (!activeLayout.value) return
|
|
const updated = activeLayout.value.sections.map(s =>
|
|
s.id === sectionId ? { ...s, visible: !s.visible } : s
|
|
)
|
|
updateLayout(activeLayout.value.id, { sections: updated })
|
|
}
|
|
|
|
function moveSection(sectionId: string, direction: 'up' | 'down') {
|
|
if (!activeLayout.value) return
|
|
const sections = [...activeLayout.value.sections].sort((a, b) => a.order - b.order)
|
|
const idx = sections.findIndex(s => s.id === sectionId)
|
|
if (idx < 0) return
|
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
|
|
if (swapIdx < 0 || swapIdx >= sections.length) return
|
|
|
|
const tempOrder = sections[idx].order
|
|
sections[idx] = { ...sections[idx], order: sections[swapIdx].order }
|
|
sections[swapIdx] = { ...sections[swapIdx], order: tempOrder }
|
|
|
|
updateLayout(activeLayout.value.id, { sections })
|
|
}
|
|
|
|
const orderedSections = computed(() => {
|
|
if (!activeLayout.value) return []
|
|
return [...activeLayout.value.sections].sort((a, b) => a.order - b.order)
|
|
})
|
|
|
|
function handleDelete(id: string) {
|
|
removeCustomLayout(id)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="pl-page">
|
|
<!-- Back -->
|
|
<NuxtLink to="/settings" class="inline-flex">
|
|
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
|
|
</NuxtLink>
|
|
|
|
<!-- Header -->
|
|
<div class="max-w-xl">
|
|
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Profile Layouts</h1>
|
|
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
|
Configure which sections appear and in what order based on your role.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Dev note -->
|
|
<div class="pl-dev-note">
|
|
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; flex-shrink: 0;" />
|
|
<div>
|
|
<p class="pl-dev-note-title">In development</p>
|
|
<p class="pl-dev-note-text">
|
|
Layout preferences are stored locally. Once the API is ready, layouts will sync across devices and can be assigned organization-wide by role.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════ Active Layout Selector ═══════════════ -->
|
|
<section>
|
|
<p class="pl-label">Active layout</p>
|
|
<div class="pl-layout-grid">
|
|
<button
|
|
v-for="layout in builtInLayouts"
|
|
:key="layout.id"
|
|
type="button"
|
|
class="pl-layout-card"
|
|
:class="activeLayoutId === layout.id ? 'pl-layout-active' : 'pl-layout-inactive'"
|
|
@click="setActiveLayout(layout.id)"
|
|
>
|
|
<div class="pl-layout-card-inner">
|
|
<div class="pl-layout-icon" :class="activeLayoutId === layout.id ? 'pl-icon-active' : 'pl-icon-inactive'">
|
|
<UIcon :name="layout.icon" style="width: 20px; height: 20px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<p class="pl-layout-name">{{ layout.name }}</p>
|
|
<UIcon
|
|
v-if="activeLayoutId === layout.id"
|
|
name="i-heroicons-check-circle-solid"
|
|
class="pl-check-icon"
|
|
/>
|
|
</div>
|
|
<p class="pl-layout-desc">{{ layout.description }}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══════════════ Section Order ═══════════════ -->
|
|
<section>
|
|
<p class="pl-label">Section order · {{ activeLayout.name }}</p>
|
|
<p class="pl-sublabel">Reorder sections and toggle visibility for the active layout.</p>
|
|
<div class="pl-section-list">
|
|
<div
|
|
v-for="(section, idx) in orderedSections"
|
|
:key="section.id"
|
|
class="pl-section-row"
|
|
:class="{ 'pl-section-hidden': !section.visible }"
|
|
>
|
|
<div class="pl-drag-handle" title="Drag to reorder (coming soon)">
|
|
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
|
|
</div>
|
|
|
|
<span class="pl-section-order">{{ idx + 1 }}</span>
|
|
|
|
<span class="pl-section-label" :class="{ 'pl-section-label-hidden': !section.visible }">
|
|
{{ section.label }}
|
|
</span>
|
|
|
|
<div class="pl-section-spacer" />
|
|
|
|
<div class="pl-section-arrows">
|
|
<button
|
|
type="button"
|
|
class="pl-arrow-btn"
|
|
:disabled="idx === 0"
|
|
title="Move up"
|
|
@click="moveSection(section.id, 'up')"
|
|
>
|
|
<UIcon name="i-heroicons-chevron-up" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-arrow-btn"
|
|
:disabled="idx === orderedSections.length - 1"
|
|
title="Move down"
|
|
@click="moveSection(section.id, 'down')"
|
|
>
|
|
<UIcon name="i-heroicons-chevron-down" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="pl-toggle"
|
|
:class="section.visible ? 'pl-toggle-on' : 'pl-toggle-off'"
|
|
:title="section.visible ? 'Hide section' : 'Show section'"
|
|
@click="toggleSectionVisibility(section.id)"
|
|
>
|
|
<span class="pl-toggle-dot" :class="section.visible ? 'pl-dot-on' : 'pl-dot-off'" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="pl-hint">Default tab: <strong>{{ activeLayout.defaultTab }}</strong></p>
|
|
</section>
|
|
|
|
<!-- ═══════════════ Custom Layouts ═══════════════ -->
|
|
<section>
|
|
<p class="pl-label">Custom layouts</p>
|
|
<p class="pl-sublabel">Create personalized layouts beyond the built-in role presets.</p>
|
|
|
|
<div v-if="customLayouts.length" class="pl-section-list">
|
|
<div
|
|
v-for="layout in customLayouts"
|
|
:key="layout.id"
|
|
class="pl-custom-row"
|
|
>
|
|
<div class="pl-layout-icon pl-icon-inactive" style="width: 32px; height: 32px;">
|
|
<UIcon :name="layout.icon" style="width: 16px; height: 16px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-[13px] font-semibold text-[var(--text-primary)]">{{ layout.name }}</p>
|
|
<p class="text-[11px] text-[var(--text-muted)]">{{ layout.description }}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="pl-action-btn"
|
|
title="Use this layout"
|
|
@click="setActiveLayout(layout.id)"
|
|
>
|
|
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="pl-action-btn pl-action-delete"
|
|
title="Delete custom layout"
|
|
@click="handleDelete(layout.id)"
|
|
>
|
|
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="pl-empty">
|
|
<p>No custom layouts yet.</p>
|
|
</div>
|
|
|
|
<button type="button" class="pl-btn-primary" style="margin-top: 12px;" disabled>
|
|
<UIcon name="i-heroicons-plus" style="width: 16px; height: 16px;" />
|
|
Create Custom Layout
|
|
</button>
|
|
<p class="pl-hint" style="margin-top: 6px;">Custom layout creation coming soon.</p>
|
|
</section>
|
|
|
|
<!-- Reset -->
|
|
<section class="pl-reset-section">
|
|
<button type="button" class="pl-btn-ghost" @click="resetToDefaults">
|
|
<UIcon name="i-heroicons-arrow-uturn-left" style="width: 14px; height: 14px;" />
|
|
Reset all layouts to defaults
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Page shell ── */
|
|
.pl-page {
|
|
max-width: 56rem;
|
|
margin: 0 auto;
|
|
display: flex; flex-direction: column; gap: 28px;
|
|
}
|
|
|
|
/* ── Dev note ── */
|
|
.pl-dev-note {
|
|
display: flex; gap: 10px; align-items: flex-start;
|
|
padding: 12px 16px; border-radius: 10px;
|
|
background: rgba(124, 58, 237, 0.06); color: #7c3aed;
|
|
}
|
|
.pl-dev-note-title { font-size: 12px; font-weight: 700; }
|
|
.pl-dev-note-text { font-size: 12px; line-height: 1.5; opacity: 0.85; }
|
|
|
|
/* ── Labels ── */
|
|
.pl-label {
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 10px;
|
|
}
|
|
.pl-sublabel {
|
|
font-size: 13px; color: var(--text-muted); margin-top: -4px; margin-bottom: 12px;
|
|
}
|
|
.pl-hint {
|
|
font-size: 12px; color: var(--text-muted); margin-top: 8px;
|
|
}
|
|
|
|
/* ── Layout selector grid ── */
|
|
.pl-layout-grid {
|
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px;
|
|
}
|
|
.pl-layout-card {
|
|
border-radius: 12px; padding: 14px; cursor: pointer;
|
|
border: 1.5px solid transparent; background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
transition: all 150ms ease; text-align: left;
|
|
}
|
|
.pl-layout-inactive {
|
|
border-color: rgba(0,0,0,0.06);
|
|
}
|
|
.pl-layout-inactive:hover {
|
|
border-color: rgba(0,0,0,0.12);
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
|
}
|
|
.pl-layout-active {
|
|
border-color: #01696f;
|
|
box-shadow: 0 0 0 1px #01696f, 0 2px 8px rgba(1,105,111,0.10);
|
|
}
|
|
.pl-layout-card-inner {
|
|
display: flex; align-items: flex-start; gap: 10px;
|
|
}
|
|
.pl-layout-icon {
|
|
width: 40px; height: 40px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.pl-icon-active { background: rgba(1,105,111,0.10); color: #01696f; }
|
|
.pl-icon-inactive { background: rgba(0,0,0,0.04); color: #8a8a86; }
|
|
.pl-layout-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
|
.pl-layout-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.4; }
|
|
.pl-check-icon { width: 16px; height: 16px; color: #01696f; flex-shrink: 0; }
|
|
|
|
/* ── Section list ── */
|
|
.pl-section-list {
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
}
|
|
.pl-section-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; border-radius: 10px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
transition: background 150ms ease;
|
|
}
|
|
.pl-section-row:hover { background: rgba(0,0,0,0.01); }
|
|
.pl-section-hidden { opacity: 0.5; }
|
|
|
|
.pl-drag-handle {
|
|
color: #c4c4c0; cursor: grab; display: flex; align-items: center;
|
|
}
|
|
.pl-section-order {
|
|
font-size: 11px; font-weight: 700; color: #8a8a86;
|
|
width: 20px; text-align: center; font-variant-numeric: tabular-nums;
|
|
}
|
|
.pl-section-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
|
.pl-section-label-hidden { text-decoration: line-through; color: var(--text-muted); }
|
|
.pl-section-spacer { flex: 1; }
|
|
|
|
/* ── Arrows ── */
|
|
.pl-section-arrows { display: flex; flex-direction: column; gap: 1px; }
|
|
.pl-arrow-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 22px; height: 18px; border-radius: 4px; border: none; cursor: pointer;
|
|
background: transparent; color: #8a8a86; transition: all 120ms ease;
|
|
}
|
|
.pl-arrow-btn:hover:not(:disabled) { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
|
.pl-arrow-btn:disabled { opacity: 0.25; cursor: not-allowed; }
|
|
|
|
/* ── Toggle ── */
|
|
.pl-toggle {
|
|
width: 36px; height: 20px; border-radius: 10px; border: none;
|
|
cursor: pointer; position: relative; transition: background 150ms ease; flex-shrink: 0;
|
|
}
|
|
.pl-toggle-on { background: #01696f; }
|
|
.pl-toggle-off { background: rgba(0,0,0,0.12); }
|
|
.pl-toggle-dot {
|
|
display: block; width: 16px; height: 16px; border-radius: 8px;
|
|
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
|
|
}
|
|
.pl-dot-on { left: 18px; }
|
|
.pl-dot-off { left: 2px; }
|
|
|
|
/* ── Custom layout rows ── */
|
|
.pl-custom-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; border-radius: 10px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
}
|
|
.pl-action-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
|
|
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
|
|
}
|
|
.pl-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
|
.pl-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
|
|
|
|
/* ── Empty state ── */
|
|
.pl-empty {
|
|
padding: 20px; border-radius: 10px; text-align: center;
|
|
border: 1px dashed rgba(0,0,0,0.10); color: var(--text-muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.pl-btn-primary {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 8px;
|
|
background: #01696f; color: #fff;
|
|
font-size: 13px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.pl-btn-primary:hover:not(:disabled) { background: #015458; }
|
|
.pl-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.pl-btn-ghost {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 6px 12px; border-radius: 6px;
|
|
background: transparent; color: var(--text-muted);
|
|
font-size: 12px; font-weight: 500; border: 1px solid rgba(0,0,0,0.08);
|
|
cursor: pointer; transition: all 150ms ease;
|
|
}
|
|
.pl-btn-ghost:hover { background: rgba(0,0,0,0.03); color: var(--text-primary); }
|
|
|
|
/* ── Reset section ── */
|
|
.pl-reset-section {
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(0,0,0,0.06);
|
|
}
|
|
</style>
|