diff --git a/src/api/accountApi.js b/src/api/accountApi.js index f0f4c72..a461886 100644 --- a/src/api/accountApi.js +++ b/src/api/accountApi.js @@ -189,6 +189,11 @@ const realApi = { return http.post(`/account/entries/${id}/confirm`, payload || {}) }, + // 데이터 복구(가져오기) — 전체 데이터 덮어쓰기 + restore(payload) { + return http.post('/account/restore', payload) + }, + // 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) ocrReceipt(blob) { const fd = new FormData() @@ -219,7 +224,7 @@ const DEMO_WRITES = new Set([ 'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings', 'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets', 'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories', - 'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', + 'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'restore', 'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome', 'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade', 'refreshPrices', 'refreshAllPrices', diff --git a/src/utils/backup.js b/src/utils/backup.js index aaf0665..1cbf371 100644 --- a/src/utils/backup.js +++ b/src/utils/backup.js @@ -82,3 +82,70 @@ export async function exportBackup() { await saveWorkbook(XLSX, wb, fileName()) } + +// ===== 가져오기(복구) — 엑셀 파싱 → 백엔드 복구 ===== +export async function importBackup(file) { + const XLSX = await import('xlsx') + const buf = await file.arrayBuffer() + const wb = XLSX.read(buf, { type: 'array' }) + + const rows = (name) => { + const ws = wb.Sheets[name] + return ws ? XLSX.utils.sheet_to_json(ws, { defval: null }) : [] + } + const str = (v) => (v === null || v === undefined || v === '' ? null : String(v).trim()) + const num = (v) => (v === null || v === undefined || v === '' ? null : Number(v)) + const bool = (v) => v === true || v === 1 || ['true', 'TRUE', '1', 'Y', '예'].includes(String(v)) + const date = (v) => { + if (v === null || v === undefined || v === '') return null + if (typeof v === 'number' && XLSX.SSF) { + const d = XLSX.SSF.parse_date_code(v) + if (d) return `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}` + } + return String(v).trim().slice(0, 10) // 'YYYY-MM-DD' + } + + const payload = { + wallets: rows('계좌') + .map((r) => ({ + type: str(r['종류']), name: str(r['이름']), issuer: str(r['은행/카드사']), + accountNumber: str(r['계좌번호']), cardType: str(r['카드유형']), + openingBalance: num(r['개시잔액']), openingDate: date(r['개시일']), currentValue: num(r['평가액(수동)']), + })) + .filter((w) => w.name && w.type), + categories: rows('분류') + .map((r) => ({ type: str(r['구분']), name: str(r['이름']), parent: str(r['대분류']) })) + .filter((c) => c.name && c.type), + tags: rows('태그').map((r) => str(r['이름'])).filter(Boolean), + entries: rows('내역') + .map((r) => ({ + entryDate: date(r['날짜']), type: str(r['구분']), category: str(r['분류']), + amount: num(r['금액']), memo: str(r['메모']), wallet: str(r['계좌']), toWallet: str(r['입금계좌']), + installmentMonths: num(r['할부개월']), + tags: str(r['태그']) ? String(r['태그']).split(',').map((s) => s.trim()).filter(Boolean) : [], + })) + .filter((e) => e.entryDate && e.type), + recurrings: rows('고정지출') + .map((r) => ({ + title: str(r['제목']), type: str(r['구분']), amount: num(r['금액']), category: str(r['분류']), memo: str(r['메모']), + wallet: str(r['계좌']), toWallet: str(r['입금계좌']), frequency: str(r['주기']), + dayOfMonth: num(r['일']), dayOfWeek: num(r['요일']), monthOfYear: num(r['월']), + startDate: date(r['시작일']), endDate: date(r['종료일']), active: bool(r['활성']), + })) + .filter((r) => r.title && r.type), + budgets: rows('예산') + .map((r) => ({ + category: str(r['분류']), fixed: bool(r['고정']), baseUnit: str(r['기준단위']), baseAmount: num(r['기준금액']), + daily: num(r['일']), weekly: num(r['주']), monthly: num(r['월']), yearly: num(r['년']), + })) + .filter((b) => b.category), + quickEntries: rows('자주쓰는내역') + .map((r) => ({ + label: str(r['라벨']), type: str(r['구분']), category: str(r['분류']), + amount: num(r['금액']), memo: str(r['메모']), wallet: str(r['계좌']), + })) + .filter((q) => q.type), + } + + await accountApi.restore(payload) +} diff --git a/src/views/settings/SettingsView.vue b/src/views/settings/SettingsView.vue index 176ff3f..94aad36 100644 --- a/src/views/settings/SettingsView.vue +++ b/src/views/settings/SettingsView.vue @@ -4,7 +4,7 @@ import { RouterLink } from 'vue-router' import { Preferences } from '@capacitor/preferences' import { useDialog } from '@/composables/dialog' import { useUiStore } from '@/stores/ui' -import { exportBackup } from '@/utils/backup' +import { exportBackup, importBackup } from '@/utils/backup' const dialog = useDialog() const ui = useUiStore() @@ -21,8 +21,32 @@ async function doExport() { exporting.value = false } } -function importBackup() { - dialog.alert('가져오기(복구)는 준비 중입니다. 곧 제공됩니다.') + +const importing = ref(false) +const fileInput = ref(null) +function pickImportFile() { + if (importing.value) return + fileInput.value?.click() +} +async function onImportFile(e) { + const file = e.target.files?.[0] + e.target.value = '' // 같은 파일 다시 선택 가능하게 초기화 + if (!file) return + const ok = await dialog.confirm( + '가져오기를 하면 현재 가계부의 모든 데이터가 백업 파일 내용으로 교체됩니다.\n(투자 종목·매매내역은 백업에 없어 사라집니다)\n계속하시겠습니까?', + { title: '데이터 가져오기', okText: '가져오기', danger: true }, + ) + if (!ok) return + importing.value = true + try { + await importBackup(file) + await dialog.alert('가져오기가 완료되었습니다. 화면을 새로고침합니다.') + window.location.reload() + } catch (err) { + window.alert(err?.response?.data?.message || err?.message || '가져오기에 실패했습니다.') + } finally { + importing.value = false + } } const THEMES = [ { value: 'system', label: '시스템' }, @@ -32,19 +56,22 @@ const THEMES = [ const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-' const clearing = ref(false) -// PC(데스크톱) 전용: 창 해상도 변경 (Electron preload 의 window.desktop 존재 시에만) +// PC(데스크톱) 전용: 창 크기(가로·세로 각각) 변경 (Electron preload 의 window.desktop 존재 시에만) const isDesktop = typeof window !== 'undefined' && !!window.desktop -const RESOLUTIONS = [ - { label: '1024 × 720', w: 1024, h: 720 }, - { label: '1280 × 800', w: 1280, h: 800 }, - { label: '1366 × 768', w: 1366, h: 768 }, - { label: '1600 × 900', w: 1600, h: 900 }, - { label: '1920 × 1080', w: 1920, h: 1080 }, -] -function onResolution(e) { - const v = e.target.value +const WIDTHS = [900, 1024, 1280, 1366, 1600, 1920] +const HEIGHTS = [700, 800, 900, 1000, 1080, 1200] +// 한 축만 바꾸고 나머지 축은 현재 창 크기를 유지 +async function setDim(dim, value) { + const v = Number(value) if (!v) return - const [w, h] = v.split('x').map(Number) + let cur = [1024, 768] + try { + cur = (await window.desktop?.getWindowSize?.()) || cur + } catch { + /* 무시 */ + } + const w = dim === 'w' ? v : cur[0] + const h = dim === 'h' ? v : cur[1] window.desktop?.setWindowSize(w, h) } @@ -95,17 +122,24 @@ async function clearAppData() { - + - 화면 해상도 - 데스크톱 창 크기를 선택한 크기로 변경 + 창 크기 + 가로·세로를 각각 선택 + + + + 가로 + {{ w }} + + × + + 세로 + {{ h }} + - - 선택 - {{ r.label }} - @@ -118,13 +152,14 @@ async function clearAppData() { {{ exporting ? '내보내는 중…' : '내보내기' }} - + 엑셀에서 가져오기 - 백업 파일로 데이터 복구 + 백업 파일로 데이터 복구 (기존 데이터 덮어씀) - 가져오기 + {{ importing ? '복구 중…' : '가져오기' }} + @@ -229,9 +264,21 @@ async function clearAppData() { overflow: hidden; flex-shrink: 0; } +.size-selects { + display: flex; + align-items: center; + gap: 0.3rem; + flex: none; +} +.size-selects .x { + opacity: 0.5; +} .res-select { flex: none; - max-width: 150px; + max-width: 90px; +} +.hidden-file { + display: none; } .seg-btn { padding: 0.4rem 0.7rem;