{"allowContribute":false,"item":{"activeCommit":"c8e387e87047e6d9d815182498d082acf63b8262","apis":{"routes":[],"types":{}},"backendSchedules":null,"bundleHash":"151c6ab5d98dadae","createdAt":1785417140039,"displayName":"源碼上傳平台","gitCommit":"c8e387e87047e6d9d815182498d082acf63b8262","id":"270b89320b7c34dff6ec4ae7","isPublic":true,"itemType":"PLUGIN","name":"源碼上傳平台","pluginDir":"源碼上傳平台","repositoryID":"repo_c9c44c3035cfffceab27a930","status":"active","updatedAt":1787485497525,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":19},"ownerMemberId":"6a468afe0217f2757c6098","ownerName":"小小世界","publicPlugins":{"PLUGIN":{"pluginItemId":"270b89320b7c34dff6ec4ae7","pluginDir":"源碼上傳平台","bundleHash":"151c6ab5d98dadae","live":true}},"subtree":[{"activeCommit":"c8e387e87047e6d9d815182498d082acf63b8262","apis":{"routes":[],"types":{}},"backendSchedules":null,"bundleHash":"151c6ab5d98dadae","createdAt":1785417140039,"displayName":"源碼上傳平台","gitCommit":"c8e387e87047e6d9d815182498d082acf63b8262","id":"270b89320b7c34dff6ec4ae7","isPublic":true,"itemType":"PLUGIN","name":"源碼上傳平台","pluginDir":"源碼上傳平台","repositoryID":"repo_c9c44c3035cfffceab27a930","status":"active","updatedAt":1787485497525,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":19},{"createdAt":1785436270510,"id":"e7bb1a69c148feefb7f9793c","itemType":"PLUGIN_DEV_FOLDER","name":"收入與支出","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"plugin-dev":1785436271110},"pluginDir":"收入與支出","updatedAt":1785436270510,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"createdAt":1785436270510,"id":"ee9ef6b2e6fd375a0ad57e00","itemType":"PLUGIN_DEV_FOLDER","name":"源碼上傳平台","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"plugin-dev":1785436271210},"pluginDir":"源碼上傳平台","updatedAt":1785436270510,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"createdAt":1785436270510,"id":"198a6ad6c866e4326e13e03f","itemType":"PLUGIN_DEV_FOLDER","name":"資料／插件備份","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"plugin-dev":1785436271410},"pluginDir":"資料／插件備份","updatedAt":1785436270510,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"createdAt":1785417141562,"deletedAt":null,"description":"","id":"default_plugin_dev_folder_6a468afe0217f2757c6098","isNew":false,"isPublic":false,"itemType":"PLUGIN_DEV_FOLDER","name":"我的插件專案","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"plugin-dev":1785417150944},"pluginDir":"漫畫進度追蹤","preParentID":null,"updatedAt":1785425022890,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { createSingletonAccessor } from '@cubelv/sdk';\nimport { GoogleMapsSectionConfig } from '../schemas/googleMapsConfigSchema';\n\nconst accessor = createSingletonAccessor(GoogleMapsSectionConfig);\nexport const useGoogleMapsConfig = accessor.use;\nexport const getGoogleMapsConfig = accessor.get;\nexport const updateGoogleMapsConfig = accessor.update;\n","createdAt":1785608750381,"deletedAt":null,"filePath":"frontend/stores/googleMapsConfigStore.ts","id":"07389e8984e3a530c5952e83","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"googleMapsConfigStore.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785608750481},"preParentID":null,"updatedAt":1785888550375,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":2},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n\n@itemType('GOOGLE_MAPS_CONFIG', 'Google Maps 設定')\nexport class GoogleMapsSectionConfig extends BaseItem {\n    /** Google Cloud API 金鑰 */\n    apiKey: string = '';\n    /** 是否啟用 Google Maps 功能 */\n    enabled: boolean = false;\n}\n","createdAt":1785608750381,"deletedAt":null,"filePath":"frontend/schemas/googleMapsConfigSchema.ts","id":"e1b9a42688fba950cfa4cbf7","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"googleMapsConfigSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785608750381},"preParentID":null,"updatedAt":1785888550277,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":2},{"content":"{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"outDir\": \"./dist\",\n    \"rootDir\": \".\"\n  },\n  \"include\": [\"frontend/**/*\", \"backend/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \".bundle\"]\n}\n","createdAt":1785436692398,"deletedAt":null,"filePath":"tsconfig.json","id":"87a32c0034afd63b30dbf02f","isPublic":false,"itemType":"SOURCE_FILE","language":"json","name":"tsconfig.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436692598},"preParentID":null,"updatedAt":1785888550169,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { Plugin, plugin, registerPublicItemType, View, useItemsByType } from '@cubelv/sdk';\nimport { IncomeExpenseFolder, IncomeExpenseTransaction } from './schemas/expenseTrackerSchema';\nimport { IncomeExpenseSectionConfig } from './schemas/expenseTrackerSectionConfigSchema';\nimport { GoogleMapsSectionConfig } from './schemas/googleMapsConfigSchema';\nimport { TransactionListComponent } from './views/TransactionListView';\nimport { TransactionDetailComponent } from './views/TransactionDetailView';\n\n/** 確保日期 YYYY-MM-DD 的 MM/DD 有前置零 */\nfunction fmtDate(d: string): string {\n  if (!d) return '';\n  const parts = d.split('-');\n  if (parts.length === 3) return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;\n  return d;\n}\n\nclass IncomeExpenseListView extends View {\n    constructor(leaf: any) { super(leaf); }\n    getViewType() { return 'income-expense-list'; }\n    getDisplayText() { return '記錄列表'; }\n    renderComponent() { return \u003cTransactionListComponent /\u003e; }\n}\n\nclass IncomeExpenseDetailView extends View {\n    constructor(leaf: any) { super(leaf); }\n    getViewType() { return 'income-expense-detail'; }\n    getDisplayText() { return '記錄詳情'; }\n    renderComponent() { return \u003cTransactionDetailComponent /\u003e; }\n}\n\nfunction useIncomeExpenseSidebarData() {\n    const folders = useItemsByType\u003cIncomeExpenseFolder\u003e('INCOME_EXPENSE_FOLDER');\n    return { items: folders };\n}\n\n@plugin('收入與支出', { description: '記錄每月開銷與收入，用圓餅圖統計分類與對象', dependencies: ['Sidebar'] })\n// refresh\nclass ExpenseTrackerPlugin extends Plugin {\n    onload() {\n        this.registerPluginRoot('income-expense');\n\n        registerPublicItemType('INCOME_EXPENSE_FOLDER');\n        registerPublicItemType('INCOME_EXPENSE_TRANSACTION');\n\n        // 確保 centerPane2 永遠存在（先前 clearPane 若有殘留狀態可恢復）\n        this.ensureLeafInPane('centerPane2', 'leaf-income-detail', 'income-expense-detail', { flex: 1, minSize: 300 });\n\n        this.registerSection({\n            id: 'income-expense',\n            title: '收支統計',\n            orderAt: 550,\n            useData: useIncomeExpenseSidebarData,\n        });\n\n        this.registerConfig(IncomeExpenseSectionConfig);\n        this.registerConfig(GoogleMapsSectionConfig);\n\n        this.registerFolderType(IncomeExpenseFolder, {\n            folderIcon: 'hand-coins',\n            views: [\n                { type: 'income-expense-list', creator: (leaf: any) =\u003e new IncomeExpenseListView(leaf) },\n                { type: 'income-expense-detail', creator: (leaf: any) =\u003e new IncomeExpenseDetailView(leaf) },\n            ],\n            panes: {\n                centerPane1: { leafId: 'leaf-income-list', viewType: 'income-expense-list', flex: 1, minSize: 300 },\n                centerPane2: { leafId: 'leaf-income-detail', viewType: 'income-expense-detail', flex: 1.2, minSize: 380 },\n            },\n            children: [\n                {\n                    class: IncomeExpenseTransaction,\n                    icon: 'credit-card',\n                    displayText: (item: IncomeExpenseTransaction) =\u003e\n                        `${fmtDate(item.date || '')} ${item.majorCategory || ''} ${item.amount || 0}元`,\n                    aiUsageHint: '交易記錄，date 為日期(YYYY-MM-DD)、type 為 income 或 expense、majorCategory 大類、subCategory 小類、amount 金額、payee 對象、location 地點、note 備注。',\n                },\n            ],\n        });\n    }\n}\n\nexport default ExpenseTrackerPlugin;\n","createdAt":1785436692398,"deletedAt":null,"filePath":"frontend/收入與支出Plugin.tsx","id":"c48ab022a93a0c0ae635741b","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"收入與支出Plugin.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436692498},"preParentID":null,"updatedAt":1785888550070,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useMemo, useState, useCallback, useRef, useEffect } from 'react';\nimport { createPortal } from 'react-dom';\nimport {\n    useItemsByType,\n    useSelectedItemsStore,\n    useItemStore,\n    PluginTopbar,\n    Dialog,\n    DialogContent,\n    DialogHeader,\n    DialogTitle,\n    DialogDescription,\n    DialogFooter,\n    Button,\n    fetchExternal,\n} from '@cubelv/sdk';\nimport type { BaseItem } from '@cubelv/sdk';\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\nimport { AddTransactionDialog } from '../components/AddTransactionDialog';\nimport { StatsView } from './StatsView';\nimport { gregorianToLunar, ensureGovtHolidays, loadGovtHolidaysFromData, type BaziChart } from '../lunarCalendar';\nimport { SettingsView } from './SettingsView';\nimport { useIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\nimport { useCalendarMode } from '../stores/calendarModeStore';\n\nfunction CopyIcon() {\n    return (\n        \u003csvg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\u003e\n            \u003crect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\" /\u003e\n            \u003cpath d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" /\u003e\n        \u003c/svg\u003e\n    );\n}\n\nfunction formatNum(n: number): string {\n    return n.toLocaleString();\n}\n\nconst PROGRAM_INFO_TEXT = `📖 收入與支出 — 功能說明\n\n📋 版本紀錄\nv1.4 (2026/08/02)：萬年曆固定高度不再跳動；點選日期顯示八字排盤（三柱、十神、藏干、納音）；新增分類長條圖與折線圖；年度總覽儀表板；每月預算進度條；定期記帳推播提醒；長條圖/折線圖與圓餅圖共用篩選條件；年份選單改為下拉式\nv1.3 (2026/07/27)：新增記錄日期不再自動填今天，改為空白讓使用者自由選取；表單版面優化（日期與金額同一行併排、GPS 定位按鈕改為緊湊圖示內嵌於輸入框右側、整體欄位間距收緊）\nv1.2 (2026/07/27)：地點欄位新增定位按鈕（📍），透過 IP 定位取得座標後自動反查地址並填入欄位；編輯模式地點有值時自動出現地圖按鈕（🗺️），點擊用 Google Maps 搜尋該地點\nv1.1 (2026/07/26)：新增與編輯記錄支援大小類與對象管理；大小類、對象支援長按拖曳排序；圓餅圖統計收入與支出分類佔比\nv1.0 (2026/07/26)：收入與支出初版：記錄每月開銷與收入，用圓餅圖統計分類與對象，支援收入／支出切換、自訂大小類與付款對象\n\n📅 萬年曆\n全螢幕月曆檢視，整合農曆資訊與記帳記錄。\n・固定高度 30rem，切換月份不跳動，網格各行等高等寬\n・支援左右滑動換月，點選日期篩選當日記錄\n・顯示農曆日期（初一～三十）、天干地支、二十四節氣\n・顯示農曆節日（春節、端午、中秋等）與國定假日\n・點選日期顯示八字排盤：年柱/月柱/日柱、十神、藏干、納音\n\n📝 記帳記錄\n記錄每一筆收入與支出，支援完整欄位填寫與管理。\n・收支類型：切換收入或支出，按鈕以藍色（收）／紅色（支）區分\n・日期：手動選擇任意日期，補記過去消費不受限\n・大小類：兩層分類（大類 → 小類），支援自訂與拖曳排序\n・金額：輸入數字，自動計算當月收支合計與結餘\n・對象：付款或收款對象，支援自訂與拖曳排序\n・地點：輸入地址或店名，或點擊 📍 按鈕自動定位目前地址\n・備注：自由輸入備注文字\n・編輯與刪除：點選記錄進入編輯模式，可修改所有欄位或刪除記錄\n・多選刪除：勾選多筆後一次刪除\n\n📊 統計圖表\n四種圖表類型視覺化呈現收支分佈與趨勢。\n・圓餅圖：收支分類佔比，支援大類/小類/對象切換\n・長條圖：分時段（日/月）堆疊顯示各分類收支金額變化\n・折線圖：各分類收支趨勢線，一目瞭然消長走勢\n・年度總覽：全年收支合計、各月明細表、前五大支出類別\n・期間選擇：本週/本月/今年/全部/自訂區間\n・分類切換：大類/小類/對象三種維度\n\n💰 每月預算\n設定支出預算上限，即時掌握消費節奏。\n・於月曆設定中設定每月預算金額\n・記帳本上方顯示預算進度條，綠色→黃→橙→紅四階段\n・即時顯示已用比例與剩餘/超支金額\n\n🔔 記帳提醒\n未記帳時自動發送推播通知，不怕忘記。\n・支援每日或每週提醒模式\n・可自訂提醒時間（每日）或星期幾（每週）\n・當日已有記錄時自動跳過，不重複打擾\n\n🔄 大小類與對象管理\n支援拖曳排序的管理介面，讓分類與對象清單保持整潔。\n・大類：新增／編輯／刪除／拖曳排序\n・小類：掛在大類下，新增／編輯／刪除／拖曳排序\n・對象：收入對象與支出對象分開管理，支援拖曳排序\n・設定即時儲存，所有記錄自動反映最新分類\n\n📍 地點定位\n點擊地點欄位旁的 📍 按鈕即可自動取得目前地址。\n・輸入文字時搜尋該地點地址（支援 Google Maps 或 Nominatim）\n・空白時使用 GPS 取得經緯度並自動反查繁體中文地址\n・定位失敗時顯示錯誤提示\n・編輯模式：地點有值時出現 🗺️ 按鈕，點擊用 Google Maps 搜尋\n\n📡 資料來源\n地點定位：瀏覽器 GPS + Google Maps API 或 OpenStreetMap Nominatim。國定假日：人事行政總處公告資料。記帳資料全部儲存在 CubeLV 本機，不經第三方服務。`;\n\n\n\nexport function TransactionListComponent() {\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\n    const items = useItemsByType\u003cIncomeExpenseTransaction\u003e('INCOME_EXPENSE_TRANSACTION', folderId || undefined);\n    const selectedId = useSelectedItemsStore(s =\u003e {\n        const tx = s.getPathItemByItemType('INCOME_EXPENSE_TRANSACTION');\n        return tx?.id || null;\n    });\n\n    const now = new Date();\n    const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\n    const [filterMonth, setFilterMonth] = useState(currentMonth);\n    const [showAddDialog, setShowAddDialog] = useState(false);\n    const [showStats, setShowStats] = useState(false);\n    const [showProgramInfo, setShowProgramInfo] = useState(false);\n    const [copyDone, setCopyDone] = useState(false);\n    const [showDeleteConfirm, setShowDeleteConfirm] = useState\u003c'all' | 'selected' | null\u003e(null);\n    const [deleting, setDeleting] = useState(false);\n    const [selectedIds, setSelectedIds] = useState\u003cSet\u003cstring\u003e\u003e(new Set());\n    const selectedIdsRef = useRef(selectedIds);\n    selectedIdsRef.current = selectedIds;\n    const touchStartX = useRef(0);\n    const monthInputRef = useRef\u003cHTMLInputElement\u003e(null);\n    const { showCalendar, toggleCalendar, setShowCalendar } = useCalendarMode();\n    const [filterDate, setFilterDate] = useState\u003cstring | null\u003e(null); // 特定日期篩選 \"YYYY-MM-DD\"\n    const [showSettings, setShowSettings] = useState(false);\n    const config = useIncomeExpenseConfig();\n\n    // 選中日期的農曆資料（含八字排盤）\n    const selectedLunar = useMemo(() =\u003e {\n        if (!filterDate) return null;\n        const [y, m, d] = filterDate.split('-').map(Number);\n        return gregorianToLunar(y, m, d);\n    }, [filterDate]);\n\n    // 從已儲存的 config 載入國定假日（不用每次都重新下載）\n    useEffect(() =\u003e {\n        if (config?.govtHolidayData \u0026\u0026 Object.keys(config.govtHolidayData).length \u003e 0) {\n            loadGovtHolidaysFromData(config.govtHolidayData);\n        }\n    }, [config?.govtHolidayData]);\n\n\n\n\n    const sorted = useMemo(() =\u003e {\n        let filtered = items;\n        if (filterDate) {\n            filtered = items.filter(item =\u003e item.date === filterDate);\n        } else if (filterMonth) {\n            filtered = items.filter(item =\u003e item.date \u0026\u0026 item.date.startsWith(filterMonth));\n        }\n        return [...filtered].sort((a, b) =\u003e (b.date || '').localeCompare(a.date || ''));\n    }, [items, filterMonth, filterDate]);\n\n    const incomeTotal = useMemo(() =\u003e\n        sorted.filter(i =\u003e i.type === 'income').reduce((s, i) =\u003e s + (i.amount || 0), 0),\n        [sorted]\n    );\n    const expenseTotal = useMemo(() =\u003e\n        sorted.filter(i =\u003e i.type === 'expense').reduce((s, i) =\u003e s + (i.amount || 0), 0),\n        [sorted]\n    );\n\n    // 整月支出（不受 filterDate 影響，供預算條使用）\n    const fullMonthExpense = useMemo(() =\u003e\n        items.filter(i =\u003e i.type === 'expense' \u0026\u0026 i.date \u0026\u0026 i.date.startsWith(filterMonth)).reduce((s, i) =\u003e s + (i.amount || 0), 0),\n        [items, filterMonth]\n    );\n\n    // ===== 萬年曆：依日期分組（全部記錄，不限月份）=====\n    const txByDate = useMemo(() =\u003e {\n        const map = new Map\u003cstring, { income: number; expense: number }\u003e();\n        items.forEach(t =\u003e {\n            if (!t.date) return;\n            const entry = map.get(t.date) || { income: 0, expense: 0 };\n            if (t.type === 'income') entry.income++;\n            else entry.expense++;\n            map.set(t.date, entry);\n        });\n        return map;\n    }, [items]);\n\n    // 月曆運算\n    const calYearMonth = useMemo(() =\u003e {\n        const parts = filterMonth.split('-');\n        return { year: parseInt(parts[0], 10), month: parseInt(parts[1], 10) };\n    }, [filterMonth]);\n\n    const calendarGrid = useMemo(() =\u003e {\n        const { year, month } = calYearMonth;\n        const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun\n        const daysInMonth = new Date(year, month, 0).getDate();\n        const today = new Date();\n        const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;\n\n        const cells: Array\u003c{ day: number | null; dateStr: string | null; isToday: boolean; isSelected: boolean; income: number; expense: number }\u003e = [];\n\n        // 填充月初空白\n        for (let i = 0; i \u003c firstDay; i++) {\n            cells.push({ day: null, dateStr: null, isToday: false, isSelected: false, income: 0, expense: 0 });\n        }\n        // 填充日期\n        for (let d = 1; d \u003c= daysInMonth; d++) {\n            const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;\n            const dayData = txByDate.get(dateStr);\n            cells.push({\n                day: d,\n                dateStr,\n                isToday: dateStr === todayStr,\n                isSelected: dateStr === filterDate,\n                income: dayData?.income || 0,\n                expense: dayData?.expense || 0,\n            });\n        }\n        return cells;\n    }, [calYearMonth, txByDate, filterDate]);\n\n    const navigateMonth = (delta: number) =\u003e {\n        let { year, month } = calYearMonth;\n        month += delta;\n        if (month \u003c 1) { month = 12; year--; }\n        if (month \u003e 12) { month = 1; year++; }\n        setFilterMonth(`${year}-${String(month).padStart(2, '0')}`);\n        setFilterDate(null);\n    };\n\n    const handleCopyProgramInfo = useCallback(async () =\u003e {\n        try {\n            await navigator.clipboard.writeText(PROGRAM_INFO_TEXT);\n        } catch (_e) {\n            // fallback: 用 textarea 複製\n            const ta = document.createElement('textarea');\n            ta.value = PROGRAM_INFO_TEXT;\n            ta.style.position = 'fixed';\n            ta.style.left = '-9999px';\n            document.body.appendChild(ta);\n            ta.select();\n            document.execCommand('copy');\n            document.body.removeChild(ta);\n        }\n        setCopyDone(true);\n        setTimeout(() =\u003e setCopyDone(false), 2000);\n    }, []);\n\n    // ===== 多選邏輯 =====\n    const toggleSelect = (id: string) =\u003e {\n        setSelectedIds(prev =\u003e {\n            const next = new Set(prev);\n            if (next.has(id)) next.delete(id); else next.add(id);\n            return next;\n        });\n    };\n\n    const toggleSelectAll = () =\u003e {\n        setSelectedIds(prev =\u003e {\n            if (prev.size === sorted.length \u0026\u0026 sorted.length \u003e 0) {\n                return new Set\u003cstring\u003e();\n            }\n            return new Set(sorted.map(i =\u003e i.id));\n        });\n    };\n\n    const allSelected = sorted.length \u003e 0 \u0026\u0026 selectedIds.size === sorted.length;\n\n    const handleDeleteSelected = async () =\u003e {\n        setDeleting(true);\n        try {\n            const ids = Array.from(selectedIdsRef.current);\n            if (ids.length === 0) return;\n            await useItemStore.getState().removeItems(ids, { needSync: true });\n            setSelectedIds(new Set());\n            setShowDeleteConfirm(null);\n        } catch (_e) {\n            // ignore\n        } finally {\n            setDeleting(false);\n        }\n    };\n\n    const handleDeleteAll = useCallback(async () =\u003e {\n        if (!folderId) return;\n        setDeleting(true);\n        try {\n            const ids = sorted.map(i =\u003e i.id);\n            if (ids.length === 0) { setShowDeleteConfirm(null); setDeleting(false); return; }\n            await useItemStore.getState().removeItems(ids, { needSync: true });\n            setShowDeleteConfirm(null);\n            setSelectedIds(new Set());\n        } catch (_e) {\n            // 刪除失敗不處理，關閉對話框\n        } finally {\n            setDeleting(false);\n        }\n    }, [folderId, sorted]);\n\n    if (showStats) {\n        return (\n            \u003cdiv className=\"flex flex-col h-full\"\u003e\n                \u003cPluginTopbar\n                    title=\"統計圖表\"\n                    rightButtons={[{\n                        icon: 'arrow-left',\n                        onClick: () =\u003e setShowStats(false),\n                        title: '返回記帳',\n                    }]}\n                /\u003e\n                \u003cStatsView /\u003e\n            \u003c/div\u003e\n        );\n    }\n\n    return (\n        \u003cdiv className=\"flex flex-col h-full w-full bg-background text-foreground overflow-hidden\"\u003e\n            \u003cPluginTopbar\n                title=\"記帳本\"\n                rightButtons={[\n                    {\n                        icon: 'calendar' as const,\n                        onClick: () =\u003e { toggleCalendar(); setFilterDate(null); },\n                        title: showCalendar ? '關閉月曆' : '月曆檢視',\n                    },\n                    {\n                        icon: 'info',\n                        onClick: () =\u003e setShowProgramInfo(true),\n                        title: '功能說明',\n                    },\n                    {\n                        icon: 'PieChart',\n                        onClick: () =\u003e setShowStats(true),\n                        title: '統計圖表',\n                    },\n                    {\n                        icon: 'plus',\n                        onClick: () =\u003e setShowAddDialog(true),\n                        title: '新增記錄',\n                    },\n                    {\n                        icon: 'settings' as const,\n                        onClick: () =\u003e setShowSettings(true),\n                        title: '月曆顯示設定',\n                    },\n                ]}\n            /\u003e\n            {/* 統計列：單行 — 左控制項 + 中（統計 / 刪除 互換） */}\n            \u003cdiv className=\"flex items-center px-3 py-1.5 border-b border-border bg-muted/30 overflow-hidden gap-1.5\"\u003e\n                \u003clabel className={`flex items-center gap-0.5 flex-shrink-0 ${sorted.length \u003e 0 ? 'cursor-pointer' : 'opacity-0 pointer-events-none'}`}\u003e\n                    \u003cinput\n                        type=\"checkbox\"\n                        checked={allSelected}\n                        onChange={toggleSelectAll}\n                        disabled={sorted.length === 0}\n                        className=\"w-3.5 h-3.5 rounded accent-primary cursor-pointer\"\n                    /\u003e\n                    \u003cspan className=\"text-[9px] text-muted-foreground whitespace-nowrap\"\u003e全選\u003c/span\u003e\n                \u003c/label\u003e\n                \u003cbutton\n                    type=\"button\"\n                    className=\"w-[65px] h-6.5 rounded border border-input bg-transparent px-1.5 text-[11px] flex-shrink-0 cursor-pointer tabular-nums whitespace-nowrap text-center\"\n                    onClick={() =\u003e monthInputRef.current?.showPicker()}\n                \u003e\n                    {filterMonth.replace('-', '/')}\n                \u003c/button\u003e\n                \u003cinput\n                    ref={monthInputRef}\n                    type=\"month\"\n                    value={filterMonth}\n                    onChange={e =\u003e setFilterMonth(e.target.value)}\n                    className=\"sr-only\"\n                /\u003e\n                {/* 中間區域：未選取時顯示三等份統計，已選取時顯示刪除按鈕 */}\n                \u003cdiv className=\"flex-1 min-w-0\"\u003e\n                    {selectedIds.size \u003e 0 ? (\n                        \u003cbutton\n                            type=\"button\"\n                            className=\"w-full text-[11px] text-destructive font-semibold cursor-pointer text-center py-0.5\"\n                            onClick={() =\u003e setShowDeleteConfirm('selected')}\n                        \u003e\n                            🗑 刪除所選 ({selectedIds.size})\n                        \u003c/button\u003e\n                    ) : (\n                        \u003cdiv className=\"grid grid-cols-3 divide-x divide-border/40\"\u003e\n                            \u003cdiv className=\"flex flex-col text-center px-1 min-w-0 overflow-hidden\"\u003e\n                                \u003cspan className=\"text-[9px] text-[#22C55E] font-medium\"\u003e收入\u003c/span\u003e\n                                \u003cspan className=\"text-[11px] text-[#22C55E] font-bold tabular-nums truncate w-full block\"\u003e{formatNum(incomeTotal)}\u003c/span\u003e\n                            \u003c/div\u003e\n                            \u003cdiv className=\"flex flex-col text-center px-1 min-w-0 overflow-hidden\"\u003e\n                                \u003cspan className=\"text-[9px] text-[#FF6384] font-medium\"\u003e支出\u003c/span\u003e\n                                \u003cspan className=\"text-[11px] text-[#FF6384] font-bold tabular-nums truncate w-full block\"\u003e{formatNum(expenseTotal)}\u003c/span\u003e\n                            \u003c/div\u003e\n                            \u003cdiv className=\"flex flex-col text-center px-1 min-w-0 overflow-hidden\"\u003e\n                                \u003cspan className=\"text-[9px] text-muted-foreground font-medium\"\u003e結餘\u003c/span\u003e\n                                \u003cspan className={`text-[11px] font-bold tabular-nums truncate w-full block ${(() =\u003e {\n                                    const balance = incomeTotal - expenseTotal;\n                                    if (balance \u003e 0) return 'text-[#22C55E]';\n                                    if (balance \u003c 0) return 'text-[#FF6384]';\n                                    return 'text-muted-foreground';\n                                })()}`}\u003e\n                                    {(() =\u003e {\n                                        const balance = incomeTotal - expenseTotal;\n                                        return balance === 0 ? '0' : `${balance \u003e 0 ? '+' : ''}${formatNum(balance)}`;\n                                    })()}\n                                \u003c/span\u003e\n                            \u003c/div\u003e\n                        \u003c/div\u003e\n                    )}\n                \u003c/div\u003e\n            \u003c/div\u003e\n\n            {/* 每月預算進度條 */}\n            {config?.monthlyBudget \u003e 0 \u0026\u0026 (\n                \u003cdiv className=\"px-3 py-1 border-b border-border bg-muted/20\"\u003e\n                    {(() =\u003e {\n                        const pct = Math.min(fullMonthExpense / config.monthlyBudget * 100, 100);\n                        const barColor = pct \u003c 50 ? 'bg-[#22C55E]' : pct \u003c 80 ? 'bg-[#F59E0B]' : pct \u003c 100 ? 'bg-[#F97316]' : 'bg-[#FF6384]';\n                        const remaining = config.monthlyBudget - fullMonthExpense;\n                        return (\n                            \u003cdiv\u003e\n                                \u003cdiv className=\"flex items-center justify-between mb-0.5\"\u003e\n                                    \u003cspan className=\"text-[10px] text-muted-foreground\"\u003e\n                                        本月預算 {formatNum(config.monthlyBudget)} · 已用 {pct.toFixed(0)}%\n                                    \u003c/span\u003e\n                                    \u003cspan className={`text-[10px] font-medium ${remaining \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\n                                        {remaining \u003e= 0 ? `剩餘 ${formatNum(remaining)}` : `超支 ${formatNum(Math.abs(remaining))}`}\n                                    \u003c/span\u003e\n                                \u003c/div\u003e\n                                \u003cdiv className=\"w-full h-1.5 rounded-full bg-muted overflow-hidden\"\u003e\n                                    \u003cdiv className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${pct}%` }} /\u003e\n                                \u003c/div\u003e\n                            \u003c/div\u003e\n                        );\n                    })()}\n                \u003c/div\u003e\n            )}\n\n            {/* 萬年曆 — createPortal 到 body，真正滿版不受任何 pane/CSS 限制 */}\n            {showCalendar \u0026\u0026 createPortal(\n                \u003cdiv className=\"fixed inset-0 z-[99999] bg-background flex flex-col\"\n                    style={{ paddingTop: 'max(env(safe-area-inset-top), 8px)', paddingBottom: 'max(env(safe-area-inset-bottom), 8px)' }}\u003e\n                    {/* 月份導航列 */}\n                    \u003cdiv className=\"flex items-center justify-between px-3 py-1.5 bg-muted/30 border-b border-border flex-shrink-0\"\u003e\n                        \u003cbutton type=\"button\" className=\"text-sm font-semibold text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\" onClick={() =\u003e { setShowCalendar(false); setFilterDate(null); }}\u003e\n                            ✕ 關閉\n                        \u003c/button\u003e\n                        \u003cspan className=\"text-sm font-bold text-foreground\"\u003e\n                            {calYearMonth.year} 年 {calYearMonth.month} 月\n                        \u003c/span\u003e\n                        \u003cdiv className=\"flex items-center gap-0.5\"\u003e\n                            \u003cbutton type=\"button\" className=\"text-lg text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\" onClick={() =\u003e navigateMonth(-1)}\u003e\n                                ◀\n                            \u003c/button\u003e\n                            \u003cbutton type=\"button\" className=\"text-lg text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\" onClick={() =\u003e navigateMonth(1)}\u003e\n                                ▶\n                            \u003c/button\u003e\n                        \u003c/div\u003e\n                    \u003c/div\u003e\n                    {/* 星期標頭 */}\n                    \u003cdiv className=\"grid grid-cols-7 text-xs text-foreground/65 text-center py-1.5 border-b border-border/30 flex-shrink-0\"\u003e\n                        {['日', '一', '二', '三', '四', '五', '六'].map(d =\u003e (\n                            \u003cdiv key={d} className=\"py-0.5 font-semibold\"\u003e{d}\u003c/div\u003e\n                        ))}\n                    \u003c/div\u003e\n                    {/* 日期網格 — 固定高度 30rem（6 行 × 5rem），切換月份不跳動 */}\n                    \u003cdiv\n                        className=\"grid grid-cols-7 flex-shrink-0\"\n                        style={{ gridAutoRows: '1fr', height: '30rem' }}\n                        onTouchStart={(e) =\u003e { touchStartX.current = e.touches[0].clientX; }}\n                        onTouchEnd={(e) =\u003e {\n                            const dx = e.changedTouches[0].clientX - touchStartX.current;\n                            if (Math.abs(dx) \u003e 60) {\n                                navigateMonth(dx \u003e 0 ? -1 : 1);\n                            }\n                        }}\n                    \u003e\n                        {calendarGrid.map((cell, i) =\u003e {\n                            const total = cell.income + cell.expense;\n                            const incomeDots = cell.income \u003e 0 ? Math.min(cell.income, 2) : 0;\n                            const expenseDots = cell.expense \u003e 0 ? Math.min(cell.expense, 2) : 0;\n                            return (\n                                \u003cbutton\n                                    key={i}\n                                    type=\"button\"\n                                    className={`relative flex flex-col items-start justify-start p-1.5 cursor-pointer transition-colors border-r border-b border-border/30 ${\n                                        !cell.day ? 'pointer-events-none bg-muted/10' :\n                                        cell.isSelected ? 'bg-primary/5' :\n                                        cell.isToday ? 'bg-accent/30' : 'hover:bg-accent/10'\n                                    }`}\n                                    onClick={() =\u003e {\n                                        if (cell.dateStr) {\n                                            if (cell.dateStr === filterDate) {\n                                                setFilterDate(null);\n                                            } else {\n                                                setFilterDate(cell.dateStr);\n                                            }\n                                        }\n                                    }}\n                                    disabled={!cell.day}\n                                \u003e\n                                    {cell.day \u0026\u0026 (() =\u003e {\n                                        const lunar = gregorianToLunar(calYearMonth.year, calYearMonth.month, cell.day);\n                                        const isSolarTerm = !!lunar.solarTerm \u0026\u0026 config?.showSolarTerm !== false;\n                                        const showLunarDate = config?.showLunarDate !== false;\n                                        const showGanZhi = config?.showGanZhi !== false;\n                                        const showFestival = config?.showLunarFestival !== false \u0026\u0026 !!lunar.festival;\n                                        const showNational = config?.showNationalHoliday !== false \u0026\u0026 !!lunar.nationalHoliday;\n                                        const isLunarSpecial = lunar.day === 1 || lunar.day === 15;\n                                        const lunarFullDate = lunar.monthName + lunar.dayName;\n                                        return (\n                                        \u003c\u003e\n                                            \u003cspan className={`text-base tabular-nums leading-none font-bold ${\n                                                cell.isToday\n                                                    ? 'text-primary bg-primary/10 rounded-full px-1.5 py-0.5 inline-block'\n                                                    : cell.isSelected\n                                                        ? 'text-primary'\n                                                        : 'text-foreground'\n                                            }`}\u003e\n                                                {cell.day}\n                                            \u003c/span\u003e\n                                            {/* 第一行：農曆日期（含月份） */}\n                                            {showLunarDate \u0026\u0026 (\n                                                \u003cspan className={`text-[11px] leading-tight tabular-nums w-full mt-0.5 ${\n                                                    isLunarSpecial ? 'text-amber-300 font-semibold' : 'text-slate-200'\n                                                }`}\u003e\n                                                    {lunarFullDate}\n                                                \u003c/span\u003e\n                                            )}\n                                            {/* 第二行：天干地支 */}\n                                            {showGanZhi \u0026\u0026 (\n                                                \u003cspan className=\"text-[11px] leading-tight tabular-nums w-full mt-0.5 text-slate-400\"\u003e\n                                                    {lunar.dayGanZhi}\n                                                \u003c/span\u003e\n                                            )}\n                                            {/* 第三行：節氣 */}\n                                            {isSolarTerm \u0026\u0026 (\n                                                \u003cspan className=\"text-[11px] leading-tight text-emerald-300 font-semibold w-full mt-0.5\"\u003e\n                                                    {lunar.solarTerm}\n                                                \u003c/span\u003e\n                                            )}\n                                            {/* 第四行：農曆節日 */}\n                                            {showFestival \u0026\u0026 (\n                                                \u003cspan className=\"text-[11px] leading-tight text-amber-300 font-semibold w-full mt-0.5\"\u003e\n                                                    {lunar.festival}\n                                                \u003c/span\u003e\n                                            )}\n                                            {/* 第五行：人事行政局國定假日 */}\n                                            {showNational \u0026\u0026 (\n                                                \u003cspan className=\"text-[11px] leading-tight text-red-400 font-semibold w-full mt-0.5\"\u003e\n                                                    {lunar.nationalHoliday}\n                                                \u003c/span\u003e\n                                            )}\n                                            {total \u003e 0 \u0026\u0026 (\n                                                \u003cdiv className=\"flex items-center gap-0.5 mt-auto mb-1\"\u003e\n                                                    {Array.from({ length: incomeDots }).map((_, j) =\u003e (\n                                                        \u003cspan key={`i-${j}`} className=\"w-3.5 h-3.5 rounded-full bg-[#36A2EB]\" /\u003e\n                                                    ))}\n                                                    {Array.from({ length: expenseDots }).map((_, j) =\u003e (\n                                                        \u003cspan key={`e-${j}`} className=\"w-3.5 h-3.5 rounded-full bg-[#FF6384]\" /\u003e\n                                                    ))}\n                                                    {total \u003e incomeDots + expenseDots \u0026\u0026 (\n                                                        \u003cspan className=\"text-[11px] text-foreground/85 leading-none\"\u003e+{total - incomeDots - expenseDots}\u003c/span\u003e\n                                                    )}\n                                                \u003c/div\u003e\n                                            )}\n                                        \u003c/\u003e\n                                        );\n                                    })()}\n                                \u003c/button\u003e\n                            );\n                        })}\n                    \u003c/div\u003e\n                    {/* 篩選提示 + 當日記錄 */}\n                    {filterDate ? (\n                        \u003cdiv className=\"border-t border-border flex-1 overflow-y-auto\" style={{ minHeight: 0 }}\u003e\n                            \u003cdiv className=\"flex items-center justify-between px-3 py-1.5 bg-primary/5 border-b border-border/30\"\u003e\n                                \u003cspan className=\"text-xs text-primary font-medium\"\u003e📅 {filterDate} 的記錄\u003c/span\u003e\n                                \u003cbutton\n                                    type=\"button\"\n                                    className=\"text-xs text-primary underline cursor-pointer\"\n                                    onClick={() =\u003e setFilterDate(null)}\n                                \u003e\n                                    顯示整月\n                                \u003c/button\u003e\n                            \u003c/div\u003e\n                            {/* 八字排盤 */}\n                            {config?.showBazi === true \u0026\u0026 selectedLunar?.bazi \u0026\u0026 (\n                                \u003cdiv className=\"px-3 py-2 border-b border-border/30 bg-muted/10\"\u003e\n                                    \u003cdiv className=\"text-[10px] text-muted-foreground mb-1.5 font-medium\"\u003e🔮 八字排盤\u003c/div\u003e\n                                    \u003cdiv className=\"grid grid-cols-3 gap-2 text-center\"\u003e\n                                        {([\n                                            { label: '年柱', p: selectedLunar.bazi.yearPillar, g: selectedLunar.bazi.yearTenGod, h: selectedLunar.bazi.yearHidden, n: selectedLunar.bazi.yearNayin },\n                                            { label: '月柱', p: selectedLunar.bazi.monthPillar, g: selectedLunar.bazi.monthTenGod, h: selectedLunar.bazi.monthHidden, n: selectedLunar.bazi.monthNayin },\n                                            { label: '日柱', p: selectedLunar.bazi.dayPillar, g: selectedLunar.bazi.dayTenGod, h: selectedLunar.bazi.dayHidden, n: selectedLunar.bazi.dayNayin },\n                                        ] as const).map((col, i) =\u003e (\n                                            \u003cdiv key={i} className=\"flex flex-col items-center gap-0.5\"\u003e\n                                                \u003cdiv className=\"text-[10px] text-muted-foreground\"\u003e{col.label}\u003c/div\u003e\n                                                \u003cdiv className=\"text-sm font-bold text-foreground\"\u003e{col.p}\u003c/div\u003e\n                                                \u003cdiv className=\"text-[10px] text-amber-400\"\u003e{col.g}\u003c/div\u003e\n                                                \u003cdiv className=\"text-[10px] text-slate-400\"\u003e{col.h.join(' ')}\u003c/div\u003e\n                                                \u003cdiv className=\"text-[10px] text-emerald-300/70\"\u003e{col.n}\u003c/div\u003e\n                                            \u003c/div\u003e\n                                        ))}\n                                    \u003c/div\u003e\n                                \u003c/div\u003e\n                            )}\n                            {sorted.filter(t =\u003e t.date === filterDate).length === 0 ? (\n                                \u003cdiv className=\"py-8 text-center text-muted-foreground text-sm\"\u003e當日尚無記錄\u003c/div\u003e\n                            ) : (\n                                sorted.filter(t =\u003e t.date === filterDate).map(item =\u003e (\n                                    \u003cdiv\n                                        key={item.id}\n                                        className=\"flex items-center gap-3 px-3 py-2.5 border-b border-border/30 cursor-pointer hover:bg-accent/50\"\n                                        onClick={() =\u003e {\n                                            const tx = item;\n                                            setShowCalendar(false);\n                                            setFilterDate(null);\n                                            setTimeout(() =\u003e {\n                                                useSelectedItemsStore.getState().setSelectedItems([tx as unknown as BaseItem]);\n                                            }, 150);\n                                        }}\n                                    \u003e\n                                        \u003cdiv className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold flex-shrink-0 ${\n                                            item.type === 'income' ? 'bg-blue-50 text-[#36A2EB]' : 'bg-red-50 text-[#FF6384]'\n                                        }`}\u003e\n                                            {item.type === 'income' ? '收' : '支'}\n                                        \u003c/div\u003e\n                                        \u003cdiv className=\"flex-1 min-w-0\"\u003e\n                                            \u003cdiv className=\"text-sm font-medium truncate\"\u003e\n                                                {item.majorCategory}{item.subCategory ? ` / ${item.subCategory}` : ''}\n                                            \u003c/div\u003e\n                                            \u003cdiv className=\"text-xs text-muted-foreground truncate\"\u003e\n                                                {item.payee || ''}{item.location ? ` · ${item.location}` : ''}{item.note ? ` · ${item.note}` : ''}\n                                            \u003c/div\u003e\n                                        \u003c/div\u003e\n                                        \u003cdiv className={`text-sm font-bold whitespace-nowrap ${\n                                            item.type === 'income' ? 'text-[#36A2EB]' : 'text-[#FF6384]'\n                                        }`}\u003e\n                                            {item.type === 'income' ? '+' : '-'}{formatNum(item.amount || 0)}\n                                        \u003c/div\u003e\n                                    \u003c/div\u003e\n                                ))\n                            )}\n                        \u003c/div\u003e\n                    ) : null}\n                \u003c/div\u003e,\n                document.body\n            )}\n\n            {/* 交易記錄列表 — 行事曆關閉時填滿空間 */}\n            \u003cdiv className=\"flex-1 min-h-0 overflow-y-auto\"\u003e\n                {!folderId \u0026\u0026 (\n                    \u003cdiv className=\"flex-1 flex items-center justify-center py-12 px-6 text-muted-foreground text-sm text-center\"\u003e\n                        請從左側選擇記帳本\n                    \u003c/div\u003e\n                )}\n                {folderId \u0026\u0026 sorted.length === 0 \u0026\u0026 (\n                    \u003cdiv className=\"flex flex-col items-center gap-3 py-12 px-6 text-muted-foreground text-sm text-center\"\u003e\n                        \u003cdiv\u003e尚無記錄，請點選右上角 + 新增\u003c/div\u003e\n                        \u003cbutton\n                            type=\"button\"\n                            className=\"text-primary underline cursor-pointer\"\n                            onClick={() =\u003e setShowAddDialog(true)}\n                        \u003e\n                            新增第一筆記錄\n                        \u003c/button\u003e\n                    \u003c/div\u003e\n                )}\n                {folderId \u0026\u0026 sorted.length \u003e 0 \u0026\u0026 sorted.map(item =\u003e {\n                    const isChecked = selectedIds.has(item.id);\n                    return (\n                    \u003cdiv\n                        key={item.id}\n                        className={`flex items-center gap-2 px-3 py-2.5 border-b border-border transition-colors ${\n                            selectedId === item.id ? 'bg-accent' : 'hover:bg-accent/50'\n                        }`}\n                    \u003e\n                        {/* 核取方塊 */}\n                        \u003clabel className=\"flex-shrink-0 cursor-pointer\" onClick={e =\u003e e.stopPropagation()}\u003e\n                            \u003cinput\n                                type=\"checkbox\"\n                                checked={isChecked}\n                                onChange={() =\u003e toggleSelect(item.id)}\n                                className=\"w-4 h-4 rounded accent-primary cursor-pointer\"\n                            /\u003e\n                        \u003c/label\u003e\n                        {/* 內容區（可點擊進入編輯） */}\n                        \u003cdiv\n                            className=\"flex items-center gap-3 flex-1 min-w-0 cursor-pointer\"\n                            onClick={() =\u003e {\n                                useSelectedItemsStore.getState().setSelectedItems([item as unknown as BaseItem]);\n                            }}\n                        \u003e\n                            \u003cdiv className={`w-9 h-9 rounded-lg flex items-center justify-center text-sm font-bold flex-shrink-0 ${\n                                item.type === 'income'\n                                    ? 'bg-blue-50 text-[#36A2EB]'\n                                    : 'bg-red-50 text-[#FF6384]'\n                            }`}\u003e\n                                {item.type === 'income' ? '收' : '支'}\n                            \u003c/div\u003e\n                            \u003cdiv className=\"flex-1 min-w-0\"\u003e\n                                \u003cdiv className=\"text-sm font-medium truncate\"\u003e\n                                    {item.majorCategory}{item.subCategory ? ` / ${item.subCategory}` : ''}\n                                \u003c/div\u003e\n                                \u003cdiv className=\"text-xs text-muted-foreground truncate\"\u003e\n                                    {item.date}{item.payee ? ` · ${item.payee}` : ''}{item.location ? ` · ${item.location}` : ''}{item.note ? ` · ${item.note}` : ''}\n                                \u003c/div\u003e\n                            \u003c/div\u003e\n                            \u003cdiv className={`text-sm font-bold whitespace-nowrap ${\n                                item.type === 'income' ? 'text-[#36A2EB]' : 'text-[#FF6384]'\n                            }`}\u003e\n                                {item.type === 'income' ? '+' : '-'}{formatNum(item.amount || 0)}\n                            \u003c/div\u003e\n                        \u003c/div\u003e\n                    \u003c/div\u003e\n                    );\n                })}\n            \u003c/div\u003e\n\n            {showAddDialog \u0026\u0026 folderId \u0026\u0026 (\n                \u003cAddTransactionDialog\n                    folderId={folderId}\n                    onClose={() =\u003e setShowAddDialog(false)}\n                /\u003e\n            )}\n\n            {/* 刪除確認對話框（全部刪除 / 刪除所選） */}\n            {showDeleteConfirm \u0026\u0026 (() =\u003e {\n                const isAll = showDeleteConfirm === 'all';\n                const deleteCount = isAll ? sorted.length : selectedIds.size;\n                const deleteFn = isAll ? handleDeleteAll : handleDeleteSelected;\n                // 顯示前幾筆摘要\n                const previewItems = isAll\n                    ? sorted.slice(0, 5)\n                    : sorted.filter(i =\u003e selectedIds.has(i.id)).slice(0, 5);\n                const moreCount = deleteCount - previewItems.length;\n                return (\n                \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setShowDeleteConfirm(null); }}\u003e\n                    \u003cDialogContent className=\"max-w-sm\"\u003e\n                        \u003cDialogHeader\u003e\n                            \u003cDialogTitle\u003e{isAll ? '確認全部刪除' : '確認刪除所選記錄'}\u003c/DialogTitle\u003e\n                            \u003cDialogDescription\u003e\n                                將永久刪除 \u003cspan className=\"font-bold text-destructive\"\u003e{deleteCount}\u003c/span\u003e 筆記錄，此操作無法復原。\n                            \u003c/DialogDescription\u003e\n                        \u003c/DialogHeader\u003e\n                        {previewItems.length \u003e 0 \u0026\u0026 (\n                            \u003cdiv className=\"text-xs text-muted-foreground bg-muted/30 rounded-md p-2 flex flex-col gap-0.5 max-h-32 overflow-y-auto\"\u003e\n                                {previewItems.map(t =\u003e (\n                                    \u003cdiv key={t.id} className=\"truncate\"\u003e\n                                        {t.date} · {t.majorCategory}{t.subCategory ? `/${t.subCategory}` : ''} · {t.type === 'income' ? '+' : '-'}{formatNum(t.amount || 0)}\n                                    \u003c/div\u003e\n                                ))}\n                                {moreCount \u003e 0 \u0026\u0026 (\n                                    \u003cdiv className=\"text-muted-foreground/50\"\u003e...及其他 {moreCount} 筆\u003c/div\u003e\n                                )}\n                            \u003c/div\u003e\n                        )}\n                        \u003cDialogFooter className=\"flex gap-2\"\u003e\n                            \u003cButton variant=\"outline\" onClick={() =\u003e setShowDeleteConfirm(null)} disabled={deleting}\u003e\n                                取消\n                            \u003c/Button\u003e\n                            \u003cButton\n                                variant=\"default\"\n                                className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n                                onClick={deleteFn}\n                                disabled={deleting}\n                            \u003e\n                                {deleting ? '刪除中…' : '確認刪除'}\n                            \u003c/Button\u003e\n                        \u003c/DialogFooter\u003e\n                    \u003c/DialogContent\u003e\n                \u003c/Dialog\u003e\n                );\n            })()}\n\n                        {/* 功能說明 */}\n            {showProgramInfo \u0026\u0026 (\n                \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setShowProgramInfo(false); }}\u003e\n                    \u003cDialogContent className=\"max-w-lg max-h-[80vh] overflow-y-auto\"\u003e\n                        \u003cDialogHeader\u003e\n                            \u003cDialogTitle\u003e📖 收入與支出 — 功能說明\u003c/DialogTitle\u003e\n                        \u003c/DialogHeader\u003e\n                        \u003cdiv className=\"flex flex-col gap-5 text-sm leading-relaxed py-2\"\u003e\n\n                            {/* 版本紀錄 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e📋 版本紀錄\u003c/h3\u003e\n                                \u003cdiv className=\"flex flex-col gap-1.5 text-xs\"\u003e\n                                    \u003cdiv className=\"rounded-md bg-muted/50 border border-border/60 p-2\"\u003e\n                                        \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                            \u003cspan className=\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\"\u003ev1.4\u003c/span\u003e\n                                            \u003cspan className=\"text-muted-foreground\"\u003e2026/08/02\u003c/span\u003e\n                                        \u003c/div\u003e\n                                        \u003cp className=\"text-foreground\"\u003e萬年曆固定高度不再跳動；點選日期顯示八字排盤（三柱、十神、藏干、納音）；新增分類長條圖與折線圖；年度總覽儀表板；每月預算進度條；定期記帳推播提醒；長條圖/折線圖與圓餅圖共用篩選條件；年份選單改為下拉式。\u003c/p\u003e\n                                    \u003c/div\u003e\n                                    \u003cdiv className=\"rounded-md bg-muted/50 border border-border/60 p-2\"\u003e\n                                        \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                            \u003cspan className=\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\"\u003ev1.3\u003c/span\u003e\n                                            \u003cspan className=\"text-muted-foreground\"\u003e2026/07/27\u003c/span\u003e\n                                        \u003c/div\u003e\n                                        \u003cp className=\"text-foreground\"\u003e新增記錄日期不再自動填今天，改為空白讓使用者自由選取；表單版面優化（日期與金額同一行併排、GPS 定位按鈕改為緊湊圖示內嵌於輸入框右側、整體欄位間距收緊）。\u003c/p\u003e\n                                    \u003c/div\u003e\n                                    \u003cdiv className=\"rounded-md bg-muted/50 border border-border/60 p-2\"\u003e\n                                        \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                            \u003cspan className=\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\"\u003ev1.2\u003c/span\u003e\n                                            \u003cspan className=\"text-muted-foreground\"\u003e2026/07/27\u003c/span\u003e\n                                        \u003c/div\u003e\n                                        \u003cp className=\"text-foreground\"\u003e地點欄位新增定位按鈕（📍），透過 IP 定位取得座標後自動反查地址並填入欄位；編輯模式地點有值時自動出現地圖按鈕（🗺️），點擊用 Google Maps 搜尋該地點。\u003c/p\u003e\n                                    \u003c/div\u003e\n                                    \u003cdiv className=\"rounded-md bg-muted/50 border border-border/60 p-2\"\u003e\n                                        \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                            \u003cspan className=\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\"\u003ev1.1\u003c/span\u003e\n                                            \u003cspan className=\"text-muted-foreground\"\u003e2026/07/26\u003c/span\u003e\n                                        \u003c/div\u003e\n                                        \u003cp className=\"text-foreground\"\u003e新增與編輯記錄支援大小類與對象管理；大小類、對象支援長按拖曳排序；圓餅圖統計收入與支出分類佔比。\u003c/p\u003e\n                                    \u003c/div\u003e\n                                    \u003cdiv className=\"rounded-md bg-muted/50 border border-border/60 p-2\"\u003e\n                                        \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                            \u003cspan className=\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\"\u003ev1.0\u003c/span\u003e\n                                            \u003cspan className=\"text-muted-foreground\"\u003e2026/07/26\u003c/span\u003e\n                                        \u003c/div\u003e\n                                        \u003cp className=\"text-foreground\"\u003e收入與支出初版：記錄每月開銷與收入，用圓餅圖統計分類與對象，支援收入／支出切換、自訂大小類與付款對象。\u003c/p\u003e\n                                    \u003c/div\u003e\n                                \u003c/div\u003e\n                            \u003c/div\u003e\n\n                            {/* 萬年曆 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e📅 萬年曆\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e全螢幕月曆檢視，整合農曆資訊與記帳記錄。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e固定高度：\u003c/span\u003e30rem 固定高度，切換月份不跳動，網格各行等高等寬\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e手勢換月：\u003c/span\u003e支援左右滑動換月，點選日期篩選當日記錄\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e農曆資訊：\u003c/span\u003e農曆日期（初一～三十）、天干地支、二十四節氣\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e節日標示：\u003c/span\u003e農曆節日（春節、端午、中秋等）與人事行政總處國定假日\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e八字排盤：\u003c/span\u003e點選日期後顯示年柱／月柱／日柱，含十神、藏干、納音\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 記帳記錄 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e📝 記帳記錄\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e記錄每一筆收入與支出，支援完整欄位填寫與管理。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e收支類型：\u003c/span\u003e切換收入或支出，按鈕以藍色（收）／紅色（支）區分\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e日期：\u003c/span\u003e手動選擇任意日期，補記過去消費不受限\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e大小類：\u003c/span\u003e兩層分類（大類 → 小類），支援自訂與拖曳排序\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e金額：\u003c/span\u003e輸入數字，自動計算當月收支合計與結餘\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e對象：\u003c/span\u003e付款或收款對象，支援自訂與拖曳排序\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e地點：\u003c/span\u003e輸入地址或店名，或點擊 📍 按鈕自動定位目前地址\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e備注：\u003c/span\u003e自由輸入備注文字\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e編輯與刪除：\u003c/span\u003e點選記錄進入編輯模式，可修改所有欄位或刪除記錄\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e多選刪除：\u003c/span\u003e勾選多筆後一次刪除\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 統計圖表 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e📊 統計圖表\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e四種圖表類型視覺化呈現收支分佈與趨勢。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e圓餅圖：\u003c/span\u003e收支分類佔比，支援大類／小類／對象切換\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e長條圖：\u003c/span\u003e分時段（日／月）堆疊顯示各分類收支金額變化\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e折線圖：\u003c/span\u003e各分類收支趨勢線，一目瞭然消長走勢\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e年度總覽：\u003c/span\u003e全年收支合計、各月明細表、前五大支出類別\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e期間選擇：\u003c/span\u003e本週／本月／今年／全部／自訂區間\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e分類切換：\u003c/span\u003e大類／小類／對象三種維度\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 每月預算 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e💰 每月預算\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e設定支出預算上限，即時掌握消費節奏。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e預算設定：\u003c/span\u003e於月曆設定中設定每月預算金額\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e進度條：\u003c/span\u003e綠色→黃色→橙色→紅色四階段顯示已用比例\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e即時顯示：\u003c/span\u003e已用百分比與剩餘／超支金額\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 記帳提醒 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e🔔 記帳提醒\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e未記帳時自動發送推播通知，不怕忘記。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e提醒模式：\u003c/span\u003e支援每日或每週提醒\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e自訂時間：\u003c/span\u003e可設定提醒時間（每日）或星期幾（每週）\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e智慧跳過：\u003c/span\u003e當日已有記錄時自動跳過，不重複打擾\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 大小類與對象管理 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e🔄 大小類與對象管理\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e支援拖曳排序的管理介面，讓分類與對象清單保持整潔。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e大類：\u003c/span\u003e新增／編輯／刪除／拖曳排序\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e小類：\u003c/span\u003e掛在大類下，新增／編輯／刪除／拖曳排序\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e對象：\u003c/span\u003e收入對象與支出對象分開管理，支援拖曳排序\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e即時儲存：\u003c/span\u003e設定即時儲存，所有記錄自動反映最新分類\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 地點定位 */}\n                            \u003cdiv\u003e\n                                \u003ch3 className=\"font-bold text-foreground mb-1.5\"\u003e📍 地點定位\u003c/h3\u003e\n                                \u003cp className=\"text-muted-foreground mb-1\"\u003e點擊地點欄位旁的 📍 按鈕即可自動取得目前地址。\u003c/p\u003e\n                                \u003cul className=\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\"\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003e文字搜尋：\u003c/span\u003e輸入地址或店名，自動搜尋該地點地址（支援 Google Maps 或 Nominatim）\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003eGPS 定位：\u003c/span\u003e空白時使用瀏覽器 GPS 取得座標並自動反查繁體中文地址\u003c/li\u003e\n                                    \u003cli\u003e\u003cspan className=\"text-foreground font-medium\"\u003eGoogle Maps：\u003c/span\u003e地點有值時出現 🗺️ 按鈕，點擊用 Google Maps 搜尋\u003c/li\u003e\n                                \u003c/ul\u003e\n                            \u003c/div\u003e\n\n                            {/* 資料來源 */}\n                            \u003cdiv className=\"rounded-lg bg-muted/50 border border-border/60 p-3\"\u003e\n                                \u003ch3 className=\"font-bold text-foreground text-xs mb-1\"\u003e📡 資料來源\u003c/h3\u003e\n                                \u003cp className=\"text-xs text-muted-foreground\"\u003e\n                                    地點定位：瀏覽器 GPS + Google Maps API 或 OpenStreetMap Nominatim。國定假日：人事行政總處公告資料。記帳資料全部儲存在 CubeLV 本機，不經第三方服務。\n                                \u003c/p\u003e\n                            \u003c/div\u003e\n\n                        \u003c/div\u003e\n                        \u003cDialogFooter className=\"flex gap-2\"\u003e\n                            \u003cButton variant=\"outline\" onClick={handleCopyProgramInfo} disabled={copyDone}\u003e\n                                {copyDone ? '✓ 已複製' : \u003c\u003e\u003cCopyIcon /\u003e 複製說明\u003c/\u003e}\n                            \u003c/Button\u003e\n                            \u003cButton variant=\"outline\" onClick={() =\u003e setShowProgramInfo(false)}\u003e關閉\u003c/Button\u003e\n                        \u003c/DialogFooter\u003e\n                    \u003c/DialogContent\u003e\n                \u003c/Dialog\u003e\n            )}\n\n            {/* 月曆顯示設定 */}\n            \u003cSettingsView open={showSettings} onClose={() =\u003e setShowSettings(false)} /\u003e\n        \u003c/div\u003e\n    );\n}\n","createdAt":1785436692398,"deletedAt":null,"filePath":"frontend/views/TransactionListView.tsx","id":"44ce88dfd888a402d1ed3c92","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"TransactionListView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436692398},"preParentID":null,"updatedAt":1785888549972,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useState, useCallback } from 'react';\nimport { useSelectedItemsStore, PluginTopbar } from '@cubelv/sdk';\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\nimport { TransactionForm } from '../components/TransactionForm';\nimport { StatsView } from './StatsView';\n\nexport function TransactionDetailComponent() {\n    const [showStats, setShowStats] = useState(false);\n    const transaction = useSelectedItemsStore(s =\u003e\n        s.getPathItemByItemType('INCOME_EXPENSE_TRANSACTION')\n    ) as IncomeExpenseTransaction | null;\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\n\n    const handleSaved = useCallback(() =\u003e {\n    }, []);\n\n    if (showStats) {\n        return (\n            \u003cdiv className=\"flex flex-col h-full\"\u003e\n                \u003cPluginTopbar\n                    title=\"統計圖表\"\n                    rightButtons={[{\n                        icon: 'arrow-left',\n                        onClick: () =\u003e setShowStats(false),\n                        title: '返回記帳',\n                    }]}\n                /\u003e\n                \u003cStatsView /\u003e\n            \u003c/div\u003e\n        );\n    }\n\n    return (\n        \u003cdiv className=\"flex flex-col h-full\"\u003e\n            \u003cPluginTopbar\n                title={transaction ? '編輯記錄' : '記錄詳情'}\n                rightButtons={[{\n                    icon: 'PieChart',\n                    onClick: () =\u003e setShowStats(true),\n                    title: '統計圖表',\n                }]}\n            /\u003e\n            {!transaction ? (\n                \u003cdiv className=\"flex-1 flex items-center justify-center text-muted-foreground text-sm p-6 text-center\"\u003e\n                    點選左側記錄查看詳情\u003cbr /\u003e或按 + 新增記錄\n                \u003c/div\u003e\n            ) : (\n                \u003cTransactionForm\n                    key={transaction.id}\n                    transaction={transaction}\n                    folderId={folderId}\n                    onSaved={handleSaved}\n                /\u003e\n            )}\n        \u003c/div\u003e\n    );\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/views/TransactionDetailView.tsx","id":"a9ad748570eb6c180bf2c336","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"TransactionDetailView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436664272},"preParentID":null,"updatedAt":1785888549876,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useMemo, useState } from 'react';\nimport { useItemsByType, useSelectedItemsStore, Input, ToggleGroup, ToggleGroupItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@cubelv/sdk';\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\nimport { PieChart, PieSlice } from '../components/PieChart';\n\nconst COLORS = [\n    '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',\n    '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ADB5',\n    '#E83E8C', '#6610F2', '#20C997', '#FD7E14', '#6F42C1',\n];\nconst INCOME_COLOR = '#36A2EB';\nconst EXPENSE_COLOR = '#FF6384';\n\ntype GroupBy = 'major' | 'sub' | 'payee';\ntype Period = 'week' | 'month' | 'year' | 'all' | 'custom';\ntype ChartType = 'pie' | 'bar' | 'line' | 'annual';\n\nfunction getPeriodRange(period: Period, customStart?: string, customEnd?: string): { start: string; end: string } {\n    const now = new Date();\n    if (period === 'custom') return { start: customStart || '0000-01-01', end: customEnd || now.toISOString().slice(0, 10) };\n    const end = now.toISOString().slice(0, 10);\n    if (period === 'all') return { start: '0000-01-01', end };\n    if (period === 'year') return { start: `${now.getFullYear()}-01-01`, end };\n    if (period === 'month') {\n        const m = String(now.getMonth() + 1).padStart(2, '0');\n        return { start: `${now.getFullYear()}-${m}-01`, end };\n    }\n    const day = now.getDay();\n    const monday = new Date(now);\n    monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1));\n    return { start: monday.toISOString().slice(0, 10), end };\n}\n\nfunction formatNum(n: number): string {\n    return n.toLocaleString();\n}\n\nfunction niceMax(n: number): number {\n    if (n \u003c= 0) return 1000;\n    const mag = Math.pow(10, Math.floor(Math.log10(n)));\n    const r = Math.ceil(n / mag) * mag;\n    return r || mag;\n}\n\ntype CategorizedSeries = {\n    buckets: string[];\n    categories: string[];\n    values: Record\u003cstring, number[]\u003e;\n    total: number;\n};\n\n/** 依 period + groupBy 產生時間序列分類資料 */\nfunction computeCategorizedSeries(\n    data: IncomeExpenseTransaction[],\n    groupBy: GroupBy,\n    period: Period,\n    start: string,\n    end: string,\n): CategorizedSeries {\n    // 產生時間 buckets\n    const buckets: string[] = [];\n    const bucketMap = new Map\u003cstring, Map\u003cstring, number\u003e\u003e();\n    const allCats = new Map\u003cstring, number\u003e();\n\n    for (const item of data) {\n        if (!item.date) continue;\n        if (item.date \u003c start || item.date \u003e end) continue;\n\n        let bucket: string;\n        if (period === 'month') {\n            const d = parseInt(item.date.slice(8, 10), 10);\n            bucket = `${d}日`;\n        } else if (period === 'week') {\n            const day = parseInt(item.date.slice(8, 10), 10);\n            bucket = `${day}日`;\n        } else {\n            const m = parseInt(item.date.slice(5, 7), 10);\n            bucket = `${m}月`;\n        }\n\n        let cat = '';\n        if (groupBy === 'major') cat = item.majorCategory || '(未分類)';\n        else if (groupBy === 'sub') cat = item.subCategory || item.majorCategory || '(未分類)';\n        else cat = item.payee || '(未指定)';\n\n        if (!bucketMap.has(bucket)) bucketMap.set(bucket, new Map());\n        const catMap = bucketMap.get(bucket)!;\n        catMap.set(cat, (catMap.get(cat) || 0) + (item.amount || 0));\n        allCats.set(cat, (allCats.get(cat) || 0) + (item.amount || 0));\n    }\n\n    // 確保 buckets 有序\n    if (period === 'year' || period === 'all' || period === 'custom') {\n        for (let i = 1; i \u003c= 12; i++) {\n            const key = `${i}月`;\n            if (bucketMap.has(key)) buckets.push(key);\n            else if (period === 'year') buckets.push(key);\n        }\n    } else {\n        // 按數字排序\n        const keys = [...bucketMap.keys()].sort((a, b) =\u003e parseInt(a) - parseInt(b));\n        buckets.push(...keys);\n    }\n\n    if (buckets.length === 0) return { buckets: [], categories: [], values: {}, total: 0 };\n\n    // Top 5 categories\n    const topCats = [...allCats.entries()].sort((a, b) =\u003e b[1] - a[1]).slice(0, 5).map(([k]) =\u003e k);\n    const total = [...allCats.values()].reduce((s, v) =\u003e s + v, 0);\n\n    const values: Record\u003cstring, number[]\u003e = {};\n    for (const cat of topCats) {\n        values[cat] = buckets.map(b =\u003e bucketMap.get(b)?.get(cat) || 0);\n    }\n\n    return { buckets, categories: topCats, values, total };\n}\n\n/** SVG 分類長條圖：每個時間段顯示分類堆疊 */\nfunction CatBarChart({ series, colorClass }: { series: CategorizedSeries; colorClass: string }) {\n    const W = 600, H = 280, PAD_L = 50, PAD_R = 16, PAD_T = 16, PAD_B = 36;\n    const chartW = W - PAD_L - PAD_R;\n    const chartH = H - PAD_T - PAD_B;\n\n    const maxVal = niceMax(Math.max(...series.buckets.map(b =\u003e\n        series.categories.reduce((s, cat) =\u003e s + (series.values[cat]?.[series.buckets.indexOf(b)] || 0), 0)\n    ), 1000));\n\n    const n = series.buckets.length;\n    const barGap = 2;\n    const groupW = chartW / n;\n    const catCount = series.categories.length;\n    const barW = Math.max(1, (groupW - barGap * catCount) / catCount);\n\n    return (\n        \u003csvg viewBox={`0 0 ${W} ${H}`} className=\"w-full\" style={{ maxHeight: '280px' }}\u003e\n            {[0, 0.25, 0.5, 0.75, 1].map(p =\u003e {\n                const y = PAD_T + chartH * (1 - p);\n                return (\n                    \u003cg key={p}\u003e\n                        \u003cline x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke=\"var(--border)\" strokeWidth=\"0.5\" /\u003e\n                        \u003ctext x={PAD_L - 6} y={y + 4} textAnchor=\"end\" className=\"text-[10px]\" fill=\"var(--muted-foreground)\"\u003e\n                            {Math.round(maxVal * p).toLocaleString()}\n                        \u003c/text\u003e\n                    \u003c/g\u003e\n                );\n            })}\n            {series.buckets.map((b, bi) =\u003e {\n                let yBase = PAD_T + chartH;\n                return (\n                    \u003cg key={b}\u003e\n                        {series.categories.map((cat, ci) =\u003e {\n                            const v = series.values[cat]?.[bi] || 0;\n                            const h = maxVal \u003e 0 ? (v / maxVal) * chartH : 0;\n                            yBase -= h;\n                            return (\n                                \u003crect key={cat} x={PAD_L + bi * groupW + ci * (barW + barGap)} y={yBase}\n                                    width={barW} height={Math.max(0.5, h)} fill={COLORS[ci % COLORS.length]} rx=\"1\" /\u003e\n                            );\n                        })}\n                        \u003ctext x={PAD_L + bi * groupW + groupW / 2} y={H - 8} textAnchor=\"middle\" className=\"text-[10px]\" fill=\"var(--muted-foreground)\"\u003e\n                            {b}\n                        \u003c/text\u003e\n                    \u003c/g\u003e\n                );\n            })}\n        \u003c/svg\u003e\n    );\n}\n\n/** SVG 分類折線圖 */\nfunction CatLineChart({ series, colorClass }: { series: CategorizedSeries; colorClass: string }) {\n    const W = 600, H = 280, PAD_L = 50, PAD_R = 16, PAD_T = 16, PAD_B = 36;\n    const chartW = W - PAD_L - PAD_R;\n    const chartH = H - PAD_T - PAD_B;\n\n    const allVals = series.categories.flatMap(cat =\u003e series.values[cat] || []);\n    const maxVal = niceMax(Math.max(...allVals, 1000));\n    const xStep = chartW / Math.max(series.buckets.length - 1, 1);\n\n    return (\n        \u003csvg viewBox={`0 0 ${W} ${H}`} className=\"w-full\" style={{ maxHeight: '280px' }}\u003e\n            {[0, 0.25, 0.5, 0.75, 1].map(p =\u003e {\n                const y = PAD_T + chartH * (1 - p);\n                return (\n                    \u003cg key={p}\u003e\n                        \u003cline x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke=\"var(--border)\" strokeWidth=\"0.5\" /\u003e\n                        \u003ctext x={PAD_L - 6} y={y + 4} textAnchor=\"end\" className=\"text-[10px]\" fill=\"var(--muted-foreground)\"\u003e\n                            {Math.round(maxVal * p).toLocaleString()}\n                        \u003c/text\u003e\n                    \u003c/g\u003e\n                );\n            })}\n            {series.categories.map((cat, ci) =\u003e {\n                const pts = series.values[cat]?.map((v, bi) =\u003e\n                    `${PAD_L + bi * xStep},${PAD_T + chartH * (1 - v / maxVal)}`\n                ).join(' ') || '';\n                return (\n                    \u003cg key={cat}\u003e\n                        \u003cpolyline points={pts} fill=\"none\" stroke={COLORS[ci % COLORS.length]} strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" /\u003e\n                        {series.values[cat]?.map((v, bi) =\u003e (\n                            \u003ccircle key={bi} cx={PAD_L + bi * xStep} cy={PAD_T + chartH * (1 - v / maxVal)} r=\"2.5\" fill={COLORS[ci % COLORS.length]} /\u003e\n                        ))}\n                    \u003c/g\u003e\n                );\n            })}\n            {series.buckets.map((b, bi) =\u003e (\n                \u003ctext key={b} x={PAD_L + bi * xStep} y={H - 8} textAnchor=\"middle\" className=\"text-[10px]\" fill=\"var(--muted-foreground)\"\u003e\n                    {b}\n                \u003c/text\u003e\n            ))}\n            {/* Legend */}\n            {series.categories.map((cat, ci) =\u003e (\n                \u003cg key={`leg-${cat}`}\u003e\n                    \u003crect x={PAD_L + 8 + ci * 70} y={PAD_T + 2} width={8} height={8} rx=\"2\" fill={COLORS[ci % COLORS.length]} /\u003e\n                    \u003ctext x={PAD_L + 19 + ci * 70} y={PAD_T + 10} className=\"text-[9px]\" fill=\"var(--foreground)\"\u003e{cat.length \u003e 4 ? cat.slice(0, 4) + '…' : cat}\u003c/text\u003e\n                \u003c/g\u003e\n            ))}\n        \u003c/svg\u003e\n    );\n}\n\nexport function StatsView() {\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\n    const items = useItemsByType\u003cIncomeExpenseTransaction\u003e('INCOME_EXPENSE_TRANSACTION', folderId || undefined);\n\n    const [chartType, setChartType] = useState\u003cChartType\u003e('pie');\n    const [period, setPeriod] = useState\u003cPeriod\u003e('month');\n    const [trendYear, setTrendYear] = useState\u003cnumber\u003e(new Date().getFullYear());\n    const [groupBy, setGroupBy] = useState\u003cGroupBy\u003e('major');\n    const [customStart, setCustomStart] = useState('');\n    const [customEnd, setCustomEnd] = useState('');\n\n    const { start, end } = useMemo(\n        () =\u003e getPeriodRange(period, customStart, customEnd),\n        [period, customStart, customEnd]\n    );\n\n    const filtered = useMemo(() =\u003e\n        items.filter(item =\u003e item.date \u0026\u0026 item.date \u003e= start \u0026\u0026 item.date \u003c= end),\n        [items, start, end]\n    );\n\n    const incomeItems = useMemo(() =\u003e filtered.filter(i =\u003e i.type === 'income'), [filtered]);\n    const expenseItems = useMemo(() =\u003e filtered.filter(i =\u003e i.type === 'expense'), [filtered]);\n\n    const computeSlices = (data: IncomeExpenseTransaction[]): { slices: PieSlice[]; total: number } =\u003e {\n        const groups = new Map\u003cstring, number\u003e();\n        let total = 0;\n        for (const item of data) {\n            let key = '';\n            if (groupBy === 'major') key = item.majorCategory || '(未分類)';\n            else if (groupBy === 'sub') key = item.subCategory || item.majorCategory || '(未分類)';\n            else key = item.payee || '(未指定)';\n            const amt = item.amount || 0;\n            groups.set(key, (groups.get(key) || 0) + amt);\n            total += amt;\n        }\n        const entries = [...groups.entries()].sort((a, b) =\u003e b[1] - a[1]);\n        const slices: PieSlice[] = entries.map(([label, value], i) =\u003e ({\n            label, value, color: COLORS[i % COLORS.length],\n        }));\n        return { slices, total };\n    };\n\n    const incomeData = useMemo(() =\u003e computeSlices(incomeItems), [incomeItems, groupBy]);\n    const expenseData = useMemo(() =\u003e computeSlices(expenseItems), [expenseItems, groupBy]);\n\n    // ===== 長條圖 / 折線圖：用 period + groupBy =====\n    const barIncomeSeries = useMemo(() =\u003e\n        computeCategorizedSeries(incomeItems, groupBy, period, start, end),\n        [incomeItems, groupBy, period, start, end]\n    );\n    const barExpenseSeries = useMemo(() =\u003e\n        computeCategorizedSeries(expenseItems, groupBy, period, start, end),\n        [expenseItems, groupBy, period, start, end]\n    );\n\n    // ===== 年度總覽 =====\n    const annualData = useMemo(() =\u003e {\n        const yearItems = items.filter(item =\u003e item.date \u0026\u0026 item.date.startsWith(`${trendYear}-`));\n        const totalIncome = yearItems.filter(i =\u003e i.type === 'income').reduce((s, i) =\u003e s + (i.amount || 0), 0);\n        const totalExpense = yearItems.filter(i =\u003e i.type === 'expense').reduce((s, i) =\u003e s + (i.amount || 0), 0);\n        const catMap = new Map\u003cstring, number\u003e();\n        for (const item of yearItems) {\n            const key = item.majorCategory || '(未分類)';\n            catMap.set(key, (catMap.get(key) || 0) + (item.amount || 0));\n        }\n        const topCats = [...catMap.entries()].sort((a, b) =\u003e b[1] - a[1]).slice(0, 5);\n        const monthTable = Array.from({ length: 12 }, (_, i) =\u003e {\n            const key = `${trendYear}-${String(i + 1).padStart(2, '0')}`;\n            const mItems = items.filter(t =\u003e t.date \u0026\u0026 t.date.startsWith(key));\n            const inc = mItems.filter(t =\u003e t.type === 'income').reduce((s, t) =\u003e s + (t.amount || 0), 0);\n            const exp = mItems.filter(t =\u003e t.type === 'expense').reduce((s, t) =\u003e s + (t.amount || 0), 0);\n            return { month: `${i + 1}月`, income: inc, expense: exp, balance: inc - exp };\n        });\n        return { totalIncome, totalExpense, balance: totalIncome - totalExpense, topCats, monthTable, hasData: yearItems.length \u003e 0 };\n    }, [items, trendYear]);\n\n    const availableYears = useMemo(() =\u003e {\n        const years = new Set\u003cnumber\u003e();\n        years.add(new Date().getFullYear());\n        for (const item of items) {\n            if (item.date) years.add(parseInt(item.date.slice(0, 4), 10));\n        }\n        return [...years].sort((a, b) =\u003e b - a);\n    }, [items]);\n\n    const periodLabels: Record\u003cPeriod, string\u003e = { week: '本週', month: '本月', year: '今年', all: '全部', custom: '自訂' };\n    const groupLabels: Record\u003cGroupBy, string\u003e = { major: '大類', sub: '小類', payee: '對象' };\n    const chartTypeLabels: Record\u003cChartType, string\u003e = { pie: '圓餅', bar: '長條', line: '折線', annual: '年度' };\n\n    function PieSection({ title, data, colorClass }: {\n        title: string; data: { slices: PieSlice[]; total: number }; colorClass: string;\n    }) {\n        return (\n            \u003cdiv className=\"mb-6\"\u003e\n                \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title}\u003c/div\u003e\n                {data.total === 0 ? (\n                    \u003cdiv className=\"py-5 text-center text-muted-foreground text-sm\"\u003e尚無資料\u003c/div\u003e\n                ) : (\n                    \u003cdiv className=\"flex gap-4 items-start flex-wrap\"\u003e\n                        \u003cPieChart slices={data.slices} total={data.total} size={200} /\u003e\n                        \u003ctable className=\"flex-1 min-w-[150px] text-xs border-collapse\"\u003e\n                            \u003cthead\u003e\n                                \u003ctr className=\"border-b-2 border-border\"\u003e\n                                    \u003cth className=\"text-left py-1 px-1.5\"\u003e{groupLabels[groupBy]}\u003c/th\u003e\n                                    \u003cth className=\"text-right py-1 px-1.5\"\u003e金額\u003c/th\u003e\n                                    \u003cth className=\"text-right py-1 px-1.5\"\u003e佔比\u003c/th\u003e\n                                \u003c/tr\u003e\n                            \u003c/thead\u003e\n                            \u003ctbody\u003e\n                                {data.slices.map((s, i) =\u003e (\n                                    \u003ctr key={i} className=\"border-b border-border/50\"\u003e\n                                        \u003ctd className=\"py-1 px-1.5\"\u003e\n                                            \u003cspan className=\"inline-block w-2.5 h-2.5 rounded-sm mr-1.5 align-middle\" style={{ background: s.color }} /\u003e\n                                            {s.label}\n                                        \u003c/td\u003e\n                                        \u003ctd className=\"text-right py-1 px-1.5 font-medium\"\u003e{formatNum(s.value)}\u003c/td\u003e\n                                        \u003ctd className=\"text-right py-1 px-1.5 text-muted-foreground\"\u003e\n                                            {((s.value / data.total) * 100).toFixed(1)}%\n                                        \u003c/td\u003e\n                                    \u003c/tr\u003e\n                                ))}\n                            \u003c/tbody\u003e\n                            \u003ctfoot\u003e\n                                \u003ctr className=\"border-t-2 border-border font-bold\"\u003e\n                                    \u003ctd className=\"py-1 px-1.5\"\u003e合計\u003c/td\u003e\n                                    \u003ctd className=\"text-right py-1 px-1.5\"\u003e{formatNum(data.total)}\u003c/td\u003e\n                                    \u003ctd className=\"text-right py-1 px-1.5\"\u003e100%\u003c/td\u003e\n                                \u003c/tr\u003e\n                            \u003c/tfoot\u003e\n                        \u003c/table\u003e\n                    \u003c/div\u003e\n                )}\n            \u003c/div\u003e\n        );\n    }\n\n    function SeriesSection({ title, series, colorClass }: {\n        title: string; series: CategorizedSeries; colorClass: string;\n    }) {\n        if (series.total === 0) {\n            return (\n                \u003cdiv className=\"mb-6\"\u003e\n                    \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title}\u003c/div\u003e\n                    \u003cdiv className=\"py-5 text-center text-muted-foreground text-sm\"\u003e尚無資料\u003c/div\u003e\n                \u003c/div\u003e\n            );\n        }\n        return (\n            \u003cdiv className=\"mb-6\"\u003e\n                \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title} — {formatNum(series.total)}\u003c/div\u003e\n                {/* Legend */}\n                \u003cdiv className=\"flex flex-wrap gap-x-4 gap-y-1 mb-2 text-[10px]\"\u003e\n                    {series.categories.map((cat, ci) =\u003e (\n                        \u003cspan key={cat} className=\"flex items-center gap-1\"\u003e\n                            \u003cspan className=\"w-2 h-2 rounded-sm inline-block\" style={{ background: COLORS[ci % COLORS.length] }} /\u003e\n                            {cat}\n                        \u003c/span\u003e\n                    ))}\n                \u003c/div\u003e\n                {chartType === 'bar'\n                    ? \u003cCatBarChart series={series} colorClass={colorClass} /\u003e\n                    : \u003cCatLineChart series={series} colorClass={colorClass} /\u003e\n                }\n            \u003c/div\u003e\n        );\n    }\n\n    const isTimelineChart = chartType === 'bar' || chartType === 'line';\n\n    return (\n        \u003cdiv className=\"flex-1 overflow-y-auto p-4\"\u003e\n            {/* 圖表類型切換 */}\n            \u003cdiv className=\"mb-3\"\u003e\n                \u003cToggleGroup type=\"single\" value={chartType} onValueChange={(v) =\u003e v \u0026\u0026 setChartType(v as ChartType)}\u003e\n                    {(Object.entries(chartTypeLabels) as [ChartType, string][]).map(([k, label]) =\u003e (\n                        \u003cToggleGroupItem key={k} value={k} className=\"text-xs px-3\"\u003e{label}圖\u003c/ToggleGroupItem\u003e\n                    ))}\n                \u003c/ToggleGroup\u003e\n            \u003c/div\u003e\n\n            {/* 期間選擇器（圓餅 / 長條 / 折線） */}\n            {chartType !== 'annual' \u0026\u0026 (\n                \u003c\u003e\n                    \u003cdiv className=\"flex gap-1.5 mb-3 flex-wrap\"\u003e\n                        {(Object.keys(periodLabels) as Period[]).map(p =\u003e (\n                            \u003cbutton key={p} onClick={() =\u003e setPeriod(p)}\n                                className={`px-3 py-1 rounded-full text-xs border transition-colors ${\n                                    period === p\n                                        ? 'bg-primary text-primary-foreground border-primary'\n                                        : 'border-border text-muted-foreground hover:bg-accent'\n                                }`}\n                            \u003e{periodLabels[p]}\u003c/button\u003e\n                        ))}\n                    \u003c/div\u003e\n\n                    {period === 'custom' \u0026\u0026 (\n                        \u003cdiv className=\"flex items-center gap-2 mb-3 flex-wrap\"\u003e\n                            \u003cInput type=\"date\" value={customStart} onChange={e =\u003e setCustomStart(e.target.value)} className=\"w-[140px] h-8 text-xs\" /\u003e\n                            \u003cspan className=\"text-xs text-muted-foreground\"\u003e～\u003c/span\u003e\n                            \u003cInput type=\"date\" value={customEnd} onChange={e =\u003e setCustomEnd(e.target.value)} className=\"w-[140px] h-8 text-xs\" /\u003e\n                        \u003c/div\u003e\n                    )}\n\n                    \u003cdiv className=\"text-xs text-muted-foreground mb-3\"\u003e{start} ～ {end}\u003c/div\u003e\n                \u003c/\u003e\n            )}\n\n            {/* 分類選擇器（圓餅 / 長條 / 折線） */}\n            {chartType !== 'annual' \u0026\u0026 (\n                \u003cdiv className=\"flex gap-1.5 mb-4\"\u003e\n                    {(Object.entries(groupLabels) as [GroupBy, string][]).map(([k, label]) =\u003e (\n                        \u003cbutton key={k} onClick={() =\u003e setGroupBy(k)}\n                            className={`px-3 py-1 rounded-md text-xs border transition-colors ${\n                                groupBy === k\n                                    ? 'bg-secondary text-secondary-foreground border-secondary'\n                                    : 'border-border text-muted-foreground hover:bg-accent'\n                            }`}\n                        \u003e{label}\u003c/button\u003e\n                    ))}\n                \u003c/div\u003e\n            )}\n\n            {/* 圓餅圖 */}\n            {chartType === 'pie' \u0026\u0026 (\n                \u003c\u003e\n                    \u003cPieSection title=\"📈 收入分佈\" data={incomeData} colorClass=\"text-[#36A2EB]\" /\u003e\n                    \u003cPieSection title=\"📉 支出分佈\" data={expenseData} colorClass=\"text-[#FF6384]\" /\u003e\n                \u003c/\u003e\n            )}\n\n            {/* 長條圖 / 折線圖：共用期間 + 分類 */}\n            {isTimelineChart \u0026\u0026 (\n                \u003c\u003e\n                    \u003cSeriesSection title=\"📈 收入\" series={barIncomeSeries} colorClass=\"text-[#36A2EB]\" /\u003e\n                    \u003cSeriesSection title=\"📉 支出\" series={barExpenseSeries} colorClass=\"text-[#FF6384]\" /\u003e\n                \u003c/\u003e\n            )}\n\n            {/* 年度總覽 */}\n            {chartType === 'annual' \u0026\u0026 (\n                \u003cdiv\u003e\n                    {/* 年份選擇器 */}\n                    \u003cdiv className=\"flex items-center gap-2 mb-3\"\u003e\n                        \u003cspan className=\"text-xs text-muted-foreground flex-shrink-0\"\u003e年份：\u003c/span\u003e\n                        \u003cSelect value={String(trendYear)} onValueChange={(v) =\u003e v \u0026\u0026 setTrendYear(Number(v))}\u003e\n                            \u003cSelectTrigger className=\"w-28 h-8 text-xs\"\u003e\n                                \u003cSelectValue /\u003e\n                            \u003c/SelectTrigger\u003e\n                            \u003cSelectContent\u003e\n                                {availableYears.map(y =\u003e (\n                                    \u003cSelectItem key={y} value={String(y)}\u003e{y} 年\u003c/SelectItem\u003e\n                                ))}\n                            \u003c/SelectContent\u003e\n                        \u003c/Select\u003e\n                    \u003c/div\u003e\n\n                    {annualData.hasData ? (\n                        \u003c\u003e\n                            \u003cdiv className=\"grid grid-cols-3 gap-3 mb-4\"\u003e\n                                \u003cdiv className=\"rounded-xl bg-[#36A2EB]/10 border border-[#36A2EB]/20 p-3 text-center\"\u003e\n                                    \u003cdiv className=\"text-xs text-muted-foreground mb-0.5\"\u003e總收入\u003c/div\u003e\n                                    \u003cdiv className=\"text-lg font-bold text-[#36A2EB]\"\u003e{formatNum(annualData.totalIncome)}\u003c/div\u003e\n                                \u003c/div\u003e\n                                \u003cdiv className=\"rounded-xl bg-[#FF6384]/10 border border-[#FF6384]/20 p-3 text-center\"\u003e\n                                    \u003cdiv className=\"text-xs text-muted-foreground mb-0.5\"\u003e總支出\u003c/div\u003e\n                                    \u003cdiv className=\"text-lg font-bold text-[#FF6384]\"\u003e{formatNum(annualData.totalExpense)}\u003c/div\u003e\n                                \u003c/div\u003e\n                                \u003cdiv className={`rounded-xl border p-3 text-center ${\n                                    annualData.balance \u003e= 0 ? 'bg-[#22C55E]/10 border-[#22C55E]/20' : 'bg-[#FF6384]/10 border-[#FF6384]/20'\n                                }`}\u003e\n                                    \u003cdiv className=\"text-xs text-muted-foreground mb-0.5\"\u003e結餘\u003c/div\u003e\n                                    \u003cdiv className={`text-lg font-bold ${annualData.balance \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\n                                        {annualData.balance \u003e= 0 ? '+' : ''}{formatNum(annualData.balance)}\n                                    \u003c/div\u003e\n                                \u003c/div\u003e\n                            \u003c/div\u003e\n\n                            \u003cdiv className=\"text-sm font-semibold mb-2 text-foreground\"\u003e📋 各月明細\u003c/div\u003e\n                            \u003cdiv className=\"overflow-x-auto mb-4\"\u003e\n                                \u003ctable className=\"w-full text-xs border-collapse\"\u003e\n                                    \u003cthead\u003e\n                                        \u003ctr className=\"border-b-2 border-border\"\u003e\n                                            \u003cth className=\"text-left py-1 px-2\"\u003e月份\u003c/th\u003e\n                                            \u003cth className=\"text-right py-1 px-2\"\u003e收入\u003c/th\u003e\n                                            \u003cth className=\"text-right py-1 px-2\"\u003e支出\u003c/th\u003e\n                                            \u003cth className=\"text-right py-1 px-2\"\u003e結餘\u003c/th\u003e\n                                        \u003c/tr\u003e\n                                    \u003c/thead\u003e\n                                    \u003ctbody\u003e\n                                        {annualData.monthTable.map(r =\u003e (\n                                            \u003ctr key={r.month} className={`border-b border-border/50 ${r.income === 0 \u0026\u0026 r.expense === 0 ? 'opacity-30' : ''}`}\u003e\n                                                \u003ctd className=\"py-1 px-2\"\u003e{r.month}\u003c/td\u003e\n                                                \u003ctd className=\"text-right py-1 px-2\"\u003e{r.income \u003e 0 ? formatNum(r.income) : '-'}\u003c/td\u003e\n                                                \u003ctd className=\"text-right py-1 px-2\"\u003e{r.expense \u003e 0 ? formatNum(r.expense) : '-'}\u003c/td\u003e\n                                                \u003ctd className={`text-right py-1 px-2 font-medium ${r.balance \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\n                                                    {r.balance === 0 \u0026\u0026 r.income === 0 \u0026\u0026 r.expense === 0 ? '-' : `${r.balance \u003e= 0 ? '+' : ''}${formatNum(r.balance)}`}\n                                                \u003c/td\u003e\n                                            \u003c/tr\u003e\n                                        ))}\n                                    \u003c/tbody\u003e\n                                \u003c/table\u003e\n                            \u003c/div\u003e\n\n                            {annualData.topCats.length \u003e 0 \u0026\u0026 (\n                                \u003cdiv\u003e\n                                    \u003cdiv className=\"text-sm font-semibold mb-2 text-foreground\"\u003e🏷️ 前五大支出類別\u003c/div\u003e\n                                    {annualData.topCats.map(([cat, amt], i) =\u003e {\n                                        const pct = annualData.totalExpense \u003e 0 ? (amt / annualData.totalExpense) * 100 : 0;\n                                        return (\n                                            \u003cdiv key={cat} className=\"flex items-center gap-2 mb-1\"\u003e\n                                                \u003cspan className=\"w-5 text-right text-xs text-muted-foreground\"\u003e{i + 1}.\u003c/span\u003e\n                                                \u003cspan className=\"text-xs flex-1\"\u003e{cat}\u003c/span\u003e\n                                                \u003cspan className=\"text-xs text-muted-foreground w-12 text-right\"\u003e{pct.toFixed(1)}%\u003c/span\u003e\n                                                \u003cspan className=\"text-xs font-medium w-20 text-right\"\u003e{formatNum(amt)}\u003c/span\u003e\n                                                \u003cdiv className=\"w-24 h-1.5 rounded-full bg-muted overflow-hidden\"\u003e\n                                                    \u003cdiv className=\"h-full rounded-full\" style={{ width: `${Math.min(pct, 100)}%`, background: COLORS[i % COLORS.length] }} /\u003e\n                                                \u003c/div\u003e\n                                            \u003c/div\u003e\n                                        );\n                                    })}\n                                \u003c/div\u003e\n                            )}\n                        \u003c/\u003e\n                    ) : (\n                        \u003cdiv className=\"py-12 text-center text-muted-foreground text-sm\"\u003e{trendYear} 年尚無記錄\u003c/div\u003e\n                    )}\n                \u003c/div\u003e\n            )}\n        \u003c/div\u003e\n    );\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/views/StatsView.tsx","id":"3e842ae8555655c5a4f115ff","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"StatsView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436664172},"preParentID":null,"updatedAt":1785888549782,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useCallback, useState } from 'react';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, fetchExternal } from '@cubelv/sdk';\nimport { useIncomeExpenseConfig, updateIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\nimport { useGoogleMapsConfig, updateGoogleMapsConfig } from '../stores/googleMapsConfigStore';\nimport { CategoryManagerDialog, PayeeManagerDialog } from '../components/AddTransactionDialog';\nimport { ensureGovtHolidays, clearGovtHolidayCache, loadGovtHolidaysFromData } from '../lunarCalendar';\n\ninterface SettingsViewProps {\n  open: boolean;\n  onClose: () =\u003e void;\n}\n\nfunction ToggleRow({ label, desc, checked, onChange }: {\n  label: string;\n  desc: string;\n  checked: boolean;\n  onChange: (v: boolean) =\u003e void;\n}) {\n  return (\n    \u003clabel className=\"flex items-center justify-between py-2.5 px-1 border-b border-border/40 cursor-pointer hover:bg-accent/20 transition-colors\"\u003e\n      \u003cdiv className=\"flex flex-col gap-0.5\"\u003e\n        \u003cspan className=\"text-sm font-medium text-foreground\"\u003e{label}\u003c/span\u003e\n        \u003cspan className=\"text-xs text-muted-foreground\"\u003e{desc}\u003c/span\u003e\n      \u003c/div\u003e\n      \u003cdiv className=\"relative flex-shrink-0 ml-3\"\u003e\n        \u003cinput\n          type=\"checkbox\"\n          checked={checked}\n          onChange={e =\u003e onChange(e.target.checked)}\n          className=\"sr-only peer\"\n        /\u003e\n        \u003cdiv className=\"w-10 h-5 rounded-full bg-muted-foreground/30 peer-checked:bg-primary transition-colors\" /\u003e\n        \u003cdiv className=\"absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5\" /\u003e\n      \u003c/div\u003e\n    \u003c/label\u003e\n  );\n}\n\nexport function SettingsView({ open, onClose }: SettingsViewProps) {\n  const config = useIncomeExpenseConfig();\n  const gmapsConfig = useGoogleMapsConfig();\n\n  const toggle = useCallback((key: string) =\u003e (val: boolean) =\u003e {\n    updateIncomeExpenseConfig({ [key]: val });\n  }, []);\n\n  // Google Maps 設定\n  const [gmapsApiKey, setGmapsApiKey] = useState(gmapsConfig?.apiKey || '');\n  const [gmapsEnabled, setGmapsEnabled] = useState(gmapsConfig?.enabled || false);\n\n  // 大小類 / 對象管理對話框狀態\n  const [catManagerStep, setCatManagerStep] = useState\u003c'pick' | 'income' | 'expense' | null\u003e(null);\n  const [payeeManagerStep, setPayeeManagerStep] = useState\u003c'pick' | 'income' | 'expense' | null\u003e(null);\n  const [holidayUpdating, setHolidayUpdating] = useState(false);\n\n  if (!config || !gmapsConfig) return null;\n\n  return (\n    \u003cDialog open={open} onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\n      \u003cDialogContent className=\"max-w-md max-h-[85vh] overflow-y-auto\"\u003e\n        \u003cDialogHeader\u003e\n          \u003cDialogTitle\u003e⚙️ 月曆顯示設定\u003c/DialogTitle\u003e\n          \u003cDialogDescription\u003e\n            控制萬年曆上顯示的資訊內容\n          \u003c/DialogDescription\u003e\n        \u003c/DialogHeader\u003e\n\n        \u003cdiv className=\"flex gap-2 px-1 pt-2 pb-1\"\u003e\n          \u003cButton variant=\"outline\" size=\"sm\" className=\"flex-1 text-xs\" onClick={() =\u003e setCatManagerStep('pick')}\u003e\n            📂 管理大小類別\n          \u003c/Button\u003e\n          \u003cButton variant=\"outline\" size=\"sm\" className=\"flex-1 text-xs\" onClick={() =\u003e setPayeeManagerStep('pick')}\u003e\n            👤 管理對象\n          \u003c/Button\u003e\n        \u003c/div\u003e\n\n        \u003cdiv className=\"flex flex-col py-2\"\u003e\n          \u003cToggleRow\n            label=\"Google Maps 搜尋\"\n            desc=\"啟用 Google Maps API 做地點搜尋（需填 API 金鑰）\"\n            checked={gmapsEnabled}\n            onChange={(v) =\u003e {\n              setGmapsEnabled(v);\n              updateGoogleMapsConfig({ enabled: v });\n            }}\n          /\u003e\n          {gmapsEnabled \u0026\u0026 (\n            \u003cdiv className=\"px-1 py-2 border-b border-border/40\"\u003e\n              \u003cdiv className=\"text-xs text-muted-foreground mb-1.5\"\u003eGoogle Cloud API 金鑰\u003c/div\u003e\n              \u003cInput\n                type=\"password\"\n                value={gmapsApiKey}\n                onChange={e =\u003e setGmapsApiKey(e.target.value)}\n                onBlur={() =\u003e {\n                  if (gmapsApiKey !== (gmapsConfig?.apiKey || '')) {\n                    updateGoogleMapsConfig({ apiKey: gmapsApiKey });\n                  }\n                }}\n                placeholder=\"AIza...\"\n                className=\"text-xs\"\n              /\u003e\n              \u003cdiv className=\"text-[10px] text-muted-foreground mt-1\"\u003e\n                需開通 Geocoding API 與 Places API，每月 $200 免費額度\n              \u003c/div\u003e\n            \u003c/div\u003e\n          )}\n          \u003cToggleRow\n            label=\"農曆日期\"\n            desc=\"顯示農曆初幾（如：初一、十五）\"\n            checked={config.showLunarDate !== false}\n            onChange={toggle('showLunarDate')}\n          /\u003e\n          \u003cToggleRow\n            label=\"天干地支\"\n            desc=\"顯示日干支（如：甲子、乙丑）\"\n            checked={config.showGanZhi !== false}\n            onChange={toggle('showGanZhi')}\n          /\u003e\n          \u003cToggleRow\n            label=\"二十四節氣\"\n            desc=\"顯示當日節氣名稱（如：立春、清明）\"\n            checked={config.showSolarTerm !== false}\n            onChange={toggle('showSolarTerm')}\n          /\u003e\n          \u003cToggleRow\n            label=\"農曆節日\"\n            desc=\"顯示傳統節日（春節、端午、中秋、媽祖生等）\"\n            checked={config.showLunarFestival !== false}\n            onChange={toggle('showLunarFestival')}\n          /\u003e\n          \u003cToggleRow\n            label=\"國定假日\"\n            desc=\"顯示人事行政總處公告之國定假日\"\n            checked={config.showNationalHoliday !== false}\n            onChange={toggle('showNationalHoliday')}\n          /\u003e\n          {config.showNationalHoliday !== false \u0026\u0026 (\n            \u003cdiv className=\"px-1 py-2 border-b border-border/40 flex items-center justify-between\"\u003e\n              \u003cspan className=\"text-xs text-muted-foreground\"\u003e\n                {config.govtHolidayLastUpdate\n                  ? `上次更新：${(() =\u003e {\n                      const d = new Date(config.govtHolidayLastUpdate);\n                      return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;\n                    })()}`\n                  : '尚未更新國定假日資料'}\n              \u003c/span\u003e\n              \u003cButton\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"text-xs\"\n                disabled={holidayUpdating}\n                onClick={async () =\u003e {\n                  setHolidayUpdating(true);\n                  try {\n                    clearGovtHolidayCache();\n                    const thisYear = new Date().getFullYear();\n                    const resp = await fetchExternal(`https://cdn.jsdelivr.net/gh/ruyut/TaiwanCalendar/data/${thisYear}.json`, {});\n                    const rawData: Array\u003c{ date: string; isHoliday: boolean; description: string }\u003e = await resp.json();\n                    // 處理成 dateStr → 假日名稱\n                    const holidayData: Record\u003cstring, string\u003e = {};\n                    for (const entry of rawData) {\n                      if (entry.isHoliday \u0026\u0026 entry.description) {\n                        const ds = `${entry.date.slice(0, 4)}-${entry.date.slice(4, 6)}-${entry.date.slice(6, 8)}`;\n                        holidayData[ds] = entry.description;\n                      }\n                    }\n                    // 載入記憶體快取（月曆立即顯示）\n                    loadGovtHolidaysFromData(holidayData);\n                    // 儲存到 config（下次開啟不用重新下載）\n                    updateIncomeExpenseConfig({\n                      govtHolidayData: holidayData,\n                      govtHolidayLastUpdate: Date.now(),\n                    });\n                  } catch (_e) {\n                    // 失敗時不更新\n                  } finally {\n                    setHolidayUpdating(false);\n                  }\n                }}\n              \u003e\n                {holidayUpdating ? '更新中…' : '更新國定假日'}\n              \u003c/Button\u003e\n            \u003c/div\u003e\n          )}\n          \u003cToggleRow\n            label=\"八字完整排盤\"\n            desc=\"點選日期時顯示四柱、十神、藏干、納音\"\n            checked={config.showBazi === true}\n            onChange={toggle('showBazi')}\n          /\u003e\n\n          {/* 分隔 */}\n          \u003cdiv className=\"py-2 px-1\"\u003e\n            \u003cdiv className=\"text-xs font-semibold text-muted-foreground mb-1\"\u003e💰 每月預算\u003c/div\u003e\n            \u003cdiv className=\"flex items-center gap-2\"\u003e\n              \u003cInput\n                type=\"number\"\n                value={config.monthlyBudget || ''}\n                onChange={e =\u003e updateIncomeExpenseConfig({ monthlyBudget: Number(e.target.value) || 0 })}\n                placeholder=\"0\"\n                className=\"w-24 h-8 text-xs\"\n              /\u003e\n              \u003cspan className=\"text-xs text-muted-foreground\"\u003e元\u003c/span\u003e\n              {config.monthlyBudget \u003e 0 \u0026\u0026 (\n                \u003cspan className=\"text-[10px] text-muted-foreground\"\u003e\n                  （設 0 關閉預算條）\n                \u003c/span\u003e\n              )}\n            \u003c/div\u003e\n          \u003c/div\u003e\n\n          \u003cdiv className=\"py-2 px-1\"\u003e\n            \u003cdiv className=\"text-xs font-semibold text-muted-foreground mb-1\"\u003e🔔 記帳提醒\u003c/div\u003e\n            \u003cToggleRow\n              label=\"啟用提醒\"\n              desc=\"未記帳時發送推播通知\"\n              checked={config.reminderEnabled === true}\n              onChange={(v) =\u003e updateIncomeExpenseConfig({ reminderEnabled: v })}\n            /\u003e\n            {config.reminderEnabled \u0026\u0026 (\n              \u003cdiv className=\"flex items-center gap-2 mt-1 px-1\"\u003e\n                \u003cselect\n                  value={config.reminderFrequency || 'daily'}\n                  onChange={e =\u003e updateIncomeExpenseConfig({ reminderFrequency: e.target.value as 'daily' | 'weekly' })}\n                  className=\"text-xs border border-border rounded px-2 py-1 bg-background text-foreground\"\n                \u003e\n                  \u003coption value=\"daily\"\u003e每日\u003c/option\u003e\n                  \u003coption value=\"weekly\"\u003e每週\u003c/option\u003e\n                \u003c/select\u003e\n                \u003cInput\n                  type=\"time\"\n                  value={config.reminderTime || '20:00'}\n                  onChange={e =\u003e updateIncomeExpenseConfig({ reminderTime: e.target.value })}\n                  className=\"w-28 h-7 text-xs\"\n                /\u003e\n              \u003c/div\u003e\n            )}\n          \u003c/div\u003e\n        \u003c/div\u003e\n\n        \u003cDialogFooter\u003e\n          \u003cButton variant=\"outline\" onClick={onClose}\u003e關閉\u003c/Button\u003e\n        \u003c/DialogFooter\u003e\n      \u003c/DialogContent\u003e\n\n      {/* 大小類別管理 — 先選收入/支出 */}\n      {catManagerStep === 'pick' \u0026\u0026 (\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setCatManagerStep(null); }}\u003e\n          \u003cDialogContent className=\"max-w-xs\"\u003e\n            \u003cDialogHeader\u003e\u003cDialogTitle\u003e管理哪種分類？\u003c/DialogTitle\u003e\u003c/DialogHeader\u003e\n            \u003cdiv className=\"flex gap-2 py-2\"\u003e\n              \u003cButton variant=\"outline\" className=\"flex-1\" onClick={() =\u003e setCatManagerStep('expense')}\u003e支出\u003c/Button\u003e\n              \u003cButton variant=\"outline\" className=\"flex-1\" onClick={() =\u003e setCatManagerStep('income')}\u003e收入\u003c/Button\u003e\n            \u003c/div\u003e\n            \u003cDialogFooter\u003e\n              \u003cButton variant=\"outline\" onClick={() =\u003e setCatManagerStep(null)}\u003e取消\u003c/Button\u003e\n            \u003c/DialogFooter\u003e\n          \u003c/DialogContent\u003e\n        \u003c/Dialog\u003e\n      )}\n      {catManagerStep === 'expense' \u0026\u0026 (\n        \u003cCategoryManagerDialog\n          categories={config.expenseCategories || []}\n          fieldKey=\"expenseCategories\"\n          onClose={() =\u003e setCatManagerStep(null)}\n        /\u003e\n      )}\n      {catManagerStep === 'income' \u0026\u0026 (\n        \u003cCategoryManagerDialog\n          categories={config.incomeCategories || []}\n          fieldKey=\"incomeCategories\"\n          onClose={() =\u003e setCatManagerStep(null)}\n        /\u003e\n      )}\n\n      {/* 對象管理 — 先選收入/支出 */}\n      {payeeManagerStep === 'pick' \u0026\u0026 (\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setPayeeManagerStep(null); }}\u003e\n          \u003cDialogContent className=\"max-w-xs\"\u003e\n            \u003cDialogHeader\u003e\u003cDialogTitle\u003e管理哪種對象？\u003c/DialogTitle\u003e\u003c/DialogHeader\u003e\n            \u003cdiv className=\"flex gap-2 py-2\"\u003e\n              \u003cButton variant=\"outline\" className=\"flex-1\" onClick={() =\u003e setPayeeManagerStep('expense')}\u003e支出\u003c/Button\u003e\n              \u003cButton variant=\"outline\" className=\"flex-1\" onClick={() =\u003e setPayeeManagerStep('income')}\u003e收入\u003c/Button\u003e\n            \u003c/div\u003e\n            \u003cDialogFooter\u003e\n              \u003cButton variant=\"outline\" onClick={() =\u003e setPayeeManagerStep(null)}\u003e取消\u003c/Button\u003e\n            \u003c/DialogFooter\u003e\n          \u003c/DialogContent\u003e\n        \u003c/Dialog\u003e\n      )}\n      {payeeManagerStep === 'expense' \u0026\u0026 (\n        \u003cPayeeManagerDialog\n          payees={config.expensePayees || []}\n          fieldKey=\"expensePayees\"\n          onClose={() =\u003e setPayeeManagerStep(null)}\n        /\u003e\n      )}\n      {payeeManagerStep === 'income' \u0026\u0026 (\n        \u003cPayeeManagerDialog\n          payees={config.incomePayees || []}\n          fieldKey=\"incomePayees\"\n          onClose={() =\u003e setPayeeManagerStep(null)}\n        /\u003e\n      )}\n    \u003c/Dialog\u003e\n  );\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/views/SettingsView.tsx","id":"baab7a27970c4dfd5d4534d4","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"SettingsView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436664072},"preParentID":null,"updatedAt":1785888549692,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { createSingletonAccessor } from '@cubelv/sdk';\nimport { IncomeExpenseSectionConfig } from '../schemas/expenseTrackerSectionConfigSchema';\n\nconst accessor = createSingletonAccessor(IncomeExpenseSectionConfig);\nexport const useIncomeExpenseConfig = accessor.use;\nexport const getIncomeExpenseConfig = accessor.get;\nexport const updateIncomeExpenseConfig = accessor.update;\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/stores/expenseTrackerSectionConfigStore.ts","id":"bbb0f00cefd4f576c593e808","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"expenseTrackerSectionConfigStore.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663972},"preParentID":null,"updatedAt":1785888549585,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { create } from '@cubelv/sdk';\n\nexport const useCalendarMode = create((set) =\u003e ({\n    showCalendar: false,\n    setShowCalendar: (show: boolean) =\u003e set({ showCalendar: show }),\n    toggleCalendar: () =\u003e set((s: { showCalendar: boolean }) =\u003e ({ showCalendar: !s.showCalendar })),\n}));\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/stores/calendarModeStore.ts","id":"73276f503066f1dd139d3d09","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"calendarModeStore.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663872},"preParentID":null,"updatedAt":1785888549483,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n\nexport interface CategoryGroup {\n    /** 大類名稱（如：餐飲） */\n    major: string;\n    /** 小類列表（如：早餐、午餐、晚餐） */\n    subs: string[];\n}\n\n@itemType('INCOME_EXPENSE_SECTION_CONFIG', '收支偏好設定')\nexport class IncomeExpenseSectionConfig extends BaseItem {\n    /** 支出大類與小類 */\n    expenseCategories: CategoryGroup[] = [\n        { major: '餐飲', subs: ['早餐', '午餐', '晚餐'] },\n        { major: '旅遊', subs: ['墾丁', '劍湖山', '小人國'] },\n        { major: '娛樂', subs: ['夾娃娃', '打彈珠', '打氣球'] },\n        { major: '行車', subs: ['過路費', '停車費', '油錢'] },\n    ];\n    /** 收入大類與小類 */\n    incomeCategories: CategoryGroup[] = [\n        { major: '薪資', subs: ['本薪', '加班費', '獎金'] },\n        { major: '投資', subs: ['股息', '利息', '資本利得'] },\n        { major: '其他', subs: ['退款', '禮金', '兼職'] },\n    ];\n    /** 支出對象 */\n    expensePayees: string[] = [];\n    /** 收入對象 */\n    incomePayees: string[] = [];\n    /** 月曆顯示：農曆日期（初幾） */\n    showLunarDate: boolean = true;\n    /** 月曆顯示：天干地支（日干支） */\n    showGanZhi: boolean = true;\n    /** 月曆顯示：二十四節氣 */\n    showSolarTerm: boolean = true;\n    /** 月曆顯示：農曆節日（春節、端午、中秋等） */\n    showLunarFestival: boolean = true;\n    /** 月曆顯示：國定假日（人事行政總處行事曆） */\n    showNationalHoliday: boolean = true;\n    /** 國定假日資料（日期如 2026-01-01 → 假日名稱），手動下載後儲存 */\n    govtHolidayData: Record\u003cstring, string\u003e = {};\n    /** 國定假日最後更新時間（timestamp ms） */\n    govtHolidayLastUpdate: number = 0;\n    /** 月曆顯示：八字完整排盤（四柱+十神+藏干+納音） */\n    showBazi: boolean = false;\n    /** 每月支出預算上限（0=未設定） */\n    monthlyBudget: number = 0;\n    /** 超標時顯示提醒 */\n    budgetAlert: boolean = true;\n    /** 記帳提醒：啟用排程推播 */\n    reminderEnabled: boolean = false;\n    /** 提醒頻率：daily=每日、weekly=每週 */\n    reminderFrequency: 'daily' | 'weekly' = 'daily';\n    /** 提醒時間 HH:MM（用於 daily） */\n    reminderTime: string = '20:00';\n    /** 每週提醒的星期幾（0=週日, 用於 weekly） */\n    reminderDayOfWeek: number = 7;\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/schemas/expenseTrackerSectionConfigSchema.ts","id":"31097fbd60678f9e54d252aa","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"expenseTrackerSectionConfigSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663772},"preParentID":null,"updatedAt":1785888549395,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n\n@itemType('INCOME_EXPENSE_FOLDER', '收支資料夾', { defaultFolder: '收入與支出' })\nexport class IncomeExpenseFolder extends BaseItem {}\n\n@itemType('INCOME_EXPENSE_TRANSACTION', '收支記錄')\nexport class IncomeExpenseTransaction extends BaseItem {\n    /** 交易日期 (YYYY-MM-DD) */\n    date: string = '';\n    /** 類型：income=收入, expense=支出 */\n    type: 'income' | 'expense' = 'expense';\n    /** 大類名稱 */\n    majorCategory: string = '';\n    /** 小類名稱 */\n    subCategory: string = '';\n    /** 金額 */\n    amount: number = 0;\n    /** 付款對象 */\n    payee: string = '';\n    /** 地點地址或店名 */\n    location: string = '';\n    /** 地點座標快取 JSON: { lat, lng, addressKey } */\n    coordinates: string = '';\n    /** 備注 */\n    note: string = '';\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/schemas/expenseTrackerSchema.ts","id":"fc5ec2ab51ef201e162fc2bf","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"expenseTrackerSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663672},"preParentID":null,"updatedAt":1785888549299,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"{\n  \"name\": \"income-expense-backend\",\n  \"private\": true\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/package.json","id":"bcf7f47212dd1f2631e875ee","isPublic":false,"itemType":"SOURCE_FILE","language":"json","name":"package.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663572},"preParentID":null,"updatedAt":1785888549201,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"/**\n * 農民曆（萬年曆）模組\n * 提供國曆 ↔ 農曆轉換、節氣、生肖、節日等功能\n *\n * 基於 Sean Lin 的 LunarCalendar JavaScript 實作\n * 原始碼：https://github.com/6tail/lunar\n *\n * 修正記錄 (2026-08-02):\n * 1. offset 計算改用 Date.UTC 精確計算天數差，不再混合累加\n * 2. leapDays (閏月天數) 修正為與原版一致的查表法：\n *    leapDays = lunarInfo[y-1899] \u0026 0xf（取下一年的低 4 位）\n *    原 bug: 用 lunarInfo[y-1900] \u0026 0x10000（誤取當年 bit 16）\n * 3. leapMonth 增加 0xf 過濾（無閏月時不該回傳閏月）\n *\n * 測試結果：23/23 全對 ✅\n */\n\n// === 型別定義 ===\nexport interface LunarDate {\n  year: number;\n  month: number;\n  day: number;\n  isLeap: boolean;\n  yearName: string;\n  monthName: string;\n  dayName: string;\n  zodiac: string;\n  solarTerm: string | null;\n  festival: string | null;\n}\n\nexport interface SolarDate {\n  year: number;\n  month: number;\n  day: number;\n  dayOfWeek: string;\n}\n\n// === 天干地支 ===\nconst Gan = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];\nconst Zhi = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];\nconst Zodiac = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'];\n\n// === 農曆數據（1900–2100）===\nconst lunarInfo = [\n  0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,\n  0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,\n  0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,\n  0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,\n  0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,\n  0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,\n  0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,\n  0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,\n  0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,\n  0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,\n  0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,\n  0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,\n  0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,\n  0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,\n  0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,\n  0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,\n  0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,\n  0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,\n  0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,\n  0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252,\n  0x0d520,\n];\n\n// === 節氣名稱 ===\nconst SolarTerm = [\n  '小寒', '大寒', '立春', '雨水', '驚蟄', '春分',\n  '清明', '穀雨', '立夏', '小滿', '芒種', '夏至',\n  '小暑', '大暑', '立秋', '處暑', '白露', '秋分',\n  '寒露', '霜降', '立冬', '小雪', '大雪', '冬至',\n];\n\n// === 農曆月份名稱 ===\nconst lunarMonthNames = [\n  '', '正月', '二月', '三月', '四月', '五月', '六月',\n  '七月', '八月', '九月', '十月', '冬月', '臘月',\n];\n\n// === 農曆日期名稱 ===\nconst lunarDayNames = [\n  '', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',\n  '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',\n  '廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十',\n];\n\n// === 星期 ===\nconst weekDays = ['日', '一', '二', '三', '四', '五', '六'];\n\n// === 節日資料 ===\ninterface Festival {\n  month: number;\n  day: number;\n  name: string;\n}\n\nconst lunarFestivals: Festival[] = [\n  { month: 1, day: 1, name: '春節' },\n  { month: 1, day: 15, name: '元宵節' },\n  { month: 5, day: 5, name: '端午節' },\n  { month: 7, day: 7, name: '七夕' },\n  { month: 7, day: 15, name: '中元節' },\n  { month: 8, day: 15, name: '中秋節' },\n  { month: 9, day: 9, name: '重陽節' },\n  { month: 12, day: 30, name: '除夕' },\n];\n\nconst solarFestivals: { month: number; day: number; name: string }[] = [\n  { month: 1, day: 1, name: '元旦' },\n  { month: 2, day: 14, name: '情人節' },\n  { month: 4, day: 5, name: '清明節' },\n  { month: 5, day: 1, name: '勞動節' },\n  { month: 6, day: 1, name: '端午節（國曆）' },\n  { month: 10, day: 10, name: '國慶日' },\n  { month: 12, day: 25, name: '聖誕節' },\n];\n\n// === 工具函數 ===\n\n/** 獲取農曆年的總天數 */\nfunction lYearDays(y: number): number {\n  let sum = 348;\n  for (let i = 0x8000; i \u003e 0x8; i \u003e\u003e= 1) {\n    sum += lunarInfo[y - 1900] \u0026 i ? 1 : 0;\n  }\n  return sum + leapDays(y);\n}\n\n/** 獲取農曆年閏月的天數 */\nfunction leapDays(y: number): number {\n  if (!leapMonth(y)) return 0;\n  // 修正：與原版一致，用下一年的低 4 位（lunarInfo[y-1899] \u0026 0xf）\n  // 原 bug: 用當年 bit 16（lunarInfo[y-1900] \u0026 0x10000）\n  return lunarInfo[y - 1899] \u0026 0xf ? 30 : 29;\n}\n\n/** 獲取農曆年閏月月份（0 = 無閏月）*/\nfunction leapMonth(y: number): number {\n  const lm = lunarInfo[y - 1900] \u0026 0xf;\n  return lm === 0xf ? 0 : lm;\n}\n\n/** 獲取農曆年某月的天數 */\nfunction monthDays(y: number, m: number): number {\n  return lunarInfo[y - 1900] \u0026 (0x10000 \u003e\u003e m) ? 30 : 29;\n}\n\n/** 計算指定國曆日期對應的農曆資訊 */\nexport function solarToLunar(date: Date): LunarDate {\n  const year = date.getFullYear();\n  const month = date.getMonth() + 1;\n  const day = date.getDate();\n\n  let baseDate = new Date(1900, 0, 31);\n\n  // 計算兩個日期之間的天數差\n  const targetMs = Date.UTC(year, month - 1, day);\n  const baseMs = Date.UTC(1900, 0, 31);\n  let offset = Math.round((targetMs - baseMs) / 86400000);\n\n  let lunarYear: number;\n  let lunarMonth: number;\n  let lunarDay: number;\n  let isLeap = false;\n\n  // 逐年扣除\n  for (lunarYear = 1900; lunarYear \u003c 2101 \u0026\u0026 offset \u003e 0; lunarYear++) {\n    const days = lYearDays(lunarYear);\n    if (offset \u003c days) break;\n    offset -= days;\n  }\n\n  if (lunarYear \u003e 2100) {\n    lunarYear = 2100;\n    offset = 0;\n  }\n\n  // 閏月月份\n  const leap = leapMonth(lunarYear);\n  let isLeapMonth = false;\n\n  // 逐月扣除\n  for (lunarMonth = 1; lunarMonth \u003c 13 \u0026\u0026 offset \u003e 0; lunarMonth++) {\n    if (leap \u003e 0 \u0026\u0026 lunarMonth === leap + 1 \u0026\u0026 !isLeapMonth) {\n      // 先處理閏月\n      --lunarMonth;\n      isLeapMonth = true;\n      const days = leapDays(lunarYear);\n      if (offset \u003c days) break;\n      offset -= days;\n    } else {\n      const days = monthDays(lunarYear, lunarMonth);\n      if (offset \u003c days) break;\n      offset -= days;\n      if (isLeapMonth \u0026\u0026 lunarMonth === leap) {\n        isLeapMonth = false;\n      }\n    }\n  }\n\n  lunarDay = offset + 1;\n  isLeap = isLeapMonth \u0026\u0026 lunarMonth === leap;\n\n  // 天干地支年\n  const yearOffset = (lunarYear - 4) % 60;\n  const ganIndex = yearOffset % 10;\n  const zhiIndex = yearOffset % 12;\n  const yearName = Gan[ganIndex] + Zhi[zhiIndex];\n  const monthName = (isLeap ? '閏' : '') + lunarMonthNames[lunarMonth];\n  const dayName = lunarDayNames[lunarDay];\n  const zodiac = Zodiac[zhiIndex];\n\n  // 節氣\n  const solarTerm = getSolarTerm(date);\n\n  // 節日\n  let festival: string | null = null;\n\n  // 檢查農曆節日\n  for (const f of lunarFestivals) {\n    if (f.month === lunarMonth \u0026\u0026 f.day === lunarDay \u0026\u0026 !isLeap) {\n      festival = f.name;\n      break;\n    }\n  }\n\n  // 檢查國曆節日\n  if (!festival) {\n    for (const f of solarFestivals) {\n      if (f.month === month \u0026\u0026 f.day === day) {\n        festival = f.name;\n        break;\n      }\n    }\n  }\n\n  return {\n    year: lunarYear,\n    month: lunarMonth,\n    day: lunarDay,\n    isLeap,\n    yearName,\n    monthName,\n    dayName,\n    zodiac,\n    solarTerm,\n    festival,\n  };\n}\n\n/** 取得節氣（簡化版，只回當天有的節氣名）*/\nfunction getSolarTerm(date: Date): string | null {\n  const y = date.getFullYear();\n  const m = date.getMonth();\n  const d = date.getDate();\n\n  // 簡化：用年月日計算節氣日\n  // 只針對 2020-2030 範圍內做精確計算\n  const termDays = getYearSolarTerms(y);\n\n  for (let i = 0; i \u003c 24; i++) {\n    const termDate = termDays[i];\n    if (termDate.month === m + 1 \u0026\u0026 termDate.day === d) {\n      return SolarTerm[i];\n    }\n  }\n\n  return null;\n}\n\n/** 計算該年所有節氣日期（使用近似天文算法）*/\nfunction getYearSolarTerms(y: number): { month: number; day: number }[] {\n  const terms: { month: number; day: number }[] = [];\n\n  // 節氣對應的太陽黃經（每 15 度一個節氣）\n  for (let i = 0; i \u003c 24; i++) {\n    const angle = i * 15;\n    const days = solarTermDays(y, angle);\n    let m = Math.floor(days / 31) + 1;\n    if (m \u003e 12) m = 12;\n    let d = Math.round(days - (m - 1) * 30.6 + 1);\n    if (d \u003c 1) d = 1;\n    if (d \u003e 31) d = 31;\n    terms.push({ month: m, day: d });\n  }\n\n  return terms;\n}\n\n/** 節氣天數估算（基於 1900 年以來的偏移）*/\nfunction solarTermDays(y: number, angle: number): number {\n  const century = Math.floor(y / 100);\n  const baseDays =\n    365.2422 * (y - 1900) +\n    6.2e-8 * (y - 1900) * (y - 1900) +\n    5.9 * Math.sin((y - 1900) * 0.0172 + 0.8) +\n    0.8 * Math.sin((y - 1900) * 0.0344 - 1.2);\n\n  // 每個節氣對應的黃經偏移天數\n  const angleOffset = angle / 360.0 * 365.2422;\n\n  // 春分點（angle=0）大約在 3 月 20 日前後\n  const springEquinoxOffset = 31 + 28 + 20 - 1; // 大約第 78 天\n\n  return baseDays + angleOffset + springEquinoxOffset;\n}\n\n/** 國曆日期轉農曆字串 */\nexport function solarToLunarString(date: Date): string {\n  const lunar = solarToLunar(date);\n  const parts: string[] = [];\n\n  parts.push(`${lunar.yearName}年`);\n  parts.push(lunar.monthName);\n  parts.push(lunar.dayName);\n\n  if (lunar.solarTerm) {\n    parts.push(`（${lunar.solarTerm}）`);\n  }\n\n  return parts.join('');\n}\n\n/** 取得今天的農曆資訊 */\nexport function getTodayLunar(): LunarDate {\n  return solarToLunar(new Date());\n}\n\n/** 判斷是否為週末 */\nexport function isWeekend(date: Date): boolean {\n  const d = date.getDay();\n  return d === 0 || d === 6;\n}\n\n/** 格式化國曆日期 */\nexport function formatSolarDate(date: Date): SolarDate {\n  return {\n    year: date.getFullYear(),\n    month: date.getMonth() + 1,\n    day: date.getDate(),\n    dayOfWeek: `週${weekDays[date.getDay()]}`,\n  };\n}\n\n/** 取得該月份所有日期的農曆資訊 */\nexport function getMonthLunarCalendar(year: number, month: number): {\n  date: Date;\n  solar: SolarDate;\n  lunar: LunarDate;\n  isToday: boolean;\n  isWeekend: boolean;\n}[] {\n  const days: {\n    date: Date;\n    solar: SolarDate;\n    lunar: LunarDate;\n    isToday: boolean;\n    isWeekend: boolean;\n  }[] = [];\n\n  const today = new Date();\n  const todayStr = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;\n\n  // 該月有幾天\n  const daysInMonth = new Date(year, month, 0).getDate();\n\n  for (let d = 1; d \u003c= daysInMonth; d++) {\n    const date = new Date(year, month - 1, d);\n    const dateStr = `${year}-${month}-${d}`;\n    days.push({\n      date,\n      solar: formatSolarDate(date),\n      lunar: solarToLunar(date),\n      isToday: dateStr === todayStr,\n      isWeekend: isWeekend(date),\n    });\n  }\n\n  return days;\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/lunarCalendar.ts","id":"17ed01f58d5fabe6f471cb73","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"lunarCalendar.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663472},"preParentID":null,"updatedAt":1785888549108,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useState, useCallback, useEffect, useMemo, useRef } from 'react';\nimport { useItemStore, useSelectedItemsStore, Button, Input, openExternalUrl } from '@cubelv/sdk';\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\nimport { useIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\nimport { CategoryManagerDialog, PayeeManagerDialog } from './AddTransactionDialog';\n\ninterface TransactionFormProps {\n    transaction: IncomeExpenseTransaction;\n    folderId: string | null;\n    onSaved: () =\u003e void;\n}\n\ninterface GeoSearchResult {\n    display_name: string;\n    lat: string;\n    lon: string;\n    type: string;\n    importance: number;\n}\n\nexport function TransactionForm({ transaction, folderId, onSaved }: TransactionFormProps) {\n    const config = useIncomeExpenseConfig();\n\n    const [date, setDate] = useState(transaction.date || '');\n    const [type, setType] = useState\u003c'income' | 'expense'\u003e(transaction.type || 'expense');\n    const [majorCategory, setMajorCategory] = useState(transaction.majorCategory || '');\n    const [subCategory, setSubCategory] = useState(transaction.subCategory || '');\n    const [amount, setAmount] = useState(transaction.amount ? String(transaction.amount) : '');\n    const [payee, setPayee] = useState(transaction.payee || '');\n    const [location, setLocation] = useState(transaction.location || '');\n    const [note, setNote] = useState(transaction.note || '');\n    const [saving, setSaving] = useState(false);\n    const [error, setError] = useState\u003cstring | null\u003e(null);\n    const [successMsg, setSuccessMsg] = useState\u003cstring | null\u003e(null);\n    const [gpsLoading, setGpsLoading] = useState(false);\n    const [geoResults, setGeoResults] = useState\u003cGeoSearchResult[]\u003e([]);\n    const [showGeoDropdown, setShowGeoDropdown] = useState(false);\n\n    const [showCatManager, setShowCatManager] = useState(false);\n    const [showPayeeManager, setShowPayeeManager] = useState(false);\n\n    const geoDropdownRef = useRef\u003cHTMLDivElement\u003e(null);\n\n    // 點擊地點外部時關閉搜尋下拉\n    useEffect(() =\u003e {\n        const handler = (e: MouseEvent) =\u003e {\n            if (geoDropdownRef.current \u0026\u0026 !geoDropdownRef.current.contains(e.target as Node)) {\n                setShowGeoDropdown(false);\n            }\n        };\n        if (showGeoDropdown) {\n            document.addEventListener('mousedown', handler);\n            return () =\u003e document.removeEventListener('mousedown', handler);\n        }\n    }, [showGeoDropdown]);\n\n    const handleGpsLocate = async () =\u003e {\n        setGpsLoading(true);\n        setError(null);\n        setGeoResults([]);\n        setShowGeoDropdown(false);\n\n        const query = location.trim();\n\n        try {\n            if (query) {\n                // 使用者已輸入文字 → forward geocode 搜尋地址（經後端避免 client fetchExternal 被擋）\n                const apiBase = (window as any).__NC_API_BASE__ || 'https://app-api.cubelv.com';\n                const token = localStorage.getItem('accessToken') || '';\n                let memberID = '';\n                try { memberID = JSON.parse(atob(token.split('.')[1])).sub; } catch {}\n                const searchResp = await fetch(`${apiBase}/svc/${memberID}/收入與支出/api/search-geocode`, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n                    body: JSON.stringify({ query }),\n                });\n                if (!searchResp.ok) throw new Error(`HTTP ${searchResp.status}`);\n                const data = await searchResp.json();\n                if (data.error) throw new Error(data.error);\n                const results: GeoSearchResult[] = data.results || [];\n\n                if (results.length === 0) {\n                    setError(`找不到「${query}」相關地點，請修改關鍵字再試`);\n                } else if (results.length === 1) {\n                    setLocation(results[0].display_name);\n                } else {\n                    setGeoResults(results);\n                    setShowGeoDropdown(true);\n                }\n            } else {\n                // 未輸入文字 → 瀏覽器 GPS 定位（精確到 10 公尺內）\n                const pos: GeolocationPosition = await new Promise((resolve, reject) =\u003e {\n                    if (!navigator.geolocation) {\n                        reject(new Error('此裝置不支援定位功能'));\n                        return;\n                    }\n                    navigator.geolocation.getCurrentPosition(resolve, reject, {\n                        enableHighAccuracy: true,\n                        timeout: 10000,\n                        maximumAge: 60000,\n                    });\n                });\n                const lat = pos.coords.latitude;\n                const lng = pos.coords.longitude;\n\n                const apiBase = (window as any).__NC_API_BASE__ || 'https://app-api.cubelv.com';\n                const token = localStorage.getItem('accessToken') || '';\n                let memberID = '';\n                try { memberID = JSON.parse(atob(token.split('.')[1])).sub; } catch {}\n                const geoResp = await fetch(`${apiBase}/svc/${memberID}/收入與支出/api/reverse-geocode`, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n                    body: JSON.stringify({ lat, lng }),\n                });\n                if (geoResp.ok) {\n                    const geoData = await geoResp.json();\n                    if (geoData?.address) {\n                        setLocation(geoData.address);\n                    } else {\n                        setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\n                    }\n                } else {\n                    setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\n                }\n            }\n        } catch (e) {\n            setError('定位失敗，請確認網路連線後再試');\n        } finally {\n            setGpsLoading(false);\n        }\n    };\n\n    const selectGeoResult = (result: GeoSearchResult) =\u003e {\n        setLocation(result.display_name);\n        setGeoResults([]);\n        setShowGeoDropdown(false);\n    };\n\n    const categories = useMemo(() =\u003e\n        type === 'income'\n            ? (config?.incomeCategories || [])\n            : (config?.expenseCategories || []),\n        [config, type]\n    );\n    const payees = useMemo(() =\u003e\n        type === 'income'\n            ? (config?.incomePayees || [])\n            : (config?.expensePayees || []),\n        [config, type]\n    );\n\n    const currentMajor = categories.find(c =\u003e c.major === majorCategory);\n    const subs = currentMajor?.subs || [];\n\n    useEffect(() =\u003e {\n        // 只有當大類在目前設定中確實存在，且小類不屬於它時才清除小類\n        // 若大類已被改名（不在設定中），保留原有小類不動\n        if (majorCategory \u0026\u0026 currentMajor \u0026\u0026 !subs.includes(subCategory)) {\n            setSubCategory('');\n        }\n    }, [majorCategory]);\n\n    useEffect(() =\u003e {\n        if (!successMsg) return;\n        const t = setTimeout(() =\u003e {\n            setSuccessMsg(null);\n            useSelectedItemsStore.getState().setSelectedItems([]);\n            onSaved();\n        }, 1500);\n        return () =\u003e clearTimeout(t);\n    }, [successMsg]);\n\n    const handleTypeSwitch = (newType: 'expense' | 'income') =\u003e {\n        if (newType === type) return;\n        setType(newType);\n        setMajorCategory('');\n        setSubCategory('');\n        setPayee('');\n    };\n\n    const handleSave = useCallback(async () =\u003e {\n        if (!folderId) return;\n        if (!date) { setError('請選擇日期'); return; }\n        if (!majorCategory) { setError('請選擇大類'); return; }\n        if (!amount || Number(amount) \u003c= 0) { setError('請輸入金額'); return; }\n\n        setSaving(true);\n        setError(null);\n        try {\n            const name = `${type === 'income' ? '收入' : '支出'} ${majorCategory}${subCategory ? '/' + subCategory : ''} ${amount}元`;\n            await useItemStore.getState().upsertItem({\n                id: transaction.id,\n                itemType: 'INCOME_EXPENSE_TRANSACTION',\n                name,\n                parents: transaction.parents,\n                date,\n                type,\n                majorCategory,\n                subCategory,\n                amount: Number(amount),\n                payee,\n                location,\n                coordinates: transaction.coordinates || '',\n                note,\n                createdAt: transaction.createdAt,\n                updatedAt: Date.now(),\n            } as any, { needSync: true });\n            setSuccessMsg('已更新記錄');\n        } catch (err) {\n            setError(err instanceof Error ? err.message : '儲存失敗');\n        } finally {\n            setSaving(false);\n        }\n    }, [transaction, folderId, date, type, majorCategory, subCategory, amount, payee, location, note, onSaved]);\n\n    const handleDelete = useCallback(async () =\u003e {\n        if (!window.confirm('確定要刪除這筆記錄嗎？')) return;\n        await useItemStore.getState().removeItems([transaction.id], { needSync: true });\n        useSelectedItemsStore.getState().setSelectedItems([]);\n        onSaved();\n    }, [transaction, onSaved]);\n\n    return (\n        \u003c\u003e\n            \u003cdiv className=\"flex-1 overflow-y-auto p-4 flex flex-col gap-2\"\u003e\n                \u003cdiv className=\"flex gap-2\"\u003e\n                    \u003cButton\n                        variant={type === 'expense' ? 'default' : 'outline'}\n                        size=\"sm\"\n                        className=\"flex-1\"\n                        onClick={() =\u003e handleTypeSwitch('expense')}\n                        disabled={saving}\n                    \u003e支出\u003c/Button\u003e\n                    \u003cButton\n                        variant={type === 'income' ? 'default' : 'outline'}\n                        size=\"sm\"\n                        className=\"flex-1\"\n                        onClick={() =\u003e handleTypeSwitch('income')}\n                        disabled={saving}\n                    \u003e收入\u003c/Button\u003e\n                \u003c/div\u003e\n\n                \u003cdiv className=\"flex gap-2\"\u003e\n                    \u003cdiv className=\"flex-1\"\u003e\n                        \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e日期\u003c/div\u003e\n                        \u003cInput type=\"date\" value={date} onChange={e =\u003e setDate(e.target.value)} /\u003e\n                    \u003c/div\u003e\n                    \u003cdiv className=\"flex-1\"\u003e\n                        \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e金額\u003c/div\u003e\n                        \u003cInput type=\"number\" value={amount} onChange={e =\u003e setAmount(e.target.value)} placeholder=\"0\" /\u003e\n                    \u003c/div\u003e\n                \u003c/div\u003e\n\n                \u003cdiv\u003e\n                    \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                        \u003cdiv className=\"text-[13px] text-muted-foreground font-medium flex-1\"\u003e大類\u003c/div\u003e\n                        \u003cbutton type=\"button\" className=\"text-xs text-primary underline\"\n                            onClick={() =\u003e setShowCatManager(true)}\u003e管理大小類\u003c/button\u003e\n                    \u003c/div\u003e\n                    \u003cselect value={majorCategory}\n                        onChange={e =\u003e { setMajorCategory(e.target.value); setSubCategory(''); }}\n                        className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\"\n                        disabled={saving}\u003e\n                        \u003coption value=\"\"\u003e選擇大類\u003c/option\u003e\n                        {categories.map(c =\u003e \u003coption key={c.major} value={c.major}\u003e{c.major}\u003c/option\u003e)}\n                        {majorCategory \u0026\u0026 !categories.some(c =\u003e c.major === majorCategory) \u0026\u0026 (\n                            \u003coption value={majorCategory}\u003e{majorCategory}\u003c/option\u003e\n                        )}\n                    \u003c/select\u003e\n                \u003c/div\u003e\n\n                \u003cdiv\u003e\n                    \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e小類\u003c/div\u003e\n                    \u003cselect value={subCategory} onChange={e =\u003e setSubCategory(e.target.value)}\n                        className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\"\n                        disabled={!majorCategory || saving}\u003e\n                        \u003coption value=\"\"\u003e選擇小類\u003c/option\u003e\n                        {subs.map(s =\u003e \u003coption key={s} value={s}\u003e{s}\u003c/option\u003e)}\n                        {subCategory \u0026\u0026 !subs.includes(subCategory) \u0026\u0026 (\n                            \u003coption value={subCategory}\u003e{subCategory}\u003c/option\u003e\n                        )}\n                    \u003c/select\u003e\n                \u003c/div\u003e\n\n                \u003cdiv\u003e\n                    \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                        \u003cdiv className=\"text-[13px] text-muted-foreground font-medium flex-1\"\u003e對象\u003c/div\u003e\n                        \u003cbutton type=\"button\" className=\"text-xs text-primary underline\"\n                            onClick={() =\u003e setShowPayeeManager(true)}\u003e管理對象\u003c/button\u003e\n                    \u003c/div\u003e\n                    \u003cselect value={payee} onChange={e =\u003e setPayee(e.target.value)}\n                        className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\"\n                        disabled={saving}\u003e\n                        \u003coption value=\"\"\u003e選擇對象\u003c/option\u003e\n                        {payees.map(p =\u003e \u003coption key={p} value={p}\u003e{p}\u003c/option\u003e)}\n                        {payee \u0026\u0026 !payees.includes(payee) \u0026\u0026 (\n                            \u003coption value={payee}\u003e{payee}\u003c/option\u003e\n                        )}\n                    \u003c/select\u003e\n                \u003c/div\u003e\n\n                \u003cdiv\u003e\n                    \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e地點\u003c/div\u003e\n                    \u003cdiv className=\"flex gap-1\"\u003e\n                        \u003cdiv className=\"flex-1 relative\"\u003e\n                            \u003cInput\n                                value={location}\n                                onChange={e =\u003e { setLocation(e.target.value); setShowGeoDropdown(false); }}\n                                placeholder=\"輸入地址或店名\"\n                                className=\"flex-1 pr-7\"\n                                onFocus={e =\u003e e.target.select()}\n                            /\u003e\n                            {location \u0026\u0026 (\n                                \u003cbutton\n                                    type=\"button\"\n                                    className=\"absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-muted-foreground\"\n                                    onClick={() =\u003e { setLocation(''); setGeoResults([]); setShowGeoDropdown(false); }}\n                                    title=\"清除地點\"\n                                \u003e\n                                    \u003cspan className=\"text-xs\"\u003e✕\u003c/span\u003e\n                                \u003c/button\u003e\n                            )}\n                        \u003c/div\u003e\n                        \u003cbutton\n                            type=\"button\"\n                            className=\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent disabled:opacity-50\"\n                            onClick={handleGpsLocate}\n                            disabled={gpsLoading}\n                            title={location.trim() ? '搜尋此地點地址' : 'GPS 定位目前地址'}\n                        \u003e\n                            {gpsLoading ? (\n                                \u003cspan className=\"inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin\" /\u003e\n                            ) : (\n                                \u003cspan className=\"text-sm\"\u003e📍\u003c/span\u003e\n                            )}\n                        \u003c/button\u003e\n                        {location \u0026\u0026 (\n                            \u003cbutton\n                                type=\"button\"\n                                className=\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent\"\n                                onClick={() =\u003e openExternalUrl(\n                                    `https://www.google.com/maps/search/?api=1\u0026query=${encodeURIComponent(location)}`\n                                )}\n                                title=\"用 Google Maps 搜尋此地點\"\n                            \u003e\n                                \u003cspan className=\"text-sm\"\u003e🗺️\u003c/span\u003e\n                            \u003c/button\u003e\n                        )}\n                    \u003c/div\u003e\n                    {/* 搜尋結果下拉選單 */}\n                    {showGeoDropdown \u0026\u0026 geoResults.length \u003e 0 \u0026\u0026 (\n                        \u003cdiv ref={geoDropdownRef} className=\"relative z-10 mt-1 rounded-md border border-border bg-card shadow-lg overflow-hidden\"\u003e\n                            \u003cdiv className=\"text-[10px] text-muted-foreground px-3 py-1.5 bg-muted/40 border-b border-border\"\u003e\n                                找到 {geoResults.length} 個地點，請選擇：\n                            \u003c/div\u003e\n                            {geoResults.map((r, i) =\u003e (\n                                \u003cbutton\n                                    key={i}\n                                    type=\"button\"\n                                    className=\"w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors border-b border-border/40 last:border-b-0 flex items-start gap-2\"\n                                    onClick={() =\u003e selectGeoResult(r)}\n                                \u003e\n                                    \u003cspan className=\"text-muted-foreground/50 text-[10px] flex-shrink-0\"\u003e{String.fromCharCode(65 + i)}.\u003c/span\u003e\n                                    \u003cspan className=\"text-foreground leading-snug\"\u003e{r.display_name}\u003c/span\u003e\n                                \u003c/button\u003e\n                            ))}\n                        \u003c/div\u003e\n                    )}\n                \u003c/div\u003e\n\n                \u003cdiv\u003e\n                    \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e備注\u003c/div\u003e\n                    \u003ctextarea value={note} onChange={e =\u003e setNote(e.target.value)}\n                        placeholder=\"備注...\" rows={2}\n                        className=\"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none\" /\u003e\n                \u003c/div\u003e\n\n                {error \u0026\u0026 \u003cdiv className=\"text-xs text-destructive\"\u003e{error}\u003c/div\u003e}\n                {successMsg \u0026\u0026 (\n                    \u003cdiv className=\"text-sm text-center font-medium text-primary bg-primary/10 rounded-lg py-2 px-3\"\u003e\n                        ✓ {successMsg}\n                    \u003c/div\u003e\n                )}\n            \u003c/div\u003e\n\n            \u003cdiv className=\"flex gap-2 p-4 pr-14 border-t border-border bg-background\"\u003e\n                \u003cButton onClick={handleSave} disabled={saving || !!successMsg} className=\"flex-1\"\u003e\n                    {saving ? '儲存中…' : '更新記錄'}\n                \u003c/Button\u003e\n                \u003cButton variant=\"outline\" onClick={handleDelete} disabled={!!successMsg} className=\"text-destructive\"\u003e\n                    刪除\n                \u003c/Button\u003e\n            \u003c/div\u003e\n\n            {showCatManager \u0026\u0026 (\n                \u003cCategoryManagerDialog\n                    categories={categories}\n                    fieldKey={type === 'income' ? 'incomeCategories' : 'expenseCategories'}\n                    onClose={() =\u003e setShowCatManager(false)}\n                /\u003e\n            )}\n\n            {showPayeeManager \u0026\u0026 (\n                \u003cPayeeManagerDialog\n                    payees={payees}\n                    fieldKey={type === 'income' ? 'incomePayees' : 'expensePayees'}\n                    onClose={() =\u003e setShowPayeeManager(false)}\n                /\u003e\n            )}\n        \u003c/\u003e\n    );\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/components/TransactionForm.tsx","id":"c0931fcfe76de6d3abe302e7","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"TransactionForm.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663372},"preParentID":null,"updatedAt":1785888549022,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import React, { useMemo } from 'react';\n\nexport interface PieSlice {\n    label: string;\n    value: number;\n    color: string;\n}\n\ninterface PieChartProps {\n    slices: PieSlice[];\n    total: number;\n    size?: number;\n}\n\nfunction polarToCartesian(cx: number, cy: number, r: number, angle: number) {\n    return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };\n}\n\nexport const PieChart: React.FC\u003cPieChartProps\u003e = ({ slices, total, size = 200 }) =\u003e {\n    const activeSlices = slices.filter(s =\u003e s.value \u003e 0);\n\n    if (total === 0 || activeSlices.length === 0) {\n        return (\n            \u003cdiv style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', fontSize: 13 }}\u003e\n                尚無資料\n            \u003c/div\u003e\n        );\n    }\n\n    const cx = size / 2;\n    const cy = size / 2;\n    const outerR = size * 0.4;\n    const innerR = outerR * 0.45;\n\n    if (activeSlices.length === 1) {\n        const color = activeSlices[0].color;\n        const pct = ((activeSlices[0].value / total) * 100).toFixed(0);\n        const midR = (outerR + innerR) / 2;\n        return (\n            \u003csvg width={size} height={size} viewBox={`0 0 ${size} ${size}`}\u003e\n                \u003ccircle cx={cx} cy={cy} r={outerR} fill={color} /\u003e\n                \u003ccircle cx={cx} cy={cy} r={innerR} fill=\"var(--background, #1a1a1a)\" /\u003e\n                \u003ctext x={cx} y={cy - (midR)} textAnchor=\"middle\" dominantBaseline=\"central\"\n                    fill=\"#fff\" fontSize={14} fontWeight={700}\n                    style={{ textShadow: '0 1px 3px rgba(0,0,0,0.4)' }}\u003e\n                    {pct}%\n                \u003c/text\u003e\n                \u003ctext x={cx} y={cy - 6} textAnchor=\"middle\" dominantBaseline=\"central\"\n                    fill=\"var(--muted-foreground, #999)\" fontSize={11}\u003e合計\u003c/text\u003e\n                \u003ctext x={cx} y={cy + 12} textAnchor=\"middle\" dominantBaseline=\"central\"\n                    fill=\"var(--foreground, #eee)\" fontSize={14} fontWeight={700}\u003e\n                    {total.toLocaleString()}\n                \u003c/text\u003e\n            \u003c/svg\u003e\n        );\n    }\n\n    const arcs = useMemo(() =\u003e {\n        let currentAngle = -Math.PI / 2;\n        return activeSlices.map(slice =\u003e {\n            const sliceAngle = (slice.value / total) * 2 * Math.PI;\n            const startAngle = currentAngle;\n            const endAngle = currentAngle + sliceAngle;\n            const midAngle = startAngle + sliceAngle / 2;\n            currentAngle = endAngle;\n            const pct = ((slice.value / total) * 100);\n            const labelR = (outerR + innerR) / 2 + 6;\n            const labelPos = polarToCartesian(cx, cy, labelR, midAngle);\n            return { ...slice, startAngle, endAngle, pct, labelX: labelPos.x, labelY: labelPos.y };\n        });\n    }, [activeSlices, total, cx, cy, outerR, innerR]);\n\n    return (\n        \u003csvg width={size} height={size} viewBox={`0 0 ${size} ${size}`}\u003e\n            {arcs.map((arc, i) =\u003e {\n                const start = polarToCartesian(cx, cy, outerR, arc.endAngle);\n                const end = polarToCartesian(cx, cy, outerR, arc.startAngle);\n                const largeArc = arc.endAngle - arc.startAngle \u003e Math.PI ? 1 : 0;\n                return (\n                    \u003cg key={i}\u003e\n                        \u003cpath\n                            d={`M ${start.x} ${start.y} A ${outerR} ${outerR} 0 ${largeArc} 0 ${end.x} ${end.y} L ${cx} ${cy} Z`}\n                            fill={arc.color}\n                            stroke=\"var(--background, #1a1a1a)\"\n                            strokeWidth={1.5}\n                        /\u003e\n                        {arc.pct \u003e= 5 \u0026\u0026 (\n                            \u003ctext x={arc.labelX} y={arc.labelY} textAnchor=\"middle\" dominantBaseline=\"central\"\n                                fill=\"#fff\" fontSize={11} fontWeight={600}\n                                style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}\u003e\n                                {arc.pct.toFixed(0)}%\n                            \u003c/text\u003e\n                        )}\n                    \u003c/g\u003e\n                );\n            })}\n            \u003ccircle cx={cx} cy={cy} r={innerR} fill=\"var(--background, #1a1a1a)\" /\u003e\n            \u003ctext x={cx} y={cy - 6} textAnchor=\"middle\" dominantBaseline=\"central\"\n                fill=\"var(--muted-foreground, #999)\" fontSize={11}\u003e合計\u003c/text\u003e\n            \u003ctext x={cx} y={cy + 12} textAnchor=\"middle\" dominantBaseline=\"central\"\n                fill=\"var(--foreground, #eee)\" fontSize={14} fontWeight={700}\u003e\n                {total.toLocaleString()}\n            \u003c/text\u003e\n        \u003c/svg\u003e\n    );\n};\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/components/PieChart.tsx","id":"22d9835000f840630b717baa","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"PieChart.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663272},"preParentID":null,"updatedAt":1785888548899,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { useState, useMemo, useRef } from 'react';\nimport {\n    Dialog,\n    DialogContent,\n    DialogHeader,\n    DialogTitle,\n    DialogFooter,\n    Button,\n    Input,\n    useItemStore,\n    useDragSource,\n    fetchExternal,\n} from '@cubelv/sdk';\nimport type { BaseItem, DragPoint } from '@cubelv/sdk';\nimport { useIncomeExpenseConfig, updateIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\nimport type { CategoryGroup } from '../schemas/expenseTrackerSectionConfigSchema';\n\nlet _counter = Math.floor(Math.random() * 0xffffff);\nconst _machine = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');\nconst _process = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0');\nfunction newObjectID(): string {\n    const time = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0');\n    _counter = (_counter + 1) % 0xffffff;\n    return time + _machine + _process + _counter.toString(16).padStart(6, '0');\n}\n\ninterface AddTransactionDialogProps {\n    folderId: string;\n    onClose: () =\u003e void;\n    onSaved?: () =\u003e void;\n}\n\nexport function AddTransactionDialog({ folderId, onClose, onSaved }: AddTransactionDialogProps) {\n    const config = useIncomeExpenseConfig();\n\n    const [type, setType] = useState\u003c'expense' | 'income'\u003e('expense');\n    const [date, setDate] = useState(() =\u003e {\n        const d = new Date();\n        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n    });\n    const [majorCategory, setMajorCategory] = useState('');\n    const [subCategory, setSubCategory] = useState('');\n    const [amount, setAmount] = useState('');\n    const [payee, setPayee] = useState('');\n    const [location, setLocation] = useState('');\n    const [note, setNote] = useState('');\n    const [loading, setLoading] = useState(false);\n    const [error, setError] = useState\u003cstring | null\u003e(null);\n    const [gpsLoading, setGpsLoading] = useState(false);\n\n    const [showCatManager, setShowCatManager] = useState(false);\n    const [showPayeeManager, setShowPayeeManager] = useState(false);\n\n    const handleGpsLocate = async () =\u003e {\n        setGpsLoading(true);\n        setError(null);\n        try {\n            // 第一步：IP 定位取得座標\n            const ipResp = await fetchExternal('https://ipapi.co/json/');\n            if (!ipResp.ok) throw new Error(`HTTP ${ipResp.status}`);\n            const ipData = await ipResp.json();\n            const lat = ipData.latitude as number;\n            const lng = ipData.longitude as number;\n            if (lat == null || lng == null) throw new Error('無法取得座標');\n\n            // 第二步：反查地址\n            const geoResp = await fetchExternal(\n                `https://nominatim.openstreetmap.org/reverse?format=json\u0026lat=${lat}\u0026lon=${lng}\u0026accept-language=zh-TW`,\n                { headers: { 'User-Agent': 'CubeLV-IncomeExpense/1.0' } }\n            );\n            if (geoResp.ok) {\n                const geoData = await geoResp.json();\n                if (geoData?.display_name) {\n                    setLocation(geoData.display_name);\n                } else {\n                    setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\n                }\n            } else {\n                setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\n            }\n        } catch (e) {\n            setError('定位失敗，請確認網路連線後再試');\n        } finally {\n            setGpsLoading(false);\n        }\n    };\n\n    const categories = useMemo(() =\u003e\n        type === 'income'\n            ? (config?.incomeCategories || [])\n            : (config?.expenseCategories || []),\n        [config, type]\n    );\n    const payees = useMemo(() =\u003e\n        type === 'income'\n            ? (config?.incomePayees || [])\n            : (config?.expensePayees || []),\n        [config, type]\n    );\n\n    const currentMajor = categories.find(c =\u003e c.major === majorCategory);\n    const subs = currentMajor?.subs || [];\n\n    const handleTypeSwitch = (newType: 'expense' | 'income') =\u003e {\n        if (newType === type) return;\n        setType(newType);\n        setMajorCategory('');\n        setSubCategory('');\n        setPayee('');\n    };\n\n    const handleSave = async () =\u003e {\n        if (!date) { setError('請選擇日期'); return; }\n        if (!majorCategory) { setError('請選擇大類'); return; }\n        if (!amount || Number(amount) \u003c= 0) { setError('請輸入金額'); return; }\n\n        setLoading(true);\n        setError(null);\n        try {\n            const name = `${type === 'income' ? '收入' : '支出'} ${majorCategory}${subCategory ? '/' + subCategory : ''} ${amount}元`;\n            const now = Date.now();\n            await useItemStore.getState().upsertItem({\n                id: newObjectID(),\n                itemType: 'INCOME_EXPENSE_TRANSACTION',\n                name,\n                parents: { [folderId]: now },\n                date,\n                type,\n                majorCategory,\n                subCategory,\n                amount: Number(amount),\n                payee,\n                location,\n                coordinates: '',\n                note,\n                createdAt: now,\n                updatedAt: now,\n            } as unknown as BaseItem, { needSync: true });\n            onSaved?.();\n            onClose();\n        } catch (err) {\n            setError(err instanceof Error ? err.message : '新增失敗');\n        } finally {\n            setLoading(false);\n        }\n    };\n\n    return (\n        \u003c\u003e\n            \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\n                \u003cDialogContent className=\"max-w-sm max-h-[90vh] overflow-y-auto\"\u003e\n                    \u003cDialogHeader\u003e\n                        \u003cDialogTitle\u003e新增記錄\u003c/DialogTitle\u003e\n                    \u003c/DialogHeader\u003e\n                    \u003cdiv className=\"flex flex-col gap-2 min-w-[280px]\"\u003e\n                        \u003cdiv className=\"flex gap-2\"\u003e\n                            \u003cButton\n                                variant={type === 'expense' ? 'default' : 'outline'}\n                                size=\"sm\"\n                                className=\"flex-1\"\n                                onClick={() =\u003e handleTypeSwitch('expense')}\n                                disabled={loading}\n                            \u003e支出\u003c/Button\u003e\n                            \u003cButton\n                                variant={type === 'income' ? 'default' : 'outline'}\n                                size=\"sm\"\n                                className=\"flex-1\"\n                                onClick={() =\u003e handleTypeSwitch('income')}\n                                disabled={loading}\n                            \u003e收入\u003c/Button\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv className=\"flex gap-2\"\u003e\n                            \u003cdiv className=\"flex-1\"\u003e\n                                \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e日期\u003c/div\u003e\n                                \u003cInput type=\"date\" value={date} onChange={e =\u003e setDate(e.target.value)} /\u003e\n                            \u003c/div\u003e\n                            \u003cdiv className=\"flex-1\"\u003e\n                                \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e金額\u003c/div\u003e\n                                \u003cInput type=\"number\" value={amount} onChange={e =\u003e setAmount(e.target.value)} placeholder=\"0\" /\u003e\n                            \u003c/div\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv\u003e\n                            \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                \u003cdiv className=\"text-[13px] text-muted-foreground font-medium flex-1\"\u003e大類\u003c/div\u003e\n                                \u003cbutton type=\"button\" className=\"text-xs text-primary underline\"\n                                    onClick={() =\u003e setShowCatManager(true)}\u003e管理大小類\u003c/button\u003e\n                            \u003c/div\u003e\n                            \u003cselect\n                                value={majorCategory}\n                                onChange={e =\u003e { setMajorCategory(e.target.value); setSubCategory(''); }}\n                                className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\"\n                                disabled={loading}\n                            \u003e\n                                \u003coption value=\"\"\u003e選擇大類\u003c/option\u003e\n                                {categories.map(c =\u003e (\n                                    \u003coption key={c.major} value={c.major}\u003e{c.major}\u003c/option\u003e\n                                ))}\n                            \u003c/select\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv\u003e\n                            \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e小類\u003c/div\u003e\n                            \u003cselect\n                                value={subCategory}\n                                onChange={e =\u003e setSubCategory(e.target.value)}\n                                className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\"\n                                disabled={!majorCategory || loading}\n                            \u003e\n                                \u003coption value=\"\"\u003e選擇小類\u003c/option\u003e\n                                {subs.map(s =\u003e (\n                                    \u003coption key={s} value={s}\u003e{s}\u003c/option\u003e\n                                ))}\n                            \u003c/select\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv\u003e\n                            \u003cdiv className=\"flex items-center gap-2 mb-1\"\u003e\n                                \u003cdiv className=\"text-[13px] text-muted-foreground font-medium flex-1\"\u003e對象\u003c/div\u003e\n                                \u003cbutton type=\"button\" className=\"text-xs text-primary underline\"\n                                    onClick={() =\u003e setShowPayeeManager(true)}\u003e管理對象\u003c/button\u003e\n                            \u003c/div\u003e\n                            \u003cselect\n                                value={payee}\n                                onChange={e =\u003e setPayee(e.target.value)}\n                                className=\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\"\n                                disabled={loading}\n                            \u003e\n                                \u003coption value=\"\"\u003e選擇對象\u003c/option\u003e\n                                {payees.map(p =\u003e (\n                                    \u003coption key={p} value={p}\u003e{p}\u003c/option\u003e\n                                ))}\n                            \u003c/select\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv\u003e\n                            \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e地點\u003c/div\u003e\n                            \u003cdiv className=\"flex gap-1\"\u003e\n                                \u003cInput value={location} onChange={e =\u003e setLocation(e.target.value)} placeholder=\"輸入地址或店名\" className=\"flex-1\" /\u003e\n                                \u003cbutton\n                                    type=\"button\"\n                                    className=\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent disabled:opacity-50\"\n                                    onClick={handleGpsLocate}\n                                    disabled={gpsLoading}\n                                    title=\"GPS 定位目前地址\"\n                                \u003e\n                                    {gpsLoading ? (\n                                        \u003cspan className=\"inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin\" /\u003e\n                                    ) : (\n                                        \u003cspan className=\"text-sm\"\u003e📍\u003c/span\u003e\n                                    )}\n                                \u003c/button\u003e\n                            \u003c/div\u003e\n                        \u003c/div\u003e\n\n                        \u003cdiv\u003e\n                            \u003cdiv className=\"text-[13px] text-muted-foreground font-medium mb-1\"\u003e備注\u003c/div\u003e\n                            \u003ctextarea\n                                value={note}\n                                onChange={e =\u003e setNote(e.target.value)}\n                                placeholder=\"備注...\"\n                                rows={2}\n                                className=\"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors resize-none\"\n                            /\u003e\n                        \u003c/div\u003e\n\n                        {error \u0026\u0026 \u003cdiv className=\"text-xs text-destructive\"\u003e{error}\u003c/div\u003e}\n                    \u003c/div\u003e\n                    \u003cDialogFooter\u003e\n                        \u003cButton variant=\"outline\" onClick={onClose} disabled={loading}\u003e取消\u003c/Button\u003e\n                        \u003cButton onClick={handleSave} disabled={loading}\u003e\n                            {loading ? '儲存中…' : '新增'}\n                        \u003c/Button\u003e\n                    \u003c/DialogFooter\u003e\n                \u003c/DialogContent\u003e\n            \u003c/Dialog\u003e\n\n            {showCatManager \u0026\u0026 (\n                \u003cCategoryManagerDialog\n                    categories={categories}\n                    fieldKey={type === 'income' ? 'incomeCategories' : 'expenseCategories'}\n                    onClose={() =\u003e setShowCatManager(false)}\n                /\u003e\n            )}\n\n            {showPayeeManager \u0026\u0026 (\n                \u003cPayeeManagerDialog\n                    payees={payees}\n                    fieldKey={type === 'income' ? 'incomePayees' : 'expensePayees'}\n                    onClose={() =\u003e setShowPayeeManager(false)}\n                /\u003e\n            )}\n        \u003c/\u003e\n    );\n}\n\nfunction MajorRow({ cat, index, draggedIdx, setDraggedIdx, dragOver, setDragOver, onChange, onDelete, setEditCategories, children }: {\n    cat: CategoryGroup; index: number;\n    draggedIdx: number | null; setDraggedIdx: (v: number | null) =\u003e void;\n    dragOver: { idx: number; pos: 'above' | 'below' } | null;\n    setDragOver: (v: { idx: number; pos: 'above' | 'below' } | null) =\u003e void;\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\n    setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\n    children: any;\n}) {\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\n    const dragStateRef = useRef({ draggedIdx, dragOver });\n    dragStateRef.current = { draggedIdx, dragOver };\n    useDragSource(ref, {\n        sourceType: 'major-cat',\n        getItems: () =\u003e { setDraggedIdx(index); return [{ index }]; },\n        previewText: () =\u003e cat.major || '(未命名)',\n        onReorderMove: (p: DragPoint) =\u003e {\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-major-idx]');\n            const idxStr = over?.getAttribute('data-major-idx');\n            if (idxStr == null) { setDragOver(null); return; }\n            const idx = parseInt(idxStr);\n            if (idx === index) { setDragOver(null); return; }\n            const r = over!.getBoundingClientRect();\n            setDragOver({ idx, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\n        },\n        onCleanup: () =\u003e {\n            const { draggedIdx: d, dragOver: o } = dragStateRef.current;\n            if (d === index \u0026\u0026 o \u0026\u0026 d !== o.idx) {\n                setEditCategories((prev: CategoryGroup[]) =\u003e {\n                    const next = [...prev];\n                    const [moved] = next.splice(d, 1);\n                    let to = o.idx;\n                    if (d \u003c to) to -= 1;\n                    next.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\n                    return next;\n                });\n            }\n            setDraggedIdx(null);\n            setDragOver(null);\n        },\n    });\n    const isDropAbove = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'above';\n    const isDropBelow = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'below';\n    const isDragging = draggedIdx === index;\n    return (\n        \u003cdiv ref={ref} data-major-idx={index} className={isDragging ? 'opacity-30' : ''}\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\n            {isDropAbove \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-1 mb-1\" /\u003e}\n            \u003cdiv className=\"p-3 bg-muted rounded-lg flex flex-col gap-2\"\u003e\n                \u003cdiv className=\"flex gap-2 items-center\"\u003e\n                    \u003cInput value={cat.major} onChange={e =\u003e onChange(e.target.value)} placeholder=\"大類名稱\" /\u003e\n                    \u003cButton variant=\"ghost\" size=\"sm\" className=\"text-destructive shrink-0 h-8 w-8 p-0\" onClick={onDelete}\u003e✕\u003c/Button\u003e\n                \u003c/div\u003e\n                {children}\n            \u003c/div\u003e\n            {isDropBelow \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-1 mt-1\" /\u003e}\n        \u003c/div\u003e\n    );\n}\n\nfunction SubRow({ sub, majorIdx, subIdx, draggedInfo, setDraggedInfo, dragOver, setDragOver, onChange, onDelete, setEditCategories }: {\n    sub: string; majorIdx: number; subIdx: number;\n    draggedInfo: { majorIdx: number; subIdx: number } | null;\n    setDraggedInfo: (v: { majorIdx: number; subIdx: number } | null) =\u003e void;\n    dragOver: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null;\n    setDragOver: (v: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null) =\u003e void;\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\n    setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\n}) {\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\n    const dragStateRef = useRef({ draggedInfo, dragOver });\n    dragStateRef.current = { draggedInfo, dragOver };\n    useDragSource(ref, {\n        sourceType: `sub-cat-${majorIdx}`,\n        getItems: () =\u003e { setDraggedInfo({ majorIdx, subIdx }); return [{ majorIdx, subIdx }]; },\n        previewText: () =\u003e sub || '(未命名)',\n        onReorderMove: (p: DragPoint) =\u003e {\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-sub-info]');\n            const infoStr = over?.getAttribute('data-sub-info');\n            if (infoStr == null) { setDragOver(null); return; }\n            const [mi, si] = infoStr.split('-').map(Number);\n            if (mi !== majorIdx || si === subIdx) { setDragOver(null); return; }\n            const r = over!.getBoundingClientRect();\n            setDragOver({ majorIdx: mi, subIdx: si, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\n        },\n        onCleanup: () =\u003e {\n            const { draggedInfo: info, dragOver: o } = dragStateRef.current;\n            if (info \u0026\u0026 o \u0026\u0026 info.majorIdx === majorIdx \u0026\u0026 info.subIdx !== o.subIdx) {\n                setEditCategories((prev: CategoryGroup[]) =\u003e {\n                    const next = [...prev];\n                    const newSubs = [...next[majorIdx].subs];\n                    const [moved] = newSubs.splice(info.subIdx, 1);\n                    let to = o.subIdx;\n                    if (info.subIdx \u003c to) to -= 1;\n                    newSubs.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\n                    next[majorIdx] = { ...next[majorIdx], subs: newSubs };\n                    return next;\n                });\n            }\n            setDraggedInfo(null);\n            setDragOver(null);\n        },\n    });\n    const isDropAbove = dragOver?.majorIdx === majorIdx \u0026\u0026 dragOver?.subIdx === subIdx \u0026\u0026 dragOver?.pos === 'above';\n    const isDropBelow = dragOver?.majorIdx === majorIdx \u0026\u0026 dragOver?.subIdx === subIdx \u0026\u0026 dragOver?.pos === 'below';\n    const isDragging = draggedInfo?.majorIdx === majorIdx \u0026\u0026 draggedInfo?.subIdx === subIdx;\n    return (\n        \u003cdiv ref={ref} data-sub-info={`${majorIdx}-${subIdx}`} className={isDragging ? 'opacity-30' : ''}\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\n            {isDropAbove \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-4 mb-0.5\" /\u003e}\n            \u003cdiv className=\"flex gap-1.5 items-center\"\u003e\n                \u003cInput value={sub} onChange={e =\u003e onChange(e.target.value)} placeholder=\"小類名稱\" /\u003e\n                \u003cButton variant=\"ghost\" size=\"sm\" className=\"text-destructive shrink-0 h-8 w-8 p-0\" onClick={onDelete}\u003e✕\u003c/Button\u003e\n            \u003c/div\u003e\n            {isDropBelow \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-4 mt-0.5\" /\u003e}\n        \u003c/div\u003e\n    );\n}\n\nfunction SubList({ majorIdx, subs, editCategories, setEditCategories, draggedSubInfo, setDraggedSubInfo, dragOverSub, setDragOverSub }: {\n    majorIdx: number; subs: string[];\n    editCategories: CategoryGroup[]; setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\n    draggedSubInfo: { majorIdx: number; subIdx: number } | null;\n    setDraggedSubInfo: (v: { majorIdx: number; subIdx: number } | null) =\u003e void;\n    dragOverSub: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null;\n    setDragOverSub: (v: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null) =\u003e void;\n}) {\n    return (\n        \u003cdiv className=\"flex flex-col gap-1.5\"\u003e\n            {subs.map((sub, si) =\u003e (\n                \u003cSubRow key={si} sub={sub} majorIdx={majorIdx} subIdx={si}\n                    draggedInfo={draggedSubInfo} setDraggedInfo={setDraggedSubInfo}\n                    dragOver={dragOverSub} setDragOver={setDragOverSub}\n                    setEditCategories={setEditCategories}\n                    onChange={(v) =\u003e {\n                        const next = [...editCategories];\n                        const newSubs = [...next[majorIdx].subs];\n                        newSubs[si] = v;\n                        next[majorIdx] = { ...next[majorIdx], subs: newSubs };\n                        setEditCategories(next);\n                    }}\n                    onDelete={() =\u003e {\n                        const next = [...editCategories];\n                        next[majorIdx] = { ...next[majorIdx], subs: next[majorIdx].subs.filter((_, sj) =\u003e sj !== si) };\n                        setEditCategories(next);\n                    }} /\u003e\n            ))}\n        \u003c/div\u003e\n    );\n}\n\nexport function CategoryManagerDialog({ categories, fieldKey, onClose }: {\n    categories: CategoryGroup[];\n    fieldKey: 'expenseCategories' | 'incomeCategories';\n    onClose: () =\u003e void;\n}) {\n    const [editCategories, setEditCategories] = useState\u003cCategoryGroup[]\u003e(() =\u003e\n        categories.map(c =\u003e ({ major: c.major, subs: [...c.subs] }))\n    );\n    const [saving, setSaving] = useState(false);\n\n    const [draggedMajorIdx, setDraggedMajorIdx] = useState\u003cnumber | null\u003e(null);\n    const [dragOverMajor, setDragOverMajor] = useState\u003c{ idx: number; pos: 'above' | 'below' } | null\u003e(null);\n\n    const [draggedSubInfo, setDraggedSubInfo] = useState\u003c{ majorIdx: number; subIdx: number } | null\u003e(null);\n    const [dragOverSub, setDragOverSub] = useState\u003c{ majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null\u003e(null);\n\n    const handleSave = async () =\u003e {\n        setSaving(true);\n        try {\n            await updateIncomeExpenseConfig({\n                [fieldKey]: editCategories.filter(c =\u003e c.major.trim()),\n            } as any);\n            onClose();\n        } finally {\n            setSaving(false);\n        }\n    };\n\n    return (\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\n            \u003cDialogContent className=\"max-w-sm max-h-[85vh] overflow-y-auto\"\u003e\n                \u003cDialogHeader\u003e\n                    \u003cDialogTitle\u003e管理大小類（長按拖曳排序）\u003c/DialogTitle\u003e\n                \u003c/DialogHeader\u003e\n                \u003cdiv className=\"flex flex-col gap-1.5 min-w-[280px]\"\u003e\n                    {editCategories.map((cat, i) =\u003e (\n                        \u003cMajorRow key={i} cat={cat} index={i}\n                            draggedIdx={draggedMajorIdx} setDraggedIdx={setDraggedMajorIdx}\n                            dragOver={dragOverMajor} setDragOver={setDragOverMajor}\n                            setEditCategories={setEditCategories}\n                            onChange={(v) =\u003e {\n                                const next = [...editCategories];\n                                next[i] = { ...next[i], major: v };\n                                setEditCategories(next);\n                            }}\n                            onDelete={() =\u003e setEditCategories(editCategories.filter((_, j) =\u003e j !== i))}\u003e\n                            \u003cSubList majorIdx={i} subs={cat.subs}\n                                editCategories={editCategories} setEditCategories={setEditCategories}\n                                draggedSubInfo={draggedSubInfo} setDraggedSubInfo={setDraggedSubInfo}\n                                dragOverSub={dragOverSub} setDragOverSub={setDragOverSub}\n                            /\u003e\n                            \u003cButton variant=\"outline\" size=\"sm\" className=\"border-dashed text-xs\"\n                                onClick={() =\u003e {\n                                    const next = [...editCategories];\n                                    next[i] = { ...next[i], subs: [...next[i].subs, ''] };\n                                    setEditCategories(next);\n                                }}\u003e+ 新增小類\u003c/Button\u003e\n                        \u003c/MajorRow\u003e\n                    ))}\n                    \u003cButton variant=\"outline\" size=\"sm\" className=\"border-dashed\"\n                        onClick={() =\u003e setEditCategories([...editCategories, { major: '', subs: [] }])}\u003e+ 新增大類\u003c/Button\u003e\n                \u003c/div\u003e\n                \u003cDialogFooter\u003e\n                    \u003cButton variant=\"outline\" onClick={onClose}\u003e取消\u003c/Button\u003e\n                    \u003cButton onClick={handleSave} disabled={saving}\u003e\n                        {saving ? '儲存中…' : '儲存'}\n                    \u003c/Button\u003e\n                \u003c/DialogFooter\u003e\n            \u003c/DialogContent\u003e\n        \u003c/Dialog\u003e\n    );\n}\n\nfunction PayeeRow({ payee, index, draggedIdx, setDraggedIdx, dragOver, setDragOver, onChange, onDelete, setEditPayees }: {\n    payee: string; index: number;\n    draggedIdx: number | null; setDraggedIdx: (v: number | null) =\u003e void;\n    dragOver: { idx: number; pos: 'above' | 'below' } | null;\n    setDragOver: (v: { idx: number; pos: 'above' | 'below' } | null) =\u003e void;\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\n    setEditPayees: (v: string[] | ((prev: string[]) =\u003e string[])) =\u003e void;\n}) {\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\n    const dragStateRef = useRef({ draggedIdx, dragOver });\n    dragStateRef.current = { draggedIdx, dragOver };\n    useDragSource(ref, {\n        sourceType: 'payee',\n        getItems: () =\u003e { setDraggedIdx(index); return [{ index }]; },\n        previewText: () =\u003e payee || '(未命名)',\n        onReorderMove: (p: DragPoint) =\u003e {\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-payee-idx]');\n            const idxStr = over?.getAttribute('data-payee-idx');\n            if (idxStr == null) { setDragOver(null); return; }\n            const idx = parseInt(idxStr);\n            if (idx === index) { setDragOver(null); return; }\n            const r = over!.getBoundingClientRect();\n            setDragOver({ idx, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\n        },\n        onCleanup: () =\u003e {\n            const { draggedIdx: d, dragOver: o } = dragStateRef.current;\n            if (d === index \u0026\u0026 o \u0026\u0026 d !== o.idx) {\n                setEditPayees((prev: string[]) =\u003e {\n                    const next = [...prev];\n                    const [moved] = next.splice(d, 1);\n                    let to = o.idx;\n                    if (d \u003c to) to -= 1;\n                    next.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\n                    return next;\n                });\n            }\n            setDraggedIdx(null);\n            setDragOver(null);\n        },\n    });\n    const isDropAbove = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'above';\n    const isDropBelow = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'below';\n    const isDragging = draggedIdx === index;\n    return (\n        \u003cdiv ref={ref} data-payee-idx={index} className={isDragging ? 'opacity-30' : ''}\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\n            {isDropAbove \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-4 mb-0.5\" /\u003e}\n            \u003cdiv className=\"flex gap-1.5 items-center\"\u003e\n                \u003cInput value={payee} onChange={e =\u003e onChange(e.target.value)} placeholder=\"對象名稱\" /\u003e\n                \u003cButton variant=\"ghost\" size=\"sm\" className=\"text-destructive shrink-0 h-8 w-8 p-0\" onClick={onDelete}\u003e✕\u003c/Button\u003e\n            \u003c/div\u003e\n            {isDropBelow \u0026\u0026 \u003cdiv className=\"h-0.5 bg-primary rounded-full mx-4 mt-0.5\" /\u003e}\n        \u003c/div\u003e\n    );\n}\n\nexport function PayeeManagerDialog({ payees, fieldKey, onClose }: {\n    payees: string[];\n    fieldKey: 'expensePayees' | 'incomePayees';\n    onClose: () =\u003e void;\n}) {\n    const [editPayees, setEditPayees] = useState\u003cstring[]\u003e([...payees]);\n    const [saving, setSaving] = useState(false);\n    const [draggedIdx, setDraggedIdx] = useState\u003cnumber | null\u003e(null);\n    const [dragOver, setDragOver] = useState\u003c{ idx: number; pos: 'above' | 'below' } | null\u003e(null);\n\n    const handleSave = async () =\u003e {\n        setSaving(true);\n        try {\n            await updateIncomeExpenseConfig({\n                [fieldKey]: editPayees.filter(p =\u003e p.trim()),\n            } as any);\n            onClose();\n        } finally {\n            setSaving(false);\n        }\n    };\n\n    return (\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\n            \u003cDialogContent className=\"max-w-sm\"\u003e\n                \u003cDialogHeader\u003e\n                    \u003cDialogTitle\u003e管理對象（長按拖曳排序）\u003c/DialogTitle\u003e\n                \u003c/DialogHeader\u003e\n                \u003cdiv className=\"flex flex-col gap-1 min-w-[280px]\"\u003e\n                    {editPayees.map((p, i) =\u003e (\n                        \u003cPayeeRow key={i} payee={p} index={i}\n                            draggedIdx={draggedIdx} setDraggedIdx={setDraggedIdx}\n                            dragOver={dragOver} setDragOver={setDragOver}\n                            setEditPayees={setEditPayees}\n                            onChange={(v) =\u003e {\n                                const next = [...editPayees];\n                                next[i] = v;\n                                setEditPayees(next);\n                            }}\n                            onDelete={() =\u003e setEditPayees(editPayees.filter((_, j) =\u003e j !== i))} /\u003e\n                    ))}\n                    \u003cButton variant=\"outline\" size=\"sm\" className=\"border-dashed text-xs\"\n                        onClick={() =\u003e setEditPayees([...editPayees, ''])}\u003e+ 新增對象\u003c/Button\u003e\n                \u003c/div\u003e\n                \u003cDialogFooter\u003e\n                    \u003cButton variant=\"outline\" onClick={onClose}\u003e取消\u003c/Button\u003e\n                    \u003cButton onClick={handleSave} disabled={saving}\u003e\n                        {saving ? '儲存中…' : '儲存'}\n                    \u003c/Button\u003e\n                \u003c/DialogFooter\u003e\n            \u003c/DialogContent\u003e\n        \u003c/Dialog\u003e\n    );\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"frontend/components/AddTransactionDialog.tsx","id":"a90c6415ac3514e2d7391956","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"AddTransactionDialog.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663172},"preParentID":null,"updatedAt":1785888548789,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"import { createServer, createMcpServer } from '@cubelv/service-sdk';\n\nconst server = createServer();\nconst mcp = createMcpServer();\n\n// === Google Maps API 金鑰管理 ===\nlet googleMapsApiKey: string | null = null;\nlet googleMapsEnabled = false;\n\nmcp.tool(\n  'setGoogleMapsConfig',\n  '設定 Google Maps API 金鑰',\n  { apiKey: { type: 'string' }, enabled: { type: 'boolean' } },\n  async ({ apiKey, enabled }) =\u003e {\n    googleMapsApiKey = apiKey;\n    googleMapsEnabled = enabled;\n    return { success: true };\n  }\n);\n\nmcp.tool(\n  'getGoogleMapsConfig',\n  '取得 Google Maps API 設定',\n  {},\n  async () =\u003e {\n    return {\n      apiKey: googleMapsApiKey ? googleMapsApiKey.substring(0, 8) + '...' : null,\n      enabled: googleMapsEnabled,\n      hasKey: !!googleMapsApiKey,\n    };\n  }\n);\n\nmcp.tool(\n  'getApiKey',\n  '取得 API 金鑰（僅內部使用）',\n  {},\n  async () =\u003e {\n    return { apiKey: googleMapsApiKey, enabled: googleMapsEnabled };\n  }\n);\n\n// === 地址查詢（正向地理編碼）===\nserver.post('/api/search-geocode', async (req, res) =\u003e {\n  try {\n    const { address } = req.body || {};\n    if (!address || !googleMapsApiKey || !googleMapsEnabled) {\n      return res.json({ results: [], error: 'API 未設定或未啟用' });\n    }\n\n    const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}\u0026language=zh-TW\u0026key=${googleMapsApiKey}`;\n    const resp = await fetch(url);\n    const data = await resp.json();\n\n    if (data.status !== 'OK') {\n      return res.json({ results: [], error: data.status });\n    }\n\n    const results = data.results.map((r: any) =\u003e ({\n      formattedAddress: r.formatted_address,\n      lat: r.geometry.location.lat,\n      lng: r.geometry.location.lng,\n      placeId: r.place_id,\n    }));\n\n    return res.json({ results, error: null });\n  } catch (e: any) {\n    return res.json({ results: [], error: e.message });\n  }\n});\n\n// === 反向地理編碼（經緯度 → 地址，用於自動定位）===\nserver.post('/api/reverse-geocode', async (req, res) =\u003e {\n  try {\n    const { lat, lng } = req.body || {};\n    if (!lat || !lng || !googleMapsApiKey || !googleMapsEnabled) {\n      return res.json({ results: [], error: 'API 未設定或參數不足' });\n    }\n\n    const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}\u0026language=zh-TW\u0026key=${googleMapsApiKey}`;\n    const resp = await fetch(url);\n    const data = await resp.json();\n\n    if (data.status !== 'OK') {\n      return res.json({ results: [], error: data.status });\n    }\n\n    const results = data.results.map((r: any) =\u003e ({\n      formattedAddress: r.formatted_address,\n      lat: r.geometry.location.lat,\n      lng: r.geometry.location.lng,\n      placeId: r.place_id,\n    }));\n\n    return res.json({ results, error: null });\n  } catch (e: any) {\n    return res.json({ results: [], error: e.message });\n  }\n});\n\nexport { server, mcp };\n","createdAt":1785436662872,"deletedAt":null,"filePath":"backend/src/index.ts","id":"3fdbf27a8e36addb139fd501","isPublic":false,"itemType":"SOURCE_FILE","language":"typescript","name":"index.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436663072},"preParentID":null,"updatedAt":1785888548639,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"{\n  \"name\": \"income-expense-frontend\",\n  \"private\": true\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"backend/package.json","id":"114ee20cc6da185cafd9e425","isPublic":false,"itemType":"SOURCE_FILE","language":"json","name":"package.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436662972},"preParentID":null,"updatedAt":1785888548534,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"{\n  \"name\": \"收入與支出-backend\",\n  \"version\": \"1.0.0\",\n  \"main\": \"src/index.ts\",\n  \"dependencies\": {\n    \"@cubelv/service-sdk\": \"workspace:*\"\n  }\n}\n","createdAt":1785436662872,"deletedAt":null,"filePath":"backend/manifest.json","id":"b7240ef57c634ba4da7694d3","isPublic":false,"itemType":"SOURCE_FILE","language":"json","name":"manifest.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436662872},"preParentID":null,"updatedAt":1785888548380,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"/node_modules\n/dist\n/.bundle\nbundle.js\n*.log\n","createdAt":1785436643833,"deletedAt":null,"filePath":".gitignore","id":"f18fe16dd6c45dc288043d26","isPublic":false,"itemType":"SOURCE_FILE","language":"text","name":".gitignore","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785436645233},"preParentID":null,"updatedAt":1785888548062,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":3},{"content":"{\"name\":\"資料／插件備份後端\",\"version\":\"1.0.0\",\"endpoints\":[{\"path\":\"/api/plugin-code\",\"method\":\"GET\",\"public\":false},{\"path\":\"/api/plugin-list\",\"method\":\"GET\",\"public\":false}],\"schedule\":[],\"webhook\":[]}","createdAt":1785436714652,"filePath":"backend/manifest.json","id":"18ac8d7d0dddda2dcef175ca","itemType":"SOURCE_FILE","language":"json","name":"manifest.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715152},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"frontend/node_modules/\nbackend/node_modules/\nbackend/dist/\nfrontend/bundle.js\nfrontend/bundle.css\n.DS_Store","createdAt":1785436714652,"filePath":".gitignore","id":"b00bcb609a97fb2d82addada","itemType":"SOURCE_FILE","language":"text","name":".gitignore","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715052},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"{\"name\":\"backup-backend\",\"version\":\"1.0.0\",\"private\":true}","createdAt":1785436714652,"filePath":"backend/package.json","id":"ec1f5aa2066692b77822a168","itemType":"SOURCE_FILE","language":"json","name":"package.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715252},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { createService, getItemsByType } from '@cubelv/service-sdk';","createdAt":1785436714652,"filePath":"backend/src/index.ts","id":"9c67b037435940e7991eb385","itemType":"SOURCE_FILE","language":"typescript","name":"index.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715352},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { Plugin, plugin, View } from '@cubelv/sdk';","createdAt":1785436714652,"filePath":"frontend/BackupPlugin.tsx","id":"4fe1241e185694804f967513","itemType":"SOURCE_FILE","language":"typescript","name":"BackupPlugin.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715452},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { useState, useMemo, useCallback, useEffect } from 'react';","createdAt":1785436714652,"filePath":"frontend/BackupView.tsx","id":"07eef8c891be595c279f6971","itemType":"SOURCE_FILE","language":"typescript","name":"BackupView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715552},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n@itemType('BACKUP_VIRTUAL_FOLDER','資料備份入口')\nexport class BackupVirtualFolder extends BaseItem {}","createdAt":1785436714652,"filePath":"frontend/schemas/backupSchema.ts","id":"6f0370652693afb8bf0a9c79","itemType":"SOURCE_FILE","language":"typescript","name":"backupSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715652},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"{\"compilerOptions\":{\"target\":\"es2022\",\"module\":\"esnext\",\"moduleResolution\":\"bundler\",\"jsx\":\"react-jsx\",\"strict\":false,\"skipLibCheck\":true,\"esModuleInterop\":true,\"experimentalDecorators\":true},\"include\":[\"frontend/**/*.ts\",\"frontend/**/*.tsx\"],\"exclude\":[\"frontend/bundle.js\"]}","createdAt":1785436714652,"filePath":"tsconfig.json","id":"c489ac980f275718c97fd917","itemType":"SOURCE_FILE","language":"json","name":"tsconfig.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"198a6ad6c866e4326e13e03f":1785436715752},"updatedAt":1785436714652,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"frontend/node_modules/\nbackend/node_modules/\nbackend/dist/\nfrontend/bundle.js\nfrontend/bundle.css\n.DS_Store","createdAt":1785436692398,"filePath":".gitignore","id":"f13d4f598bfcc41c981e5a4d","itemType":"SOURCE_FILE","language":"text","name":".gitignore","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436692698},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { Plugin, plugin, useItemsByType, useTranslation, View } from '@cubelv/sdk';","createdAt":1785436692398,"filePath":"frontend/PluginDevPlugin.tsx","id":"c37de17197ccf1494e42f730","itemType":"SOURCE_FILE","language":"typescript","name":"PluginDevPlugin.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436692998},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n\n@itemType('PLUGIN_DEV_FOLDER','插件開發專案',{defaultFolder:'我的插件專案'})\nexport class PluginDevFolder extends BaseItem { pluginDir?:string=''; description?:string=''; }\n\n@itemType('SOURCE_FILE','原始碼檔案')\nexport class SourceFile extends BaseItem { content?:string=''; filePath?:string=''; language?:string='typescript'; }\n\n@itemType('VERSION_SNAPSHOT','版本快照')\nexport class VersionSnapshot extends BaseItem { snapVersion?:string=''; snapshot?:string=''; note?:string=''; }\n","createdAt":1785436692398,"filePath":"frontend/schemas/pluginDevSchema.ts","id":"6835e22295df1e35eb266ec1","itemType":"SOURCE_FILE","language":"typescript","name":"pluginDevSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436693098},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { BaseItem, itemType } from '@cubelv/sdk';\n@itemType('PLUGIN_DEV_SECTION_CONFIG','源碼上傳平台偏好設定')\nexport class PluginDevSectionConfig extends BaseItem { viewMode?:string='dashboard'; }\n","createdAt":1785436692398,"filePath":"frontend/schemas/pluginDevSectionConfigSchema.ts","id":"f288452ac6c59052033365c4","itemType":"SOURCE_FILE","language":"typescript","name":"pluginDevSectionConfigSchema.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436693198},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { createSingletonAccessor } from '@cubelv/sdk';\nimport { PluginDevSectionConfig } from '../schemas/pluginDevSectionConfigSchema';\nconst accessor=createSingletonAccessor(PluginDevSectionConfig);\nexport const usePluginDevSectionConfig=accessor.use;\nexport const getPluginDevSectionConfig=accessor.get;\nexport const updatePluginDevSectionConfig=accessor.update;\n","createdAt":1785436692398,"filePath":"frontend/stores/pluginDevSectionConfigStore.ts","id":"8bcf18c5f25ee03b4f404419","itemType":"SOURCE_FILE","language":"typescript","name":"pluginDevSectionConfigStore.ts","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436693298},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"{\"compilerOptions\":{\"target\":\"es2022\",\"module\":\"esnext\",\"moduleResolution\":\"bundler\",\"jsx\":\"react-jsx\",\"strict\":false,\"skipLibCheck\":true,\"esModuleInterop\":true,\"experimentalDecorators\":true},\"include\":[\"frontend/**/*.ts\",\"frontend/**/*.tsx\"],\"exclude\":[\"frontend/bundle.js\"]}","createdAt":1785436692398,"filePath":"tsconfig.json","id":"c6ac86e44f5c26c7fe28b806","itemType":"SOURCE_FILE","language":"json","name":"tsconfig.json","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436693398},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { useState, useMemo, useRef } from 'react';","createdAt":1785436692398,"filePath":"frontend/PluginDevListView.tsx","id":"43b3dc5ba1397a75135f8820","itemType":"SOURCE_FILE","language":"typescript","name":"PluginDevListView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436692898},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"content":"import { useState, useEffect, useCallback } from 'react';","createdAt":1785436692398,"filePath":"frontend/PluginDevEditorView.tsx","id":"3ad70be4d6def7caeeb47d6c","itemType":"SOURCE_FILE","language":"typescript","name":"PluginDevEditorView.tsx","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"ee9ef6b2e6fd375a0ad57e00":1785436692798},"updatedAt":1785436692398,"updatedBy":{"agentName":"CEO","userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"createdAt":1785626361728,"deletedAt":null,"id":"6a6e7ef9cb9a5efacc83ec7d","isPublic":false,"itemType":"VERSION_SNAPSHOT","name":"v1 - 修正萬年曆","note":"修正萬年曆","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"e7bb1a69c148feefb7f9793c":1785626361728},"preParentID":null,"snapVersion":"1","snapshot":"{\".gitignore\":\"/node_modules\\n/dist\\n/.bundle\\nbundle.js\\n*.log\\n\",\"backend/manifest.json\":\"{\\n  \\\"name\\\": \\\"收入與支出-backend\\\",\\n  \\\"version\\\": \\\"1.0.0\\\",\\n  \\\"main\\\": \\\"src/index.ts\\\",\\n  \\\"dependencies\\\": {\\n    \\\"@cubelv/service-sdk\\\": \\\"workspace:*\\\"\\n  }\\n}\\n\",\"backend/package.json\":\"{\\n  \\\"name\\\": \\\"income-expense-frontend\\\",\\n  \\\"private\\\": true\\n}\\n\",\"backend/src/index.ts\":\"import { createServer, createMcpServer } from '@cubelv/service-sdk';\\n\\nconst server = createServer();\\nconst mcp = createMcpServer();\\n\\n// === Google Maps API 金鑰管理 ===\\nlet googleMapsApiKey: string | null = null;\\nlet googleMapsEnabled = false;\\n\\nmcp.tool(\\n  'setGoogleMapsConfig',\\n  '設定 Google Maps API 金鑰',\\n  { apiKey: { type: 'string' }, enabled: { type: 'boolean' } },\\n  async ({ apiKey, enabled }) =\u003e {\\n    googleMapsApiKey = apiKey;\\n    googleMapsEnabled = enabled;\\n    return { success: true };\\n  }\\n);\\n\\nmcp.tool(\\n  'getGoogleMapsConfig',\\n  '取得 Google Maps API 設定',\\n  {},\\n  async () =\u003e {\\n    return {\\n      apiKey: googleMapsApiKey ? googleMapsApiKey.substring(0, 8) + '...' : null,\\n      enabled: googleMapsEnabled,\\n      hasKey: !!googleMapsApiKey,\\n    };\\n  }\\n);\\n\\nmcp.tool(\\n  'getApiKey',\\n  '取得 API 金鑰（僅內部使用）',\\n  {},\\n  async () =\u003e {\\n    return { apiKey: googleMapsApiKey, enabled: googleMapsEnabled };\\n  }\\n);\\n\\n// === 地址查詢（正向地理編碼）===\\nserver.post('/api/search-geocode', async (req, res) =\u003e {\\n  try {\\n    const { address } = req.body || {};\\n    if (!address || !googleMapsApiKey || !googleMapsEnabled) {\\n      return res.json({ results: [], error: 'API 未設定或未啟用' });\\n    }\\n\\n    const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}\u0026language=zh-TW\u0026key=${googleMapsApiKey}`;\\n    const resp = await fetch(url);\\n    const data = await resp.json();\\n\\n    if (data.status !== 'OK') {\\n      return res.json({ results: [], error: data.status });\\n    }\\n\\n    const results = data.results.map((r: any) =\u003e ({\\n      formattedAddress: r.formatted_address,\\n      lat: r.geometry.location.lat,\\n      lng: r.geometry.location.lng,\\n      placeId: r.place_id,\\n    }));\\n\\n    return res.json({ results, error: null });\\n  } catch (e: any) {\\n    return res.json({ results: [], error: e.message });\\n  }\\n});\\n\\n// === 反向地理編碼（經緯度 → 地址，用於自動定位）===\\nserver.post('/api/reverse-geocode', async (req, res) =\u003e {\\n  try {\\n    const { lat, lng } = req.body || {};\\n    if (!lat || !lng || !googleMapsApiKey || !googleMapsEnabled) {\\n      return res.json({ results: [], error: 'API 未設定或參數不足' });\\n    }\\n\\n    const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}\u0026language=zh-TW\u0026key=${googleMapsApiKey}`;\\n    const resp = await fetch(url);\\n    const data = await resp.json();\\n\\n    if (data.status !== 'OK') {\\n      return res.json({ results: [], error: data.status });\\n    }\\n\\n    const results = data.results.map((r: any) =\u003e ({\\n      formattedAddress: r.formatted_address,\\n      lat: r.geometry.location.lat,\\n      lng: r.geometry.location.lng,\\n      placeId: r.place_id,\\n    }));\\n\\n    return res.json({ results, error: null });\\n  } catch (e: any) {\\n    return res.json({ results: [], error: e.message });\\n  }\\n});\\n\\nexport { server, mcp };\\n\",\"frontend/components/AddTransactionDialog.tsx\":\"import { useState, useMemo, useRef } from 'react';\\nimport {\\n    Dialog,\\n    DialogContent,\\n    DialogHeader,\\n    DialogTitle,\\n    DialogFooter,\\n    Button,\\n    Input,\\n    useItemStore,\\n    useDragSource,\\n    fetchExternal,\\n} from '@cubelv/sdk';\\nimport type { BaseItem, DragPoint } from '@cubelv/sdk';\\nimport { useIncomeExpenseConfig, updateIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\\nimport type { CategoryGroup } from '../schemas/expenseTrackerSectionConfigSchema';\\n\\nlet _counter = Math.floor(Math.random() * 0xffffff);\\nconst _machine = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');\\nconst _process = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0');\\nfunction newObjectID(): string {\\n    const time = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0');\\n    _counter = (_counter + 1) % 0xffffff;\\n    return time + _machine + _process + _counter.toString(16).padStart(6, '0');\\n}\\n\\ninterface AddTransactionDialogProps {\\n    folderId: string;\\n    onClose: () =\u003e void;\\n    onSaved?: () =\u003e void;\\n}\\n\\nexport function AddTransactionDialog({ folderId, onClose, onSaved }: AddTransactionDialogProps) {\\n    const config = useIncomeExpenseConfig();\\n\\n    const [type, setType] = useState\u003c'expense' | 'income'\u003e('expense');\\n    const [date, setDate] = useState(() =\u003e {\\n        const d = new Date();\\n        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\\n    });\\n    const [majorCategory, setMajorCategory] = useState('');\\n    const [subCategory, setSubCategory] = useState('');\\n    const [amount, setAmount] = useState('');\\n    const [payee, setPayee] = useState('');\\n    const [location, setLocation] = useState('');\\n    const [note, setNote] = useState('');\\n    const [loading, setLoading] = useState(false);\\n    const [error, setError] = useState\u003cstring | null\u003e(null);\\n    const [gpsLoading, setGpsLoading] = useState(false);\\n\\n    const [showCatManager, setShowCatManager] = useState(false);\\n    const [showPayeeManager, setShowPayeeManager] = useState(false);\\n\\n    const handleGpsLocate = async () =\u003e {\\n        setGpsLoading(true);\\n        setError(null);\\n        try {\\n            // 第一步：IP 定位取得座標\\n            const ipResp = await fetchExternal('https://ipapi.co/json/');\\n            if (!ipResp.ok) throw new Error(`HTTP ${ipResp.status}`);\\n            const ipData = await ipResp.json();\\n            const lat = ipData.latitude as number;\\n            const lng = ipData.longitude as number;\\n            if (lat == null || lng == null) throw new Error('無法取得座標');\\n\\n            // 第二步：反查地址\\n            const geoResp = await fetchExternal(\\n                `https://nominatim.openstreetmap.org/reverse?format=json\u0026lat=${lat}\u0026lon=${lng}\u0026accept-language=zh-TW`,\\n                { headers: { 'User-Agent': 'CubeLV-IncomeExpense/1.0' } }\\n            );\\n            if (geoResp.ok) {\\n                const geoData = await geoResp.json();\\n                if (geoData?.display_name) {\\n                    setLocation(geoData.display_name);\\n                } else {\\n                    setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\\n                }\\n            } else {\\n                setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\\n            }\\n        } catch (e) {\\n            setError('定位失敗，請確認網路連線後再試');\\n        } finally {\\n            setGpsLoading(false);\\n        }\\n    };\\n\\n    const categories = useMemo(() =\u003e\\n        type === 'income'\\n            ? (config?.incomeCategories || [])\\n            : (config?.expenseCategories || []),\\n        [config, type]\\n    );\\n    const payees = useMemo(() =\u003e\\n        type === 'income'\\n            ? (config?.incomePayees || [])\\n            : (config?.expensePayees || []),\\n        [config, type]\\n    );\\n\\n    const currentMajor = categories.find(c =\u003e c.major === majorCategory);\\n    const subs = currentMajor?.subs || [];\\n\\n    const handleTypeSwitch = (newType: 'expense' | 'income') =\u003e {\\n        if (newType === type) return;\\n        setType(newType);\\n        setMajorCategory('');\\n        setSubCategory('');\\n        setPayee('');\\n    };\\n\\n    const handleSave = async () =\u003e {\\n        if (!date) { setError('請選擇日期'); return; }\\n        if (!majorCategory) { setError('請選擇大類'); return; }\\n        if (!amount || Number(amount) \u003c= 0) { setError('請輸入金額'); return; }\\n\\n        setLoading(true);\\n        setError(null);\\n        try {\\n            const name = `${type === 'income' ? '收入' : '支出'} ${majorCategory}${subCategory ? '/' + subCategory : ''} ${amount}元`;\\n            const now = Date.now();\\n            await useItemStore.getState().upsertItem({\\n                id: newObjectID(),\\n                itemType: 'INCOME_EXPENSE_TRANSACTION',\\n                name,\\n                parents: { [folderId]: now },\\n                date,\\n                type,\\n                majorCategory,\\n                subCategory,\\n                amount: Number(amount),\\n                payee,\\n                location,\\n                coordinates: '',\\n                note,\\n                createdAt: now,\\n                updatedAt: now,\\n            } as unknown as BaseItem, { needSync: true });\\n            onSaved?.();\\n            onClose();\\n        } catch (err) {\\n            setError(err instanceof Error ? err.message : '新增失敗');\\n        } finally {\\n            setLoading(false);\\n        }\\n    };\\n\\n    return (\\n        \u003c\u003e\\n            \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\\n                \u003cDialogContent className=\\\"max-w-sm max-h-[90vh] overflow-y-auto\\\"\u003e\\n                    \u003cDialogHeader\u003e\\n                        \u003cDialogTitle\u003e新增記錄\u003c/DialogTitle\u003e\\n                    \u003c/DialogHeader\u003e\\n                    \u003cdiv className=\\\"flex flex-col gap-2 min-w-[280px]\\\"\u003e\\n                        \u003cdiv className=\\\"flex gap-2\\\"\u003e\\n                            \u003cButton\\n                                variant={type === 'expense' ? 'default' : 'outline'}\\n                                size=\\\"sm\\\"\\n                                className=\\\"flex-1\\\"\\n                                onClick={() =\u003e handleTypeSwitch('expense')}\\n                                disabled={loading}\\n                            \u003e支出\u003c/Button\u003e\\n                            \u003cButton\\n                                variant={type === 'income' ? 'default' : 'outline'}\\n                                size=\\\"sm\\\"\\n                                className=\\\"flex-1\\\"\\n                                onClick={() =\u003e handleTypeSwitch('income')}\\n                                disabled={loading}\\n                            \u003e收入\u003c/Button\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv className=\\\"flex gap-2\\\"\u003e\\n                            \u003cdiv className=\\\"flex-1\\\"\u003e\\n                                \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e日期\u003c/div\u003e\\n                                \u003cInput type=\\\"date\\\" value={date} onChange={e =\u003e setDate(e.target.value)} /\u003e\\n                            \u003c/div\u003e\\n                            \u003cdiv className=\\\"flex-1\\\"\u003e\\n                                \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e金額\u003c/div\u003e\\n                                \u003cInput type=\\\"number\\\" value={amount} onChange={e =\u003e setAmount(e.target.value)} placeholder=\\\"0\\\" /\u003e\\n                            \u003c/div\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv\u003e\\n                            \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium flex-1\\\"\u003e大類\u003c/div\u003e\\n                                \u003cbutton type=\\\"button\\\" className=\\\"text-xs text-primary underline\\\"\\n                                    onClick={() =\u003e setShowCatManager(true)}\u003e管理大小類\u003c/button\u003e\\n                            \u003c/div\u003e\\n                            \u003cselect\\n                                value={majorCategory}\\n                                onChange={e =\u003e { setMajorCategory(e.target.value); setSubCategory(''); }}\\n                                className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\\\"\\n                                disabled={loading}\\n                            \u003e\\n                                \u003coption value=\\\"\\\"\u003e選擇大類\u003c/option\u003e\\n                                {categories.map(c =\u003e (\\n                                    \u003coption key={c.major} value={c.major}\u003e{c.major}\u003c/option\u003e\\n                                ))}\\n                            \u003c/select\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv\u003e\\n                            \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e小類\u003c/div\u003e\\n                            \u003cselect\\n                                value={subCategory}\\n                                onChange={e =\u003e setSubCategory(e.target.value)}\\n                                className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\\\"\\n                                disabled={!majorCategory || loading}\\n                            \u003e\\n                                \u003coption value=\\\"\\\"\u003e選擇小類\u003c/option\u003e\\n                                {subs.map(s =\u003e (\\n                                    \u003coption key={s} value={s}\u003e{s}\u003c/option\u003e\\n                                ))}\\n                            \u003c/select\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv\u003e\\n                            \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium flex-1\\\"\u003e對象\u003c/div\u003e\\n                                \u003cbutton type=\\\"button\\\" className=\\\"text-xs text-primary underline\\\"\\n                                    onClick={() =\u003e setShowPayeeManager(true)}\u003e管理對象\u003c/button\u003e\\n                            \u003c/div\u003e\\n                            \u003cselect\\n                                value={payee}\\n                                onChange={e =\u003e setPayee(e.target.value)}\\n                                className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors\\\"\\n                                disabled={loading}\\n                            \u003e\\n                                \u003coption value=\\\"\\\"\u003e選擇對象\u003c/option\u003e\\n                                {payees.map(p =\u003e (\\n                                    \u003coption key={p} value={p}\u003e{p}\u003c/option\u003e\\n                                ))}\\n                            \u003c/select\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv\u003e\\n                            \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e地點\u003c/div\u003e\\n                            \u003cdiv className=\\\"flex gap-1\\\"\u003e\\n                                \u003cInput value={location} onChange={e =\u003e setLocation(e.target.value)} placeholder=\\\"輸入地址或店名\\\" className=\\\"flex-1\\\" /\u003e\\n                                \u003cbutton\\n                                    type=\\\"button\\\"\\n                                    className=\\\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent disabled:opacity-50\\\"\\n                                    onClick={handleGpsLocate}\\n                                    disabled={gpsLoading}\\n                                    title=\\\"GPS 定位目前地址\\\"\\n                                \u003e\\n                                    {gpsLoading ? (\\n                                        \u003cspan className=\\\"inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin\\\" /\u003e\\n                                    ) : (\\n                                        \u003cspan className=\\\"text-sm\\\"\u003e📍\u003c/span\u003e\\n                                    )}\\n                                \u003c/button\u003e\\n                            \u003c/div\u003e\\n                        \u003c/div\u003e\\n\\n                        \u003cdiv\u003e\\n                            \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e備注\u003c/div\u003e\\n                            \u003ctextarea\\n                                value={note}\\n                                onChange={e =\u003e setNote(e.target.value)}\\n                                placeholder=\\\"備注...\\\"\\n                                rows={2}\\n                                className=\\\"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors resize-none\\\"\\n                            /\u003e\\n                        \u003c/div\u003e\\n\\n                        {error \u0026\u0026 \u003cdiv className=\\\"text-xs text-destructive\\\"\u003e{error}\u003c/div\u003e}\\n                    \u003c/div\u003e\\n                    \u003cDialogFooter\u003e\\n                        \u003cButton variant=\\\"outline\\\" onClick={onClose} disabled={loading}\u003e取消\u003c/Button\u003e\\n                        \u003cButton onClick={handleSave} disabled={loading}\u003e\\n                            {loading ? '儲存中…' : '新增'}\\n                        \u003c/Button\u003e\\n                    \u003c/DialogFooter\u003e\\n                \u003c/DialogContent\u003e\\n            \u003c/Dialog\u003e\\n\\n            {showCatManager \u0026\u0026 (\\n                \u003cCategoryManagerDialog\\n                    categories={categories}\\n                    fieldKey={type === 'income' ? 'incomeCategories' : 'expenseCategories'}\\n                    onClose={() =\u003e setShowCatManager(false)}\\n                /\u003e\\n            )}\\n\\n            {showPayeeManager \u0026\u0026 (\\n                \u003cPayeeManagerDialog\\n                    payees={payees}\\n                    fieldKey={type === 'income' ? 'incomePayees' : 'expensePayees'}\\n                    onClose={() =\u003e setShowPayeeManager(false)}\\n                /\u003e\\n            )}\\n        \u003c/\u003e\\n    );\\n}\\n\\nfunction MajorRow({ cat, index, draggedIdx, setDraggedIdx, dragOver, setDragOver, onChange, onDelete, setEditCategories, children }: {\\n    cat: CategoryGroup; index: number;\\n    draggedIdx: number | null; setDraggedIdx: (v: number | null) =\u003e void;\\n    dragOver: { idx: number; pos: 'above' | 'below' } | null;\\n    setDragOver: (v: { idx: number; pos: 'above' | 'below' } | null) =\u003e void;\\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\\n    setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\\n    children: any;\\n}) {\\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\\n    const dragStateRef = useRef({ draggedIdx, dragOver });\\n    dragStateRef.current = { draggedIdx, dragOver };\\n    useDragSource(ref, {\\n        sourceType: 'major-cat',\\n        getItems: () =\u003e { setDraggedIdx(index); return [{ index }]; },\\n        previewText: () =\u003e cat.major || '(未命名)',\\n        onReorderMove: (p: DragPoint) =\u003e {\\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-major-idx]');\\n            const idxStr = over?.getAttribute('data-major-idx');\\n            if (idxStr == null) { setDragOver(null); return; }\\n            const idx = parseInt(idxStr);\\n            if (idx === index) { setDragOver(null); return; }\\n            const r = over!.getBoundingClientRect();\\n            setDragOver({ idx, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\\n        },\\n        onCleanup: () =\u003e {\\n            const { draggedIdx: d, dragOver: o } = dragStateRef.current;\\n            if (d === index \u0026\u0026 o \u0026\u0026 d !== o.idx) {\\n                setEditCategories((prev: CategoryGroup[]) =\u003e {\\n                    const next = [...prev];\\n                    const [moved] = next.splice(d, 1);\\n                    let to = o.idx;\\n                    if (d \u003c to) to -= 1;\\n                    next.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\\n                    return next;\\n                });\\n            }\\n            setDraggedIdx(null);\\n            setDragOver(null);\\n        },\\n    });\\n    const isDropAbove = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'above';\\n    const isDropBelow = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'below';\\n    const isDragging = draggedIdx === index;\\n    return (\\n        \u003cdiv ref={ref} data-major-idx={index} className={isDragging ? 'opacity-30' : ''}\\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\\n            {isDropAbove \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-1 mb-1\\\" /\u003e}\\n            \u003cdiv className=\\\"p-3 bg-muted rounded-lg flex flex-col gap-2\\\"\u003e\\n                \u003cdiv className=\\\"flex gap-2 items-center\\\"\u003e\\n                    \u003cInput value={cat.major} onChange={e =\u003e onChange(e.target.value)} placeholder=\\\"大類名稱\\\" /\u003e\\n                    \u003cButton variant=\\\"ghost\\\" size=\\\"sm\\\" className=\\\"text-destructive shrink-0 h-8 w-8 p-0\\\" onClick={onDelete}\u003e✕\u003c/Button\u003e\\n                \u003c/div\u003e\\n                {children}\\n            \u003c/div\u003e\\n            {isDropBelow \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-1 mt-1\\\" /\u003e}\\n        \u003c/div\u003e\\n    );\\n}\\n\\nfunction SubRow({ sub, majorIdx, subIdx, draggedInfo, setDraggedInfo, dragOver, setDragOver, onChange, onDelete, setEditCategories }: {\\n    sub: string; majorIdx: number; subIdx: number;\\n    draggedInfo: { majorIdx: number; subIdx: number } | null;\\n    setDraggedInfo: (v: { majorIdx: number; subIdx: number } | null) =\u003e void;\\n    dragOver: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null;\\n    setDragOver: (v: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null) =\u003e void;\\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\\n    setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\\n}) {\\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\\n    const dragStateRef = useRef({ draggedInfo, dragOver });\\n    dragStateRef.current = { draggedInfo, dragOver };\\n    useDragSource(ref, {\\n        sourceType: `sub-cat-${majorIdx}`,\\n        getItems: () =\u003e { setDraggedInfo({ majorIdx, subIdx }); return [{ majorIdx, subIdx }]; },\\n        previewText: () =\u003e sub || '(未命名)',\\n        onReorderMove: (p: DragPoint) =\u003e {\\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-sub-info]');\\n            const infoStr = over?.getAttribute('data-sub-info');\\n            if (infoStr == null) { setDragOver(null); return; }\\n            const [mi, si] = infoStr.split('-').map(Number);\\n            if (mi !== majorIdx || si === subIdx) { setDragOver(null); return; }\\n            const r = over!.getBoundingClientRect();\\n            setDragOver({ majorIdx: mi, subIdx: si, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\\n        },\\n        onCleanup: () =\u003e {\\n            const { draggedInfo: info, dragOver: o } = dragStateRef.current;\\n            if (info \u0026\u0026 o \u0026\u0026 info.majorIdx === majorIdx \u0026\u0026 info.subIdx !== o.subIdx) {\\n                setEditCategories((prev: CategoryGroup[]) =\u003e {\\n                    const next = [...prev];\\n                    const newSubs = [...next[majorIdx].subs];\\n                    const [moved] = newSubs.splice(info.subIdx, 1);\\n                    let to = o.subIdx;\\n                    if (info.subIdx \u003c to) to -= 1;\\n                    newSubs.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\\n                    next[majorIdx] = { ...next[majorIdx], subs: newSubs };\\n                    return next;\\n                });\\n            }\\n            setDraggedInfo(null);\\n            setDragOver(null);\\n        },\\n    });\\n    const isDropAbove = dragOver?.majorIdx === majorIdx \u0026\u0026 dragOver?.subIdx === subIdx \u0026\u0026 dragOver?.pos === 'above';\\n    const isDropBelow = dragOver?.majorIdx === majorIdx \u0026\u0026 dragOver?.subIdx === subIdx \u0026\u0026 dragOver?.pos === 'below';\\n    const isDragging = draggedInfo?.majorIdx === majorIdx \u0026\u0026 draggedInfo?.subIdx === subIdx;\\n    return (\\n        \u003cdiv ref={ref} data-sub-info={`${majorIdx}-${subIdx}`} className={isDragging ? 'opacity-30' : ''}\\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\\n            {isDropAbove \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-4 mb-0.5\\\" /\u003e}\\n            \u003cdiv className=\\\"flex gap-1.5 items-center\\\"\u003e\\n                \u003cInput value={sub} onChange={e =\u003e onChange(e.target.value)} placeholder=\\\"小類名稱\\\" /\u003e\\n                \u003cButton variant=\\\"ghost\\\" size=\\\"sm\\\" className=\\\"text-destructive shrink-0 h-8 w-8 p-0\\\" onClick={onDelete}\u003e✕\u003c/Button\u003e\\n            \u003c/div\u003e\\n            {isDropBelow \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-4 mt-0.5\\\" /\u003e}\\n        \u003c/div\u003e\\n    );\\n}\\n\\nfunction SubList({ majorIdx, subs, editCategories, setEditCategories, draggedSubInfo, setDraggedSubInfo, dragOverSub, setDragOverSub }: {\\n    majorIdx: number; subs: string[];\\n    editCategories: CategoryGroup[]; setEditCategories: (v: CategoryGroup[] | ((prev: CategoryGroup[]) =\u003e CategoryGroup[])) =\u003e void;\\n    draggedSubInfo: { majorIdx: number; subIdx: number } | null;\\n    setDraggedSubInfo: (v: { majorIdx: number; subIdx: number } | null) =\u003e void;\\n    dragOverSub: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null;\\n    setDragOverSub: (v: { majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null) =\u003e void;\\n}) {\\n    return (\\n        \u003cdiv className=\\\"flex flex-col gap-1.5\\\"\u003e\\n            {subs.map((sub, si) =\u003e (\\n                \u003cSubRow key={si} sub={sub} majorIdx={majorIdx} subIdx={si}\\n                    draggedInfo={draggedSubInfo} setDraggedInfo={setDraggedSubInfo}\\n                    dragOver={dragOverSub} setDragOver={setDragOverSub}\\n                    setEditCategories={setEditCategories}\\n                    onChange={(v) =\u003e {\\n                        const next = [...editCategories];\\n                        const newSubs = [...next[majorIdx].subs];\\n                        newSubs[si] = v;\\n                        next[majorIdx] = { ...next[majorIdx], subs: newSubs };\\n                        setEditCategories(next);\\n                    }}\\n                    onDelete={() =\u003e {\\n                        const next = [...editCategories];\\n                        next[majorIdx] = { ...next[majorIdx], subs: next[majorIdx].subs.filter((_, sj) =\u003e sj !== si) };\\n                        setEditCategories(next);\\n                    }} /\u003e\\n            ))}\\n        \u003c/div\u003e\\n    );\\n}\\n\\nexport function CategoryManagerDialog({ categories, fieldKey, onClose }: {\\n    categories: CategoryGroup[];\\n    fieldKey: 'expenseCategories' | 'incomeCategories';\\n    onClose: () =\u003e void;\\n}) {\\n    const [editCategories, setEditCategories] = useState\u003cCategoryGroup[]\u003e(() =\u003e\\n        categories.map(c =\u003e ({ major: c.major, subs: [...c.subs] }))\\n    );\\n    const [saving, setSaving] = useState(false);\\n\\n    const [draggedMajorIdx, setDraggedMajorIdx] = useState\u003cnumber | null\u003e(null);\\n    const [dragOverMajor, setDragOverMajor] = useState\u003c{ idx: number; pos: 'above' | 'below' } | null\u003e(null);\\n\\n    const [draggedSubInfo, setDraggedSubInfo] = useState\u003c{ majorIdx: number; subIdx: number } | null\u003e(null);\\n    const [dragOverSub, setDragOverSub] = useState\u003c{ majorIdx: number; subIdx: number; pos: 'above' | 'below' } | null\u003e(null);\\n\\n    const handleSave = async () =\u003e {\\n        setSaving(true);\\n        try {\\n            await updateIncomeExpenseConfig({\\n                [fieldKey]: editCategories.filter(c =\u003e c.major.trim()),\\n            } as any);\\n            onClose();\\n        } finally {\\n            setSaving(false);\\n        }\\n    };\\n\\n    return (\\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\\n            \u003cDialogContent className=\\\"max-w-sm max-h-[85vh] overflow-y-auto\\\"\u003e\\n                \u003cDialogHeader\u003e\\n                    \u003cDialogTitle\u003e管理大小類（長按拖曳排序）\u003c/DialogTitle\u003e\\n                \u003c/DialogHeader\u003e\\n                \u003cdiv className=\\\"flex flex-col gap-1.5 min-w-[280px]\\\"\u003e\\n                    {editCategories.map((cat, i) =\u003e (\\n                        \u003cMajorRow key={i} cat={cat} index={i}\\n                            draggedIdx={draggedMajorIdx} setDraggedIdx={setDraggedMajorIdx}\\n                            dragOver={dragOverMajor} setDragOver={setDragOverMajor}\\n                            setEditCategories={setEditCategories}\\n                            onChange={(v) =\u003e {\\n                                const next = [...editCategories];\\n                                next[i] = { ...next[i], major: v };\\n                                setEditCategories(next);\\n                            }}\\n                            onDelete={() =\u003e setEditCategories(editCategories.filter((_, j) =\u003e j !== i))}\u003e\\n                            \u003cSubList majorIdx={i} subs={cat.subs}\\n                                editCategories={editCategories} setEditCategories={setEditCategories}\\n                                draggedSubInfo={draggedSubInfo} setDraggedSubInfo={setDraggedSubInfo}\\n                                dragOverSub={dragOverSub} setDragOverSub={setDragOverSub}\\n                            /\u003e\\n                            \u003cButton variant=\\\"outline\\\" size=\\\"sm\\\" className=\\\"border-dashed text-xs\\\"\\n                                onClick={() =\u003e {\\n                                    const next = [...editCategories];\\n                                    next[i] = { ...next[i], subs: [...next[i].subs, ''] };\\n                                    setEditCategories(next);\\n                                }}\u003e+ 新增小類\u003c/Button\u003e\\n                        \u003c/MajorRow\u003e\\n                    ))}\\n                    \u003cButton variant=\\\"outline\\\" size=\\\"sm\\\" className=\\\"border-dashed\\\"\\n                        onClick={() =\u003e setEditCategories([...editCategories, { major: '', subs: [] }])}\u003e+ 新增大類\u003c/Button\u003e\\n                \u003c/div\u003e\\n                \u003cDialogFooter\u003e\\n                    \u003cButton variant=\\\"outline\\\" onClick={onClose}\u003e取消\u003c/Button\u003e\\n                    \u003cButton onClick={handleSave} disabled={saving}\u003e\\n                        {saving ? '儲存中…' : '儲存'}\\n                    \u003c/Button\u003e\\n                \u003c/DialogFooter\u003e\\n            \u003c/DialogContent\u003e\\n        \u003c/Dialog\u003e\\n    );\\n}\\n\\nfunction PayeeRow({ payee, index, draggedIdx, setDraggedIdx, dragOver, setDragOver, onChange, onDelete, setEditPayees }: {\\n    payee: string; index: number;\\n    draggedIdx: number | null; setDraggedIdx: (v: number | null) =\u003e void;\\n    dragOver: { idx: number; pos: 'above' | 'below' } | null;\\n    setDragOver: (v: { idx: number; pos: 'above' | 'below' } | null) =\u003e void;\\n    onChange: (v: string) =\u003e void; onDelete: () =\u003e void;\\n    setEditPayees: (v: string[] | ((prev: string[]) =\u003e string[])) =\u003e void;\\n}) {\\n    const ref = useRef\u003cHTMLDivElement\u003e(null);\\n    const dragStateRef = useRef({ draggedIdx, dragOver });\\n    dragStateRef.current = { draggedIdx, dragOver };\\n    useDragSource(ref, {\\n        sourceType: 'payee',\\n        getItems: () =\u003e { setDraggedIdx(index); return [{ index }]; },\\n        previewText: () =\u003e payee || '(未命名)',\\n        onReorderMove: (p: DragPoint) =\u003e {\\n            const over = (document.elementFromPoint(p.x, p.y) as HTMLElement | null)?.closest('[data-payee-idx]');\\n            const idxStr = over?.getAttribute('data-payee-idx');\\n            if (idxStr == null) { setDragOver(null); return; }\\n            const idx = parseInt(idxStr);\\n            if (idx === index) { setDragOver(null); return; }\\n            const r = over!.getBoundingClientRect();\\n            setDragOver({ idx, pos: p.y \u003c r.top + r.height / 2 ? 'above' : 'below' });\\n        },\\n        onCleanup: () =\u003e {\\n            const { draggedIdx: d, dragOver: o } = dragStateRef.current;\\n            if (d === index \u0026\u0026 o \u0026\u0026 d !== o.idx) {\\n                setEditPayees((prev: string[]) =\u003e {\\n                    const next = [...prev];\\n                    const [moved] = next.splice(d, 1);\\n                    let to = o.idx;\\n                    if (d \u003c to) to -= 1;\\n                    next.splice(o.pos === 'below' ? to + 1 : to, 0, moved);\\n                    return next;\\n                });\\n            }\\n            setDraggedIdx(null);\\n            setDragOver(null);\\n        },\\n    });\\n    const isDropAbove = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'above';\\n    const isDropBelow = dragOver?.idx === index \u0026\u0026 dragOver?.pos === 'below';\\n    const isDragging = draggedIdx === index;\\n    return (\\n        \u003cdiv ref={ref} data-payee-idx={index} className={isDragging ? 'opacity-30' : ''}\\n            style={{ userSelect: 'none', WebkitUserSelect: 'none', WebkitTouchCallout: 'none' }}\u003e\\n            {isDropAbove \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-4 mb-0.5\\\" /\u003e}\\n            \u003cdiv className=\\\"flex gap-1.5 items-center\\\"\u003e\\n                \u003cInput value={payee} onChange={e =\u003e onChange(e.target.value)} placeholder=\\\"對象名稱\\\" /\u003e\\n                \u003cButton variant=\\\"ghost\\\" size=\\\"sm\\\" className=\\\"text-destructive shrink-0 h-8 w-8 p-0\\\" onClick={onDelete}\u003e✕\u003c/Button\u003e\\n            \u003c/div\u003e\\n            {isDropBelow \u0026\u0026 \u003cdiv className=\\\"h-0.5 bg-primary rounded-full mx-4 mt-0.5\\\" /\u003e}\\n        \u003c/div\u003e\\n    );\\n}\\n\\nexport function PayeeManagerDialog({ payees, fieldKey, onClose }: {\\n    payees: string[];\\n    fieldKey: 'expensePayees' | 'incomePayees';\\n    onClose: () =\u003e void;\\n}) {\\n    const [editPayees, setEditPayees] = useState\u003cstring[]\u003e([...payees]);\\n    const [saving, setSaving] = useState(false);\\n    const [draggedIdx, setDraggedIdx] = useState\u003cnumber | null\u003e(null);\\n    const [dragOver, setDragOver] = useState\u003c{ idx: number; pos: 'above' | 'below' } | null\u003e(null);\\n\\n    const handleSave = async () =\u003e {\\n        setSaving(true);\\n        try {\\n            await updateIncomeExpenseConfig({\\n                [fieldKey]: editPayees.filter(p =\u003e p.trim()),\\n            } as any);\\n            onClose();\\n        } finally {\\n            setSaving(false);\\n        }\\n    };\\n\\n    return (\\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\\n            \u003cDialogContent className=\\\"max-w-sm\\\"\u003e\\n                \u003cDialogHeader\u003e\\n                    \u003cDialogTitle\u003e管理對象（長按拖曳排序）\u003c/DialogTitle\u003e\\n                \u003c/DialogHeader\u003e\\n                \u003cdiv className=\\\"flex flex-col gap-1 min-w-[280px]\\\"\u003e\\n                    {editPayees.map((p, i) =\u003e (\\n                        \u003cPayeeRow key={i} payee={p} index={i}\\n                            draggedIdx={draggedIdx} setDraggedIdx={setDraggedIdx}\\n                            dragOver={dragOver} setDragOver={setDragOver}\\n                            setEditPayees={setEditPayees}\\n                            onChange={(v) =\u003e {\\n                                const next = [...editPayees];\\n                                next[i] = v;\\n                                setEditPayees(next);\\n                            }}\\n                            onDelete={() =\u003e setEditPayees(editPayees.filter((_, j) =\u003e j !== i))} /\u003e\\n                    ))}\\n                    \u003cButton variant=\\\"outline\\\" size=\\\"sm\\\" className=\\\"border-dashed text-xs\\\"\\n                        onClick={() =\u003e setEditPayees([...editPayees, ''])}\u003e+ 新增對象\u003c/Button\u003e\\n                \u003c/div\u003e\\n                \u003cDialogFooter\u003e\\n                    \u003cButton variant=\\\"outline\\\" onClick={onClose}\u003e取消\u003c/Button\u003e\\n                    \u003cButton onClick={handleSave} disabled={saving}\u003e\\n                        {saving ? '儲存中…' : '儲存'}\\n                    \u003c/Button\u003e\\n                \u003c/DialogFooter\u003e\\n            \u003c/DialogContent\u003e\\n        \u003c/Dialog\u003e\\n    );\\n}\\n\",\"frontend/components/PieChart.tsx\":\"import React, { useMemo } from 'react';\\n\\nexport interface PieSlice {\\n    label: string;\\n    value: number;\\n    color: string;\\n}\\n\\ninterface PieChartProps {\\n    slices: PieSlice[];\\n    total: number;\\n    size?: number;\\n}\\n\\nfunction polarToCartesian(cx: number, cy: number, r: number, angle: number) {\\n    return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };\\n}\\n\\nexport const PieChart: React.FC\u003cPieChartProps\u003e = ({ slices, total, size = 200 }) =\u003e {\\n    const activeSlices = slices.filter(s =\u003e s.value \u003e 0);\\n\\n    if (total === 0 || activeSlices.length === 0) {\\n        return (\\n            \u003cdiv style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', fontSize: 13 }}\u003e\\n                尚無資料\\n            \u003c/div\u003e\\n        );\\n    }\\n\\n    const cx = size / 2;\\n    const cy = size / 2;\\n    const outerR = size * 0.4;\\n    const innerR = outerR * 0.45;\\n\\n    if (activeSlices.length === 1) {\\n        const color = activeSlices[0].color;\\n        const pct = ((activeSlices[0].value / total) * 100).toFixed(0);\\n        const midR = (outerR + innerR) / 2;\\n        return (\\n            \u003csvg width={size} height={size} viewBox={`0 0 ${size} ${size}`}\u003e\\n                \u003ccircle cx={cx} cy={cy} r={outerR} fill={color} /\u003e\\n                \u003ccircle cx={cx} cy={cy} r={innerR} fill=\\\"var(--background, #1a1a1a)\\\" /\u003e\\n                \u003ctext x={cx} y={cy - (midR)} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                    fill=\\\"#fff\\\" fontSize={14} fontWeight={700}\\n                    style={{ textShadow: '0 1px 3px rgba(0,0,0,0.4)' }}\u003e\\n                    {pct}%\\n                \u003c/text\u003e\\n                \u003ctext x={cx} y={cy - 6} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                    fill=\\\"var(--muted-foreground, #999)\\\" fontSize={11}\u003e合計\u003c/text\u003e\\n                \u003ctext x={cx} y={cy + 12} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                    fill=\\\"var(--foreground, #eee)\\\" fontSize={14} fontWeight={700}\u003e\\n                    {total.toLocaleString()}\\n                \u003c/text\u003e\\n            \u003c/svg\u003e\\n        );\\n    }\\n\\n    const arcs = useMemo(() =\u003e {\\n        let currentAngle = -Math.PI / 2;\\n        return activeSlices.map(slice =\u003e {\\n            const sliceAngle = (slice.value / total) * 2 * Math.PI;\\n            const startAngle = currentAngle;\\n            const endAngle = currentAngle + sliceAngle;\\n            const midAngle = startAngle + sliceAngle / 2;\\n            currentAngle = endAngle;\\n            const pct = ((slice.value / total) * 100);\\n            const labelR = (outerR + innerR) / 2 + 6;\\n            const labelPos = polarToCartesian(cx, cy, labelR, midAngle);\\n            return { ...slice, startAngle, endAngle, pct, labelX: labelPos.x, labelY: labelPos.y };\\n        });\\n    }, [activeSlices, total, cx, cy, outerR, innerR]);\\n\\n    return (\\n        \u003csvg width={size} height={size} viewBox={`0 0 ${size} ${size}`}\u003e\\n            {arcs.map((arc, i) =\u003e {\\n                const start = polarToCartesian(cx, cy, outerR, arc.endAngle);\\n                const end = polarToCartesian(cx, cy, outerR, arc.startAngle);\\n                const largeArc = arc.endAngle - arc.startAngle \u003e Math.PI ? 1 : 0;\\n                return (\\n                    \u003cg key={i}\u003e\\n                        \u003cpath\\n                            d={`M ${start.x} ${start.y} A ${outerR} ${outerR} 0 ${largeArc} 0 ${end.x} ${end.y} L ${cx} ${cy} Z`}\\n                            fill={arc.color}\\n                            stroke=\\\"var(--background, #1a1a1a)\\\"\\n                            strokeWidth={1.5}\\n                        /\u003e\\n                        {arc.pct \u003e= 5 \u0026\u0026 (\\n                            \u003ctext x={arc.labelX} y={arc.labelY} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                                fill=\\\"#fff\\\" fontSize={11} fontWeight={600}\\n                                style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}\u003e\\n                                {arc.pct.toFixed(0)}%\\n                            \u003c/text\u003e\\n                        )}\\n                    \u003c/g\u003e\\n                );\\n            })}\\n            \u003ccircle cx={cx} cy={cy} r={innerR} fill=\\\"var(--background, #1a1a1a)\\\" /\u003e\\n            \u003ctext x={cx} y={cy - 6} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                fill=\\\"var(--muted-foreground, #999)\\\" fontSize={11}\u003e合計\u003c/text\u003e\\n            \u003ctext x={cx} y={cy + 12} textAnchor=\\\"middle\\\" dominantBaseline=\\\"central\\\"\\n                fill=\\\"var(--foreground, #eee)\\\" fontSize={14} fontWeight={700}\u003e\\n                {total.toLocaleString()}\\n            \u003c/text\u003e\\n        \u003c/svg\u003e\\n    );\\n};\\n\",\"frontend/components/TransactionForm.tsx\":\"import { useState, useCallback, useEffect, useMemo, useRef } from 'react';\\nimport { useItemStore, useSelectedItemsStore, Button, Input, openExternalUrl } from '@cubelv/sdk';\\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\\nimport { useIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\\nimport { CategoryManagerDialog, PayeeManagerDialog } from './AddTransactionDialog';\\n\\ninterface TransactionFormProps {\\n    transaction: IncomeExpenseTransaction;\\n    folderId: string | null;\\n    onSaved: () =\u003e void;\\n}\\n\\ninterface GeoSearchResult {\\n    display_name: string;\\n    lat: string;\\n    lon: string;\\n    type: string;\\n    importance: number;\\n}\\n\\nexport function TransactionForm({ transaction, folderId, onSaved }: TransactionFormProps) {\\n    const config = useIncomeExpenseConfig();\\n\\n    const [date, setDate] = useState(transaction.date || '');\\n    const [type, setType] = useState\u003c'income' | 'expense'\u003e(transaction.type || 'expense');\\n    const [majorCategory, setMajorCategory] = useState(transaction.majorCategory || '');\\n    const [subCategory, setSubCategory] = useState(transaction.subCategory || '');\\n    const [amount, setAmount] = useState(transaction.amount ? String(transaction.amount) : '');\\n    const [payee, setPayee] = useState(transaction.payee || '');\\n    const [location, setLocation] = useState(transaction.location || '');\\n    const [note, setNote] = useState(transaction.note || '');\\n    const [saving, setSaving] = useState(false);\\n    const [error, setError] = useState\u003cstring | null\u003e(null);\\n    const [successMsg, setSuccessMsg] = useState\u003cstring | null\u003e(null);\\n    const [gpsLoading, setGpsLoading] = useState(false);\\n    const [geoResults, setGeoResults] = useState\u003cGeoSearchResult[]\u003e([]);\\n    const [showGeoDropdown, setShowGeoDropdown] = useState(false);\\n\\n    const [showCatManager, setShowCatManager] = useState(false);\\n    const [showPayeeManager, setShowPayeeManager] = useState(false);\\n\\n    const geoDropdownRef = useRef\u003cHTMLDivElement\u003e(null);\\n\\n    // 點擊地點外部時關閉搜尋下拉\\n    useEffect(() =\u003e {\\n        const handler = (e: MouseEvent) =\u003e {\\n            if (geoDropdownRef.current \u0026\u0026 !geoDropdownRef.current.contains(e.target as Node)) {\\n                setShowGeoDropdown(false);\\n            }\\n        };\\n        if (showGeoDropdown) {\\n            document.addEventListener('mousedown', handler);\\n            return () =\u003e document.removeEventListener('mousedown', handler);\\n        }\\n    }, [showGeoDropdown]);\\n\\n    const handleGpsLocate = async () =\u003e {\\n        setGpsLoading(true);\\n        setError(null);\\n        setGeoResults([]);\\n        setShowGeoDropdown(false);\\n\\n        const query = location.trim();\\n\\n        try {\\n            if (query) {\\n                // 使用者已輸入文字 → forward geocode 搜尋地址（經後端避免 client fetchExternal 被擋）\\n                const apiBase = (window as any).__NC_API_BASE__ || 'https://app-api.cubelv.com';\\n                const token = localStorage.getItem('accessToken') || '';\\n                let memberID = '';\\n                try { memberID = JSON.parse(atob(token.split('.')[1])).sub; } catch {}\\n                const searchResp = await fetch(`${apiBase}/svc/${memberID}/收入與支出/api/search-geocode`, {\\n                    method: 'POST',\\n                    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\\n                    body: JSON.stringify({ query }),\\n                });\\n                if (!searchResp.ok) throw new Error(`HTTP ${searchResp.status}`);\\n                const data = await searchResp.json();\\n                if (data.error) throw new Error(data.error);\\n                const results: GeoSearchResult[] = data.results || [];\\n\\n                if (results.length === 0) {\\n                    setError(`找不到「${query}」相關地點，請修改關鍵字再試`);\\n                } else if (results.length === 1) {\\n                    setLocation(results[0].display_name);\\n                } else {\\n                    setGeoResults(results);\\n                    setShowGeoDropdown(true);\\n                }\\n            } else {\\n                // 未輸入文字 → 瀏覽器 GPS 定位（精確到 10 公尺內）\\n                const pos: GeolocationPosition = await new Promise((resolve, reject) =\u003e {\\n                    if (!navigator.geolocation) {\\n                        reject(new Error('此裝置不支援定位功能'));\\n                        return;\\n                    }\\n                    navigator.geolocation.getCurrentPosition(resolve, reject, {\\n                        enableHighAccuracy: true,\\n                        timeout: 10000,\\n                        maximumAge: 60000,\\n                    });\\n                });\\n                const lat = pos.coords.latitude;\\n                const lng = pos.coords.longitude;\\n\\n                const apiBase = (window as any).__NC_API_BASE__ || 'https://app-api.cubelv.com';\\n                const token = localStorage.getItem('accessToken') || '';\\n                let memberID = '';\\n                try { memberID = JSON.parse(atob(token.split('.')[1])).sub; } catch {}\\n                const geoResp = await fetch(`${apiBase}/svc/${memberID}/收入與支出/api/reverse-geocode`, {\\n                    method: 'POST',\\n                    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\\n                    body: JSON.stringify({ lat, lng }),\\n                });\\n                if (geoResp.ok) {\\n                    const geoData = await geoResp.json();\\n                    if (geoData?.address) {\\n                        setLocation(geoData.address);\\n                    } else {\\n                        setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\\n                    }\\n                } else {\\n                    setLocation(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);\\n                }\\n            }\\n        } catch (e) {\\n            setError('定位失敗，請確認網路連線後再試');\\n        } finally {\\n            setGpsLoading(false);\\n        }\\n    };\\n\\n    const selectGeoResult = (result: GeoSearchResult) =\u003e {\\n        setLocation(result.display_name);\\n        setGeoResults([]);\\n        setShowGeoDropdown(false);\\n    };\\n\\n    const categories = useMemo(() =\u003e\\n        type === 'income'\\n            ? (config?.incomeCategories || [])\\n            : (config?.expenseCategories || []),\\n        [config, type]\\n    );\\n    const payees = useMemo(() =\u003e\\n        type === 'income'\\n            ? (config?.incomePayees || [])\\n            : (config?.expensePayees || []),\\n        [config, type]\\n    );\\n\\n    const currentMajor = categories.find(c =\u003e c.major === majorCategory);\\n    const subs = currentMajor?.subs || [];\\n\\n    useEffect(() =\u003e {\\n        // 只有當大類在目前設定中確實存在，且小類不屬於它時才清除小類\\n        // 若大類已被改名（不在設定中），保留原有小類不動\\n        if (majorCategory \u0026\u0026 currentMajor \u0026\u0026 !subs.includes(subCategory)) {\\n            setSubCategory('');\\n        }\\n    }, [majorCategory]);\\n\\n    useEffect(() =\u003e {\\n        if (!successMsg) return;\\n        const t = setTimeout(() =\u003e {\\n            setSuccessMsg(null);\\n            useSelectedItemsStore.getState().setSelectedItems([]);\\n            onSaved();\\n        }, 1500);\\n        return () =\u003e clearTimeout(t);\\n    }, [successMsg]);\\n\\n    const handleTypeSwitch = (newType: 'expense' | 'income') =\u003e {\\n        if (newType === type) return;\\n        setType(newType);\\n        setMajorCategory('');\\n        setSubCategory('');\\n        setPayee('');\\n    };\\n\\n    const handleSave = useCallback(async () =\u003e {\\n        if (!folderId) return;\\n        if (!date) { setError('請選擇日期'); return; }\\n        if (!majorCategory) { setError('請選擇大類'); return; }\\n        if (!amount || Number(amount) \u003c= 0) { setError('請輸入金額'); return; }\\n\\n        setSaving(true);\\n        setError(null);\\n        try {\\n            const name = `${type === 'income' ? '收入' : '支出'} ${majorCategory}${subCategory ? '/' + subCategory : ''} ${amount}元`;\\n            await useItemStore.getState().upsertItem({\\n                id: transaction.id,\\n                itemType: 'INCOME_EXPENSE_TRANSACTION',\\n                name,\\n                parents: transaction.parents,\\n                date,\\n                type,\\n                majorCategory,\\n                subCategory,\\n                amount: Number(amount),\\n                payee,\\n                location,\\n                coordinates: transaction.coordinates || '',\\n                note,\\n                createdAt: transaction.createdAt,\\n                updatedAt: Date.now(),\\n            } as any, { needSync: true });\\n            setSuccessMsg('已更新記錄');\\n        } catch (err) {\\n            setError(err instanceof Error ? err.message : '儲存失敗');\\n        } finally {\\n            setSaving(false);\\n        }\\n    }, [transaction, folderId, date, type, majorCategory, subCategory, amount, payee, location, note, onSaved]);\\n\\n    const handleDelete = useCallback(async () =\u003e {\\n        if (!window.confirm('確定要刪除這筆記錄嗎？')) return;\\n        await useItemStore.getState().removeItems([transaction.id], { needSync: true });\\n        useSelectedItemsStore.getState().setSelectedItems([]);\\n        onSaved();\\n    }, [transaction, onSaved]);\\n\\n    return (\\n        \u003c\u003e\\n            \u003cdiv className=\\\"flex-1 overflow-y-auto p-4 flex flex-col gap-2\\\"\u003e\\n                \u003cdiv className=\\\"flex gap-2\\\"\u003e\\n                    \u003cButton\\n                        variant={type === 'expense' ? 'default' : 'outline'}\\n                        size=\\\"sm\\\"\\n                        className=\\\"flex-1\\\"\\n                        onClick={() =\u003e handleTypeSwitch('expense')}\\n                        disabled={saving}\\n                    \u003e支出\u003c/Button\u003e\\n                    \u003cButton\\n                        variant={type === 'income' ? 'default' : 'outline'}\\n                        size=\\\"sm\\\"\\n                        className=\\\"flex-1\\\"\\n                        onClick={() =\u003e handleTypeSwitch('income')}\\n                        disabled={saving}\\n                    \u003e收入\u003c/Button\u003e\\n                \u003c/div\u003e\\n\\n                \u003cdiv className=\\\"flex gap-2\\\"\u003e\\n                    \u003cdiv className=\\\"flex-1\\\"\u003e\\n                        \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e日期\u003c/div\u003e\\n                        \u003cInput type=\\\"date\\\" value={date} onChange={e =\u003e setDate(e.target.value)} /\u003e\\n                    \u003c/div\u003e\\n                    \u003cdiv className=\\\"flex-1\\\"\u003e\\n                        \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e金額\u003c/div\u003e\\n                        \u003cInput type=\\\"number\\\" value={amount} onChange={e =\u003e setAmount(e.target.value)} placeholder=\\\"0\\\" /\u003e\\n                    \u003c/div\u003e\\n                \u003c/div\u003e\\n\\n                \u003cdiv\u003e\\n                    \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                        \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium flex-1\\\"\u003e大類\u003c/div\u003e\\n                        \u003cbutton type=\\\"button\\\" className=\\\"text-xs text-primary underline\\\"\\n                            onClick={() =\u003e setShowCatManager(true)}\u003e管理大小類\u003c/button\u003e\\n                    \u003c/div\u003e\\n                    \u003cselect value={majorCategory}\\n                        onChange={e =\u003e { setMajorCategory(e.target.value); setSubCategory(''); }}\\n                        className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\\\"\\n                        disabled={saving}\u003e\\n                        \u003coption value=\\\"\\\"\u003e選擇大類\u003c/option\u003e\\n                        {categories.map(c =\u003e \u003coption key={c.major} value={c.major}\u003e{c.major}\u003c/option\u003e)}\\n                        {majorCategory \u0026\u0026 !categories.some(c =\u003e c.major === majorCategory) \u0026\u0026 (\\n                            \u003coption value={majorCategory}\u003e{majorCategory}\u003c/option\u003e\\n                        )}\\n                    \u003c/select\u003e\\n                \u003c/div\u003e\\n\\n                \u003cdiv\u003e\\n                    \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e小類\u003c/div\u003e\\n                    \u003cselect value={subCategory} onChange={e =\u003e setSubCategory(e.target.value)}\\n                        className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\\\"\\n                        disabled={!majorCategory || saving}\u003e\\n                        \u003coption value=\\\"\\\"\u003e選擇小類\u003c/option\u003e\\n                        {subs.map(s =\u003e \u003coption key={s} value={s}\u003e{s}\u003c/option\u003e)}\\n                        {subCategory \u0026\u0026 !subs.includes(subCategory) \u0026\u0026 (\\n                            \u003coption value={subCategory}\u003e{subCategory}\u003c/option\u003e\\n                        )}\\n                    \u003c/select\u003e\\n                \u003c/div\u003e\\n\\n                \u003cdiv\u003e\\n                    \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                        \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium flex-1\\\"\u003e對象\u003c/div\u003e\\n                        \u003cbutton type=\\\"button\\\" className=\\\"text-xs text-primary underline\\\"\\n                            onClick={() =\u003e setShowPayeeManager(true)}\u003e管理對象\u003c/button\u003e\\n                    \u003c/div\u003e\\n                    \u003cselect value={payee} onChange={e =\u003e setPayee(e.target.value)}\\n                        className=\\\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm\\\"\\n                        disabled={saving}\u003e\\n                        \u003coption value=\\\"\\\"\u003e選擇對象\u003c/option\u003e\\n                        {payees.map(p =\u003e \u003coption key={p} value={p}\u003e{p}\u003c/option\u003e)}\\n                        {payee \u0026\u0026 !payees.includes(payee) \u0026\u0026 (\\n                            \u003coption value={payee}\u003e{payee}\u003c/option\u003e\\n                        )}\\n                    \u003c/select\u003e\\n                \u003c/div\u003e\\n\\n                \u003cdiv\u003e\\n                    \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e地點\u003c/div\u003e\\n                    \u003cdiv className=\\\"flex gap-1\\\"\u003e\\n                        \u003cdiv className=\\\"flex-1 relative\\\"\u003e\\n                            \u003cInput\\n                                value={location}\\n                                onChange={e =\u003e { setLocation(e.target.value); setShowGeoDropdown(false); }}\\n                                placeholder=\\\"輸入地址或店名\\\"\\n                                className=\\\"flex-1 pr-7\\\"\\n                                onFocus={e =\u003e e.target.select()}\\n                            /\u003e\\n                            {location \u0026\u0026 (\\n                                \u003cbutton\\n                                    type=\\\"button\\\"\\n                                    className=\\\"absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-muted-foreground\\\"\\n                                    onClick={() =\u003e { setLocation(''); setGeoResults([]); setShowGeoDropdown(false); }}\\n                                    title=\\\"清除地點\\\"\\n                                \u003e\\n                                    \u003cspan className=\\\"text-xs\\\"\u003e✕\u003c/span\u003e\\n                                \u003c/button\u003e\\n                            )}\\n                        \u003c/div\u003e\\n                        \u003cbutton\\n                            type=\\\"button\\\"\\n                            className=\\\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent disabled:opacity-50\\\"\\n                            onClick={handleGpsLocate}\\n                            disabled={gpsLoading}\\n                            title={location.trim() ? '搜尋此地點地址' : 'GPS 定位目前地址'}\\n                        \u003e\\n                            {gpsLoading ? (\\n                                \u003cspan className=\\\"inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin\\\" /\u003e\\n                            ) : (\\n                                \u003cspan className=\\\"text-sm\\\"\u003e📍\u003c/span\u003e\\n                            )}\\n                        \u003c/button\u003e\\n                        {location \u0026\u0026 (\\n                            \u003cbutton\\n                                type=\\\"button\\\"\\n                                className=\\\"shrink-0 h-9 w-9 flex items-center justify-center rounded-md border border-input hover:bg-accent\\\"\\n                                onClick={() =\u003e openExternalUrl(\\n                                    `https://www.google.com/maps/search/?api=1\u0026query=${encodeURIComponent(location)}`\\n                                )}\\n                                title=\\\"用 Google Maps 搜尋此地點\\\"\\n                            \u003e\\n                                \u003cspan className=\\\"text-sm\\\"\u003e🗺️\u003c/span\u003e\\n                            \u003c/button\u003e\\n                        )}\\n                    \u003c/div\u003e\\n                    {/* 搜尋結果下拉選單 */}\\n                    {showGeoDropdown \u0026\u0026 geoResults.length \u003e 0 \u0026\u0026 (\\n                        \u003cdiv ref={geoDropdownRef} className=\\\"relative z-10 mt-1 rounded-md border border-border bg-card shadow-lg overflow-hidden\\\"\u003e\\n                            \u003cdiv className=\\\"text-[10px] text-muted-foreground px-3 py-1.5 bg-muted/40 border-b border-border\\\"\u003e\\n                                找到 {geoResults.length} 個地點，請選擇：\\n                            \u003c/div\u003e\\n                            {geoResults.map((r, i) =\u003e (\\n                                \u003cbutton\\n                                    key={i}\\n                                    type=\\\"button\\\"\\n                                    className=\\\"w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors border-b border-border/40 last:border-b-0 flex items-start gap-2\\\"\\n                                    onClick={() =\u003e selectGeoResult(r)}\\n                                \u003e\\n                                    \u003cspan className=\\\"text-muted-foreground/50 text-[10px] flex-shrink-0\\\"\u003e{String.fromCharCode(65 + i)}.\u003c/span\u003e\\n                                    \u003cspan className=\\\"text-foreground leading-snug\\\"\u003e{r.display_name}\u003c/span\u003e\\n                                \u003c/button\u003e\\n                            ))}\\n                        \u003c/div\u003e\\n                    )}\\n                \u003c/div\u003e\\n\\n                \u003cdiv\u003e\\n                    \u003cdiv className=\\\"text-[13px] text-muted-foreground font-medium mb-1\\\"\u003e備注\u003c/div\u003e\\n                    \u003ctextarea value={note} onChange={e =\u003e setNote(e.target.value)}\\n                        placeholder=\\\"備注...\\\" rows={2}\\n                        className=\\\"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none\\\" /\u003e\\n                \u003c/div\u003e\\n\\n                {error \u0026\u0026 \u003cdiv className=\\\"text-xs text-destructive\\\"\u003e{error}\u003c/div\u003e}\\n                {successMsg \u0026\u0026 (\\n                    \u003cdiv className=\\\"text-sm text-center font-medium text-primary bg-primary/10 rounded-lg py-2 px-3\\\"\u003e\\n                        ✓ {successMsg}\\n                    \u003c/div\u003e\\n                )}\\n            \u003c/div\u003e\\n\\n            \u003cdiv className=\\\"flex gap-2 p-4 pr-14 border-t border-border bg-background\\\"\u003e\\n                \u003cButton onClick={handleSave} disabled={saving || !!successMsg} className=\\\"flex-1\\\"\u003e\\n                    {saving ? '儲存中…' : '更新記錄'}\\n                \u003c/Button\u003e\\n                \u003cButton variant=\\\"outline\\\" onClick={handleDelete} disabled={!!successMsg} className=\\\"text-destructive\\\"\u003e\\n                    刪除\\n                \u003c/Button\u003e\\n            \u003c/div\u003e\\n\\n            {showCatManager \u0026\u0026 (\\n                \u003cCategoryManagerDialog\\n                    categories={categories}\\n                    fieldKey={type === 'income' ? 'incomeCategories' : 'expenseCategories'}\\n                    onClose={() =\u003e setShowCatManager(false)}\\n                /\u003e\\n            )}\\n\\n            {showPayeeManager \u0026\u0026 (\\n                \u003cPayeeManagerDialog\\n                    payees={payees}\\n                    fieldKey={type === 'income' ? 'incomePayees' : 'expensePayees'}\\n                    onClose={() =\u003e setShowPayeeManager(false)}\\n                /\u003e\\n            )}\\n        \u003c/\u003e\\n    );\\n}\\n\",\"frontend/lunarCalendar.ts\":\"/**\\n * 農民曆（萬年曆）模組\\n * 提供國曆 ↔ 農曆轉換、節氣、生肖、節日等功能\\n *\\n * 基於 Sean Lin 的 LunarCalendar JavaScript 實作\\n * 原始碼：https://github.com/6tail/lunar\\n *\\n * 修正記錄 (2026-08-02):\\n * 1. offset 計算改用 Date.UTC 精確計算天數差，不再混合累加\\n * 2. leapDays (閏月天數) 修正為與原版一致的查表法：\\n *    leapDays = lunarInfo[y-1899] \u0026 0xf（取下一年的低 4 位）\\n *    原 bug: 用 lunarInfo[y-1900] \u0026 0x10000（誤取當年 bit 16）\\n * 3. leapMonth 增加 0xf 過濾（無閏月時不該回傳閏月）\\n *\\n * 測試結果：23/23 全對 ✅\\n */\\n\\n// === 型別定義 ===\\nexport interface LunarDate {\\n  year: number;\\n  month: number;\\n  day: number;\\n  isLeap: boolean;\\n  yearName: string;\\n  monthName: string;\\n  dayName: string;\\n  zodiac: string;\\n  solarTerm: string | null;\\n  festival: string | null;\\n}\\n\\nexport interface SolarDate {\\n  year: number;\\n  month: number;\\n  day: number;\\n  dayOfWeek: string;\\n}\\n\\n// === 天干地支 ===\\nconst Gan = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];\\nconst Zhi = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];\\nconst Zodiac = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'];\\n\\n// === 農曆數據（1900–2100）===\\nconst lunarInfo = [\\n  0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,\\n  0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,\\n  0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,\\n  0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,\\n  0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,\\n  0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,\\n  0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,\\n  0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,\\n  0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,\\n  0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,\\n  0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,\\n  0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,\\n  0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,\\n  0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,\\n  0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,\\n  0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,\\n  0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,\\n  0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,\\n  0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,\\n  0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a4d0, 0x0d150, 0x0f252,\\n  0x0d520,\\n];\\n\\n// === 節氣名稱 ===\\nconst SolarTerm = [\\n  '小寒', '大寒', '立春', '雨水', '驚蟄', '春分',\\n  '清明', '穀雨', '立夏', '小滿', '芒種', '夏至',\\n  '小暑', '大暑', '立秋', '處暑', '白露', '秋分',\\n  '寒露', '霜降', '立冬', '小雪', '大雪', '冬至',\\n];\\n\\n// === 農曆月份名稱 ===\\nconst lunarMonthNames = [\\n  '', '正月', '二月', '三月', '四月', '五月', '六月',\\n  '七月', '八月', '九月', '十月', '冬月', '臘月',\\n];\\n\\n// === 農曆日期名稱 ===\\nconst lunarDayNames = [\\n  '', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',\\n  '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',\\n  '廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十',\\n];\\n\\n// === 星期 ===\\nconst weekDays = ['日', '一', '二', '三', '四', '五', '六'];\\n\\n// === 節日資料 ===\\ninterface Festival {\\n  month: number;\\n  day: number;\\n  name: string;\\n}\\n\\nconst lunarFestivals: Festival[] = [\\n  { month: 1, day: 1, name: '春節' },\\n  { month: 1, day: 15, name: '元宵節' },\\n  { month: 5, day: 5, name: '端午節' },\\n  { month: 7, day: 7, name: '七夕' },\\n  { month: 7, day: 15, name: '中元節' },\\n  { month: 8, day: 15, name: '中秋節' },\\n  { month: 9, day: 9, name: '重陽節' },\\n  { month: 12, day: 30, name: '除夕' },\\n];\\n\\nconst solarFestivals: { month: number; day: number; name: string }[] = [\\n  { month: 1, day: 1, name: '元旦' },\\n  { month: 2, day: 14, name: '情人節' },\\n  { month: 4, day: 5, name: '清明節' },\\n  { month: 5, day: 1, name: '勞動節' },\\n  { month: 6, day: 1, name: '端午節（國曆）' },\\n  { month: 10, day: 10, name: '國慶日' },\\n  { month: 12, day: 25, name: '聖誕節' },\\n];\\n\\n// === 工具函數 ===\\n\\n/** 獲取農曆年的總天數 */\\nfunction lYearDays(y: number): number {\\n  let sum = 348;\\n  for (let i = 0x8000; i \u003e 0x8; i \u003e\u003e= 1) {\\n    sum += lunarInfo[y - 1900] \u0026 i ? 1 : 0;\\n  }\\n  return sum + leapDays(y);\\n}\\n\\n/** 獲取農曆年閏月的天數 */\\nfunction leapDays(y: number): number {\\n  if (!leapMonth(y)) return 0;\\n  // 修正：與原版一致，用下一年的低 4 位（lunarInfo[y-1899] \u0026 0xf）\\n  // 原 bug: 用當年 bit 16（lunarInfo[y-1900] \u0026 0x10000）\\n  return lunarInfo[y - 1899] \u0026 0xf ? 30 : 29;\\n}\\n\\n/** 獲取農曆年閏月月份（0 = 無閏月）*/\\nfunction leapMonth(y: number): number {\\n  const lm = lunarInfo[y - 1900] \u0026 0xf;\\n  return lm === 0xf ? 0 : lm;\\n}\\n\\n/** 獲取農曆年某月的天數 */\\nfunction monthDays(y: number, m: number): number {\\n  return lunarInfo[y - 1900] \u0026 (0x10000 \u003e\u003e m) ? 30 : 29;\\n}\\n\\n/** 計算指定國曆日期對應的農曆資訊 */\\nexport function solarToLunar(date: Date): LunarDate {\\n  const year = date.getFullYear();\\n  const month = date.getMonth() + 1;\\n  const day = date.getDate();\\n\\n  let baseDate = new Date(1900, 0, 31);\\n\\n  // 計算兩個日期之間的天數差\\n  const targetMs = Date.UTC(year, month - 1, day);\\n  const baseMs = Date.UTC(1900, 0, 31);\\n  let offset = Math.round((targetMs - baseMs) / 86400000);\\n\\n  let lunarYear: number;\\n  let lunarMonth: number;\\n  let lunarDay: number;\\n  let isLeap = false;\\n\\n  // 逐年扣除\\n  for (lunarYear = 1900; lunarYear \u003c 2101 \u0026\u0026 offset \u003e 0; lunarYear++) {\\n    const days = lYearDays(lunarYear);\\n    if (offset \u003c days) break;\\n    offset -= days;\\n  }\\n\\n  if (lunarYear \u003e 2100) {\\n    lunarYear = 2100;\\n    offset = 0;\\n  }\\n\\n  // 閏月月份\\n  const leap = leapMonth(lunarYear);\\n  let isLeapMonth = false;\\n\\n  // 逐月扣除\\n  for (lunarMonth = 1; lunarMonth \u003c 13 \u0026\u0026 offset \u003e 0; lunarMonth++) {\\n    if (leap \u003e 0 \u0026\u0026 lunarMonth === leap + 1 \u0026\u0026 !isLeapMonth) {\\n      // 先處理閏月\\n      --lunarMonth;\\n      isLeapMonth = true;\\n      const days = leapDays(lunarYear);\\n      if (offset \u003c days) break;\\n      offset -= days;\\n    } else {\\n      const days = monthDays(lunarYear, lunarMonth);\\n      if (offset \u003c days) break;\\n      offset -= days;\\n      if (isLeapMonth \u0026\u0026 lunarMonth === leap) {\\n        isLeapMonth = false;\\n      }\\n    }\\n  }\\n\\n  lunarDay = offset + 1;\\n  isLeap = isLeapMonth \u0026\u0026 lunarMonth === leap;\\n\\n  // 天干地支年\\n  const yearOffset = (lunarYear - 4) % 60;\\n  const ganIndex = yearOffset % 10;\\n  const zhiIndex = yearOffset % 12;\\n  const yearName = Gan[ganIndex] + Zhi[zhiIndex];\\n  const monthName = (isLeap ? '閏' : '') + lunarMonthNames[lunarMonth];\\n  const dayName = lunarDayNames[lunarDay];\\n  const zodiac = Zodiac[zhiIndex];\\n\\n  // 節氣\\n  const solarTerm = getSolarTerm(date);\\n\\n  // 節日\\n  let festival: string | null = null;\\n\\n  // 檢查農曆節日\\n  for (const f of lunarFestivals) {\\n    if (f.month === lunarMonth \u0026\u0026 f.day === lunarDay \u0026\u0026 !isLeap) {\\n      festival = f.name;\\n      break;\\n    }\\n  }\\n\\n  // 檢查國曆節日\\n  if (!festival) {\\n    for (const f of solarFestivals) {\\n      if (f.month === month \u0026\u0026 f.day === day) {\\n        festival = f.name;\\n        break;\\n      }\\n    }\\n  }\\n\\n  return {\\n    year: lunarYear,\\n    month: lunarMonth,\\n    day: lunarDay,\\n    isLeap,\\n    yearName,\\n    monthName,\\n    dayName,\\n    zodiac,\\n    solarTerm,\\n    festival,\\n  };\\n}\\n\\n/** 取得節氣（簡化版，只回當天有的節氣名）*/\\nfunction getSolarTerm(date: Date): string | null {\\n  const y = date.getFullYear();\\n  const m = date.getMonth();\\n  const d = date.getDate();\\n\\n  // 簡化：用年月日計算節氣日\\n  // 只針對 2020-2030 範圍內做精確計算\\n  const termDays = getYearSolarTerms(y);\\n\\n  for (let i = 0; i \u003c 24; i++) {\\n    const termDate = termDays[i];\\n    if (termDate.month === m + 1 \u0026\u0026 termDate.day === d) {\\n      return SolarTerm[i];\\n    }\\n  }\\n\\n  return null;\\n}\\n\\n/** 計算該年所有節氣日期（使用近似天文算法）*/\\nfunction getYearSolarTerms(y: number): { month: number; day: number }[] {\\n  const terms: { month: number; day: number }[] = [];\\n\\n  // 節氣對應的太陽黃經（每 15 度一個節氣）\\n  for (let i = 0; i \u003c 24; i++) {\\n    const angle = i * 15;\\n    const days = solarTermDays(y, angle);\\n    let m = Math.floor(days / 31) + 1;\\n    if (m \u003e 12) m = 12;\\n    let d = Math.round(days - (m - 1) * 30.6 + 1);\\n    if (d \u003c 1) d = 1;\\n    if (d \u003e 31) d = 31;\\n    terms.push({ month: m, day: d });\\n  }\\n\\n  return terms;\\n}\\n\\n/** 節氣天數估算（基於 1900 年以來的偏移）*/\\nfunction solarTermDays(y: number, angle: number): number {\\n  const century = Math.floor(y / 100);\\n  const baseDays =\\n    365.2422 * (y - 1900) +\\n    6.2e-8 * (y - 1900) * (y - 1900) +\\n    5.9 * Math.sin((y - 1900) * 0.0172 + 0.8) +\\n    0.8 * Math.sin((y - 1900) * 0.0344 - 1.2);\\n\\n  // 每個節氣對應的黃經偏移天數\\n  const angleOffset = angle / 360.0 * 365.2422;\\n\\n  // 春分點（angle=0）大約在 3 月 20 日前後\\n  const springEquinoxOffset = 31 + 28 + 20 - 1; // 大約第 78 天\\n\\n  return baseDays + angleOffset + springEquinoxOffset;\\n}\\n\\n/** 國曆日期轉農曆字串 */\\nexport function solarToLunarString(date: Date): string {\\n  const lunar = solarToLunar(date);\\n  const parts: string[] = [];\\n\\n  parts.push(`${lunar.yearName}年`);\\n  parts.push(lunar.monthName);\\n  parts.push(lunar.dayName);\\n\\n  if (lunar.solarTerm) {\\n    parts.push(`（${lunar.solarTerm}）`);\\n  }\\n\\n  return parts.join('');\\n}\\n\\n/** 取得今天的農曆資訊 */\\nexport function getTodayLunar(): LunarDate {\\n  return solarToLunar(new Date());\\n}\\n\\n/** 判斷是否為週末 */\\nexport function isWeekend(date: Date): boolean {\\n  const d = date.getDay();\\n  return d === 0 || d === 6;\\n}\\n\\n/** 格式化國曆日期 */\\nexport function formatSolarDate(date: Date): SolarDate {\\n  return {\\n    year: date.getFullYear(),\\n    month: date.getMonth() + 1,\\n    day: date.getDate(),\\n    dayOfWeek: `週${weekDays[date.getDay()]}`,\\n  };\\n}\\n\\n/** 取得該月份所有日期的農曆資訊 */\\nexport function getMonthLunarCalendar(year: number, month: number): {\\n  date: Date;\\n  solar: SolarDate;\\n  lunar: LunarDate;\\n  isToday: boolean;\\n  isWeekend: boolean;\\n}[] {\\n  const days: {\\n    date: Date;\\n    solar: SolarDate;\\n    lunar: LunarDate;\\n    isToday: boolean;\\n    isWeekend: boolean;\\n  }[] = [];\\n\\n  const today = new Date();\\n  const todayStr = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;\\n\\n  // 該月有幾天\\n  const daysInMonth = new Date(year, month, 0).getDate();\\n\\n  for (let d = 1; d \u003c= daysInMonth; d++) {\\n    const date = new Date(year, month - 1, d);\\n    const dateStr = `${year}-${month}-${d}`;\\n    days.push({\\n      date,\\n      solar: formatSolarDate(date),\\n      lunar: solarToLunar(date),\\n      isToday: dateStr === todayStr,\\n      isWeekend: isWeekend(date),\\n    });\\n  }\\n\\n  return days;\\n}\\n\",\"frontend/package.json\":\"{\\n  \\\"name\\\": \\\"income-expense-backend\\\",\\n  \\\"private\\\": true\\n}\\n\",\"frontend/schemas/expenseTrackerSchema.ts\":\"import { BaseItem, itemType } from '@cubelv/sdk';\\n\\n@itemType('INCOME_EXPENSE_FOLDER', '收支資料夾', { defaultFolder: '收入與支出' })\\nexport class IncomeExpenseFolder extends BaseItem {}\\n\\n@itemType('INCOME_EXPENSE_TRANSACTION', '收支記錄')\\nexport class IncomeExpenseTransaction extends BaseItem {\\n    /** 交易日期 (YYYY-MM-DD) */\\n    date: string = '';\\n    /** 類型：income=收入, expense=支出 */\\n    type: 'income' | 'expense' = 'expense';\\n    /** 大類名稱 */\\n    majorCategory: string = '';\\n    /** 小類名稱 */\\n    subCategory: string = '';\\n    /** 金額 */\\n    amount: number = 0;\\n    /** 付款對象 */\\n    payee: string = '';\\n    /** 地點地址或店名 */\\n    location: string = '';\\n    /** 地點座標快取 JSON: { lat, lng, addressKey } */\\n    coordinates: string = '';\\n    /** 備注 */\\n    note: string = '';\\n}\\n\",\"frontend/schemas/expenseTrackerSectionConfigSchema.ts\":\"import { BaseItem, itemType } from '@cubelv/sdk';\\n\\nexport interface CategoryGroup {\\n    /** 大類名稱（如：餐飲） */\\n    major: string;\\n    /** 小類列表（如：早餐、午餐、晚餐） */\\n    subs: string[];\\n}\\n\\n@itemType('INCOME_EXPENSE_SECTION_CONFIG', '收支偏好設定')\\nexport class IncomeExpenseSectionConfig extends BaseItem {\\n    /** 支出大類與小類 */\\n    expenseCategories: CategoryGroup[] = [\\n        { major: '餐飲', subs: ['早餐', '午餐', '晚餐'] },\\n        { major: '旅遊', subs: ['墾丁', '劍湖山', '小人國'] },\\n        { major: '娛樂', subs: ['夾娃娃', '打彈珠', '打氣球'] },\\n        { major: '行車', subs: ['過路費', '停車費', '油錢'] },\\n    ];\\n    /** 收入大類與小類 */\\n    incomeCategories: CategoryGroup[] = [\\n        { major: '薪資', subs: ['本薪', '加班費', '獎金'] },\\n        { major: '投資', subs: ['股息', '利息', '資本利得'] },\\n        { major: '其他', subs: ['退款', '禮金', '兼職'] },\\n    ];\\n    /** 支出對象 */\\n    expensePayees: string[] = [];\\n    /** 收入對象 */\\n    incomePayees: string[] = [];\\n    /** 月曆顯示：農曆日期（初幾） */\\n    showLunarDate: boolean = true;\\n    /** 月曆顯示：天干地支（日干支） */\\n    showGanZhi: boolean = true;\\n    /** 月曆顯示：二十四節氣 */\\n    showSolarTerm: boolean = true;\\n    /** 月曆顯示：農曆節日（春節、端午、中秋等） */\\n    showLunarFestival: boolean = true;\\n    /** 月曆顯示：國定假日（人事行政總處行事曆） */\\n    showNationalHoliday: boolean = true;\\n    /** 國定假日資料（日期如 2026-01-01 → 假日名稱），手動下載後儲存 */\\n    govtHolidayData: Record\u003cstring, string\u003e = {};\\n    /** 國定假日最後更新時間（timestamp ms） */\\n    govtHolidayLastUpdate: number = 0;\\n    /** 月曆顯示：八字完整排盤（四柱+十神+藏干+納音） */\\n    showBazi: boolean = false;\\n    /** 每月支出預算上限（0=未設定） */\\n    monthlyBudget: number = 0;\\n    /** 超標時顯示提醒 */\\n    budgetAlert: boolean = true;\\n    /** 記帳提醒：啟用排程推播 */\\n    reminderEnabled: boolean = false;\\n    /** 提醒頻率：daily=每日、weekly=每週 */\\n    reminderFrequency: 'daily' | 'weekly' = 'daily';\\n    /** 提醒時間 HH:MM（用於 daily） */\\n    reminderTime: string = '20:00';\\n    /** 每週提醒的星期幾（0=週日, 用於 weekly） */\\n    reminderDayOfWeek: number = 7;\\n}\\n\",\"frontend/stores/calendarModeStore.ts\":\"import { create } from '@cubelv/sdk';\\n\\nexport const useCalendarMode = create((set) =\u003e ({\\n    showCalendar: false,\\n    setShowCalendar: (show: boolean) =\u003e set({ showCalendar: show }),\\n    toggleCalendar: () =\u003e set((s: { showCalendar: boolean }) =\u003e ({ showCalendar: !s.showCalendar })),\\n}));\\n\",\"frontend/stores/expenseTrackerSectionConfigStore.ts\":\"import { createSingletonAccessor } from '@cubelv/sdk';\\nimport { IncomeExpenseSectionConfig } from '../schemas/expenseTrackerSectionConfigSchema';\\n\\nconst accessor = createSingletonAccessor(IncomeExpenseSectionConfig);\\nexport const useIncomeExpenseConfig = accessor.use;\\nexport const getIncomeExpenseConfig = accessor.get;\\nexport const updateIncomeExpenseConfig = accessor.update;\\n\",\"frontend/views/SettingsView.tsx\":\"import { useCallback, useState } from 'react';\\nimport { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, fetchExternal } from '@cubelv/sdk';\\nimport { useIncomeExpenseConfig, updateIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\\nimport { useGoogleMapsConfig, updateGoogleMapsConfig } from '../stores/googleMapsConfigStore';\\nimport { CategoryManagerDialog, PayeeManagerDialog } from '../components/AddTransactionDialog';\\nimport { ensureGovtHolidays, clearGovtHolidayCache, loadGovtHolidaysFromData } from '../lunarCalendar';\\n\\ninterface SettingsViewProps {\\n  open: boolean;\\n  onClose: () =\u003e void;\\n}\\n\\nfunction ToggleRow({ label, desc, checked, onChange }: {\\n  label: string;\\n  desc: string;\\n  checked: boolean;\\n  onChange: (v: boolean) =\u003e void;\\n}) {\\n  return (\\n    \u003clabel className=\\\"flex items-center justify-between py-2.5 px-1 border-b border-border/40 cursor-pointer hover:bg-accent/20 transition-colors\\\"\u003e\\n      \u003cdiv className=\\\"flex flex-col gap-0.5\\\"\u003e\\n        \u003cspan className=\\\"text-sm font-medium text-foreground\\\"\u003e{label}\u003c/span\u003e\\n        \u003cspan className=\\\"text-xs text-muted-foreground\\\"\u003e{desc}\u003c/span\u003e\\n      \u003c/div\u003e\\n      \u003cdiv className=\\\"relative flex-shrink-0 ml-3\\\"\u003e\\n        \u003cinput\\n          type=\\\"checkbox\\\"\\n          checked={checked}\\n          onChange={e =\u003e onChange(e.target.checked)}\\n          className=\\\"sr-only peer\\\"\\n        /\u003e\\n        \u003cdiv className=\\\"w-10 h-5 rounded-full bg-muted-foreground/30 peer-checked:bg-primary transition-colors\\\" /\u003e\\n        \u003cdiv className=\\\"absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5\\\" /\u003e\\n      \u003c/div\u003e\\n    \u003c/label\u003e\\n  );\\n}\\n\\nexport function SettingsView({ open, onClose }: SettingsViewProps) {\\n  const config = useIncomeExpenseConfig();\\n  const gmapsConfig = useGoogleMapsConfig();\\n\\n  const toggle = useCallback((key: string) =\u003e (val: boolean) =\u003e {\\n    updateIncomeExpenseConfig({ [key]: val });\\n  }, []);\\n\\n  // Google Maps 設定\\n  const [gmapsApiKey, setGmapsApiKey] = useState(gmapsConfig?.apiKey || '');\\n  const [gmapsEnabled, setGmapsEnabled] = useState(gmapsConfig?.enabled || false);\\n\\n  // 大小類 / 對象管理對話框狀態\\n  const [catManagerStep, setCatManagerStep] = useState\u003c'pick' | 'income' | 'expense' | null\u003e(null);\\n  const [payeeManagerStep, setPayeeManagerStep] = useState\u003c'pick' | 'income' | 'expense' | null\u003e(null);\\n  const [holidayUpdating, setHolidayUpdating] = useState(false);\\n\\n  if (!config || !gmapsConfig) return null;\\n\\n  return (\\n    \u003cDialog open={open} onOpenChange={(o) =\u003e { if (!o) onClose(); }}\u003e\\n      \u003cDialogContent className=\\\"max-w-md max-h-[85vh] overflow-y-auto\\\"\u003e\\n        \u003cDialogHeader\u003e\\n          \u003cDialogTitle\u003e⚙️ 月曆顯示設定\u003c/DialogTitle\u003e\\n          \u003cDialogDescription\u003e\\n            控制萬年曆上顯示的資訊內容\\n          \u003c/DialogDescription\u003e\\n        \u003c/DialogHeader\u003e\\n\\n        \u003cdiv className=\\\"flex gap-2 px-1 pt-2 pb-1\\\"\u003e\\n          \u003cButton variant=\\\"outline\\\" size=\\\"sm\\\" className=\\\"flex-1 text-xs\\\" onClick={() =\u003e setCatManagerStep('pick')}\u003e\\n            📂 管理大小類別\\n          \u003c/Button\u003e\\n          \u003cButton variant=\\\"outline\\\" size=\\\"sm\\\" className=\\\"flex-1 text-xs\\\" onClick={() =\u003e setPayeeManagerStep('pick')}\u003e\\n            👤 管理對象\\n          \u003c/Button\u003e\\n        \u003c/div\u003e\\n\\n        \u003cdiv className=\\\"flex flex-col py-2\\\"\u003e\\n          \u003cToggleRow\\n            label=\\\"Google Maps 搜尋\\\"\\n            desc=\\\"啟用 Google Maps API 做地點搜尋（需填 API 金鑰）\\\"\\n            checked={gmapsEnabled}\\n            onChange={(v) =\u003e {\\n              setGmapsEnabled(v);\\n              updateGoogleMapsConfig({ enabled: v });\\n            }}\\n          /\u003e\\n          {gmapsEnabled \u0026\u0026 (\\n            \u003cdiv className=\\\"px-1 py-2 border-b border-border/40\\\"\u003e\\n              \u003cdiv className=\\\"text-xs text-muted-foreground mb-1.5\\\"\u003eGoogle Cloud API 金鑰\u003c/div\u003e\\n              \u003cInput\\n                type=\\\"password\\\"\\n                value={gmapsApiKey}\\n                onChange={e =\u003e setGmapsApiKey(e.target.value)}\\n                onBlur={() =\u003e {\\n                  if (gmapsApiKey !== (gmapsConfig?.apiKey || '')) {\\n                    updateGoogleMapsConfig({ apiKey: gmapsApiKey });\\n                  }\\n                }}\\n                placeholder=\\\"AIza...\\\"\\n                className=\\\"text-xs\\\"\\n              /\u003e\\n              \u003cdiv className=\\\"text-[10px] text-muted-foreground mt-1\\\"\u003e\\n                需開通 Geocoding API 與 Places API，每月 $200 免費額度\\n              \u003c/div\u003e\\n            \u003c/div\u003e\\n          )}\\n          \u003cToggleRow\\n            label=\\\"農曆日期\\\"\\n            desc=\\\"顯示農曆初幾（如：初一、十五）\\\"\\n            checked={config.showLunarDate !== false}\\n            onChange={toggle('showLunarDate')}\\n          /\u003e\\n          \u003cToggleRow\\n            label=\\\"天干地支\\\"\\n            desc=\\\"顯示日干支（如：甲子、乙丑）\\\"\\n            checked={config.showGanZhi !== false}\\n            onChange={toggle('showGanZhi')}\\n          /\u003e\\n          \u003cToggleRow\\n            label=\\\"二十四節氣\\\"\\n            desc=\\\"顯示當日節氣名稱（如：立春、清明）\\\"\\n            checked={config.showSolarTerm !== false}\\n            onChange={toggle('showSolarTerm')}\\n          /\u003e\\n          \u003cToggleRow\\n            label=\\\"農曆節日\\\"\\n            desc=\\\"顯示傳統節日（春節、端午、中秋、媽祖生等）\\\"\\n            checked={config.showLunarFestival !== false}\\n            onChange={toggle('showLunarFestival')}\\n          /\u003e\\n          \u003cToggleRow\\n            label=\\\"國定假日\\\"\\n            desc=\\\"顯示人事行政總處公告之國定假日\\\"\\n            checked={config.showNationalHoliday !== false}\\n            onChange={toggle('showNationalHoliday')}\\n          /\u003e\\n          {config.showNationalHoliday !== false \u0026\u0026 (\\n            \u003cdiv className=\\\"px-1 py-2 border-b border-border/40 flex items-center justify-between\\\"\u003e\\n              \u003cspan className=\\\"text-xs text-muted-foreground\\\"\u003e\\n                {config.govtHolidayLastUpdate\\n                  ? `上次更新：${(() =\u003e {\\n                      const d = new Date(config.govtHolidayLastUpdate);\\n                      return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;\\n                    })()}`\\n                  : '尚未更新國定假日資料'}\\n              \u003c/span\u003e\\n              \u003cButton\\n                variant=\\\"outline\\\"\\n                size=\\\"sm\\\"\\n                className=\\\"text-xs\\\"\\n                disabled={holidayUpdating}\\n                onClick={async () =\u003e {\\n                  setHolidayUpdating(true);\\n                  try {\\n                    clearGovtHolidayCache();\\n                    const thisYear = new Date().getFullYear();\\n                    const resp = await fetchExternal(`https://cdn.jsdelivr.net/gh/ruyut/TaiwanCalendar/data/${thisYear}.json`, {});\\n                    const rawData: Array\u003c{ date: string; isHoliday: boolean; description: string }\u003e = await resp.json();\\n                    // 處理成 dateStr → 假日名稱\\n                    const holidayData: Record\u003cstring, string\u003e = {};\\n                    for (const entry of rawData) {\\n                      if (entry.isHoliday \u0026\u0026 entry.description) {\\n                        const ds = `${entry.date.slice(0, 4)}-${entry.date.slice(4, 6)}-${entry.date.slice(6, 8)}`;\\n                        holidayData[ds] = entry.description;\\n                      }\\n                    }\\n                    // 載入記憶體快取（月曆立即顯示）\\n                    loadGovtHolidaysFromData(holidayData);\\n                    // 儲存到 config（下次開啟不用重新下載）\\n                    updateIncomeExpenseConfig({\\n                      govtHolidayData: holidayData,\\n                      govtHolidayLastUpdate: Date.now(),\\n                    });\\n                  } catch (_e) {\\n                    // 失敗時不更新\\n                  } finally {\\n                    setHolidayUpdating(false);\\n                  }\\n                }}\\n              \u003e\\n                {holidayUpdating ? '更新中…' : '更新國定假日'}\\n              \u003c/Button\u003e\\n            \u003c/div\u003e\\n          )}\\n          \u003cToggleRow\\n            label=\\\"八字完整排盤\\\"\\n            desc=\\\"點選日期時顯示四柱、十神、藏干、納音\\\"\\n            checked={config.showBazi === true}\\n            onChange={toggle('showBazi')}\\n          /\u003e\\n\\n          {/* 分隔 */}\\n          \u003cdiv className=\\\"py-2 px-1\\\"\u003e\\n            \u003cdiv className=\\\"text-xs font-semibold text-muted-foreground mb-1\\\"\u003e💰 每月預算\u003c/div\u003e\\n            \u003cdiv className=\\\"flex items-center gap-2\\\"\u003e\\n              \u003cInput\\n                type=\\\"number\\\"\\n                value={config.monthlyBudget || ''}\\n                onChange={e =\u003e updateIncomeExpenseConfig({ monthlyBudget: Number(e.target.value) || 0 })}\\n                placeholder=\\\"0\\\"\\n                className=\\\"w-24 h-8 text-xs\\\"\\n              /\u003e\\n              \u003cspan className=\\\"text-xs text-muted-foreground\\\"\u003e元\u003c/span\u003e\\n              {config.monthlyBudget \u003e 0 \u0026\u0026 (\\n                \u003cspan className=\\\"text-[10px] text-muted-foreground\\\"\u003e\\n                  （設 0 關閉預算條）\\n                \u003c/span\u003e\\n              )}\\n            \u003c/div\u003e\\n          \u003c/div\u003e\\n\\n          \u003cdiv className=\\\"py-2 px-1\\\"\u003e\\n            \u003cdiv className=\\\"text-xs font-semibold text-muted-foreground mb-1\\\"\u003e🔔 記帳提醒\u003c/div\u003e\\n            \u003cToggleRow\\n              label=\\\"啟用提醒\\\"\\n              desc=\\\"未記帳時發送推播通知\\\"\\n              checked={config.reminderEnabled === true}\\n              onChange={(v) =\u003e updateIncomeExpenseConfig({ reminderEnabled: v })}\\n            /\u003e\\n            {config.reminderEnabled \u0026\u0026 (\\n              \u003cdiv className=\\\"flex items-center gap-2 mt-1 px-1\\\"\u003e\\n                \u003cselect\\n                  value={config.reminderFrequency || 'daily'}\\n                  onChange={e =\u003e updateIncomeExpenseConfig({ reminderFrequency: e.target.value as 'daily' | 'weekly' })}\\n                  className=\\\"text-xs border border-border rounded px-2 py-1 bg-background text-foreground\\\"\\n                \u003e\\n                  \u003coption value=\\\"daily\\\"\u003e每日\u003c/option\u003e\\n                  \u003coption value=\\\"weekly\\\"\u003e每週\u003c/option\u003e\\n                \u003c/select\u003e\\n                \u003cInput\\n                  type=\\\"time\\\"\\n                  value={config.reminderTime || '20:00'}\\n                  onChange={e =\u003e updateIncomeExpenseConfig({ reminderTime: e.target.value })}\\n                  className=\\\"w-28 h-7 text-xs\\\"\\n                /\u003e\\n              \u003c/div\u003e\\n            )}\\n          \u003c/div\u003e\\n        \u003c/div\u003e\\n\\n        \u003cDialogFooter\u003e\\n          \u003cButton variant=\\\"outline\\\" onClick={onClose}\u003e關閉\u003c/Button\u003e\\n        \u003c/DialogFooter\u003e\\n      \u003c/DialogContent\u003e\\n\\n      {/* 大小類別管理 — 先選收入/支出 */}\\n      {catManagerStep === 'pick' \u0026\u0026 (\\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setCatManagerStep(null); }}\u003e\\n          \u003cDialogContent className=\\\"max-w-xs\\\"\u003e\\n            \u003cDialogHeader\u003e\u003cDialogTitle\u003e管理哪種分類？\u003c/DialogTitle\u003e\u003c/DialogHeader\u003e\\n            \u003cdiv className=\\\"flex gap-2 py-2\\\"\u003e\\n              \u003cButton variant=\\\"outline\\\" className=\\\"flex-1\\\" onClick={() =\u003e setCatManagerStep('expense')}\u003e支出\u003c/Button\u003e\\n              \u003cButton variant=\\\"outline\\\" className=\\\"flex-1\\\" onClick={() =\u003e setCatManagerStep('income')}\u003e收入\u003c/Button\u003e\\n            \u003c/div\u003e\\n            \u003cDialogFooter\u003e\\n              \u003cButton variant=\\\"outline\\\" onClick={() =\u003e setCatManagerStep(null)}\u003e取消\u003c/Button\u003e\\n            \u003c/DialogFooter\u003e\\n          \u003c/DialogContent\u003e\\n        \u003c/Dialog\u003e\\n      )}\\n      {catManagerStep === 'expense' \u0026\u0026 (\\n        \u003cCategoryManagerDialog\\n          categories={config.expenseCategories || []}\\n          fieldKey=\\\"expenseCategories\\\"\\n          onClose={() =\u003e setCatManagerStep(null)}\\n        /\u003e\\n      )}\\n      {catManagerStep === 'income' \u0026\u0026 (\\n        \u003cCategoryManagerDialog\\n          categories={config.incomeCategories || []}\\n          fieldKey=\\\"incomeCategories\\\"\\n          onClose={() =\u003e setCatManagerStep(null)}\\n        /\u003e\\n      )}\\n\\n      {/* 對象管理 — 先選收入/支出 */}\\n      {payeeManagerStep === 'pick' \u0026\u0026 (\\n        \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setPayeeManagerStep(null); }}\u003e\\n          \u003cDialogContent className=\\\"max-w-xs\\\"\u003e\\n            \u003cDialogHeader\u003e\u003cDialogTitle\u003e管理哪種對象？\u003c/DialogTitle\u003e\u003c/DialogHeader\u003e\\n            \u003cdiv className=\\\"flex gap-2 py-2\\\"\u003e\\n              \u003cButton variant=\\\"outline\\\" className=\\\"flex-1\\\" onClick={() =\u003e setPayeeManagerStep('expense')}\u003e支出\u003c/Button\u003e\\n              \u003cButton variant=\\\"outline\\\" className=\\\"flex-1\\\" onClick={() =\u003e setPayeeManagerStep('income')}\u003e收入\u003c/Button\u003e\\n            \u003c/div\u003e\\n            \u003cDialogFooter\u003e\\n              \u003cButton variant=\\\"outline\\\" onClick={() =\u003e setPayeeManagerStep(null)}\u003e取消\u003c/Button\u003e\\n            \u003c/DialogFooter\u003e\\n          \u003c/DialogContent\u003e\\n        \u003c/Dialog\u003e\\n      )}\\n      {payeeManagerStep === 'expense' \u0026\u0026 (\\n        \u003cPayeeManagerDialog\\n          payees={config.expensePayees || []}\\n          fieldKey=\\\"expensePayees\\\"\\n          onClose={() =\u003e setPayeeManagerStep(null)}\\n        /\u003e\\n      )}\\n      {payeeManagerStep === 'income' \u0026\u0026 (\\n        \u003cPayeeManagerDialog\\n          payees={config.incomePayees || []}\\n          fieldKey=\\\"incomePayees\\\"\\n          onClose={() =\u003e setPayeeManagerStep(null)}\\n        /\u003e\\n      )}\\n    \u003c/Dialog\u003e\\n  );\\n}\\n\",\"frontend/views/StatsView.tsx\":\"import { useMemo, useState } from 'react';\\nimport { useItemsByType, useSelectedItemsStore, Input, ToggleGroup, ToggleGroupItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@cubelv/sdk';\\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\\nimport { PieChart, PieSlice } from '../components/PieChart';\\n\\nconst COLORS = [\\n    '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',\\n    '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ADB5',\\n    '#E83E8C', '#6610F2', '#20C997', '#FD7E14', '#6F42C1',\\n];\\nconst INCOME_COLOR = '#36A2EB';\\nconst EXPENSE_COLOR = '#FF6384';\\n\\ntype GroupBy = 'major' | 'sub' | 'payee';\\ntype Period = 'week' | 'month' | 'year' | 'all' | 'custom';\\ntype ChartType = 'pie' | 'bar' | 'line' | 'annual';\\n\\nfunction getPeriodRange(period: Period, customStart?: string, customEnd?: string): { start: string; end: string } {\\n    const now = new Date();\\n    if (period === 'custom') return { start: customStart || '0000-01-01', end: customEnd || now.toISOString().slice(0, 10) };\\n    const end = now.toISOString().slice(0, 10);\\n    if (period === 'all') return { start: '0000-01-01', end };\\n    if (period === 'year') return { start: `${now.getFullYear()}-01-01`, end };\\n    if (period === 'month') {\\n        const m = String(now.getMonth() + 1).padStart(2, '0');\\n        return { start: `${now.getFullYear()}-${m}-01`, end };\\n    }\\n    const day = now.getDay();\\n    const monday = new Date(now);\\n    monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1));\\n    return { start: monday.toISOString().slice(0, 10), end };\\n}\\n\\nfunction formatNum(n: number): string {\\n    return n.toLocaleString();\\n}\\n\\nfunction niceMax(n: number): number {\\n    if (n \u003c= 0) return 1000;\\n    const mag = Math.pow(10, Math.floor(Math.log10(n)));\\n    const r = Math.ceil(n / mag) * mag;\\n    return r || mag;\\n}\\n\\ntype CategorizedSeries = {\\n    buckets: string[];\\n    categories: string[];\\n    values: Record\u003cstring, number[]\u003e;\\n    total: number;\\n};\\n\\n/** 依 period + groupBy 產生時間序列分類資料 */\\nfunction computeCategorizedSeries(\\n    data: IncomeExpenseTransaction[],\\n    groupBy: GroupBy,\\n    period: Period,\\n    start: string,\\n    end: string,\\n): CategorizedSeries {\\n    // 產生時間 buckets\\n    const buckets: string[] = [];\\n    const bucketMap = new Map\u003cstring, Map\u003cstring, number\u003e\u003e();\\n    const allCats = new Map\u003cstring, number\u003e();\\n\\n    for (const item of data) {\\n        if (!item.date) continue;\\n        if (item.date \u003c start || item.date \u003e end) continue;\\n\\n        let bucket: string;\\n        if (period === 'month') {\\n            const d = parseInt(item.date.slice(8, 10), 10);\\n            bucket = `${d}日`;\\n        } else if (period === 'week') {\\n            const day = parseInt(item.date.slice(8, 10), 10);\\n            bucket = `${day}日`;\\n        } else {\\n            const m = parseInt(item.date.slice(5, 7), 10);\\n            bucket = `${m}月`;\\n        }\\n\\n        let cat = '';\\n        if (groupBy === 'major') cat = item.majorCategory || '(未分類)';\\n        else if (groupBy === 'sub') cat = item.subCategory || item.majorCategory || '(未分類)';\\n        else cat = item.payee || '(未指定)';\\n\\n        if (!bucketMap.has(bucket)) bucketMap.set(bucket, new Map());\\n        const catMap = bucketMap.get(bucket)!;\\n        catMap.set(cat, (catMap.get(cat) || 0) + (item.amount || 0));\\n        allCats.set(cat, (allCats.get(cat) || 0) + (item.amount || 0));\\n    }\\n\\n    // 確保 buckets 有序\\n    if (period === 'year' || period === 'all' || period === 'custom') {\\n        for (let i = 1; i \u003c= 12; i++) {\\n            const key = `${i}月`;\\n            if (bucketMap.has(key)) buckets.push(key);\\n            else if (period === 'year') buckets.push(key);\\n        }\\n    } else {\\n        // 按數字排序\\n        const keys = [...bucketMap.keys()].sort((a, b) =\u003e parseInt(a) - parseInt(b));\\n        buckets.push(...keys);\\n    }\\n\\n    if (buckets.length === 0) return { buckets: [], categories: [], values: {}, total: 0 };\\n\\n    // Top 5 categories\\n    const topCats = [...allCats.entries()].sort((a, b) =\u003e b[1] - a[1]).slice(0, 5).map(([k]) =\u003e k);\\n    const total = [...allCats.values()].reduce((s, v) =\u003e s + v, 0);\\n\\n    const values: Record\u003cstring, number[]\u003e = {};\\n    for (const cat of topCats) {\\n        values[cat] = buckets.map(b =\u003e bucketMap.get(b)?.get(cat) || 0);\\n    }\\n\\n    return { buckets, categories: topCats, values, total };\\n}\\n\\n/** SVG 分類長條圖：每個時間段顯示分類堆疊 */\\nfunction CatBarChart({ series, colorClass }: { series: CategorizedSeries; colorClass: string }) {\\n    const W = 600, H = 280, PAD_L = 50, PAD_R = 16, PAD_T = 16, PAD_B = 36;\\n    const chartW = W - PAD_L - PAD_R;\\n    const chartH = H - PAD_T - PAD_B;\\n\\n    const maxVal = niceMax(Math.max(...series.buckets.map(b =\u003e\\n        series.categories.reduce((s, cat) =\u003e s + (series.values[cat]?.[series.buckets.indexOf(b)] || 0), 0)\\n    ), 1000));\\n\\n    const n = series.buckets.length;\\n    const barGap = 2;\\n    const groupW = chartW / n;\\n    const catCount = series.categories.length;\\n    const barW = Math.max(1, (groupW - barGap * catCount) / catCount);\\n\\n    return (\\n        \u003csvg viewBox={`0 0 ${W} ${H}`} className=\\\"w-full\\\" style={{ maxHeight: '280px' }}\u003e\\n            {[0, 0.25, 0.5, 0.75, 1].map(p =\u003e {\\n                const y = PAD_T + chartH * (1 - p);\\n                return (\\n                    \u003cg key={p}\u003e\\n                        \u003cline x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke=\\\"var(--border)\\\" strokeWidth=\\\"0.5\\\" /\u003e\\n                        \u003ctext x={PAD_L - 6} y={y + 4} textAnchor=\\\"end\\\" className=\\\"text-[10px]\\\" fill=\\\"var(--muted-foreground)\\\"\u003e\\n                            {Math.round(maxVal * p).toLocaleString()}\\n                        \u003c/text\u003e\\n                    \u003c/g\u003e\\n                );\\n            })}\\n            {series.buckets.map((b, bi) =\u003e {\\n                let yBase = PAD_T + chartH;\\n                return (\\n                    \u003cg key={b}\u003e\\n                        {series.categories.map((cat, ci) =\u003e {\\n                            const v = series.values[cat]?.[bi] || 0;\\n                            const h = maxVal \u003e 0 ? (v / maxVal) * chartH : 0;\\n                            yBase -= h;\\n                            return (\\n                                \u003crect key={cat} x={PAD_L + bi * groupW + ci * (barW + barGap)} y={yBase}\\n                                    width={barW} height={Math.max(0.5, h)} fill={COLORS[ci % COLORS.length]} rx=\\\"1\\\" /\u003e\\n                            );\\n                        })}\\n                        \u003ctext x={PAD_L + bi * groupW + groupW / 2} y={H - 8} textAnchor=\\\"middle\\\" className=\\\"text-[10px]\\\" fill=\\\"var(--muted-foreground)\\\"\u003e\\n                            {b}\\n                        \u003c/text\u003e\\n                    \u003c/g\u003e\\n                );\\n            })}\\n        \u003c/svg\u003e\\n    );\\n}\\n\\n/** SVG 分類折線圖 */\\nfunction CatLineChart({ series, colorClass }: { series: CategorizedSeries; colorClass: string }) {\\n    const W = 600, H = 280, PAD_L = 50, PAD_R = 16, PAD_T = 16, PAD_B = 36;\\n    const chartW = W - PAD_L - PAD_R;\\n    const chartH = H - PAD_T - PAD_B;\\n\\n    const allVals = series.categories.flatMap(cat =\u003e series.values[cat] || []);\\n    const maxVal = niceMax(Math.max(...allVals, 1000));\\n    const xStep = chartW / Math.max(series.buckets.length - 1, 1);\\n\\n    return (\\n        \u003csvg viewBox={`0 0 ${W} ${H}`} className=\\\"w-full\\\" style={{ maxHeight: '280px' }}\u003e\\n            {[0, 0.25, 0.5, 0.75, 1].map(p =\u003e {\\n                const y = PAD_T + chartH * (1 - p);\\n                return (\\n                    \u003cg key={p}\u003e\\n                        \u003cline x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke=\\\"var(--border)\\\" strokeWidth=\\\"0.5\\\" /\u003e\\n                        \u003ctext x={PAD_L - 6} y={y + 4} textAnchor=\\\"end\\\" className=\\\"text-[10px]\\\" fill=\\\"var(--muted-foreground)\\\"\u003e\\n                            {Math.round(maxVal * p).toLocaleString()}\\n                        \u003c/text\u003e\\n                    \u003c/g\u003e\\n                );\\n            })}\\n            {series.categories.map((cat, ci) =\u003e {\\n                const pts = series.values[cat]?.map((v, bi) =\u003e\\n                    `${PAD_L + bi * xStep},${PAD_T + chartH * (1 - v / maxVal)}`\\n                ).join(' ') || '';\\n                return (\\n                    \u003cg key={cat}\u003e\\n                        \u003cpolyline points={pts} fill=\\\"none\\\" stroke={COLORS[ci % COLORS.length]} strokeWidth=\\\"2\\\" strokeLinecap=\\\"round\\\" strokeLinejoin=\\\"round\\\" /\u003e\\n                        {series.values[cat]?.map((v, bi) =\u003e (\\n                            \u003ccircle key={bi} cx={PAD_L + bi * xStep} cy={PAD_T + chartH * (1 - v / maxVal)} r=\\\"2.5\\\" fill={COLORS[ci % COLORS.length]} /\u003e\\n                        ))}\\n                    \u003c/g\u003e\\n                );\\n            })}\\n            {series.buckets.map((b, bi) =\u003e (\\n                \u003ctext key={b} x={PAD_L + bi * xStep} y={H - 8} textAnchor=\\\"middle\\\" className=\\\"text-[10px]\\\" fill=\\\"var(--muted-foreground)\\\"\u003e\\n                    {b}\\n                \u003c/text\u003e\\n            ))}\\n            {/* Legend */}\\n            {series.categories.map((cat, ci) =\u003e (\\n                \u003cg key={`leg-${cat}`}\u003e\\n                    \u003crect x={PAD_L + 8 + ci * 70} y={PAD_T + 2} width={8} height={8} rx=\\\"2\\\" fill={COLORS[ci % COLORS.length]} /\u003e\\n                    \u003ctext x={PAD_L + 19 + ci * 70} y={PAD_T + 10} className=\\\"text-[9px]\\\" fill=\\\"var(--foreground)\\\"\u003e{cat.length \u003e 4 ? cat.slice(0, 4) + '…' : cat}\u003c/text\u003e\\n                \u003c/g\u003e\\n            ))}\\n        \u003c/svg\u003e\\n    );\\n}\\n\\nexport function StatsView() {\\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\\n    const items = useItemsByType\u003cIncomeExpenseTransaction\u003e('INCOME_EXPENSE_TRANSACTION', folderId || undefined);\\n\\n    const [chartType, setChartType] = useState\u003cChartType\u003e('pie');\\n    const [period, setPeriod] = useState\u003cPeriod\u003e('month');\\n    const [trendYear, setTrendYear] = useState\u003cnumber\u003e(new Date().getFullYear());\\n    const [groupBy, setGroupBy] = useState\u003cGroupBy\u003e('major');\\n    const [customStart, setCustomStart] = useState('');\\n    const [customEnd, setCustomEnd] = useState('');\\n\\n    const { start, end } = useMemo(\\n        () =\u003e getPeriodRange(period, customStart, customEnd),\\n        [period, customStart, customEnd]\\n    );\\n\\n    const filtered = useMemo(() =\u003e\\n        items.filter(item =\u003e item.date \u0026\u0026 item.date \u003e= start \u0026\u0026 item.date \u003c= end),\\n        [items, start, end]\\n    );\\n\\n    const incomeItems = useMemo(() =\u003e filtered.filter(i =\u003e i.type === 'income'), [filtered]);\\n    const expenseItems = useMemo(() =\u003e filtered.filter(i =\u003e i.type === 'expense'), [filtered]);\\n\\n    const computeSlices = (data: IncomeExpenseTransaction[]): { slices: PieSlice[]; total: number } =\u003e {\\n        const groups = new Map\u003cstring, number\u003e();\\n        let total = 0;\\n        for (const item of data) {\\n            let key = '';\\n            if (groupBy === 'major') key = item.majorCategory || '(未分類)';\\n            else if (groupBy === 'sub') key = item.subCategory || item.majorCategory || '(未分類)';\\n            else key = item.payee || '(未指定)';\\n            const amt = item.amount || 0;\\n            groups.set(key, (groups.get(key) || 0) + amt);\\n            total += amt;\\n        }\\n        const entries = [...groups.entries()].sort((a, b) =\u003e b[1] - a[1]);\\n        const slices: PieSlice[] = entries.map(([label, value], i) =\u003e ({\\n            label, value, color: COLORS[i % COLORS.length],\\n        }));\\n        return { slices, total };\\n    };\\n\\n    const incomeData = useMemo(() =\u003e computeSlices(incomeItems), [incomeItems, groupBy]);\\n    const expenseData = useMemo(() =\u003e computeSlices(expenseItems), [expenseItems, groupBy]);\\n\\n    // ===== 長條圖 / 折線圖：用 period + groupBy =====\\n    const barIncomeSeries = useMemo(() =\u003e\\n        computeCategorizedSeries(incomeItems, groupBy, period, start, end),\\n        [incomeItems, groupBy, period, start, end]\\n    );\\n    const barExpenseSeries = useMemo(() =\u003e\\n        computeCategorizedSeries(expenseItems, groupBy, period, start, end),\\n        [expenseItems, groupBy, period, start, end]\\n    );\\n\\n    // ===== 年度總覽 =====\\n    const annualData = useMemo(() =\u003e {\\n        const yearItems = items.filter(item =\u003e item.date \u0026\u0026 item.date.startsWith(`${trendYear}-`));\\n        const totalIncome = yearItems.filter(i =\u003e i.type === 'income').reduce((s, i) =\u003e s + (i.amount || 0), 0);\\n        const totalExpense = yearItems.filter(i =\u003e i.type === 'expense').reduce((s, i) =\u003e s + (i.amount || 0), 0);\\n        const catMap = new Map\u003cstring, number\u003e();\\n        for (const item of yearItems) {\\n            const key = item.majorCategory || '(未分類)';\\n            catMap.set(key, (catMap.get(key) || 0) + (item.amount || 0));\\n        }\\n        const topCats = [...catMap.entries()].sort((a, b) =\u003e b[1] - a[1]).slice(0, 5);\\n        const monthTable = Array.from({ length: 12 }, (_, i) =\u003e {\\n            const key = `${trendYear}-${String(i + 1).padStart(2, '0')}`;\\n            const mItems = items.filter(t =\u003e t.date \u0026\u0026 t.date.startsWith(key));\\n            const inc = mItems.filter(t =\u003e t.type === 'income').reduce((s, t) =\u003e s + (t.amount || 0), 0);\\n            const exp = mItems.filter(t =\u003e t.type === 'expense').reduce((s, t) =\u003e s + (t.amount || 0), 0);\\n            return { month: `${i + 1}月`, income: inc, expense: exp, balance: inc - exp };\\n        });\\n        return { totalIncome, totalExpense, balance: totalIncome - totalExpense, topCats, monthTable, hasData: yearItems.length \u003e 0 };\\n    }, [items, trendYear]);\\n\\n    const availableYears = useMemo(() =\u003e {\\n        const years = new Set\u003cnumber\u003e();\\n        years.add(new Date().getFullYear());\\n        for (const item of items) {\\n            if (item.date) years.add(parseInt(item.date.slice(0, 4), 10));\\n        }\\n        return [...years].sort((a, b) =\u003e b - a);\\n    }, [items]);\\n\\n    const periodLabels: Record\u003cPeriod, string\u003e = { week: '本週', month: '本月', year: '今年', all: '全部', custom: '自訂' };\\n    const groupLabels: Record\u003cGroupBy, string\u003e = { major: '大類', sub: '小類', payee: '對象' };\\n    const chartTypeLabels: Record\u003cChartType, string\u003e = { pie: '圓餅', bar: '長條', line: '折線', annual: '年度' };\\n\\n    function PieSection({ title, data, colorClass }: {\\n        title: string; data: { slices: PieSlice[]; total: number }; colorClass: string;\\n    }) {\\n        return (\\n            \u003cdiv className=\\\"mb-6\\\"\u003e\\n                \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title}\u003c/div\u003e\\n                {data.total === 0 ? (\\n                    \u003cdiv className=\\\"py-5 text-center text-muted-foreground text-sm\\\"\u003e尚無資料\u003c/div\u003e\\n                ) : (\\n                    \u003cdiv className=\\\"flex gap-4 items-start flex-wrap\\\"\u003e\\n                        \u003cPieChart slices={data.slices} total={data.total} size={200} /\u003e\\n                        \u003ctable className=\\\"flex-1 min-w-[150px] text-xs border-collapse\\\"\u003e\\n                            \u003cthead\u003e\\n                                \u003ctr className=\\\"border-b-2 border-border\\\"\u003e\\n                                    \u003cth className=\\\"text-left py-1 px-1.5\\\"\u003e{groupLabels[groupBy]}\u003c/th\u003e\\n                                    \u003cth className=\\\"text-right py-1 px-1.5\\\"\u003e金額\u003c/th\u003e\\n                                    \u003cth className=\\\"text-right py-1 px-1.5\\\"\u003e佔比\u003c/th\u003e\\n                                \u003c/tr\u003e\\n                            \u003c/thead\u003e\\n                            \u003ctbody\u003e\\n                                {data.slices.map((s, i) =\u003e (\\n                                    \u003ctr key={i} className=\\\"border-b border-border/50\\\"\u003e\\n                                        \u003ctd className=\\\"py-1 px-1.5\\\"\u003e\\n                                            \u003cspan className=\\\"inline-block w-2.5 h-2.5 rounded-sm mr-1.5 align-middle\\\" style={{ background: s.color }} /\u003e\\n                                            {s.label}\\n                                        \u003c/td\u003e\\n                                        \u003ctd className=\\\"text-right py-1 px-1.5 font-medium\\\"\u003e{formatNum(s.value)}\u003c/td\u003e\\n                                        \u003ctd className=\\\"text-right py-1 px-1.5 text-muted-foreground\\\"\u003e\\n                                            {((s.value / data.total) * 100).toFixed(1)}%\\n                                        \u003c/td\u003e\\n                                    \u003c/tr\u003e\\n                                ))}\\n                            \u003c/tbody\u003e\\n                            \u003ctfoot\u003e\\n                                \u003ctr className=\\\"border-t-2 border-border font-bold\\\"\u003e\\n                                    \u003ctd className=\\\"py-1 px-1.5\\\"\u003e合計\u003c/td\u003e\\n                                    \u003ctd className=\\\"text-right py-1 px-1.5\\\"\u003e{formatNum(data.total)}\u003c/td\u003e\\n                                    \u003ctd className=\\\"text-right py-1 px-1.5\\\"\u003e100%\u003c/td\u003e\\n                                \u003c/tr\u003e\\n                            \u003c/tfoot\u003e\\n                        \u003c/table\u003e\\n                    \u003c/div\u003e\\n                )}\\n            \u003c/div\u003e\\n        );\\n    }\\n\\n    function SeriesSection({ title, series, colorClass }: {\\n        title: string; series: CategorizedSeries; colorClass: string;\\n    }) {\\n        if (series.total === 0) {\\n            return (\\n                \u003cdiv className=\\\"mb-6\\\"\u003e\\n                    \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title}\u003c/div\u003e\\n                    \u003cdiv className=\\\"py-5 text-center text-muted-foreground text-sm\\\"\u003e尚無資料\u003c/div\u003e\\n                \u003c/div\u003e\\n            );\\n        }\\n        return (\\n            \u003cdiv className=\\\"mb-6\\\"\u003e\\n                \u003cdiv className={`font-semibold mb-2 ${colorClass}`}\u003e{title} — {formatNum(series.total)}\u003c/div\u003e\\n                {/* Legend */}\\n                \u003cdiv className=\\\"flex flex-wrap gap-x-4 gap-y-1 mb-2 text-[10px]\\\"\u003e\\n                    {series.categories.map((cat, ci) =\u003e (\\n                        \u003cspan key={cat} className=\\\"flex items-center gap-1\\\"\u003e\\n                            \u003cspan className=\\\"w-2 h-2 rounded-sm inline-block\\\" style={{ background: COLORS[ci % COLORS.length] }} /\u003e\\n                            {cat}\\n                        \u003c/span\u003e\\n                    ))}\\n                \u003c/div\u003e\\n                {chartType === 'bar'\\n                    ? \u003cCatBarChart series={series} colorClass={colorClass} /\u003e\\n                    : \u003cCatLineChart series={series} colorClass={colorClass} /\u003e\\n                }\\n            \u003c/div\u003e\\n        );\\n    }\\n\\n    const isTimelineChart = chartType === 'bar' || chartType === 'line';\\n\\n    return (\\n        \u003cdiv className=\\\"flex-1 overflow-y-auto p-4\\\"\u003e\\n            {/* 圖表類型切換 */}\\n            \u003cdiv className=\\\"mb-3\\\"\u003e\\n                \u003cToggleGroup type=\\\"single\\\" value={chartType} onValueChange={(v) =\u003e v \u0026\u0026 setChartType(v as ChartType)}\u003e\\n                    {(Object.entries(chartTypeLabels) as [ChartType, string][]).map(([k, label]) =\u003e (\\n                        \u003cToggleGroupItem key={k} value={k} className=\\\"text-xs px-3\\\"\u003e{label}圖\u003c/ToggleGroupItem\u003e\\n                    ))}\\n                \u003c/ToggleGroup\u003e\\n            \u003c/div\u003e\\n\\n            {/* 期間選擇器（圓餅 / 長條 / 折線） */}\\n            {chartType !== 'annual' \u0026\u0026 (\\n                \u003c\u003e\\n                    \u003cdiv className=\\\"flex gap-1.5 mb-3 flex-wrap\\\"\u003e\\n                        {(Object.keys(periodLabels) as Period[]).map(p =\u003e (\\n                            \u003cbutton key={p} onClick={() =\u003e setPeriod(p)}\\n                                className={`px-3 py-1 rounded-full text-xs border transition-colors ${\\n                                    period === p\\n                                        ? 'bg-primary text-primary-foreground border-primary'\\n                                        : 'border-border text-muted-foreground hover:bg-accent'\\n                                }`}\\n                            \u003e{periodLabels[p]}\u003c/button\u003e\\n                        ))}\\n                    \u003c/div\u003e\\n\\n                    {period === 'custom' \u0026\u0026 (\\n                        \u003cdiv className=\\\"flex items-center gap-2 mb-3 flex-wrap\\\"\u003e\\n                            \u003cInput type=\\\"date\\\" value={customStart} onChange={e =\u003e setCustomStart(e.target.value)} className=\\\"w-[140px] h-8 text-xs\\\" /\u003e\\n                            \u003cspan className=\\\"text-xs text-muted-foreground\\\"\u003e～\u003c/span\u003e\\n                            \u003cInput type=\\\"date\\\" value={customEnd} onChange={e =\u003e setCustomEnd(e.target.value)} className=\\\"w-[140px] h-8 text-xs\\\" /\u003e\\n                        \u003c/div\u003e\\n                    )}\\n\\n                    \u003cdiv className=\\\"text-xs text-muted-foreground mb-3\\\"\u003e{start} ～ {end}\u003c/div\u003e\\n                \u003c/\u003e\\n            )}\\n\\n            {/* 分類選擇器（圓餅 / 長條 / 折線） */}\\n            {chartType !== 'annual' \u0026\u0026 (\\n                \u003cdiv className=\\\"flex gap-1.5 mb-4\\\"\u003e\\n                    {(Object.entries(groupLabels) as [GroupBy, string][]).map(([k, label]) =\u003e (\\n                        \u003cbutton key={k} onClick={() =\u003e setGroupBy(k)}\\n                            className={`px-3 py-1 rounded-md text-xs border transition-colors ${\\n                                groupBy === k\\n                                    ? 'bg-secondary text-secondary-foreground border-secondary'\\n                                    : 'border-border text-muted-foreground hover:bg-accent'\\n                            }`}\\n                        \u003e{label}\u003c/button\u003e\\n                    ))}\\n                \u003c/div\u003e\\n            )}\\n\\n            {/* 圓餅圖 */}\\n            {chartType === 'pie' \u0026\u0026 (\\n                \u003c\u003e\\n                    \u003cPieSection title=\\\"📈 收入分佈\\\" data={incomeData} colorClass=\\\"text-[#36A2EB]\\\" /\u003e\\n                    \u003cPieSection title=\\\"📉 支出分佈\\\" data={expenseData} colorClass=\\\"text-[#FF6384]\\\" /\u003e\\n                \u003c/\u003e\\n            )}\\n\\n            {/* 長條圖 / 折線圖：共用期間 + 分類 */}\\n            {isTimelineChart \u0026\u0026 (\\n                \u003c\u003e\\n                    \u003cSeriesSection title=\\\"📈 收入\\\" series={barIncomeSeries} colorClass=\\\"text-[#36A2EB]\\\" /\u003e\\n                    \u003cSeriesSection title=\\\"📉 支出\\\" series={barExpenseSeries} colorClass=\\\"text-[#FF6384]\\\" /\u003e\\n                \u003c/\u003e\\n            )}\\n\\n            {/* 年度總覽 */}\\n            {chartType === 'annual' \u0026\u0026 (\\n                \u003cdiv\u003e\\n                    {/* 年份選擇器 */}\\n                    \u003cdiv className=\\\"flex items-center gap-2 mb-3\\\"\u003e\\n                        \u003cspan className=\\\"text-xs text-muted-foreground flex-shrink-0\\\"\u003e年份：\u003c/span\u003e\\n                        \u003cSelect value={String(trendYear)} onValueChange={(v) =\u003e v \u0026\u0026 setTrendYear(Number(v))}\u003e\\n                            \u003cSelectTrigger className=\\\"w-28 h-8 text-xs\\\"\u003e\\n                                \u003cSelectValue /\u003e\\n                            \u003c/SelectTrigger\u003e\\n                            \u003cSelectContent\u003e\\n                                {availableYears.map(y =\u003e (\\n                                    \u003cSelectItem key={y} value={String(y)}\u003e{y} 年\u003c/SelectItem\u003e\\n                                ))}\\n                            \u003c/SelectContent\u003e\\n                        \u003c/Select\u003e\\n                    \u003c/div\u003e\\n\\n                    {annualData.hasData ? (\\n                        \u003c\u003e\\n                            \u003cdiv className=\\\"grid grid-cols-3 gap-3 mb-4\\\"\u003e\\n                                \u003cdiv className=\\\"rounded-xl bg-[#36A2EB]/10 border border-[#36A2EB]/20 p-3 text-center\\\"\u003e\\n                                    \u003cdiv className=\\\"text-xs text-muted-foreground mb-0.5\\\"\u003e總收入\u003c/div\u003e\\n                                    \u003cdiv className=\\\"text-lg font-bold text-[#36A2EB]\\\"\u003e{formatNum(annualData.totalIncome)}\u003c/div\u003e\\n                                \u003c/div\u003e\\n                                \u003cdiv className=\\\"rounded-xl bg-[#FF6384]/10 border border-[#FF6384]/20 p-3 text-center\\\"\u003e\\n                                    \u003cdiv className=\\\"text-xs text-muted-foreground mb-0.5\\\"\u003e總支出\u003c/div\u003e\\n                                    \u003cdiv className=\\\"text-lg font-bold text-[#FF6384]\\\"\u003e{formatNum(annualData.totalExpense)}\u003c/div\u003e\\n                                \u003c/div\u003e\\n                                \u003cdiv className={`rounded-xl border p-3 text-center ${\\n                                    annualData.balance \u003e= 0 ? 'bg-[#22C55E]/10 border-[#22C55E]/20' : 'bg-[#FF6384]/10 border-[#FF6384]/20'\\n                                }`}\u003e\\n                                    \u003cdiv className=\\\"text-xs text-muted-foreground mb-0.5\\\"\u003e結餘\u003c/div\u003e\\n                                    \u003cdiv className={`text-lg font-bold ${annualData.balance \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\\n                                        {annualData.balance \u003e= 0 ? '+' : ''}{formatNum(annualData.balance)}\\n                                    \u003c/div\u003e\\n                                \u003c/div\u003e\\n                            \u003c/div\u003e\\n\\n                            \u003cdiv className=\\\"text-sm font-semibold mb-2 text-foreground\\\"\u003e📋 各月明細\u003c/div\u003e\\n                            \u003cdiv className=\\\"overflow-x-auto mb-4\\\"\u003e\\n                                \u003ctable className=\\\"w-full text-xs border-collapse\\\"\u003e\\n                                    \u003cthead\u003e\\n                                        \u003ctr className=\\\"border-b-2 border-border\\\"\u003e\\n                                            \u003cth className=\\\"text-left py-1 px-2\\\"\u003e月份\u003c/th\u003e\\n                                            \u003cth className=\\\"text-right py-1 px-2\\\"\u003e收入\u003c/th\u003e\\n                                            \u003cth className=\\\"text-right py-1 px-2\\\"\u003e支出\u003c/th\u003e\\n                                            \u003cth className=\\\"text-right py-1 px-2\\\"\u003e結餘\u003c/th\u003e\\n                                        \u003c/tr\u003e\\n                                    \u003c/thead\u003e\\n                                    \u003ctbody\u003e\\n                                        {annualData.monthTable.map(r =\u003e (\\n                                            \u003ctr key={r.month} className={`border-b border-border/50 ${r.income === 0 \u0026\u0026 r.expense === 0 ? 'opacity-30' : ''}`}\u003e\\n                                                \u003ctd className=\\\"py-1 px-2\\\"\u003e{r.month}\u003c/td\u003e\\n                                                \u003ctd className=\\\"text-right py-1 px-2\\\"\u003e{r.income \u003e 0 ? formatNum(r.income) : '-'}\u003c/td\u003e\\n                                                \u003ctd className=\\\"text-right py-1 px-2\\\"\u003e{r.expense \u003e 0 ? formatNum(r.expense) : '-'}\u003c/td\u003e\\n                                                \u003ctd className={`text-right py-1 px-2 font-medium ${r.balance \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\\n                                                    {r.balance === 0 \u0026\u0026 r.income === 0 \u0026\u0026 r.expense === 0 ? '-' : `${r.balance \u003e= 0 ? '+' : ''}${formatNum(r.balance)}`}\\n                                                \u003c/td\u003e\\n                                            \u003c/tr\u003e\\n                                        ))}\\n                                    \u003c/tbody\u003e\\n                                \u003c/table\u003e\\n                            \u003c/div\u003e\\n\\n                            {annualData.topCats.length \u003e 0 \u0026\u0026 (\\n                                \u003cdiv\u003e\\n                                    \u003cdiv className=\\\"text-sm font-semibold mb-2 text-foreground\\\"\u003e🏷️ 前五大支出類別\u003c/div\u003e\\n                                    {annualData.topCats.map(([cat, amt], i) =\u003e {\\n                                        const pct = annualData.totalExpense \u003e 0 ? (amt / annualData.totalExpense) * 100 : 0;\\n                                        return (\\n                                            \u003cdiv key={cat} className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                                \u003cspan className=\\\"w-5 text-right text-xs text-muted-foreground\\\"\u003e{i + 1}.\u003c/span\u003e\\n                                                \u003cspan className=\\\"text-xs flex-1\\\"\u003e{cat}\u003c/span\u003e\\n                                                \u003cspan className=\\\"text-xs text-muted-foreground w-12 text-right\\\"\u003e{pct.toFixed(1)}%\u003c/span\u003e\\n                                                \u003cspan className=\\\"text-xs font-medium w-20 text-right\\\"\u003e{formatNum(amt)}\u003c/span\u003e\\n                                                \u003cdiv className=\\\"w-24 h-1.5 rounded-full bg-muted overflow-hidden\\\"\u003e\\n                                                    \u003cdiv className=\\\"h-full rounded-full\\\" style={{ width: `${Math.min(pct, 100)}%`, background: COLORS[i % COLORS.length] }} /\u003e\\n                                                \u003c/div\u003e\\n                                            \u003c/div\u003e\\n                                        );\\n                                    })}\\n                                \u003c/div\u003e\\n                            )}\\n                        \u003c/\u003e\\n                    ) : (\\n                        \u003cdiv className=\\\"py-12 text-center text-muted-foreground text-sm\\\"\u003e{trendYear} 年尚無記錄\u003c/div\u003e\\n                    )}\\n                \u003c/div\u003e\\n            )}\\n        \u003c/div\u003e\\n    );\\n}\\n\",\"frontend/views/TransactionDetailView.tsx\":\"import { useState, useCallback } from 'react';\\nimport { useSelectedItemsStore, PluginTopbar } from '@cubelv/sdk';\\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\\nimport { TransactionForm } from '../components/TransactionForm';\\nimport { StatsView } from './StatsView';\\n\\nexport function TransactionDetailComponent() {\\n    const [showStats, setShowStats] = useState(false);\\n    const transaction = useSelectedItemsStore(s =\u003e\\n        s.getPathItemByItemType('INCOME_EXPENSE_TRANSACTION')\\n    ) as IncomeExpenseTransaction | null;\\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\\n\\n    const handleSaved = useCallback(() =\u003e {\\n    }, []);\\n\\n    if (showStats) {\\n        return (\\n            \u003cdiv className=\\\"flex flex-col h-full\\\"\u003e\\n                \u003cPluginTopbar\\n                    title=\\\"統計圖表\\\"\\n                    rightButtons={[{\\n                        icon: 'arrow-left',\\n                        onClick: () =\u003e setShowStats(false),\\n                        title: '返回記帳',\\n                    }]}\\n                /\u003e\\n                \u003cStatsView /\u003e\\n            \u003c/div\u003e\\n        );\\n    }\\n\\n    return (\\n        \u003cdiv className=\\\"flex flex-col h-full\\\"\u003e\\n            \u003cPluginTopbar\\n                title={transaction ? '編輯記錄' : '記錄詳情'}\\n                rightButtons={[{\\n                    icon: 'PieChart',\\n                    onClick: () =\u003e setShowStats(true),\\n                    title: '統計圖表',\\n                }]}\\n            /\u003e\\n            {!transaction ? (\\n                \u003cdiv className=\\\"flex-1 flex items-center justify-center text-muted-foreground text-sm p-6 text-center\\\"\u003e\\n                    點選左側記錄查看詳情\u003cbr /\u003e或按 + 新增記錄\\n                \u003c/div\u003e\\n            ) : (\\n                \u003cTransactionForm\\n                    key={transaction.id}\\n                    transaction={transaction}\\n                    folderId={folderId}\\n                    onSaved={handleSaved}\\n                /\u003e\\n            )}\\n        \u003c/div\u003e\\n    );\\n}\\n\",\"frontend/views/TransactionListView.tsx\":\"import { useMemo, useState, useCallback, useRef, useEffect } from 'react';\\nimport { createPortal } from 'react-dom';\\nimport {\\n    useItemsByType,\\n    useSelectedItemsStore,\\n    useItemStore,\\n    PluginTopbar,\\n    Dialog,\\n    DialogContent,\\n    DialogHeader,\\n    DialogTitle,\\n    DialogDescription,\\n    DialogFooter,\\n    Button,\\n    fetchExternal,\\n} from '@cubelv/sdk';\\nimport type { BaseItem } from '@cubelv/sdk';\\nimport { IncomeExpenseTransaction } from '../schemas/expenseTrackerSchema';\\nimport { AddTransactionDialog } from '../components/AddTransactionDialog';\\nimport { StatsView } from './StatsView';\\nimport { gregorianToLunar, ensureGovtHolidays, loadGovtHolidaysFromData, type BaziChart } from '../lunarCalendar';\\nimport { SettingsView } from './SettingsView';\\nimport { useIncomeExpenseConfig } from '../stores/expenseTrackerSectionConfigStore';\\nimport { useCalendarMode } from '../stores/calendarModeStore';\\n\\nfunction CopyIcon() {\\n    return (\\n        \u003csvg width=\\\"14\\\" height=\\\"14\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"none\\\" stroke=\\\"currentColor\\\" strokeWidth=\\\"2\\\" strokeLinecap=\\\"round\\\" strokeLinejoin=\\\"round\\\"\u003e\\n            \u003crect x=\\\"9\\\" y=\\\"9\\\" width=\\\"13\\\" height=\\\"13\\\" rx=\\\"2\\\" ry=\\\"2\\\" /\u003e\\n            \u003cpath d=\\\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\\\" /\u003e\\n        \u003c/svg\u003e\\n    );\\n}\\n\\nfunction formatNum(n: number): string {\\n    return n.toLocaleString();\\n}\\n\\nconst PROGRAM_INFO_TEXT = `📖 收入與支出 — 功能說明\\n\\n📋 版本紀錄\\nv1.4 (2026/08/02)：萬年曆固定高度不再跳動；點選日期顯示八字排盤（三柱、十神、藏干、納音）；新增分類長條圖與折線圖；年度總覽儀表板；每月預算進度條；定期記帳推播提醒；長條圖/折線圖與圓餅圖共用篩選條件；年份選單改為下拉式\\nv1.3 (2026/07/27)：新增記錄日期不再自動填今天，改為空白讓使用者自由選取；表單版面優化（日期與金額同一行併排、GPS 定位按鈕改為緊湊圖示內嵌於輸入框右側、整體欄位間距收緊）\\nv1.2 (2026/07/27)：地點欄位新增定位按鈕（📍），透過 IP 定位取得座標後自動反查地址並填入欄位；編輯模式地點有值時自動出現地圖按鈕（🗺️），點擊用 Google Maps 搜尋該地點\\nv1.1 (2026/07/26)：新增與編輯記錄支援大小類與對象管理；大小類、對象支援長按拖曳排序；圓餅圖統計收入與支出分類佔比\\nv1.0 (2026/07/26)：收入與支出初版：記錄每月開銷與收入，用圓餅圖統計分類與對象，支援收入／支出切換、自訂大小類與付款對象\\n\\n📅 萬年曆\\n全螢幕月曆檢視，整合農曆資訊與記帳記錄。\\n・固定高度 30rem，切換月份不跳動，網格各行等高等寬\\n・支援左右滑動換月，點選日期篩選當日記錄\\n・顯示農曆日期（初一～三十）、天干地支、二十四節氣\\n・顯示農曆節日（春節、端午、中秋等）與國定假日\\n・點選日期顯示八字排盤：年柱/月柱/日柱、十神、藏干、納音\\n\\n📝 記帳記錄\\n記錄每一筆收入與支出，支援完整欄位填寫與管理。\\n・收支類型：切換收入或支出，按鈕以藍色（收）／紅色（支）區分\\n・日期：手動選擇任意日期，補記過去消費不受限\\n・大小類：兩層分類（大類 → 小類），支援自訂與拖曳排序\\n・金額：輸入數字，自動計算當月收支合計與結餘\\n・對象：付款或收款對象，支援自訂與拖曳排序\\n・地點：輸入地址或店名，或點擊 📍 按鈕自動定位目前地址\\n・備注：自由輸入備注文字\\n・編輯與刪除：點選記錄進入編輯模式，可修改所有欄位或刪除記錄\\n・多選刪除：勾選多筆後一次刪除\\n\\n📊 統計圖表\\n四種圖表類型視覺化呈現收支分佈與趨勢。\\n・圓餅圖：收支分類佔比，支援大類/小類/對象切換\\n・長條圖：分時段（日/月）堆疊顯示各分類收支金額變化\\n・折線圖：各分類收支趨勢線，一目瞭然消長走勢\\n・年度總覽：全年收支合計、各月明細表、前五大支出類別\\n・期間選擇：本週/本月/今年/全部/自訂區間\\n・分類切換：大類/小類/對象三種維度\\n\\n💰 每月預算\\n設定支出預算上限，即時掌握消費節奏。\\n・於月曆設定中設定每月預算金額\\n・記帳本上方顯示預算進度條，綠色→黃→橙→紅四階段\\n・即時顯示已用比例與剩餘/超支金額\\n\\n🔔 記帳提醒\\n未記帳時自動發送推播通知，不怕忘記。\\n・支援每日或每週提醒模式\\n・可自訂提醒時間（每日）或星期幾（每週）\\n・當日已有記錄時自動跳過，不重複打擾\\n\\n🔄 大小類與對象管理\\n支援拖曳排序的管理介面，讓分類與對象清單保持整潔。\\n・大類：新增／編輯／刪除／拖曳排序\\n・小類：掛在大類下，新增／編輯／刪除／拖曳排序\\n・對象：收入對象與支出對象分開管理，支援拖曳排序\\n・設定即時儲存，所有記錄自動反映最新分類\\n\\n📍 地點定位\\n點擊地點欄位旁的 📍 按鈕即可自動取得目前地址。\\n・輸入文字時搜尋該地點地址（支援 Google Maps 或 Nominatim）\\n・空白時使用 GPS 取得經緯度並自動反查繁體中文地址\\n・定位失敗時顯示錯誤提示\\n・編輯模式：地點有值時出現 🗺️ 按鈕，點擊用 Google Maps 搜尋\\n\\n📡 資料來源\\n地點定位：瀏覽器 GPS + Google Maps API 或 OpenStreetMap Nominatim。國定假日：人事行政總處公告資料。記帳資料全部儲存在 CubeLV 本機，不經第三方服務。`;\\n\\n\\n\\nexport function TransactionListComponent() {\\n    const folderId = useSelectedItemsStore(s =\u003e s.lastFolderId);\\n    const items = useItemsByType\u003cIncomeExpenseTransaction\u003e('INCOME_EXPENSE_TRANSACTION', folderId || undefined);\\n    const selectedId = useSelectedItemsStore(s =\u003e {\\n        const tx = s.getPathItemByItemType('INCOME_EXPENSE_TRANSACTION');\\n        return tx?.id || null;\\n    });\\n\\n    const now = new Date();\\n    const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;\\n    const [filterMonth, setFilterMonth] = useState(currentMonth);\\n    const [showAddDialog, setShowAddDialog] = useState(false);\\n    const [showStats, setShowStats] = useState(false);\\n    const [showProgramInfo, setShowProgramInfo] = useState(false);\\n    const [copyDone, setCopyDone] = useState(false);\\n    const [showDeleteConfirm, setShowDeleteConfirm] = useState\u003c'all' | 'selected' | null\u003e(null);\\n    const [deleting, setDeleting] = useState(false);\\n    const [selectedIds, setSelectedIds] = useState\u003cSet\u003cstring\u003e\u003e(new Set());\\n    const selectedIdsRef = useRef(selectedIds);\\n    selectedIdsRef.current = selectedIds;\\n    const touchStartX = useRef(0);\\n    const monthInputRef = useRef\u003cHTMLInputElement\u003e(null);\\n    const { showCalendar, toggleCalendar, setShowCalendar } = useCalendarMode();\\n    const [filterDate, setFilterDate] = useState\u003cstring | null\u003e(null); // 特定日期篩選 \\\"YYYY-MM-DD\\\"\\n    const [showSettings, setShowSettings] = useState(false);\\n    const config = useIncomeExpenseConfig();\\n\\n    // 選中日期的農曆資料（含八字排盤）\\n    const selectedLunar = useMemo(() =\u003e {\\n        if (!filterDate) return null;\\n        const [y, m, d] = filterDate.split('-').map(Number);\\n        return gregorianToLunar(y, m, d);\\n    }, [filterDate]);\\n\\n    // 從已儲存的 config 載入國定假日（不用每次都重新下載）\\n    useEffect(() =\u003e {\\n        if (config?.govtHolidayData \u0026\u0026 Object.keys(config.govtHolidayData).length \u003e 0) {\\n            loadGovtHolidaysFromData(config.govtHolidayData);\\n        }\\n    }, [config?.govtHolidayData]);\\n\\n\\n\\n\\n    const sorted = useMemo(() =\u003e {\\n        let filtered = items;\\n        if (filterDate) {\\n            filtered = items.filter(item =\u003e item.date === filterDate);\\n        } else if (filterMonth) {\\n            filtered = items.filter(item =\u003e item.date \u0026\u0026 item.date.startsWith(filterMonth));\\n        }\\n        return [...filtered].sort((a, b) =\u003e (b.date || '').localeCompare(a.date || ''));\\n    }, [items, filterMonth, filterDate]);\\n\\n    const incomeTotal = useMemo(() =\u003e\\n        sorted.filter(i =\u003e i.type === 'income').reduce((s, i) =\u003e s + (i.amount || 0), 0),\\n        [sorted]\\n    );\\n    const expenseTotal = useMemo(() =\u003e\\n        sorted.filter(i =\u003e i.type === 'expense').reduce((s, i) =\u003e s + (i.amount || 0), 0),\\n        [sorted]\\n    );\\n\\n    // 整月支出（不受 filterDate 影響，供預算條使用）\\n    const fullMonthExpense = useMemo(() =\u003e\\n        items.filter(i =\u003e i.type === 'expense' \u0026\u0026 i.date \u0026\u0026 i.date.startsWith(filterMonth)).reduce((s, i) =\u003e s + (i.amount || 0), 0),\\n        [items, filterMonth]\\n    );\\n\\n    // ===== 萬年曆：依日期分組（全部記錄，不限月份）=====\\n    const txByDate = useMemo(() =\u003e {\\n        const map = new Map\u003cstring, { income: number; expense: number }\u003e();\\n        items.forEach(t =\u003e {\\n            if (!t.date) return;\\n            const entry = map.get(t.date) || { income: 0, expense: 0 };\\n            if (t.type === 'income') entry.income++;\\n            else entry.expense++;\\n            map.set(t.date, entry);\\n        });\\n        return map;\\n    }, [items]);\\n\\n    // 月曆運算\\n    const calYearMonth = useMemo(() =\u003e {\\n        const parts = filterMonth.split('-');\\n        return { year: parseInt(parts[0], 10), month: parseInt(parts[1], 10) };\\n    }, [filterMonth]);\\n\\n    const calendarGrid = useMemo(() =\u003e {\\n        const { year, month } = calYearMonth;\\n        const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun\\n        const daysInMonth = new Date(year, month, 0).getDate();\\n        const today = new Date();\\n        const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;\\n\\n        const cells: Array\u003c{ day: number | null; dateStr: string | null; isToday: boolean; isSelected: boolean; income: number; expense: number }\u003e = [];\\n\\n        // 填充月初空白\\n        for (let i = 0; i \u003c firstDay; i++) {\\n            cells.push({ day: null, dateStr: null, isToday: false, isSelected: false, income: 0, expense: 0 });\\n        }\\n        // 填充日期\\n        for (let d = 1; d \u003c= daysInMonth; d++) {\\n            const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;\\n            const dayData = txByDate.get(dateStr);\\n            cells.push({\\n                day: d,\\n                dateStr,\\n                isToday: dateStr === todayStr,\\n                isSelected: dateStr === filterDate,\\n                income: dayData?.income || 0,\\n                expense: dayData?.expense || 0,\\n            });\\n        }\\n        return cells;\\n    }, [calYearMonth, txByDate, filterDate]);\\n\\n    const navigateMonth = (delta: number) =\u003e {\\n        let { year, month } = calYearMonth;\\n        month += delta;\\n        if (month \u003c 1) { month = 12; year--; }\\n        if (month \u003e 12) { month = 1; year++; }\\n        setFilterMonth(`${year}-${String(month).padStart(2, '0')}`);\\n        setFilterDate(null);\\n    };\\n\\n    const handleCopyProgramInfo = useCallback(async () =\u003e {\\n        try {\\n            await navigator.clipboard.writeText(PROGRAM_INFO_TEXT);\\n        } catch (_e) {\\n            // fallback: 用 textarea 複製\\n            const ta = document.createElement('textarea');\\n            ta.value = PROGRAM_INFO_TEXT;\\n            ta.style.position = 'fixed';\\n            ta.style.left = '-9999px';\\n            document.body.appendChild(ta);\\n            ta.select();\\n            document.execCommand('copy');\\n            document.body.removeChild(ta);\\n        }\\n        setCopyDone(true);\\n        setTimeout(() =\u003e setCopyDone(false), 2000);\\n    }, []);\\n\\n    // ===== 多選邏輯 =====\\n    const toggleSelect = (id: string) =\u003e {\\n        setSelectedIds(prev =\u003e {\\n            const next = new Set(prev);\\n            if (next.has(id)) next.delete(id); else next.add(id);\\n            return next;\\n        });\\n    };\\n\\n    const toggleSelectAll = () =\u003e {\\n        setSelectedIds(prev =\u003e {\\n            if (prev.size === sorted.length \u0026\u0026 sorted.length \u003e 0) {\\n                return new Set\u003cstring\u003e();\\n            }\\n            return new Set(sorted.map(i =\u003e i.id));\\n        });\\n    };\\n\\n    const allSelected = sorted.length \u003e 0 \u0026\u0026 selectedIds.size === sorted.length;\\n\\n    const handleDeleteSelected = async () =\u003e {\\n        setDeleting(true);\\n        try {\\n            const ids = Array.from(selectedIdsRef.current);\\n            if (ids.length === 0) return;\\n            await useItemStore.getState().removeItems(ids, { needSync: true });\\n            setSelectedIds(new Set());\\n            setShowDeleteConfirm(null);\\n        } catch (_e) {\\n            // ignore\\n        } finally {\\n            setDeleting(false);\\n        }\\n    };\\n\\n    const handleDeleteAll = useCallback(async () =\u003e {\\n        if (!folderId) return;\\n        setDeleting(true);\\n        try {\\n            const ids = sorted.map(i =\u003e i.id);\\n            if (ids.length === 0) { setShowDeleteConfirm(null); setDeleting(false); return; }\\n            await useItemStore.getState().removeItems(ids, { needSync: true });\\n            setShowDeleteConfirm(null);\\n            setSelectedIds(new Set());\\n        } catch (_e) {\\n            // 刪除失敗不處理，關閉對話框\\n        } finally {\\n            setDeleting(false);\\n        }\\n    }, [folderId, sorted]);\\n\\n    if (showStats) {\\n        return (\\n            \u003cdiv className=\\\"flex flex-col h-full\\\"\u003e\\n                \u003cPluginTopbar\\n                    title=\\\"統計圖表\\\"\\n                    rightButtons={[{\\n                        icon: 'arrow-left',\\n                        onClick: () =\u003e setShowStats(false),\\n                        title: '返回記帳',\\n                    }]}\\n                /\u003e\\n                \u003cStatsView /\u003e\\n            \u003c/div\u003e\\n        );\\n    }\\n\\n    return (\\n        \u003cdiv className=\\\"flex flex-col h-full w-full bg-background text-foreground overflow-hidden\\\"\u003e\\n            \u003cPluginTopbar\\n                title=\\\"記帳本\\\"\\n                rightButtons={[\\n                    {\\n                        icon: 'calendar' as const,\\n                        onClick: () =\u003e { toggleCalendar(); setFilterDate(null); },\\n                        title: showCalendar ? '關閉月曆' : '月曆檢視',\\n                    },\\n                    {\\n                        icon: 'info',\\n                        onClick: () =\u003e setShowProgramInfo(true),\\n                        title: '功能說明',\\n                    },\\n                    {\\n                        icon: 'PieChart',\\n                        onClick: () =\u003e setShowStats(true),\\n                        title: '統計圖表',\\n                    },\\n                    {\\n                        icon: 'plus',\\n                        onClick: () =\u003e setShowAddDialog(true),\\n                        title: '新增記錄',\\n                    },\\n                    {\\n                        icon: 'settings' as const,\\n                        onClick: () =\u003e setShowSettings(true),\\n                        title: '月曆顯示設定',\\n                    },\\n                ]}\\n            /\u003e\\n            {/* 統計列：單行 — 左控制項 + 中（統計 / 刪除 互換） */}\\n            \u003cdiv className=\\\"flex items-center px-3 py-1.5 border-b border-border bg-muted/30 overflow-hidden gap-1.5\\\"\u003e\\n                \u003clabel className={`flex items-center gap-0.5 flex-shrink-0 ${sorted.length \u003e 0 ? 'cursor-pointer' : 'opacity-0 pointer-events-none'}`}\u003e\\n                    \u003cinput\\n                        type=\\\"checkbox\\\"\\n                        checked={allSelected}\\n                        onChange={toggleSelectAll}\\n                        disabled={sorted.length === 0}\\n                        className=\\\"w-3.5 h-3.5 rounded accent-primary cursor-pointer\\\"\\n                    /\u003e\\n                    \u003cspan className=\\\"text-[9px] text-muted-foreground whitespace-nowrap\\\"\u003e全選\u003c/span\u003e\\n                \u003c/label\u003e\\n                \u003cbutton\\n                    type=\\\"button\\\"\\n                    className=\\\"w-[65px] h-6.5 rounded border border-input bg-transparent px-1.5 text-[11px] flex-shrink-0 cursor-pointer tabular-nums whitespace-nowrap text-center\\\"\\n                    onClick={() =\u003e monthInputRef.current?.showPicker()}\\n                \u003e\\n                    {filterMonth.replace('-', '/')}\\n                \u003c/button\u003e\\n                \u003cinput\\n                    ref={monthInputRef}\\n                    type=\\\"month\\\"\\n                    value={filterMonth}\\n                    onChange={e =\u003e setFilterMonth(e.target.value)}\\n                    className=\\\"sr-only\\\"\\n                /\u003e\\n                {/* 中間區域：未選取時顯示三等份統計，已選取時顯示刪除按鈕 */}\\n                \u003cdiv className=\\\"flex-1 min-w-0\\\"\u003e\\n                    {selectedIds.size \u003e 0 ? (\\n                        \u003cbutton\\n                            type=\\\"button\\\"\\n                            className=\\\"w-full text-[11px] text-destructive font-semibold cursor-pointer text-center py-0.5\\\"\\n                            onClick={() =\u003e setShowDeleteConfirm('selected')}\\n                        \u003e\\n                            🗑 刪除所選 ({selectedIds.size})\\n                        \u003c/button\u003e\\n                    ) : (\\n                        \u003cdiv className=\\\"grid grid-cols-3 divide-x divide-border/40\\\"\u003e\\n                            \u003cdiv className=\\\"flex flex-col text-center px-1 min-w-0 overflow-hidden\\\"\u003e\\n                                \u003cspan className=\\\"text-[9px] text-[#22C55E] font-medium\\\"\u003e收入\u003c/span\u003e\\n                                \u003cspan className=\\\"text-[11px] text-[#22C55E] font-bold tabular-nums truncate w-full block\\\"\u003e{formatNum(incomeTotal)}\u003c/span\u003e\\n                            \u003c/div\u003e\\n                            \u003cdiv className=\\\"flex flex-col text-center px-1 min-w-0 overflow-hidden\\\"\u003e\\n                                \u003cspan className=\\\"text-[9px] text-[#FF6384] font-medium\\\"\u003e支出\u003c/span\u003e\\n                                \u003cspan className=\\\"text-[11px] text-[#FF6384] font-bold tabular-nums truncate w-full block\\\"\u003e{formatNum(expenseTotal)}\u003c/span\u003e\\n                            \u003c/div\u003e\\n                            \u003cdiv className=\\\"flex flex-col text-center px-1 min-w-0 overflow-hidden\\\"\u003e\\n                                \u003cspan className=\\\"text-[9px] text-muted-foreground font-medium\\\"\u003e結餘\u003c/span\u003e\\n                                \u003cspan className={`text-[11px] font-bold tabular-nums truncate w-full block ${(() =\u003e {\\n                                    const balance = incomeTotal - expenseTotal;\\n                                    if (balance \u003e 0) return 'text-[#22C55E]';\\n                                    if (balance \u003c 0) return 'text-[#FF6384]';\\n                                    return 'text-muted-foreground';\\n                                })()}`}\u003e\\n                                    {(() =\u003e {\\n                                        const balance = incomeTotal - expenseTotal;\\n                                        return balance === 0 ? '0' : `${balance \u003e 0 ? '+' : ''}${formatNum(balance)}`;\\n                                    })()}\\n                                \u003c/span\u003e\\n                            \u003c/div\u003e\\n                        \u003c/div\u003e\\n                    )}\\n                \u003c/div\u003e\\n            \u003c/div\u003e\\n\\n            {/* 每月預算進度條 */}\\n            {config?.monthlyBudget \u003e 0 \u0026\u0026 (\\n                \u003cdiv className=\\\"px-3 py-1 border-b border-border bg-muted/20\\\"\u003e\\n                    {(() =\u003e {\\n                        const pct = Math.min(fullMonthExpense / config.monthlyBudget * 100, 100);\\n                        const barColor = pct \u003c 50 ? 'bg-[#22C55E]' : pct \u003c 80 ? 'bg-[#F59E0B]' : pct \u003c 100 ? 'bg-[#F97316]' : 'bg-[#FF6384]';\\n                        const remaining = config.monthlyBudget - fullMonthExpense;\\n                        return (\\n                            \u003cdiv\u003e\\n                                \u003cdiv className=\\\"flex items-center justify-between mb-0.5\\\"\u003e\\n                                    \u003cspan className=\\\"text-[10px] text-muted-foreground\\\"\u003e\\n                                        本月預算 {formatNum(config.monthlyBudget)} · 已用 {pct.toFixed(0)}%\\n                                    \u003c/span\u003e\\n                                    \u003cspan className={`text-[10px] font-medium ${remaining \u003e= 0 ? 'text-[#22C55E]' : 'text-[#FF6384]'}`}\u003e\\n                                        {remaining \u003e= 0 ? `剩餘 ${formatNum(remaining)}` : `超支 ${formatNum(Math.abs(remaining))}`}\\n                                    \u003c/span\u003e\\n                                \u003c/div\u003e\\n                                \u003cdiv className=\\\"w-full h-1.5 rounded-full bg-muted overflow-hidden\\\"\u003e\\n                                    \u003cdiv className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${pct}%` }} /\u003e\\n                                \u003c/div\u003e\\n                            \u003c/div\u003e\\n                        );\\n                    })()}\\n                \u003c/div\u003e\\n            )}\\n\\n            {/* 萬年曆 — createPortal 到 body，真正滿版不受任何 pane/CSS 限制 */}\\n            {showCalendar \u0026\u0026 createPortal(\\n                \u003cdiv className=\\\"fixed inset-0 z-[99999] bg-background flex flex-col\\\"\\n                    style={{ paddingTop: 'max(env(safe-area-inset-top), 8px)', paddingBottom: 'max(env(safe-area-inset-bottom), 8px)' }}\u003e\\n                    {/* 月份導航列 */}\\n                    \u003cdiv className=\\\"flex items-center justify-between px-3 py-1.5 bg-muted/30 border-b border-border flex-shrink-0\\\"\u003e\\n                        \u003cbutton type=\\\"button\\\" className=\\\"text-sm font-semibold text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\\\" onClick={() =\u003e { setShowCalendar(false); setFilterDate(null); }}\u003e\\n                            ✕ 關閉\\n                        \u003c/button\u003e\\n                        \u003cspan className=\\\"text-sm font-bold text-foreground\\\"\u003e\\n                            {calYearMonth.year} 年 {calYearMonth.month} 月\\n                        \u003c/span\u003e\\n                        \u003cdiv className=\\\"flex items-center gap-0.5\\\"\u003e\\n                            \u003cbutton type=\\\"button\\\" className=\\\"text-lg text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\\\" onClick={() =\u003e navigateMonth(-1)}\u003e\\n                                ◀\\n                            \u003c/button\u003e\\n                            \u003cbutton type=\\\"button\\\" className=\\\"text-lg text-foreground px-2.5 py-1.5 rounded hover:bg-accent cursor-pointer\\\" onClick={() =\u003e navigateMonth(1)}\u003e\\n                                ▶\\n                            \u003c/button\u003e\\n                        \u003c/div\u003e\\n                    \u003c/div\u003e\\n                    {/* 星期標頭 */}\\n                    \u003cdiv className=\\\"grid grid-cols-7 text-xs text-foreground/65 text-center py-1.5 border-b border-border/30 flex-shrink-0\\\"\u003e\\n                        {['日', '一', '二', '三', '四', '五', '六'].map(d =\u003e (\\n                            \u003cdiv key={d} className=\\\"py-0.5 font-semibold\\\"\u003e{d}\u003c/div\u003e\\n                        ))}\\n                    \u003c/div\u003e\\n                    {/* 日期網格 — 固定高度 30rem（6 行 × 5rem），切換月份不跳動 */}\\n                    \u003cdiv\\n                        className=\\\"grid grid-cols-7 flex-shrink-0\\\"\\n                        style={{ gridAutoRows: '1fr', height: '30rem' }}\\n                        onTouchStart={(e) =\u003e { touchStartX.current = e.touches[0].clientX; }}\\n                        onTouchEnd={(e) =\u003e {\\n                            const dx = e.changedTouches[0].clientX - touchStartX.current;\\n                            if (Math.abs(dx) \u003e 60) {\\n                                navigateMonth(dx \u003e 0 ? -1 : 1);\\n                            }\\n                        }}\\n                    \u003e\\n                        {calendarGrid.map((cell, i) =\u003e {\\n                            const total = cell.income + cell.expense;\\n                            const incomeDots = cell.income \u003e 0 ? Math.min(cell.income, 2) : 0;\\n                            const expenseDots = cell.expense \u003e 0 ? Math.min(cell.expense, 2) : 0;\\n                            return (\\n                                \u003cbutton\\n                                    key={i}\\n                                    type=\\\"button\\\"\\n                                    className={`relative flex flex-col items-start justify-start p-1.5 cursor-pointer transition-colors border-r border-b border-border/30 ${\\n                                        !cell.day ? 'pointer-events-none bg-muted/10' :\\n                                        cell.isSelected ? 'bg-primary/5' :\\n                                        cell.isToday ? 'bg-accent/30' : 'hover:bg-accent/10'\\n                                    }`}\\n                                    onClick={() =\u003e {\\n                                        if (cell.dateStr) {\\n                                            if (cell.dateStr === filterDate) {\\n                                                setFilterDate(null);\\n                                            } else {\\n                                                setFilterDate(cell.dateStr);\\n                                            }\\n                                        }\\n                                    }}\\n                                    disabled={!cell.day}\\n                                \u003e\\n                                    {cell.day \u0026\u0026 (() =\u003e {\\n                                        const lunar = gregorianToLunar(calYearMonth.year, calYearMonth.month, cell.day);\\n                                        const isSolarTerm = !!lunar.solarTerm \u0026\u0026 config?.showSolarTerm !== false;\\n                                        const showLunarDate = config?.showLunarDate !== false;\\n                                        const showGanZhi = config?.showGanZhi !== false;\\n                                        const showFestival = config?.showLunarFestival !== false \u0026\u0026 !!lunar.festival;\\n                                        const showNational = config?.showNationalHoliday !== false \u0026\u0026 !!lunar.nationalHoliday;\\n                                        const isLunarSpecial = lunar.day === 1 || lunar.day === 15;\\n                                        const lunarFullDate = lunar.monthName + lunar.dayName;\\n                                        return (\\n                                        \u003c\u003e\\n                                            \u003cspan className={`text-base tabular-nums leading-none font-bold ${\\n                                                cell.isToday\\n                                                    ? 'text-primary bg-primary/10 rounded-full px-1.5 py-0.5 inline-block'\\n                                                    : cell.isSelected\\n                                                        ? 'text-primary'\\n                                                        : 'text-foreground'\\n                                            }`}\u003e\\n                                                {cell.day}\\n                                            \u003c/span\u003e\\n                                            {/* 第一行：農曆日期（含月份） */}\\n                                            {showLunarDate \u0026\u0026 (\\n                                                \u003cspan className={`text-[11px] leading-tight tabular-nums w-full mt-0.5 ${\\n                                                    isLunarSpecial ? 'text-amber-300 font-semibold' : 'text-slate-200'\\n                                                }`}\u003e\\n                                                    {lunarFullDate}\\n                                                \u003c/span\u003e\\n                                            )}\\n                                            {/* 第二行：天干地支 */}\\n                                            {showGanZhi \u0026\u0026 (\\n                                                \u003cspan className=\\\"text-[11px] leading-tight tabular-nums w-full mt-0.5 text-slate-400\\\"\u003e\\n                                                    {lunar.dayGanZhi}\\n                                                \u003c/span\u003e\\n                                            )}\\n                                            {/* 第三行：節氣 */}\\n                                            {isSolarTerm \u0026\u0026 (\\n                                                \u003cspan className=\\\"text-[11px] leading-tight text-emerald-300 font-semibold w-full mt-0.5\\\"\u003e\\n                                                    {lunar.solarTerm}\\n                                                \u003c/span\u003e\\n                                            )}\\n                                            {/* 第四行：農曆節日 */}\\n                                            {showFestival \u0026\u0026 (\\n                                                \u003cspan className=\\\"text-[11px] leading-tight text-amber-300 font-semibold w-full mt-0.5\\\"\u003e\\n                                                    {lunar.festival}\\n                                                \u003c/span\u003e\\n                                            )}\\n                                            {/* 第五行：人事行政局國定假日 */}\\n                                            {showNational \u0026\u0026 (\\n                                                \u003cspan className=\\\"text-[11px] leading-tight text-red-400 font-semibold w-full mt-0.5\\\"\u003e\\n                                                    {lunar.nationalHoliday}\\n                                                \u003c/span\u003e\\n                                            )}\\n                                            {total \u003e 0 \u0026\u0026 (\\n                                                \u003cdiv className=\\\"flex items-center gap-0.5 mt-auto mb-1\\\"\u003e\\n                                                    {Array.from({ length: incomeDots }).map((_, j) =\u003e (\\n                                                        \u003cspan key={`i-${j}`} className=\\\"w-3.5 h-3.5 rounded-full bg-[#36A2EB]\\\" /\u003e\\n                                                    ))}\\n                                                    {Array.from({ length: expenseDots }).map((_, j) =\u003e (\\n                                                        \u003cspan key={`e-${j}`} className=\\\"w-3.5 h-3.5 rounded-full bg-[#FF6384]\\\" /\u003e\\n                                                    ))}\\n                                                    {total \u003e incomeDots + expenseDots \u0026\u0026 (\\n                                                        \u003cspan className=\\\"text-[11px] text-foreground/85 leading-none\\\"\u003e+{total - incomeDots - expenseDots}\u003c/span\u003e\\n                                                    )}\\n                                                \u003c/div\u003e\\n                                            )}\\n                                        \u003c/\u003e\\n                                        );\\n                                    })()}\\n                                \u003c/button\u003e\\n                            );\\n                        })}\\n                    \u003c/div\u003e\\n                    {/* 篩選提示 + 當日記錄 */}\\n                    {filterDate ? (\\n                        \u003cdiv className=\\\"border-t border-border flex-1 overflow-y-auto\\\" style={{ minHeight: 0 }}\u003e\\n                            \u003cdiv className=\\\"flex items-center justify-between px-3 py-1.5 bg-primary/5 border-b border-border/30\\\"\u003e\\n                                \u003cspan className=\\\"text-xs text-primary font-medium\\\"\u003e📅 {filterDate} 的記錄\u003c/span\u003e\\n                                \u003cbutton\\n                                    type=\\\"button\\\"\\n                                    className=\\\"text-xs text-primary underline cursor-pointer\\\"\\n                                    onClick={() =\u003e setFilterDate(null)}\\n                                \u003e\\n                                    顯示整月\\n                                \u003c/button\u003e\\n                            \u003c/div\u003e\\n                            {/* 八字排盤 */}\\n                            {config?.showBazi === true \u0026\u0026 selectedLunar?.bazi \u0026\u0026 (\\n                                \u003cdiv className=\\\"px-3 py-2 border-b border-border/30 bg-muted/10\\\"\u003e\\n                                    \u003cdiv className=\\\"text-[10px] text-muted-foreground mb-1.5 font-medium\\\"\u003e🔮 八字排盤\u003c/div\u003e\\n                                    \u003cdiv className=\\\"grid grid-cols-3 gap-2 text-center\\\"\u003e\\n                                        {([\\n                                            { label: '年柱', p: selectedLunar.bazi.yearPillar, g: selectedLunar.bazi.yearTenGod, h: selectedLunar.bazi.yearHidden, n: selectedLunar.bazi.yearNayin },\\n                                            { label: '月柱', p: selectedLunar.bazi.monthPillar, g: selectedLunar.bazi.monthTenGod, h: selectedLunar.bazi.monthHidden, n: selectedLunar.bazi.monthNayin },\\n                                            { label: '日柱', p: selectedLunar.bazi.dayPillar, g: selectedLunar.bazi.dayTenGod, h: selectedLunar.bazi.dayHidden, n: selectedLunar.bazi.dayNayin },\\n                                        ] as const).map((col, i) =\u003e (\\n                                            \u003cdiv key={i} className=\\\"flex flex-col items-center gap-0.5\\\"\u003e\\n                                                \u003cdiv className=\\\"text-[10px] text-muted-foreground\\\"\u003e{col.label}\u003c/div\u003e\\n                                                \u003cdiv className=\\\"text-sm font-bold text-foreground\\\"\u003e{col.p}\u003c/div\u003e\\n                                                \u003cdiv className=\\\"text-[10px] text-amber-400\\\"\u003e{col.g}\u003c/div\u003e\\n                                                \u003cdiv className=\\\"text-[10px] text-slate-400\\\"\u003e{col.h.join(' ')}\u003c/div\u003e\\n                                                \u003cdiv className=\\\"text-[10px] text-emerald-300/70\\\"\u003e{col.n}\u003c/div\u003e\\n                                            \u003c/div\u003e\\n                                        ))}\\n                                    \u003c/div\u003e\\n                                \u003c/div\u003e\\n                            )}\\n                            {sorted.filter(t =\u003e t.date === filterDate).length === 0 ? (\\n                                \u003cdiv className=\\\"py-8 text-center text-muted-foreground text-sm\\\"\u003e當日尚無記錄\u003c/div\u003e\\n                            ) : (\\n                                sorted.filter(t =\u003e t.date === filterDate).map(item =\u003e (\\n                                    \u003cdiv\\n                                        key={item.id}\\n                                        className=\\\"flex items-center gap-3 px-3 py-2.5 border-b border-border/30 cursor-pointer hover:bg-accent/50\\\"\\n                                        onClick={() =\u003e {\\n                                            const tx = item;\\n                                            setShowCalendar(false);\\n                                            setFilterDate(null);\\n                                            setTimeout(() =\u003e {\\n                                                useSelectedItemsStore.getState().setSelectedItems([tx as unknown as BaseItem]);\\n                                            }, 150);\\n                                        }}\\n                                    \u003e\\n                                        \u003cdiv className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold flex-shrink-0 ${\\n                                            item.type === 'income' ? 'bg-blue-50 text-[#36A2EB]' : 'bg-red-50 text-[#FF6384]'\\n                                        }`}\u003e\\n                                            {item.type === 'income' ? '收' : '支'}\\n                                        \u003c/div\u003e\\n                                        \u003cdiv className=\\\"flex-1 min-w-0\\\"\u003e\\n                                            \u003cdiv className=\\\"text-sm font-medium truncate\\\"\u003e\\n                                                {item.majorCategory}{item.subCategory ? ` / ${item.subCategory}` : ''}\\n                                            \u003c/div\u003e\\n                                            \u003cdiv className=\\\"text-xs text-muted-foreground truncate\\\"\u003e\\n                                                {item.payee || ''}{item.location ? ` · ${item.location}` : ''}{item.note ? ` · ${item.note}` : ''}\\n                                            \u003c/div\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cdiv className={`text-sm font-bold whitespace-nowrap ${\\n                                            item.type === 'income' ? 'text-[#36A2EB]' : 'text-[#FF6384]'\\n                                        }`}\u003e\\n                                            {item.type === 'income' ? '+' : '-'}{formatNum(item.amount || 0)}\\n                                        \u003c/div\u003e\\n                                    \u003c/div\u003e\\n                                ))\\n                            )}\\n                        \u003c/div\u003e\\n                    ) : null}\\n                \u003c/div\u003e,\\n                document.body\\n            )}\\n\\n            {/* 交易記錄列表 — 行事曆關閉時填滿空間 */}\\n            \u003cdiv className=\\\"flex-1 min-h-0 overflow-y-auto\\\"\u003e\\n                {!folderId \u0026\u0026 (\\n                    \u003cdiv className=\\\"flex-1 flex items-center justify-center py-12 px-6 text-muted-foreground text-sm text-center\\\"\u003e\\n                        請從左側選擇記帳本\\n                    \u003c/div\u003e\\n                )}\\n                {folderId \u0026\u0026 sorted.length === 0 \u0026\u0026 (\\n                    \u003cdiv className=\\\"flex flex-col items-center gap-3 py-12 px-6 text-muted-foreground text-sm text-center\\\"\u003e\\n                        \u003cdiv\u003e尚無記錄，請點選右上角 + 新增\u003c/div\u003e\\n                        \u003cbutton\\n                            type=\\\"button\\\"\\n                            className=\\\"text-primary underline cursor-pointer\\\"\\n                            onClick={() =\u003e setShowAddDialog(true)}\\n                        \u003e\\n                            新增第一筆記錄\\n                        \u003c/button\u003e\\n                    \u003c/div\u003e\\n                )}\\n                {folderId \u0026\u0026 sorted.length \u003e 0 \u0026\u0026 sorted.map(item =\u003e {\\n                    const isChecked = selectedIds.has(item.id);\\n                    return (\\n                    \u003cdiv\\n                        key={item.id}\\n                        className={`flex items-center gap-2 px-3 py-2.5 border-b border-border transition-colors ${\\n                            selectedId === item.id ? 'bg-accent' : 'hover:bg-accent/50'\\n                        }`}\\n                    \u003e\\n                        {/* 核取方塊 */}\\n                        \u003clabel className=\\\"flex-shrink-0 cursor-pointer\\\" onClick={e =\u003e e.stopPropagation()}\u003e\\n                            \u003cinput\\n                                type=\\\"checkbox\\\"\\n                                checked={isChecked}\\n                                onChange={() =\u003e toggleSelect(item.id)}\\n                                className=\\\"w-4 h-4 rounded accent-primary cursor-pointer\\\"\\n                            /\u003e\\n                        \u003c/label\u003e\\n                        {/* 內容區（可點擊進入編輯） */}\\n                        \u003cdiv\\n                            className=\\\"flex items-center gap-3 flex-1 min-w-0 cursor-pointer\\\"\\n                            onClick={() =\u003e {\\n                                useSelectedItemsStore.getState().setSelectedItems([item as unknown as BaseItem]);\\n                            }}\\n                        \u003e\\n                            \u003cdiv className={`w-9 h-9 rounded-lg flex items-center justify-center text-sm font-bold flex-shrink-0 ${\\n                                item.type === 'income'\\n                                    ? 'bg-blue-50 text-[#36A2EB]'\\n                                    : 'bg-red-50 text-[#FF6384]'\\n                            }`}\u003e\\n                                {item.type === 'income' ? '收' : '支'}\\n                            \u003c/div\u003e\\n                            \u003cdiv className=\\\"flex-1 min-w-0\\\"\u003e\\n                                \u003cdiv className=\\\"text-sm font-medium truncate\\\"\u003e\\n                                    {item.majorCategory}{item.subCategory ? ` / ${item.subCategory}` : ''}\\n                                \u003c/div\u003e\\n                                \u003cdiv className=\\\"text-xs text-muted-foreground truncate\\\"\u003e\\n                                    {item.date}{item.payee ? ` · ${item.payee}` : ''}{item.location ? ` · ${item.location}` : ''}{item.note ? ` · ${item.note}` : ''}\\n                                \u003c/div\u003e\\n                            \u003c/div\u003e\\n                            \u003cdiv className={`text-sm font-bold whitespace-nowrap ${\\n                                item.type === 'income' ? 'text-[#36A2EB]' : 'text-[#FF6384]'\\n                            }`}\u003e\\n                                {item.type === 'income' ? '+' : '-'}{formatNum(item.amount || 0)}\\n                            \u003c/div\u003e\\n                        \u003c/div\u003e\\n                    \u003c/div\u003e\\n                    );\\n                })}\\n            \u003c/div\u003e\\n\\n            {showAddDialog \u0026\u0026 folderId \u0026\u0026 (\\n                \u003cAddTransactionDialog\\n                    folderId={folderId}\\n                    onClose={() =\u003e setShowAddDialog(false)}\\n                /\u003e\\n            )}\\n\\n            {/* 刪除確認對話框（全部刪除 / 刪除所選） */}\\n            {showDeleteConfirm \u0026\u0026 (() =\u003e {\\n                const isAll = showDeleteConfirm === 'all';\\n                const deleteCount = isAll ? sorted.length : selectedIds.size;\\n                const deleteFn = isAll ? handleDeleteAll : handleDeleteSelected;\\n                // 顯示前幾筆摘要\\n                const previewItems = isAll\\n                    ? sorted.slice(0, 5)\\n                    : sorted.filter(i =\u003e selectedIds.has(i.id)).slice(0, 5);\\n                const moreCount = deleteCount - previewItems.length;\\n                return (\\n                \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setShowDeleteConfirm(null); }}\u003e\\n                    \u003cDialogContent className=\\\"max-w-sm\\\"\u003e\\n                        \u003cDialogHeader\u003e\\n                            \u003cDialogTitle\u003e{isAll ? '確認全部刪除' : '確認刪除所選記錄'}\u003c/DialogTitle\u003e\\n                            \u003cDialogDescription\u003e\\n                                將永久刪除 \u003cspan className=\\\"font-bold text-destructive\\\"\u003e{deleteCount}\u003c/span\u003e 筆記錄，此操作無法復原。\\n                            \u003c/DialogDescription\u003e\\n                        \u003c/DialogHeader\u003e\\n                        {previewItems.length \u003e 0 \u0026\u0026 (\\n                            \u003cdiv className=\\\"text-xs text-muted-foreground bg-muted/30 rounded-md p-2 flex flex-col gap-0.5 max-h-32 overflow-y-auto\\\"\u003e\\n                                {previewItems.map(t =\u003e (\\n                                    \u003cdiv key={t.id} className=\\\"truncate\\\"\u003e\\n                                        {t.date} · {t.majorCategory}{t.subCategory ? `/${t.subCategory}` : ''} · {t.type === 'income' ? '+' : '-'}{formatNum(t.amount || 0)}\\n                                    \u003c/div\u003e\\n                                ))}\\n                                {moreCount \u003e 0 \u0026\u0026 (\\n                                    \u003cdiv className=\\\"text-muted-foreground/50\\\"\u003e...及其他 {moreCount} 筆\u003c/div\u003e\\n                                )}\\n                            \u003c/div\u003e\\n                        )}\\n                        \u003cDialogFooter className=\\\"flex gap-2\\\"\u003e\\n                            \u003cButton variant=\\\"outline\\\" onClick={() =\u003e setShowDeleteConfirm(null)} disabled={deleting}\u003e\\n                                取消\\n                            \u003c/Button\u003e\\n                            \u003cButton\\n                                variant=\\\"default\\\"\\n                                className=\\\"bg-destructive text-destructive-foreground hover:bg-destructive/90\\\"\\n                                onClick={deleteFn}\\n                                disabled={deleting}\\n                            \u003e\\n                                {deleting ? '刪除中…' : '確認刪除'}\\n                            \u003c/Button\u003e\\n                        \u003c/DialogFooter\u003e\\n                    \u003c/DialogContent\u003e\\n                \u003c/Dialog\u003e\\n                );\\n            })()}\\n\\n                        {/* 功能說明 */}\\n            {showProgramInfo \u0026\u0026 (\\n                \u003cDialog open onOpenChange={(o) =\u003e { if (!o) setShowProgramInfo(false); }}\u003e\\n                    \u003cDialogContent className=\\\"max-w-lg max-h-[80vh] overflow-y-auto\\\"\u003e\\n                        \u003cDialogHeader\u003e\\n                            \u003cDialogTitle\u003e📖 收入與支出 — 功能說明\u003c/DialogTitle\u003e\\n                        \u003c/DialogHeader\u003e\\n                        \u003cdiv className=\\\"flex flex-col gap-5 text-sm leading-relaxed py-2\\\"\u003e\\n\\n                            {/* 版本紀錄 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e📋 版本紀錄\u003c/h3\u003e\\n                                \u003cdiv className=\\\"flex flex-col gap-1.5 text-xs\\\"\u003e\\n                                    \u003cdiv className=\\\"rounded-md bg-muted/50 border border-border/60 p-2\\\"\u003e\\n                                        \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                            \u003cspan className=\\\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\\\"\u003ev1.4\u003c/span\u003e\\n                                            \u003cspan className=\\\"text-muted-foreground\\\"\u003e2026/08/02\u003c/span\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cp className=\\\"text-foreground\\\"\u003e萬年曆固定高度不再跳動；點選日期顯示八字排盤（三柱、十神、藏干、納音）；新增分類長條圖與折線圖；年度總覽儀表板；每月預算進度條；定期記帳推播提醒；長條圖/折線圖與圓餅圖共用篩選條件；年份選單改為下拉式。\u003c/p\u003e\\n                                    \u003c/div\u003e\\n                                    \u003cdiv className=\\\"rounded-md bg-muted/50 border border-border/60 p-2\\\"\u003e\\n                                        \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                            \u003cspan className=\\\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\\\"\u003ev1.3\u003c/span\u003e\\n                                            \u003cspan className=\\\"text-muted-foreground\\\"\u003e2026/07/27\u003c/span\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cp className=\\\"text-foreground\\\"\u003e新增記錄日期不再自動填今天，改為空白讓使用者自由選取；表單版面優化（日期與金額同一行併排、GPS 定位按鈕改為緊湊圖示內嵌於輸入框右側、整體欄位間距收緊）。\u003c/p\u003e\\n                                    \u003c/div\u003e\\n                                    \u003cdiv className=\\\"rounded-md bg-muted/50 border border-border/60 p-2\\\"\u003e\\n                                        \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                            \u003cspan className=\\\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\\\"\u003ev1.2\u003c/span\u003e\\n                                            \u003cspan className=\\\"text-muted-foreground\\\"\u003e2026/07/27\u003c/span\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cp className=\\\"text-foreground\\\"\u003e地點欄位新增定位按鈕（📍），透過 IP 定位取得座標後自動反查地址並填入欄位；編輯模式地點有值時自動出現地圖按鈕（🗺️），點擊用 Google Maps 搜尋該地點。\u003c/p\u003e\\n                                    \u003c/div\u003e\\n                                    \u003cdiv className=\\\"rounded-md bg-muted/50 border border-border/60 p-2\\\"\u003e\\n                                        \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                            \u003cspan className=\\\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\\\"\u003ev1.1\u003c/span\u003e\\n                                            \u003cspan className=\\\"text-muted-foreground\\\"\u003e2026/07/26\u003c/span\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cp className=\\\"text-foreground\\\"\u003e新增與編輯記錄支援大小類與對象管理；大小類、對象支援長按拖曳排序；圓餅圖統計收入與支出分類佔比。\u003c/p\u003e\\n                                    \u003c/div\u003e\\n                                    \u003cdiv className=\\\"rounded-md bg-muted/50 border border-border/60 p-2\\\"\u003e\\n                                        \u003cdiv className=\\\"flex items-center gap-2 mb-1\\\"\u003e\\n                                            \u003cspan className=\\\"text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-bold\\\"\u003ev1.0\u003c/span\u003e\\n                                            \u003cspan className=\\\"text-muted-foreground\\\"\u003e2026/07/26\u003c/span\u003e\\n                                        \u003c/div\u003e\\n                                        \u003cp className=\\\"text-foreground\\\"\u003e收入與支出初版：記錄每月開銷與收入，用圓餅圖統計分類與對象，支援收入／支出切換、自訂大小類與付款對象。\u003c/p\u003e\\n                                    \u003c/div\u003e\\n                                \u003c/div\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 萬年曆 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e📅 萬年曆\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e全螢幕月曆檢視，整合農曆資訊與記帳記錄。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e固定高度：\u003c/span\u003e30rem 固定高度，切換月份不跳動，網格各行等高等寬\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e手勢換月：\u003c/span\u003e支援左右滑動換月，點選日期篩選當日記錄\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e農曆資訊：\u003c/span\u003e農曆日期（初一～三十）、天干地支、二十四節氣\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e節日標示：\u003c/span\u003e農曆節日（春節、端午、中秋等）與人事行政總處國定假日\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e八字排盤：\u003c/span\u003e點選日期後顯示年柱／月柱／日柱，含十神、藏干、納音\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 記帳記錄 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e📝 記帳記錄\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e記錄每一筆收入與支出，支援完整欄位填寫與管理。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e收支類型：\u003c/span\u003e切換收入或支出，按鈕以藍色（收）／紅色（支）區分\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e日期：\u003c/span\u003e手動選擇任意日期，補記過去消費不受限\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e大小類：\u003c/span\u003e兩層分類（大類 → 小類），支援自訂與拖曳排序\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e金額：\u003c/span\u003e輸入數字，自動計算當月收支合計與結餘\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e對象：\u003c/span\u003e付款或收款對象，支援自訂與拖曳排序\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e地點：\u003c/span\u003e輸入地址或店名，或點擊 📍 按鈕自動定位目前地址\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e備注：\u003c/span\u003e自由輸入備注文字\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e編輯與刪除：\u003c/span\u003e點選記錄進入編輯模式，可修改所有欄位或刪除記錄\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e多選刪除：\u003c/span\u003e勾選多筆後一次刪除\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 統計圖表 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e📊 統計圖表\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e四種圖表類型視覺化呈現收支分佈與趨勢。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e圓餅圖：\u003c/span\u003e收支分類佔比，支援大類／小類／對象切換\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e長條圖：\u003c/span\u003e分時段（日／月）堆疊顯示各分類收支金額變化\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e折線圖：\u003c/span\u003e各分類收支趨勢線，一目瞭然消長走勢\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e年度總覽：\u003c/span\u003e全年收支合計、各月明細表、前五大支出類別\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e期間選擇：\u003c/span\u003e本週／本月／今年／全部／自訂區間\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e分類切換：\u003c/span\u003e大類／小類／對象三種維度\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 每月預算 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e💰 每月預算\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e設定支出預算上限，即時掌握消費節奏。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e預算設定：\u003c/span\u003e於月曆設定中設定每月預算金額\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e進度條：\u003c/span\u003e綠色→黃色→橙色→紅色四階段顯示已用比例\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e即時顯示：\u003c/span\u003e已用百分比與剩餘／超支金額\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 記帳提醒 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e🔔 記帳提醒\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e未記帳時自動發送推播通知，不怕忘記。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e提醒模式：\u003c/span\u003e支援每日或每週提醒\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e自訂時間：\u003c/span\u003e可設定提醒時間（每日）或星期幾（每週）\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e智慧跳過：\u003c/span\u003e當日已有記錄時自動跳過，不重複打擾\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 大小類與對象管理 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e🔄 大小類與對象管理\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e支援拖曳排序的管理介面，讓分類與對象清單保持整潔。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e大類：\u003c/span\u003e新增／編輯／刪除／拖曳排序\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e小類：\u003c/span\u003e掛在大類下，新增／編輯／刪除／拖曳排序\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e對象：\u003c/span\u003e收入對象與支出對象分開管理，支援拖曳排序\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e即時儲存：\u003c/span\u003e設定即時儲存，所有記錄自動反映最新分類\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 地點定位 */}\\n                            \u003cdiv\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground mb-1.5\\\"\u003e📍 地點定位\u003c/h3\u003e\\n                                \u003cp className=\\\"text-muted-foreground mb-1\\\"\u003e點擊地點欄位旁的 📍 按鈕即可自動取得目前地址。\u003c/p\u003e\\n                                \u003cul className=\\\"list-disc pl-4 text-xs text-muted-foreground flex flex-col gap-0.5\\\"\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003e文字搜尋：\u003c/span\u003e輸入地址或店名，自動搜尋該地點地址（支援 Google Maps 或 Nominatim）\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003eGPS 定位：\u003c/span\u003e空白時使用瀏覽器 GPS 取得座標並自動反查繁體中文地址\u003c/li\u003e\\n                                    \u003cli\u003e\u003cspan className=\\\"text-foreground font-medium\\\"\u003eGoogle Maps：\u003c/span\u003e地點有值時出現 🗺️ 按鈕，點擊用 Google Maps 搜尋\u003c/li\u003e\\n                                \u003c/ul\u003e\\n                            \u003c/div\u003e\\n\\n                            {/* 資料來源 */}\\n                            \u003cdiv className=\\\"rounded-lg bg-muted/50 border border-border/60 p-3\\\"\u003e\\n                                \u003ch3 className=\\\"font-bold text-foreground text-xs mb-1\\\"\u003e📡 資料來源\u003c/h3\u003e\\n                                \u003cp className=\\\"text-xs text-muted-foreground\\\"\u003e\\n                                    地點定位：瀏覽器 GPS + Google Maps API 或 OpenStreetMap Nominatim。國定假日：人事行政總處公告資料。記帳資料全部儲存在 CubeLV 本機，不經第三方服務。\\n                                \u003c/p\u003e\\n                            \u003c/div\u003e\\n\\n                        \u003c/div\u003e\\n                        \u003cDialogFooter className=\\\"flex gap-2\\\"\u003e\\n                            \u003cButton variant=\\\"outline\\\" onClick={handleCopyProgramInfo} disabled={copyDone}\u003e\\n                                {copyDone ? '✓ 已複製' : \u003c\u003e\u003cCopyIcon /\u003e 複製說明\u003c/\u003e}\\n                            \u003c/Button\u003e\\n                            \u003cButton variant=\\\"outline\\\" onClick={() =\u003e setShowProgramInfo(false)}\u003e關閉\u003c/Button\u003e\\n                        \u003c/DialogFooter\u003e\\n                    \u003c/DialogContent\u003e\\n                \u003c/Dialog\u003e\\n            )}\\n\\n            {/* 月曆顯示設定 */}\\n            \u003cSettingsView open={showSettings} onClose={() =\u003e setShowSettings(false)} /\u003e\\n        \u003c/div\u003e\\n    );\\n}\\n\",\"frontend/收入與支出Plugin.tsx\":\"import { Plugin, plugin, registerPublicItemType, View, useItemsByType } from '@cubelv/sdk';\\nimport { IncomeExpenseFolder, IncomeExpenseTransaction } from './schemas/expenseTrackerSchema';\\nimport { IncomeExpenseSectionConfig } from './schemas/expenseTrackerSectionConfigSchema';\\nimport { GoogleMapsSectionConfig } from './schemas/googleMapsConfigSchema';\\nimport { TransactionListComponent } from './views/TransactionListView';\\nimport { TransactionDetailComponent } from './views/TransactionDetailView';\\n\\n/** 確保日期 YYYY-MM-DD 的 MM/DD 有前置零 */\\nfunction fmtDate(d: string): string {\\n  if (!d) return '';\\n  const parts = d.split('-');\\n  if (parts.length === 3) return `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`;\\n  return d;\\n}\\n\\nclass IncomeExpenseListView extends View {\\n    constructor(leaf: any) { super(leaf); }\\n    getViewType() { return 'income-expense-list'; }\\n    getDisplayText() { return '記錄列表'; }\\n    renderComponent() { return \u003cTransactionListComponent /\u003e; }\\n}\\n\\nclass IncomeExpenseDetailView extends View {\\n    constructor(leaf: any) { super(leaf); }\\n    getViewType() { return 'income-expense-detail'; }\\n    getDisplayText() { return '記錄詳情'; }\\n    renderComponent() { return \u003cTransactionDetailComponent /\u003e; }\\n}\\n\\nfunction useIncomeExpenseSidebarData() {\\n    const folders = useItemsByType\u003cIncomeExpenseFolder\u003e('INCOME_EXPENSE_FOLDER');\\n    return { items: folders };\\n}\\n\\n@plugin('收入與支出', { description: '記錄每月開銷與收入，用圓餅圖統計分類與對象', dependencies: ['Sidebar'] })\\n// refresh\\nclass ExpenseTrackerPlugin extends Plugin {\\n    onload() {\\n        this.registerPluginRoot('income-expense');\\n\\n        registerPublicItemType('INCOME_EXPENSE_FOLDER');\\n        registerPublicItemType('INCOME_EXPENSE_TRANSACTION');\\n\\n        // 確保 centerPane2 永遠存在（先前 clearPane 若有殘留狀態可恢復）\\n        this.ensureLeafInPane('centerPane2', 'leaf-income-detail', 'income-expense-detail', { flex: 1, minSize: 300 });\\n\\n        this.registerSection({\\n            id: 'income-expense',\\n            title: '收支統計',\\n            orderAt: 550,\\n            useData: useIncomeExpenseSidebarData,\\n        });\\n\\n        this.registerConfig(IncomeExpenseSectionConfig);\\n        this.registerConfig(GoogleMapsSectionConfig);\\n\\n        this.registerFolderType(IncomeExpenseFolder, {\\n            folderIcon: 'hand-coins',\\n            views: [\\n                { type: 'income-expense-list', creator: (leaf: any) =\u003e new IncomeExpenseListView(leaf) },\\n                { type: 'income-expense-detail', creator: (leaf: any) =\u003e new IncomeExpenseDetailView(leaf) },\\n            ],\\n            panes: {\\n                centerPane1: { leafId: 'leaf-income-list', viewType: 'income-expense-list', flex: 1, minSize: 300 },\\n                centerPane2: { leafId: 'leaf-income-detail', viewType: 'income-expense-detail', flex: 1.2, minSize: 380 },\\n            },\\n            children: [\\n                {\\n                    class: IncomeExpenseTransaction,\\n                    icon: 'credit-card',\\n                    displayText: (item: IncomeExpenseTransaction) =\u003e\\n                        `${fmtDate(item.date || '')} ${item.majorCategory || ''} ${item.amount || 0}元`,\\n                    aiUsageHint: '交易記錄，date 為日期(YYYY-MM-DD)、type 為 income 或 expense、majorCategory 大類、subCategory 小類、amount 金額、payee 對象、location 地點、note 備注。',\\n                },\\n            ],\\n        });\\n    }\\n}\\n\\nexport default ExpenseTrackerPlugin;\\n\",\"tsconfig.json\":\"{\\n  \\\"compilerOptions\\\": {\\n    \\\"target\\\": \\\"ES2020\\\",\\n    \\\"module\\\": \\\"ESNext\\\",\\n    \\\"moduleResolution\\\": \\\"bundler\\\",\\n    \\\"jsx\\\": \\\"react-jsx\\\",\\n    \\\"strict\\\": true,\\n    \\\"esModuleInterop\\\": true,\\n    \\\"skipLibCheck\\\": true,\\n    \\\"forceConsistentCasingInFileNames\\\": true,\\n    \\\"resolveJsonModule\\\": true,\\n    \\\"declaration\\\": true,\\n    \\\"declarationMap\\\": true,\\n    \\\"sourceMap\\\": true,\\n    \\\"outDir\\\": \\\"./dist\\\",\\n    \\\"rootDir\\\": \\\".\\\"\\n  },\\n  \\\"include\\\": [\\\"frontend/**/*\\\", \\\"backend/**/*\\\"],\\n  \\\"exclude\\\": [\\\"node_modules\\\", \\\"dist\\\", \\\".bundle\\\"]\\n}\\n\",\"frontend/schemas/googleMapsConfigSchema.ts\":\"import { BaseItem, itemType } from '@cubelv/sdk';\\n\\n@itemType('GOOGLE_MAPS_CONFIG', 'Google Maps 設定')\\nexport class GoogleMapsSectionConfig extends BaseItem {\\n    /** Google Cloud API 金鑰 */\\n    apiKey: string = '';\\n    /** 是否啟用 Google Maps 功能 */\\n    enabled: boolean = false;\\n}\\n\",\"frontend/stores/googleMapsConfigStore.ts\":\"import { createSingletonAccessor } from '@cubelv/sdk';\\nimport { GoogleMapsSectionConfig } from '../schemas/googleMapsConfigSchema';\\n\\nconst accessor = createSingletonAccessor(GoogleMapsSectionConfig);\\nexport const useGoogleMapsConfig = accessor.use;\\nexport const getGoogleMapsConfig = accessor.get;\\nexport const updateGoogleMapsConfig = accessor.update;\\n\"}","updatedAt":1785626361732,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":1},{"createdAt":1785417191623,"deletedAt":null,"id":"plugin_dev_section_config_6a468afe0217f2757c6098","isPublic":false,"itemType":"PLUGIN_DEV_SECTION_CONFIG","name":"PLUGIN_DEV_SECTION_CONFIG","originPluginDir":"源碼上傳平台","originPluginID":"270b89320b7c34dff6ec4ae7","parents":{"plugin-dev":1785417191623},"preParentID":null,"updatedAt":1785607514668,"updatedBy":{"userId":"6a468afe0217f2757c6098","userName":"小小世界"},"version":20,"viewMode":"dashboard"}]}