Files
KArtSell.Aegis/frontend/src/shared/shell/KsSidebar.vue
T

441 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAnimatedCollapse } from '../composables/useAnimatedCollapse'
interface MenuItem {
id: string
label: string
icon: string
path: string
children?: MenuItem[]
}
const props = defineProps<{
collapsed?: boolean
mobileOpen?: boolean
}>()
const emit = defineEmits<{
'update:collapsed': [value: boolean]
'close-mobile': []
}>()
const router = useRouter()
const route = useRoute()
const expandedSections = ref(new Set<string>())
const menuItems: MenuItem[] = [
{
id: 'dashboard',
label: '대시보드 (홈)',
icon: '📊',
path: '/home',
},
{
id: 'shadow-run',
label: 'Shadow Run',
icon: '⚡',
path: '/model-ops/shadow-run-jobs',
children: [
{ id: 'sr-queue', label: '작업 큐', icon: '📋', path: '/model-ops/shadow-run-jobs' },
{ id: 'sr-history', label: '실행 검증', icon: '📜', path: '/model-ops/shadow-runs' },
{ id: 'sr-settings', label: '데이터 품질', icon: '⚙️', path: '/ops/data-quality' },
],
},
{
id: 'models',
label: '모델 관리',
icon: '🤖',
path: '/model-ops/models-master',
children: [
{ id: 'models-list', label: '모델 마스터-상세', icon: '📂', path: '/model-ops/models-master' },
{ id: 'models-ops', label: '모델 버전 거버넌스', icon: '🔍', path: '/ops/model-operations' },
{ id: 'models-compare', label: '모델 관리', icon: '⚖️', path: '/model-ops/models' },
],
},
{
id: 'approval',
label: '승인 워크벤치',
icon: '✅',
path: '/governance/approvals',
children: [
{ id: 'apr-queue', label: '승인 큐', icon: '📋', path: '/governance/approvals' },
{ id: 'apr-sell', label: '매도 의사결정', icon: '💵', path: '/research/sell-decision' },
],
},
{
id: 'portfolio',
label: '포트폴리오',
icon: '📈',
path: '/portfolio/risk',
children: [
{ id: 'port-risk', label: '리스크 대시보드', icon: '🛡️', path: '/portfolio/risk' },
{ id: 'port-rebalance', label: '리밸런싱 제안', icon: '⚖️', path: '/portfolio/rebalance' },
{ id: 'market-data', label: '시장 데이터 수집', icon: '💾', path: '/ops/market-data-ingestion' },
],
},
{
id: 'ui-catalog',
label: 'UI 컴포넌트 갤러리',
icon: '🎨',
path: '/internal/ui-standard',
},
]
const isActive = (path: string) => route.path === path
const toggleSection = (itemId: string) => {
if (expandedSections.value.has(itemId)) {
expandedSections.value.delete(itemId)
} else {
expandedSections.value.add(itemId)
}
}
const isSectionExpanded = (itemId: string) => expandedSections.value.has(itemId)
const handleMenuClick = (path: string) => {
router.push(path)
if (props.mobileOpen) {
emit('close-mobile')
}
}
const handleToggleCollapse = () => {
emit('update:collapsed', !props.collapsed)
}
const sidebarClass = computed(() => ({
'ks-sidebar--collapsed': props.collapsed,
'ks-sidebar--mobile-open': props.mobileOpen,
}))
</script>
<template>
<aside aria-label="주요 메뉴" :class="['ks-sidebar', sidebarClass]">
<!-- Header with collapse button -->
<div class="ks-sidebar__header">
<button
type="button"
class="ks-sidebar__collapse-btn"
:aria-label="collapsed ? '사이드바 펴기' : '사이드바 접기'"
@click="handleToggleCollapse"
>
{{ collapsed ? '' : '' }}
</button>
</div>
<!-- Menu items -->
<nav class="ks-sidebar__nav">
<div v-for="item in menuItems" :key="item.id" class="ks-sidebar__section">
<!-- Main item (or section header if has children) -->
<button
type="button"
:class="[
'ks-sidebar__item',
{
'ks-sidebar__item--active': isActive(item.path) || (item.children?.some(child => isActive(child.path))),
'ks-sidebar__item--has-children': item.children,
},
]"
:title="item.label"
@click="item.children ? toggleSection(item.id) : handleMenuClick(item.path)"
>
<span class="ks-sidebar__icon">{{ item.icon }}</span>
<span class="ks-sidebar__label">{{ item.label }}</span>
<span v-if="item.children" class="ks-sidebar__expand-icon">
{{ isSectionExpanded(item.id) ? '▼' : '▶' }}
</span>
</button>
<!-- Sub items -->
<div
v-if="item.children && isSectionExpanded(item.id)"
class="ks-sidebar__children"
>
<button
v-for="child in item.children"
:key="child.id"
type="button"
:class="[
'ks-sidebar__item',
'ks-sidebar__item--child',
{ 'ks-sidebar__item--active': isActive(child.path) },
]"
:title="child.label"
@click="handleMenuClick(child.path)"
>
<span class="ks-sidebar__icon">{{ child.icon }}</span>
<span class="ks-sidebar__label">{{ child.label }}</span>
</button>
</div>
</div>
</nav>
<!-- Sidebar footer -->
<div class="ks-sidebar__footer">
<button
type="button"
class="ks-sidebar__footer-btn"
title="도움말"
>
</button>
<button
type="button"
class="ks-sidebar__footer-btn"
title="설정"
>
⚙️
</button>
</div>
</aside>
</template>
<style scoped>
.ks-sidebar {
width: 280px;
background-color: var(--color-background-secondary);
border-right: 1px solid var(--color-border-primary);
display: flex;
flex-direction: column;
transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
overflow-x: hidden;
}
.ks-sidebar--collapsed {
width: 80px;
}
.ks-sidebar__header {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border-primary);
display: flex;
justify-content: flex-end;
}
.ks-sidebar__collapse-btn {
background-color: var(--color-background-primary);
border: 1px solid var(--color-border-primary);
font-size: 24px;
cursor: pointer;
color: var(--color-text-primary);
padding: var(--spacing-1);
border-radius: var(--border-radius-base);
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.ks-sidebar__collapse-btn:hover {
background-color: var(--color-background-hover);
border-color: var(--color-border-secondary);
}
.ks-sidebar__collapse-btn:active {
background-color: var(--color-background-active);
}
.ks-sidebar__nav {
flex: 1;
padding: var(--spacing-2) 0;
overflow-y: auto;
}
.ks-sidebar__section {
display: flex;
flex-direction: column;
}
.ks-sidebar__item {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-2) var(--spacing-3);
background-color: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-base);
text-align: left;
font-family: inherit;
font-size: var(--font-size-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.ks-sidebar__item:hover {
background-color: var(--color-background-hover);
color: var(--color-text-primary);
}
.ks-sidebar__item:active {
background-color: var(--color-background-active);
}
.ks-sidebar__item--active {
background-color: var(--color-primary-100);
color: var(--color-primary-600);
font-weight: var(--font-weight-semibold);
border-left: 3px solid var(--color-primary-500);
padding-left: calc(var(--spacing-3) - 3px);
}
.ks-sidebar__item--has-children {
justify-content: space-between;
}
.ks-sidebar__icon {
font-size: 18px;
flex-shrink: 0;
}
.ks-sidebar__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.ks-sidebar__expand-icon {
font-size: 12px;
flex-shrink: 0;
transition: transform var(--transition-base);
}
.ks-sidebar__children {
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.03);
animation: slideDown var(--transition-base);
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.ks-sidebar__item--child {
padding-left: var(--spacing-6);
font-size: var(--font-size-xs);
}
.ks-sidebar__item--child.ks-sidebar__item--active {
padding-left: calc(var(--spacing-6) - 3px);
}
.ks-sidebar__footer {
padding: var(--spacing-3);
border-top: 1px solid var(--color-border-primary);
display: flex;
gap: var(--spacing-2);
justify-content: center;
}
.ks-sidebar__footer-btn {
background-color: var(--color-background-primary);
border: 1px solid var(--color-border-primary);
font-size: 18px;
cursor: pointer;
padding: var(--spacing-2);
border-radius: var(--border-radius-base);
color: var(--color-text-secondary);
transition: all var(--transition-base);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.ks-sidebar__footer-btn:hover {
background-color: var(--color-background-hover);
color: var(--color-text-primary);
border-color: var(--color-border-secondary);
}
.ks-sidebar__footer-btn:active {
background-color: var(--color-background-active);
}
/* Collapsed state styles */
.ks-sidebar--collapsed .ks-sidebar__label {
display: none;
}
.ks-sidebar--collapsed .ks-sidebar__expand-icon {
display: none;
}
.ks-sidebar--collapsed .ks-sidebar__item {
justify-content: center;
padding: var(--spacing-3);
}
.ks-sidebar--collapsed .ks-sidebar__item--active {
padding-left: 0;
border-left: 3px solid var(--color-primary-500);
}
.ks-sidebar--collapsed .ks-sidebar__children {
display: none;
}
/* Mobile styles */
@media (max-width: 768px) {
.ks-sidebar {
position: fixed;
left: 0;
top: 56px;
bottom: 0;
width: 250px;
z-index: 98;
transform: translateX(-100%);
transition: transform var(--transition-base);
}
.ks-sidebar--mobile-open {
transform: translateX(0);
}
.ks-sidebar--collapsed {
width: 250px;
}
.ks-sidebar--collapsed .ks-sidebar__label {
display: inline;
}
.ks-sidebar--collapsed .ks-sidebar__expand-icon {
display: inline;
}
.ks-sidebar--collapsed .ks-sidebar__item {
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
}
}
/* Prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
.ks-sidebar,
.ks-sidebar__collapse-btn,
.ks-sidebar__item,
.ks-sidebar__children {
transition: none;
}
}
</style>