Merge branch 'dev' into main
This commit is contained in:
@@ -10,6 +10,7 @@ android {
|
|||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
|||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
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')
|
||||||
|
|||||||
Generated
+10
@@ -12,6 +12,7 @@
|
|||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/cli": "^8.3.4",
|
"@capacitor/cli": "^8.3.4",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@toast-ui/editor": "^3.2.2",
|
"@toast-ui/editor": "^3.2.2",
|
||||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
@@ -633,6 +634,15 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/cli": "^8.3.4",
|
"@capacitor/cli": "^8.3.4",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
"@toast-ui/editor": "^3.2.2",
|
"@toast-ui/editor": "^3.2.2",
|
||||||
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
"@toast-ui/editor-plugin-code-syntax-highlight": "^3.1.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
+8
-4
@@ -6,13 +6,17 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { setupCapacitor } from './capacitor'
|
import { setupCapacitor } from './capacitor'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.mount('#app')
|
// 저장된 세션(자동 로그인)을 복원한 뒤 마운트 — 로그인 상태가 결정된 후 렌더
|
||||||
|
const auth = useAuthStore()
|
||||||
// 네이티브 앱(안드로이드)일 때만 동작 (웹은 no-op)
|
auth.restore().finally(() => {
|
||||||
setupCapacitor(router)
|
app.mount('#app')
|
||||||
|
// 네이티브 앱(안드로이드)일 때만 동작 (웹은 no-op)
|
||||||
|
setupCapacitor(router)
|
||||||
|
})
|
||||||
|
|||||||
+37
-11
@@ -1,30 +1,56 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { authApi } from '@/api/authApi'
|
import { authApi } from '@/api/authApi'
|
||||||
|
|
||||||
// 세션은 Redis(서버) + localStorage(클라이언트) 양쪽에 보관한다.
|
// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다.
|
||||||
|
// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다.
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
const USER_KEY = 'auth_user'
|
const USER_KEY = 'auth_user'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
const token = ref('')
|
||||||
const user = ref(safeParse(localStorage.getItem(USER_KEY)))
|
const user = ref(null)
|
||||||
|
const ready = ref(false)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
function persist() {
|
// localStorage 동기화 (http.js 가 동기로 토큰을 읽음)
|
||||||
|
function mirrorLocal() {
|
||||||
if (token.value) localStorage.setItem(TOKEN_KEY, token.value)
|
if (token.value) localStorage.setItem(TOKEN_KEY, token.value)
|
||||||
else localStorage.removeItem(TOKEN_KEY)
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
|
||||||
if (user.value) localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
if (user.value) localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||||
else localStorage.removeItem(USER_KEY)
|
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) {
|
async function login(loginId, password, rememberMe = false) {
|
||||||
const res = await authApi.login({ loginId, password, rememberMe })
|
const res = await authApi.login({ loginId, password, rememberMe })
|
||||||
token.value = res.token
|
token.value = res.token
|
||||||
user.value = res.member
|
user.value = res.member
|
||||||
persist()
|
await persist()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +62,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
async function fetchMe() {
|
async function fetchMe() {
|
||||||
const me = await authApi.me()
|
const me = await authApi.me()
|
||||||
user.value = { ...(user.value || {}), ...me }
|
user.value = { ...(user.value || {}), ...me }
|
||||||
persist()
|
await persist()
|
||||||
return me
|
return me
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,16 +72,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// 서버 세션이 이미 만료됐어도 클라이언트는 정리
|
// 서버 세션이 이미 만료됐어도 클라이언트는 정리
|
||||||
}
|
}
|
||||||
clear()
|
await clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
async function clear() {
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
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) {
|
function safeParse(value) {
|
||||||
|
|||||||
+171
-125
@@ -40,7 +40,7 @@ function resetFilters() {
|
|||||||
// 추가/수정 모달
|
// 추가/수정 모달
|
||||||
const formOpen = ref(false)
|
const formOpen = ref(false)
|
||||||
const editId = ref(null)
|
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 isRepayment = computed(() => form.type === 'REPAYMENT')
|
||||||
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
|
const liabilityWallets = computed(() => wallets.value.filter((w) => w.type === 'LOAN' || w.type === 'CARD'))
|
||||||
const submitting = ref(false)
|
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([])
|
const categories = ref([])
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
@@ -131,11 +151,6 @@ const periodLabel = computed(() => `${year.value}년 ${String(month.value).padSt
|
|||||||
function won(n) {
|
function won(n) {
|
||||||
return (n ?? 0).toLocaleString('ko-KR')
|
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) {
|
function dotClass(type) {
|
||||||
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
return type === 'INCOME' ? 'income' : type === 'TRANSFER' ? 'transfer' : 'expense'
|
||||||
}
|
}
|
||||||
@@ -146,6 +161,30 @@ function amountSign(type) {
|
|||||||
return type === 'INCOME' ? '+' : type === 'TRANSFER' ? '' : '-'
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -188,7 +227,7 @@ function todayStr() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editId.value = null
|
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 = []
|
selectedTagIds.value = []
|
||||||
cancelAddCategory()
|
cancelAddCategory()
|
||||||
formError.value = null
|
formError.value = null
|
||||||
@@ -202,7 +241,9 @@ function openEdit(e) {
|
|||||||
category: e.category || '',
|
category: e.category || '',
|
||||||
amount: e.amount,
|
amount: e.amount,
|
||||||
memo: e.memo || '',
|
memo: e.memo || '',
|
||||||
|
walletKind: walletKindOf(e.walletId),
|
||||||
walletId: e.walletId || '',
|
walletId: e.walletId || '',
|
||||||
|
toWalletKind: walletKindOf(e.toWalletId),
|
||||||
toWalletId: e.toWalletId || '',
|
toWalletId: e.toWalletId || '',
|
||||||
})
|
})
|
||||||
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
// 태그 이름 → id 매핑 (현재 태그 목록 기준)
|
||||||
@@ -378,40 +419,39 @@ onMounted(async () => {
|
|||||||
<p v-if="error" class="msg error">{{ error }}</p>
|
<p v-if="error" class="msg error">{{ error }}</p>
|
||||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||||
|
|
||||||
<table v-else-if="entries.length" class="entry-table">
|
<div v-else-if="entries.length" class="day-list">
|
||||||
<thead>
|
<section v-for="g in entriesByDay" :key="g.date" class="day-group">
|
||||||
<tr>
|
<div class="day-head">
|
||||||
<th class="col-date">날짜</th>
|
<span class="day-date">{{ dayLabel(g.date) }}</span>
|
||||||
<th class="col-cat">분류</th>
|
<span class="day-sums">
|
||||||
<th>메모</th>
|
<span v-if="g.income" class="income">+{{ won(g.income) }}</span>
|
||||||
<th class="col-amount">금액</th>
|
<span v-if="g.expense" class="expense">-{{ won(g.expense) }}</span>
|
||||||
<th class="col-act"></th>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<ul class="day-items">
|
||||||
<tbody>
|
<li v-for="e in g.items" :key="e.id" class="entry-item">
|
||||||
<tr v-for="e in entries" :key="e.id">
|
<div class="ei-line1">
|
||||||
<td class="col-date">{{ formatDate(e.entryDate) }}</td>
|
<span class="ei-cat">
|
||||||
<td class="col-cat">
|
|
||||||
<span class="type-dot" :class="dotClass(e.type)"></span>
|
<span class="type-dot" :class="dotClass(e.type)"></span>
|
||||||
<template v-if="e.type === 'TRANSFER'">이체</template>
|
<template v-if="e.type === 'TRANSFER'">이체</template>
|
||||||
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
|
<template v-else>{{ e.category || (e.type === 'INCOME' ? '수입' : '지출') }}</template>
|
||||||
</td>
|
</span>
|
||||||
<td class="memo">
|
<span v-if="e.type === 'TRANSFER'" class="ei-wallet">{{ e.walletName }} → {{ e.toWalletName }}</span>
|
||||||
<span v-if="e.type === 'TRANSFER'" class="row-wallet">{{ e.walletName }} → {{ e.toWalletName }}</span>
|
<span v-else-if="e.walletName" class="ei-wallet">{{ e.walletName }}</span>
|
||||||
<span v-else-if="e.walletName" class="row-wallet">{{ e.walletName }}</span>
|
<span class="ei-amount" :class="amountClass(e.type)">{{ amountSign(e.type) }}{{ won(e.amount) }}</span>
|
||||||
{{ e.memo }}
|
<span class="ei-act">
|
||||||
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="col-amount" :class="amountClass(e.type)">
|
|
||||||
{{ amountSign(e.type) }}{{ won(e.amount) }}
|
|
||||||
</td>
|
|
||||||
<td class="col-act">
|
|
||||||
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
|
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(e)" />
|
||||||
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(e)" />
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
<div v-if="e.memo || (e.tags && e.tags.length)" class="ei-line2">
|
||||||
</table>
|
<span v-if="e.memo" class="ei-memo">{{ e.memo }}</span>
|
||||||
|
<span v-for="t in e.tags" :key="t" class="row-tag">{{ t }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}</p>
|
<p v-else-if="!loading" class="msg">{{ hasFilter ? '조건에 맞는 내역이 없습니다.' : '이 달의 내역이 없습니다.' }}</p>
|
||||||
|
|
||||||
<!-- 추가/수정 모달 -->
|
<!-- 추가/수정 모달 -->
|
||||||
@@ -432,22 +472,37 @@ onMounted(async () => {
|
|||||||
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
|
<option v-if="!editId" value="REPAYMENT">상환/납부</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
|
<!-- 계좌 종류 먼저 선택 → 해당 종류만 목록에 -->
|
||||||
<select v-model="form.walletId" :disabled="submitting">
|
<label>계좌 종류
|
||||||
|
<select v-model="form.walletKind" :disabled="submitting" @change="onWalletKindChange">
|
||||||
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
|
<option value="">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '(선택 안 함)' : '(선택)' }}</option>
|
||||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
<option v-for="k in WALLET_KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="form.walletKind">{{ form.type === 'INCOME' || form.type === 'EXPENSE' ? '계좌/카드' : '출금 계좌' }}
|
||||||
|
<select v-model="form.walletId" :disabled="submitting">
|
||||||
|
<option value="">(선택)</option>
|
||||||
|
<option v-for="w in walletsOfKind" :key="w.id" :value="w.id">
|
||||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="form.type === 'TRANSFER'">입금 계좌
|
<template v-if="form.type === 'TRANSFER'">
|
||||||
|
<label>입금 종류
|
||||||
|
<select v-model="form.toWalletKind" :disabled="submitting" @change="onToWalletKindChange">
|
||||||
|
<option value="">(선택)</option>
|
||||||
|
<option v-for="k in WALLET_KINDS" :key="k.value" :value="k.value">{{ k.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="form.toWalletKind">입금 계좌
|
||||||
<select v-model="form.toWalletId" :disabled="submitting">
|
<select v-model="form.toWalletId" :disabled="submitting">
|
||||||
<option value="">(선택)</option>
|
<option value="">(선택)</option>
|
||||||
<option v-for="w in wallets" :key="w.id" :value="w.id">
|
<option v-for="w in toWalletsOfKind" :key="w.id" :value="w.id">
|
||||||
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
{{ w.name }}{{ w.issuer ? ` (${w.issuer})` : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 -->
|
<!-- 상환/납부: 대상(대출/카드) + 원금 + 이자 -->
|
||||||
<template v-if="isRepayment">
|
<template v-if="isRepayment">
|
||||||
@@ -624,43 +679,90 @@ button.primary {
|
|||||||
.card.expense .value {
|
.card.expense .value {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
.entry-table {
|
/* 일별 그룹 목록 */
|
||||||
width: 100%;
|
.day-group {
|
||||||
border-collapse: collapse;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.entry-table th,
|
.day-head {
|
||||||
.entry-table td {
|
display: flex;
|
||||||
padding: 0.55rem 0.5rem;
|
align-items: baseline;
|
||||||
border-bottom: 1px solid var(--color-border);
|
justify-content: space-between;
|
||||||
text-align: left;
|
padding: 0.3rem 0.1rem;
|
||||||
font-size: 0.92rem;
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.col-date {
|
.day-date {
|
||||||
width: 60px;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.col-cat {
|
.day-sums {
|
||||||
width: 120px;
|
display: flex;
|
||||||
white-space: nowrap;
|
gap: 0.7rem;
|
||||||
}
|
font-size: 0.85rem;
|
||||||
.col-amount {
|
|
||||||
width: 120px;
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.col-amount.income {
|
.day-sums .income {
|
||||||
color: #2e7d32;
|
color: #2e7d32;
|
||||||
}
|
}
|
||||||
.col-amount.expense {
|
.day-sums .expense {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
}
|
}
|
||||||
.col-act {
|
.day-items {
|
||||||
width: 110px;
|
list-style: none;
|
||||||
|
}
|
||||||
|
.entry-item {
|
||||||
|
padding: 0.5rem 0.1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ei-line1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.ei-cat {
|
||||||
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.col-act button {
|
.ei-wallet {
|
||||||
padding: 0.2rem 0.5rem;
|
font-size: 0.74rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
.ei-amount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ei-amount.income {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.ei-amount.expense {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
.ei-amount.transfer {
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.ei-act {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ei-line2 {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-left: 1.1rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.type-dot {
|
.type-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -678,14 +780,6 @@ button.primary {
|
|||||||
.type-dot.transfer {
|
.type-dot.transfer {
|
||||||
background: #888;
|
background: #888;
|
||||||
}
|
}
|
||||||
.col-amount.transfer {
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.memo {
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.msg {
|
.msg {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
@@ -826,7 +920,7 @@ button.primary {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 모바일: 내역 표를 2줄(분류·금액 / 날짜·메모·액션)로 ===== */
|
/* ===== 모바일 ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.account {
|
.account {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -840,56 +934,8 @@ button.primary {
|
|||||||
.card .value {
|
.card .value {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.entry-table thead {
|
.ei-wallet {
|
||||||
display: none;
|
max-width: 38%;
|
||||||
}
|
|
||||||
.entry-table,
|
|
||||||
.entry-table tbody,
|
|
||||||
.entry-table tr,
|
|
||||||
.entry-table td {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.entry-table tr {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
column-gap: 0.5rem;
|
|
||||||
row-gap: 0.15rem;
|
|
||||||
padding: 0.6rem 0.1rem;
|
|
||||||
}
|
|
||||||
.entry-table td {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.entry-table .col-cat {
|
|
||||||
order: 0;
|
|
||||||
flex: 1;
|
|
||||||
white-space: normal;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.entry-table .col-amount {
|
|
||||||
order: 1;
|
|
||||||
width: auto;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.entry-table .col-date {
|
|
||||||
order: 2;
|
|
||||||
width: auto;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
.entry-table .memo {
|
|
||||||
order: 3;
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.entry-table .col-act {
|
|
||||||
order: 4;
|
|
||||||
width: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
}
|
||||||
.filter-bar .f-keyword {
|
.filter-bar .f-keyword {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user