diff --git a/frontend/.tmp-pw-retrofit.mjs b/frontend/.tmp-pw-retrofit.mjs deleted file mode 100644 index f5f33406..00000000 --- a/frontend/.tmp-pw-retrofit.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { chromium } from '@playwright/test' -import { writeFileSync } from 'node:fs' -const OUT = 'D:/Temp/claude/D--JobRoomz-KArtSell-Aegis/da832daa-83e6-4660-8d4b-a698136f10e3/scratchpad/pw-capture' -const BASE = 'http://127.0.0.1:5173' -const pages = [ - ['31-sell-decision', '/research/sell-decision'], - ['32-data-quality', '/ops/data-quality'], - ['33-rebalance', '/portfolio/rebalance'], - ['34-market-ingestion', '/ops/market-data-ingestion'], - ['35-ingestion-status', '/ops/market-data-history'], - ['36-risk-dashboard', '/portfolio/risk'], -] - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) -const consoleLog = [] -const pageErrors = [] -page.on('console', msg => { if (msg.type() === 'error' || msg.type() === 'warning') consoleLog.push(`[${msg.type()}] ${msg.text()}`) }) -page.on('pageerror', err => pageErrors.push(String(err))) - -for (const [name, path] of pages) { - consoleLog.push(`--- ${name} ${path} ---`) - await page.goto(BASE + path, { waitUntil: 'networkidle' }).catch(e => pageErrors.push(`goto ${path}: ${e}`)) - await page.waitForTimeout(400) - await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true }) -} - -writeFileSync(`${OUT}/retrofit-console.log`, consoleLog.join('\n')) -writeFileSync(`${OUT}/retrofit-page-errors.log`, pageErrors.join('\n')) -await browser.close() -console.log('DONE', 'console:', consoleLog.length, 'errors:', pageErrors.length) diff --git a/frontend/src/shared/composables/useAnimatedCollapse.ts b/frontend/src/shared/composables/useAnimatedCollapse.ts new file mode 100644 index 00000000..a71b1bdd --- /dev/null +++ b/frontend/src/shared/composables/useAnimatedCollapse.ts @@ -0,0 +1,82 @@ +/** + * useAnimatedCollapse - Reusable collapse/expand animation logic + * SOLID: Single Responsibility - handles animation logic only + * Not coupled to UI framework + */ + +import { ref, computed, Ref } from 'vue' + +export interface AnimateCollapseOptions { + duration?: number // ms + easing?: string // CSS easing function +} + +export function useAnimatedCollapse( + initialState: boolean = false, + options: AnimateCollapseOptions = {} +) { + const { duration = 300, easing = 'ease-in-out' } = options + + const isCollapsed = ref(initialState) + const isAnimating = ref(false) + + const toggle = async () => { + if (isAnimating.value) return + + isAnimating.value = true + isCollapsed.value = !isCollapsed.value + + // Allow CSS animation to complete + await new Promise(resolve => setTimeout(resolve, duration)) + isAnimating.value = false + } + + const expand = async () => { + if (!isCollapsed.value || isAnimating.value) return + await toggle() + } + + const collapse = async () => { + if (isCollapsed.value || isAnimating.value) return + await toggle() + } + + return { + isCollapsed: computed(() => isCollapsed.value), + isAnimating: computed(() => isAnimating.value), + toggle, + expand, + collapse, + animationDuration: duration, + animationEasing: easing, + } +} + +export interface AnimateSectionToggleOptions extends AnimateCollapseOptions {} + +/** + * useAnimatedSectionToggle - For toggling individual sections in sidebar + * SOLID: Composition over inheritance + */ +export function useAnimatedSectionToggle(id: string, options: AnimateSectionToggleOptions = {}) { + const expanded = ref(true) + const isAnimating = ref(false) + const { duration = 250, easing = 'ease-in-out' } = options + + const toggle = async () => { + if (isAnimating.value) return + + isAnimating.value = true + expanded.value = !expanded.value + + await new Promise(resolve => setTimeout(resolve, duration)) + isAnimating.value = false + } + + return { + id, + expanded: computed(() => expanded.value), + isAnimating: computed(() => isAnimating.value), + toggle, + } +} diff --git a/frontend/src/shared/shell/KsAppShell.vue b/frontend/src/shared/shell/KsAppShell.vue index 096fb234..2421c75b 100644 --- a/frontend/src/shared/shell/KsAppShell.vue +++ b/frontend/src/shared/shell/KsAppShell.vue @@ -1,118 +1,160 @@ diff --git a/frontend/src/shared/shell/KsFooter.vue b/frontend/src/shared/shell/KsFooter.vue new file mode 100644 index 00000000..ef7ffdfa --- /dev/null +++ b/frontend/src/shared/shell/KsFooter.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/shared/shell/KsHeader.vue b/frontend/src/shared/shell/KsHeader.vue new file mode 100644 index 00000000..79125145 --- /dev/null +++ b/frontend/src/shared/shell/KsHeader.vue @@ -0,0 +1,552 @@ + + + + + diff --git a/frontend/src/shared/shell/KsNotifications.vue b/frontend/src/shared/shell/KsNotifications.vue new file mode 100644 index 00000000..d290fc23 --- /dev/null +++ b/frontend/src/shared/shell/KsNotifications.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/shared/shell/KsSidebar.vue b/frontend/src/shared/shell/KsSidebar.vue new file mode 100644 index 00000000..71264e80 --- /dev/null +++ b/frontend/src/shared/shell/KsSidebar.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/frontend/src/shared/shell/KsTabs.vue b/frontend/src/shared/shell/KsTabs.vue new file mode 100644 index 00000000..dee3de36 --- /dev/null +++ b/frontend/src/shared/shell/KsTabs.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/frontend/src/shared/shell/layoutStore.ts b/frontend/src/shared/shell/layoutStore.ts new file mode 100644 index 00000000..0de4f6a3 --- /dev/null +++ b/frontend/src/shared/shell/layoutStore.ts @@ -0,0 +1,192 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface OpenedTab { + id: string + title: string + path: string + icon: string + timestamp: number +} + +export const useLayoutStore = defineStore('layout', () => { + // Sidebar state + const sidebarCollapsed = ref( + localStorage.getItem('sidebar-collapsed') === 'true' + ) + const mobileMenuOpen = ref(false) + + // Tabs state + const openedTabs = ref([]) + const activeTabId = ref(null) + const maxTabs = ref(8) // Maximum number of open tabs + + // Notifications + const notifications = ref>([]) + + // Theme + const isDarkMode = ref( + localStorage.getItem('theme') === 'dark' || + window.matchMedia('(prefers-color-scheme: dark)').matches + ) + + // Save sidebar state + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + localStorage.setItem('sidebar-collapsed', String(sidebarCollapsed.value)) + } + + // Tab management + const addTab = (tab: Omit) => { + // Check if tab already exists + const existing = openedTabs.value.find(t => t.path === tab.path) + if (existing) { + activeTabId.value = existing.id + return + } + + // Respect max tabs limit + if (openedTabs.value.length >= maxTabs.value) { + openedTabs.value.shift() // Remove oldest tab + } + + const newTab: OpenedTab = { + ...tab, + timestamp: Date.now(), + } + openedTabs.value.push(newTab) + activeTabId.value = tab.id + saveTabs() + } + + const closeTab = (tabId: string) => { + const index = openedTabs.value.findIndex(t => t.id === tabId) + if (index === -1) return + + openedTabs.value.splice(index, 1) + + // Switch to next tab if closed tab was active + if (activeTabId.value === tabId) { + activeTabId.value = openedTabs.value[Math.max(0, index - 1)]?.id || null + } + + saveTabs() + } + + const closeAllTabs = () => { + openedTabs.value = [] + activeTabId.value = null + localStorage.removeItem('opened-tabs') + } + + const closeOtherTabs = (tabId: string) => { + openedTabs.value = openedTabs.value.filter(t => t.id === tabId) + activeTabId.value = tabId + saveTabs() + } + + const saveTabs = () => { + localStorage.setItem('opened-tabs', JSON.stringify(openedTabs.value)) + } + + const loadTabs = () => { + const saved = localStorage.getItem('opened-tabs') + if (saved) { + try { + openedTabs.value = JSON.parse(saved) + if (openedTabs.value.length > 0) { + activeTabId.value = openedTabs.value[0].id + } + } catch { + openedTabs.value = [] + } + } + } + + // Notification management + const addNotification = ( + message: string, + type: 'success' | 'error' | 'warning' | 'info' = 'info', + duration = 3000 + ) => { + const id = `notif-${Date.now()}-${Math.random()}` + const notif = { + id, + type, + message, + timestamp: Date.now(), + autoClose: true, + duration, + } + notifications.value.push(notif) + + if (duration > 0) { + setTimeout(() => { + removeNotification(id) + }, duration) + } + } + + const removeNotification = (id: string) => { + const index = notifications.value.findIndex(n => n.id === id) + if (index !== -1) { + notifications.value.splice(index, 1) + } + } + + // Theme management + const setDarkMode = (dark: boolean) => { + isDarkMode.value = dark + localStorage.setItem('theme', dark ? 'dark' : 'light') + const root = document.documentElement + root.setAttribute('data-theme', dark ? 'dark' : 'light') + } + + const toggleTheme = () => { + setDarkMode(!isDarkMode.value) + } + + // Computed + const hasOpenedTabs = computed(() => openedTabs.value.length > 0) + + // Load persisted state + loadTabs() + if (isDarkMode.value) { + const root = document.documentElement + root.setAttribute('data-theme', 'dark') + } + + return { + // Sidebar + sidebarCollapsed, + mobileMenuOpen, + toggleSidebar, + + // Tabs + openedTabs, + activeTabId, + hasOpenedTabs, + addTab, + closeTab, + closeAllTabs, + closeOtherTabs, + saveTabs, + + // Notifications + notifications, + addNotification, + removeNotification, + + // Theme + isDarkMode, + setDarkMode, + toggleTheme, + } +}) diff --git a/frontend/src/shared/ui/components/KsButton.vue b/frontend/src/shared/ui/components/KsButton.vue index 618161cc..eb0c9477 100644 --- a/frontend/src/shared/ui/components/KsButton.vue +++ b/frontend/src/shared/ui/components/KsButton.vue @@ -1,5 +1,6 @@