diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 1005ebc..bf02646 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,6 +10,7 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') + implementation project(':capacitor-preferences') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 2085c86..543170e 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') diff --git a/package-lock.json b/package-lock.json index e6fbc3b..e850ff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@capacitor/app": "^8.1.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/preferences": "^8.0.1", "@toast-ui/editor": "^3.2.2", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "axios": "^1.16.1", @@ -633,6 +634,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/preferences": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", + "integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index 18fa108..9eb94f3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@capacitor/app": "^8.1.0", "@capacitor/cli": "^8.3.4", "@capacitor/core": "^8.3.4", + "@capacitor/preferences": "^8.0.1", "@toast-ui/editor": "^3.2.2", "@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0", "axios": "^1.16.1", diff --git a/src/main.js b/src/main.js index f5f653e..18705f9 100644 --- a/src/main.js +++ b/src/main.js @@ -6,13 +6,17 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import { setupCapacitor } from './capacitor' +import { useAuthStore } from './stores/auth' const app = createApp(App) app.use(createPinia()) app.use(router) -app.mount('#app') - -// 네이티브 앱(안드로이드)일 때만 동작 (웹은 no-op) -setupCapacitor(router) +// 저장된 세션(자동 로그인)을 복원한 뒤 마운트 — 로그인 상태가 결정된 후 렌더 +const auth = useAuthStore() +auth.restore().finally(() => { + app.mount('#app') + // 네이티브 앱(안드로이드)일 때만 동작 (웹은 no-op) + setupCapacitor(router) +}) diff --git a/src/stores/auth.js b/src/stores/auth.js index 73d5e96..f94ba04 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -1,30 +1,56 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' +import { Preferences } from '@capacitor/preferences' import { authApi } from '@/api/authApi' -// 세션은 Redis(서버) + localStorage(클라이언트) 양쪽에 보관한다. +// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다. +// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다. const TOKEN_KEY = 'token' const USER_KEY = 'auth_user' export const useAuthStore = defineStore('auth', () => { - const token = ref(localStorage.getItem(TOKEN_KEY) || '') - const user = ref(safeParse(localStorage.getItem(USER_KEY))) + const token = ref('') + const user = ref(null) + const ready = ref(false) const isAuthenticated = computed(() => !!token.value) - function persist() { + // localStorage 동기화 (http.js 가 동기로 토큰을 읽음) + function mirrorLocal() { if (token.value) localStorage.setItem(TOKEN_KEY, token.value) else localStorage.removeItem(TOKEN_KEY) - if (user.value) localStorage.setItem(USER_KEY, JSON.stringify(user.value)) else localStorage.removeItem(USER_KEY) } + async function persist() { + mirrorLocal() + if (token.value) await Preferences.set({ key: TOKEN_KEY, value: token.value }) + else await Preferences.remove({ key: TOKEN_KEY }) + if (user.value) await Preferences.set({ key: USER_KEY, value: JSON.stringify(user.value) }) + else await Preferences.remove({ key: USER_KEY }) + } + + // 앱/웹 시작 시 저장된 세션 복원 (자동 로그인). main.js 에서 mount 전 await. + async function restore() { + try { + const t = (await Preferences.get({ key: TOKEN_KEY })).value || localStorage.getItem(TOKEN_KEY) || '' + const uRaw = (await Preferences.get({ key: USER_KEY })).value || localStorage.getItem(USER_KEY) + token.value = t + user.value = safeParse(uRaw) + mirrorLocal() + } catch { + // 무시 — 비로그인 상태로 시작 + } finally { + ready.value = true + } + } + async function login(loginId, password, rememberMe = false) { const res = await authApi.login({ loginId, password, rememberMe }) token.value = res.token user.value = res.member - persist() + await persist() return res } @@ -36,7 +62,7 @@ export const useAuthStore = defineStore('auth', () => { async function fetchMe() { const me = await authApi.me() user.value = { ...(user.value || {}), ...me } - persist() + await persist() return me } @@ -46,16 +72,16 @@ export const useAuthStore = defineStore('auth', () => { } catch { // 서버 세션이 이미 만료됐어도 클라이언트는 정리 } - clear() + await clear() } - function clear() { + async function clear() { token.value = '' user.value = null - persist() + await persist() } - return { token, user, isAuthenticated, login, signup, fetchMe, logout, clear } + return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, logout, clear } }) function safeParse(value) { diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 7f47d9f..156d36a 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -40,7 +40,7 @@ function resetFilters() { // 추가/수정 모달 const formOpen = ref(false) const editId = ref(null) -const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null }) +const form = reactive({ entryDate: '', type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null }) const isRepayment = computed(() => form.type === 'REPAYMENT') const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD')) const submitting = ref(false) @@ -56,6 +56,26 @@ async function loadWallets() { } } +// 계좌 종류(콤보) → 해당 종류만 목록에 +const WALLET_KINDS = [ + { value: 'BANK', label: '계좌' }, + { value: 'CARD', label: '카드' }, + { value: 'LOAN', label: '대출' }, + { value: 'INVEST', label: '증권' }, +] +const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind)) +const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind)) +function walletKindOf(id) { + const w = wallets.value.find((x) => x.id === id) + return w ? w.type : '' +} +function onWalletKindChange() { + form.walletId = '' +} +function onToWalletKindChange() { + form.toWalletId = '' +} + // 분류(카테고리) const categories = ref([]) async function loadCategories() { @@ -131,11 +151,6 @@ const periodLabel = computed(() => `${year.value}년 ${String(month.value).padSt function won(n) { return (n ?? 0).toLocaleString('ko-KR') } -function formatDate(value) { - if (!value) return '-' - const d = new Date(value) - return Number.isNaN(d.getTime()) ? value : `${d.getMonth() + 1}/${d.getDate()}` -} function dotClass(type) { return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense' } @@ -146,6 +161,30 @@ function amountSign(type) { return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-' } +// 일별 그룹 + 일 합계 (백엔드가 날짜 내림차순 정렬 → 순서 유지) +const entriesByDay = computed(() => { + const groups = [] + const idx = {} + for (const e of entries.value) { + const d = (e.entryDate || '').slice(0, 10) + if (idx[d] === undefined) { + idx[d] = groups.length + groups.push({ date: d, income: 0, expense: 0, items: [] }) + } + const g = groups[idx[d]] + g.items.push(e) + if (e.type === 'INCOME') g.income += e.amount + else if (e.type === 'EXPENSE') g.expense += e.amount + } + return groups +}) +function dayLabel(d) { + const dt = new Date(d) + if (Number.isNaN(dt.getTime())) return d + const wd = ['일', '월', '화', '수', '목', '금', '토'][dt.getDay()] + return `${dt.getMonth() + 1}월 ${dt.getDate()}일 (${wd})` +} + async function load() { loading.value = true error.value = null @@ -188,7 +227,7 @@ function todayStr() { function openCreate() { editId.value = null - Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletId: '', toWalletId: '', principal: null, interest: null }) + Object.assign(form, { entryDate: todayStr(), type: 'EXPENSE', category: '', amount: null, memo: '', walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', principal: null, interest: null }) selectedTagIds.value = [] cancelAddCategory() formError.value = null @@ -202,7 +241,9 @@ function openEdit(e) { category: e.category || '', amount: e.amount, memo: e.memo || '', + walletKind: walletKindOf(e.walletId), walletId: e.walletId || '', + toWalletKind: walletKindOf(e.toWalletId), toWalletId: e.toWalletId || '', }) // 태그 이름 → id 매핑 (현재 태그 목록 기준) @@ -378,40 +419,39 @@ onMounted(async () => {

{{ error }}

불러오는 중...

- - - - - - - - - - - - - - - - - - - -
날짜분류메모금액
{{ formatDate(e.entryDate) }} - - - - - {{ e.walletName }} → {{ e.toWalletName }} - {{ e.walletName }} - {{ e.memo }} - {{ t }} - - {{ amountSign(e.type) }}{{ won(e.amount) }} - - - -
+
+
+
+ {{ dayLabel(g.date) }} + + +{{ won(g.income) }} + -{{ won(g.expense) }} + +
+ +
+

{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}

@@ -432,22 +472,37 @@ onMounted(async () => { - - +