- 사이드바 '대출·할부 상환' 메뉴(프리미엄 잠금), /account/repayments 라우트 - 대출: 상환방식별(원리금균등·원금균등·만기일시) 남은 회차 상각 스케줄 - 카드 할부: 무이자 가정, 진행 중 할부 건별 남은 회차 - 회차별 원금·이자·합계 + 남은 잔액 표기, 이번 달/남은 총액 합계 - 계산은 프런트(utils/repayment.js), 지갑·카드 내역만으로 산출 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ const icons = {
|
|||||||
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||||
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
||||||
|
repay: '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ const icons = {
|
|||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.wallets" />
|
||||||
<span>계좌 관리</span>
|
<span>계좌 관리</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink to="/account/repayments" class="menu-item">
|
||||||
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.repay" />
|
||||||
|
<span>대출·할부 상환</span>
|
||||||
|
<span v-if="lockPremium" class="lock-badge">🔒</span>
|
||||||
|
</RouterLink>
|
||||||
<RouterLink to="/account/categories" class="menu-item">
|
<RouterLink to="/account/categories" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.categories" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.categories" />
|
||||||
<span>분류 관리</span>
|
<span>분류 관리</span>
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/account/AccountWalletDetailView.vue'),
|
component: () => import('../views/account/AccountWalletDetailView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/account/repayments',
|
||||||
|
name: 'account-repayments',
|
||||||
|
component: () => import('../views/account/RepaymentScheduleView.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresPremium: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/account/categories',
|
path: '/account/categories',
|
||||||
name: 'account-categories',
|
name: 'account-categories',
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 대출·카드할부 남은 상환 스케줄 계산 (원금/이자/합계/회차별 남은 잔액)
|
||||||
|
// - 카드 할부: 무이자 가정(원금만, 이자 0)
|
||||||
|
// - 대출: 상환방식별 상각 (원리금균등/원금균등/만기일시), 현재 남은 원금(balance) 기준
|
||||||
|
|
||||||
|
function addMonths(y, m, delta) {
|
||||||
|
const d = new Date(y, m - 1 + delta, 1)
|
||||||
|
return { y: d.getFullYear(), m: d.getMonth() + 1 }
|
||||||
|
}
|
||||||
|
function label(y, m) {
|
||||||
|
return `${y}.${String(m).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
// (by,bm) - (ay,am) 개월 차
|
||||||
|
function monthsBetween(ay, am, by, bm) {
|
||||||
|
return (by - ay) * 12 + (bm - am)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentYm() {
|
||||||
|
const d = new Date()
|
||||||
|
return { y: d.getFullYear(), m: d.getMonth() + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대출 남은 상환 스케줄. 반환: { rows:[{label,principal,interest,total,balance}], partial:boolean }
|
||||||
|
export function loanSchedule(w, now = currentYm(), cap = 600) {
|
||||||
|
const r = (Number(w.loanRate) || 0) / 100 / 12
|
||||||
|
let bal = Math.abs(Number(w.balance) || 0) // 현재 남은 원금
|
||||||
|
const method = w.loanMethod || 'EQUAL_PAYMENT'
|
||||||
|
if (bal <= 0) return { rows: [], partial: false }
|
||||||
|
|
||||||
|
// 경과·남은 개월수
|
||||||
|
let elapsed = 0
|
||||||
|
if (w.loanStart) {
|
||||||
|
const [sy, sm] = String(w.loanStart).slice(0, 7).split('-').map(Number)
|
||||||
|
if (sy && sm) elapsed = Math.max(0, monthsBetween(sy, sm, now.y, now.m))
|
||||||
|
}
|
||||||
|
const nRem = w.loanMonths ? Math.max(1, Number(w.loanMonths) - elapsed) : null
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
let { y, m } = now
|
||||||
|
|
||||||
|
if (method === 'EQUAL_PRINCIPAL') {
|
||||||
|
// 원금균등: 매월 원금 고정 = 최초원금 / 총개월 (없으면 잔액/남은개월)
|
||||||
|
const principalPer =
|
||||||
|
w.loanAmount && w.loanMonths
|
||||||
|
? Math.round(Number(w.loanAmount) / Number(w.loanMonths))
|
||||||
|
: nRem
|
||||||
|
? Math.round(bal / nRem)
|
||||||
|
: bal
|
||||||
|
while (bal > 0 && rows.length < cap) {
|
||||||
|
const interest = Math.round(bal * r)
|
||||||
|
const principal = Math.min(principalPer, bal)
|
||||||
|
bal = Math.max(0, bal - principal)
|
||||||
|
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
|
||||||
|
;({ y, m } = addMonths(y, m, 1))
|
||||||
|
}
|
||||||
|
return { rows, partial: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'BULLET') {
|
||||||
|
// 만기일시: 매월 이자만, 마지막 달에 원금 전액
|
||||||
|
const n = nRem || 1
|
||||||
|
for (let i = 0; i < n && rows.length < cap; i++) {
|
||||||
|
const interest = Math.round(bal * r)
|
||||||
|
const isLast = i === n - 1
|
||||||
|
const principal = isLast ? bal : 0
|
||||||
|
const nb = isLast ? 0 : bal
|
||||||
|
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: nb })
|
||||||
|
bal = nb
|
||||||
|
;({ y, m } = addMonths(y, m, 1))
|
||||||
|
}
|
||||||
|
return { rows, partial: !nRem }
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQUAL_PAYMENT 원리금균등: 매월 상환액 고정
|
||||||
|
if (!nRem) {
|
||||||
|
// 총개월 정보 없음 → 이번 달 이자만 안내(원금 분리 불가)
|
||||||
|
const interest = Math.round(bal * r)
|
||||||
|
return {
|
||||||
|
rows: [{ label: label(y, m), principal: null, interest, total: null, balance: bal }],
|
||||||
|
partial: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payment = r > 0 ? Math.round((bal * r) / (1 - Math.pow(1 + r, -nRem))) : Math.round(bal / nRem)
|
||||||
|
for (let i = 0; i < nRem && bal > 0 && rows.length < cap; i++) {
|
||||||
|
const interest = Math.round(bal * r)
|
||||||
|
let principal = payment - interest
|
||||||
|
if (i === nRem - 1 || principal > bal) principal = bal // 마지막 회차 잔액 정산
|
||||||
|
bal = Math.max(0, bal - principal)
|
||||||
|
rows.push({ label: label(y, m), principal, interest, total: principal + interest, balance: bal })
|
||||||
|
;({ y, m } = addMonths(y, m, 1))
|
||||||
|
}
|
||||||
|
return { rows, partial: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 할부(무이자) 남은 스케줄. entry: {amount, installmentMonths, entryDate}
|
||||||
|
// 반환: { rows:[...], monthly } 또는 null(진행중 할부 아님)
|
||||||
|
export function cardInstallmentSchedule(entry, now = currentYm()) {
|
||||||
|
const N = Number(entry.installmentMonths) || 0
|
||||||
|
const amount = Math.abs(Number(entry.amount) || 0)
|
||||||
|
const date = entry.entryDate ? String(entry.entryDate).slice(0, 7) : ''
|
||||||
|
if (N < 2 || amount <= 0 || !date) return null
|
||||||
|
const [py, pm] = date.split('-').map(Number)
|
||||||
|
if (!py || !pm) return null
|
||||||
|
|
||||||
|
const monthly = Math.round(amount / N)
|
||||||
|
// 청구 회차: 구매 다음 달부터 N개월 (k = 1..N). 마지막 회차는 반올림 오차 정산.
|
||||||
|
const all = []
|
||||||
|
for (let k = 1; k <= N; k++) {
|
||||||
|
const { y, m } = addMonths(py, pm, k)
|
||||||
|
const principal = k === N ? amount - monthly * (N - 1) : monthly
|
||||||
|
all.push({ y, m, principal })
|
||||||
|
}
|
||||||
|
const remaining = all.filter((x) => monthsBetween(now.y, now.m, x.y, x.m) >= 0)
|
||||||
|
if (!remaining.length) return null
|
||||||
|
|
||||||
|
let bal = remaining.reduce((s, x) => s + x.principal, 0)
|
||||||
|
const rows = remaining.map((x) => {
|
||||||
|
bal -= x.principal
|
||||||
|
return { label: label(x.y, x.m), principal: x.principal, interest: 0, total: x.principal, balance: Math.max(0, bal) }
|
||||||
|
})
|
||||||
|
return { rows, monthly }
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ const premiumFeatures = [
|
|||||||
'영수증 OCR 인식',
|
'영수증 OCR 인식',
|
||||||
'카드 알림 자동 인식',
|
'카드 알림 자동 인식',
|
||||||
'투자 · 자산 관리',
|
'투자 · 자산 관리',
|
||||||
|
'대출 · 할부 상환표 (원금 · 이자 분리)',
|
||||||
'태그 · 소분류 무제한',
|
'태그 · 소분류 무제한',
|
||||||
'데이터 백업 / 복구 (엑셀)',
|
'데이터 백업 / 복구 (엑셀)',
|
||||||
'광고 제거',
|
'광고 제거',
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { accountApi } from '@/api/accountApi'
|
||||||
|
import { loanSchedule, cardInstallmentSchedule, currentYm } from '@/utils/repayment'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const items = ref([]) // { id, kind, name, sub, rows, partial }
|
||||||
|
const expanded = ref(new Set())
|
||||||
|
|
||||||
|
const now = currentYm()
|
||||||
|
const nowLabel = `${now.y}.${String(now.m).padStart(2, '0')}`
|
||||||
|
|
||||||
|
function won(n) {
|
||||||
|
return n == null ? '-' : Number(n).toLocaleString('ko-KR')
|
||||||
|
}
|
||||||
|
function loanMethodLabel(v) {
|
||||||
|
return v === 'EQUAL_PAYMENT'
|
||||||
|
? '원리금균등'
|
||||||
|
: v === 'EQUAL_PRINCIPAL'
|
||||||
|
? '원금균등'
|
||||||
|
: v === 'BULLET'
|
||||||
|
? '만기일시'
|
||||||
|
: '상환'
|
||||||
|
}
|
||||||
|
function loanSub(w) {
|
||||||
|
const parts = [loanMethodLabel(w.loanMethod)]
|
||||||
|
if (w.loanRate) parts.push(`${w.loanRate}%`)
|
||||||
|
if (w.loanMonths) parts.push(`${w.loanMonths}개월`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const ws = await accountApi.wallets()
|
||||||
|
const list = []
|
||||||
|
|
||||||
|
// 대출: 금리가 설정된 대출만 상각 스케줄 계산
|
||||||
|
for (const w of ws.filter((x) => x.type === 'LOAN' && x.loanRate)) {
|
||||||
|
const { rows, partial } = loanSchedule(w, now)
|
||||||
|
if (!rows.length) continue
|
||||||
|
list.push({ id: `loan-${w.id}`, kind: 'LOAN', name: w.name, sub: loanSub(w), rows, partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 할부(무이자): 진행 중인 할부 건별로
|
||||||
|
const cards = ws.filter((x) => x.type === 'CARD')
|
||||||
|
const entryLists = await Promise.all(
|
||||||
|
cards.map((c) => accountApi.walletEntries(c.id).catch(() => [])),
|
||||||
|
)
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const purchases = (entryLists[i] || []).filter(
|
||||||
|
(e) => e.type === 'EXPENSE' && Number(e.installmentMonths) >= 2,
|
||||||
|
)
|
||||||
|
for (const e of purchases) {
|
||||||
|
const sch = cardInstallmentSchedule(e, now)
|
||||||
|
if (!sch) continue
|
||||||
|
const desc = e.category || e.memo || '할부'
|
||||||
|
list.push({
|
||||||
|
id: `card-${e.id}`,
|
||||||
|
kind: 'CARD',
|
||||||
|
name: c.name,
|
||||||
|
sub: `${desc} · ${e.installmentMonths}개월 무이자 · ${String(e.entryDate || '').slice(0, 10)}`,
|
||||||
|
rows: sch.rows,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
items.value = list
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || '불러오지 못했습니다.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
// 이번 달 상환 합계 (이번 달 회차만)
|
||||||
|
const thisMonth = computed(() => {
|
||||||
|
let p = 0, i = 0, t = 0
|
||||||
|
for (const it of items.value) {
|
||||||
|
for (const r of it.rows) {
|
||||||
|
if (r.label !== nowLabel) continue
|
||||||
|
p += r.principal || 0
|
||||||
|
i += r.interest || 0
|
||||||
|
t += r.total ?? (r.principal || 0) + (r.interest || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { p, i, t }
|
||||||
|
})
|
||||||
|
// 남은 상환 총액
|
||||||
|
const remainTotal = computed(() => {
|
||||||
|
let p = 0, i = 0, t = 0
|
||||||
|
for (const it of items.value) {
|
||||||
|
for (const r of it.rows) {
|
||||||
|
p += r.principal || 0
|
||||||
|
i += r.interest || 0
|
||||||
|
t += r.total ?? (r.principal || 0) + (r.interest || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { p, i, t }
|
||||||
|
})
|
||||||
|
|
||||||
|
function itemThisMonth(it) {
|
||||||
|
const r = it.rows.find((x) => x.label === nowLabel)
|
||||||
|
return r ? (r.total ?? (r.principal || 0) + (r.interest || 0)) : null
|
||||||
|
}
|
||||||
|
function itemRemain(it) {
|
||||||
|
return it.rows.reduce((s, r) => s + (r.total ?? (r.principal || 0) + (r.interest || 0)), 0)
|
||||||
|
}
|
||||||
|
function toggle(id) {
|
||||||
|
const s = new Set(expanded.value)
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id)
|
||||||
|
expanded.value = s
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="repay">
|
||||||
|
<h1 class="title">대출·할부 상환</h1>
|
||||||
|
<p class="lead">대출 월 상환금과 카드 할부금을 원금·이자로 나눠, 남은 회차까지 한눈에 봅니다.</p>
|
||||||
|
|
||||||
|
<p v-if="error" class="msg error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading" class="msg">불러오는 중...</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 합계 요약 -->
|
||||||
|
<div class="summary">
|
||||||
|
<div class="sum-card accent">
|
||||||
|
<span class="k">이번 달 상환</span>
|
||||||
|
<span class="v">{{ won(thisMonth.t) }}</span>
|
||||||
|
<span class="split">원금 {{ won(thisMonth.p) }} · 이자 {{ won(thisMonth.i) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sum-card">
|
||||||
|
<span class="k">남은 상환 총액</span>
|
||||||
|
<span class="v">{{ won(remainTotal.t) }}</span>
|
||||||
|
<span class="split">원금 {{ won(remainTotal.p) }} · 이자 {{ won(remainTotal.i) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!items.length" class="empty-state">
|
||||||
|
<p class="empty-emoji">💳</p>
|
||||||
|
<p class="empty-title">상환 중인 대출·할부가 없어요</p>
|
||||||
|
<p class="empty-desc">대출 계좌(금리 입력)나 카드 할부 결제가 있으면 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 항목별 -->
|
||||||
|
<div v-for="it in items" :key="it.id" class="item">
|
||||||
|
<button type="button" class="item-head" @click="toggle(it.id)">
|
||||||
|
<span class="kind" :class="it.kind === 'LOAN' ? 'k-loan' : 'k-card'">
|
||||||
|
{{ it.kind === 'LOAN' ? '대출' : '카드' }}
|
||||||
|
</span>
|
||||||
|
<span class="info">
|
||||||
|
<span class="name">{{ it.name }}</span>
|
||||||
|
<span class="sub">{{ it.sub }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="amt">
|
||||||
|
<span class="amt-now">이번 달 {{ won(itemThisMonth(it)) }}</span>
|
||||||
|
<span class="amt-rem">남은 {{ it.rows.length }}회 · {{ won(itemRemain(it)) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="chev">{{ expanded.has(it.id) ? '▾' : '▸' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="expanded.has(it.id)" class="sched-wrap">
|
||||||
|
<p v-if="it.partial" class="partial-note">상환 개월(기간) 정보가 없어 이번 달 이자만 표시합니다. 계좌 관리에서 대출 개월수를 입력하면 전체 스케줄이 계산됩니다.</p>
|
||||||
|
<table class="sched">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>회차</th>
|
||||||
|
<th class="num">원금</th>
|
||||||
|
<th class="num">이자</th>
|
||||||
|
<th class="num">합계</th>
|
||||||
|
<th class="num">남은 잔액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(r, idx) in it.rows" :key="idx" :class="{ current: r.label === nowLabel }">
|
||||||
|
<td>{{ r.label }}</td>
|
||||||
|
<td class="num">{{ won(r.principal) }}</td>
|
||||||
|
<td class="num">{{ won(r.interest) }}</td>
|
||||||
|
<td class="num strong">{{ won(r.total) }}</td>
|
||||||
|
<td class="num muted">{{ won(r.balance) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.repay {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.msg.error {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
.sum-card {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
.sum-card.accent {
|
||||||
|
border-color: hsla(160, 100%, 37%, 0.6);
|
||||||
|
background: hsla(160, 100%, 37%, 0.06);
|
||||||
|
}
|
||||||
|
.sum-card .k {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.sum-card .v {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.sum-card .split {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.item-head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.kind {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.k-loan {
|
||||||
|
background: hsla(215, 90%, 55%, 0.15);
|
||||||
|
color: hsla(215, 90%, 55%, 1);
|
||||||
|
}
|
||||||
|
.k-card {
|
||||||
|
background: hsla(280, 60%, 55%, 0.15);
|
||||||
|
color: hsla(280, 60%, 60%, 1);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.info .name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.info .sub {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.amt {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.amt-now {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.amt-rem {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.sched-wrap {
|
||||||
|
padding: 0 0.5rem 0.6rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.partial-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
padding: 0.4rem 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
.sched {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.sched th,
|
||||||
|
.sched td {
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sched th {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sched .num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.sched td.strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sched td.muted {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.sched tr.current td {
|
||||||
|
background: hsla(160, 100%, 37%, 0.08);
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.empty-emoji {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.empty-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user