Files
sb-front/src/views/account/BudgetView.vue
T
ByungCheol c4c58d2e24 fix: 월 네비 가운데 유지·액션 우측 정렬 + 검색 모달 버튼 넘침 수정
- 가계부/예산 month-nav: 좌 spacer + 우 mn-actions(flex:1)로 네비 중앙 고정,
  추가/검색·필터 버튼 우측 정렬
- 커뮤니티 검색 모달: input min-width:0 + select/버튼 flex:none 으로
  검색 아이콘이 모달 밖으로 밀리는 문제 해결

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:35:24 +09:00

687 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
const dialog = useDialog()
const now = new Date()
const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1)
const statuses = ref([])
const budgetsRaw = ref([])
const loading = ref(false)
const error = ref(null)
const formOpen = ref(false)
const editId = ref(null)
const form = reactive({
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
const submitting = ref(false)
const formError = ref(null)
// 분류(지출) 드롭다운
const categories = ref([])
async function loadCategories() {
try {
categories.value = await accountApi.categories()
} catch {
categories.value = []
}
}
const categoryOptions = computed(() => {
const names = categories.value.filter((c) => c.type === 'EXPENSE').map((c) => c.name)
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
// 대분류로 그룹핑 (소분류=optgroup 옵션 / 자식없는 대분류=단독 옵션)
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === 'EXPENSE')
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
return groups
})
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
function won(n) {
return (n ?? 0).toLocaleString('ko-KR')
}
function daysInMonth(y, m) {
return new Date(y, m, 0).getDate()
}
function daysInYear(y) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 366 : 365
}
function baseDays(unit) {
return unit === 'WEEK' ? 7 : unit === 'MONTH' ? 30 : 1
}
// 고정 모드 자동 환산 미리보기 (현재 월/년 실제 일수 기준)
const fixedPreview = computed(() => {
if (!form.fixed) return null
const amount = Number(form.baseAmount) || 0
const daily = amount / baseDays(form.baseUnit)
return {
weekly: Math.round(daily * 7),
monthly: Math.round(daily * daysInMonth(year.value, month.value)),
yearly: Math.round(daily * daysInYear(year.value)),
}
})
function ratio(s) {
if (!s.monthlyBudget) return 0
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
}
function barClass(s) {
const r = s.monthlyBudget ? s.spent / s.monthlyBudget : 0
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
}
// ===== 월 예상 수입 vs 총 예산 비교 =====
const expectedIncome = ref(null)
const incomeDraft = ref('')
async function loadIncome() {
try {
const r = await accountApi.expectedIncome({ year: year.value, month: month.value })
expectedIncome.value = r.expectedIncome
incomeDraft.value = r.expectedIncome ?? ''
} catch {
expectedIncome.value = null
incomeDraft.value = ''
}
}
async function saveIncome() {
try {
const v = incomeDraft.value === '' || incomeDraft.value == null ? 0 : Number(incomeDraft.value)
const r = await accountApi.setExpectedIncome({ year: year.value, month: month.value, expectedIncome: v })
expectedIncome.value = r.expectedIncome
} catch (e) {
alert(e.response?.data?.message || '저장에 실패했습니다.')
}
}
const totalBudget = computed(() => statuses.value.reduce((s, x) => s + (x.monthlyBudget || 0), 0))
const incomeVal = computed(() => Number(expectedIncome.value) || 0)
const leftover = computed(() => incomeVal.value - totalBudget.value) // 여유(저축 가능액)
const budgetOfIncome = computed(() => (incomeVal.value > 0 ? Math.min(totalBudget.value / incomeVal.value, 1) : 0))
async function load() {
loading.value = true
error.value = null
try {
const params = { year: year.value, month: month.value }
const [st, raw] = await Promise.all([accountApi.budgetStatus(params), accountApi.budgets()])
statuses.value = st
budgetsRaw.value = raw
} catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally {
loading.value = false
}
}
function prevMonth() {
if (month.value === 1) {
month.value = 12
year.value -= 1
} else month.value -= 1
load()
loadIncome()
}
function nextMonth() {
if (month.value === 12) {
month.value = 1
year.value += 1
} else month.value += 1
load()
loadIncome()
}
function openCreate() {
editId.value = null
Object.assign(form, {
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
formError.value = null
formOpen.value = true
}
function openEdit(s) {
const b = budgetsRaw.value.find((x) => x.id === s.id)
if (!b) return
editId.value = b.id
Object.assign(form, {
category: b.category,
fixed: b.fixed,
baseUnit: b.baseUnit || 'DAY',
baseAmount: b.baseAmount,
daily: b.daily,
weekly: b.weekly,
monthly: b.monthly,
yearly: b.yearly,
})
formError.value = null
formOpen.value = true
}
async function submit() {
formError.value = null
if (!form.category.trim()) {
formError.value = '카테고리를 입력하세요.'
return
}
let payload
if (form.fixed) {
if (!form.baseAmount || form.baseAmount <= 0) {
formError.value = '기준 금액을 입력하세요.'
return
}
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
} else {
payload = {
category: form.category.trim(),
fixed: false,
daily: form.daily != null ? Number(form.daily) : null,
weekly: form.weekly != null ? Number(form.weekly) : null,
monthly: form.monthly != null ? Number(form.monthly) : null,
yearly: form.yearly != null ? Number(form.yearly) : null,
}
}
submitting.value = true
try {
if (editId.value) await accountApi.updateBudget(editId.value, payload)
else await accountApi.createBudget(payload)
formOpen.value = false
await load()
} catch (e) {
formError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
submitting.value = false
}
}
async function remove(s) {
if (!(await dialog.confirm(`'${s.category}' 예산을 삭제할까요?`, { title: '예산 삭제', danger: true }))) return
try {
await accountApi.removeBudget(s.id)
await load()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(() => {
load()
loadCategories()
loadIncome()
})
</script>
<template>
<section class="budget">
<div class="month-nav">
<span class="mn-spacer"></span>
<IconBtn icon="chevronLeft" title="이전 달" size="sm" @click="prevMonth" />
<span class="period">{{ periodLabel }}</span>
<IconBtn icon="chevronRight" title="다음 달" size="sm" @click="nextMonth" />
<div class="mn-actions">
<IconBtn icon="plus" title="예산 추가" variant="primary" @click="openCreate" />
</div>
</div>
<!-- 예상 수입 vs 예산 -->
<div class="income-panel">
<div class="inc-input">
<label> 예상 수입</label>
<input v-model.number="incomeDraft" type="number" min="0" placeholder="예상 수입(원)" @keyup.enter="saveIncome" />
<IconBtn icon="check" title="저장" variant="primary" size="sm" @click="saveIncome" />
</div>
<div v-if="incomeVal > 0" class="inc-compare">
<div class="inc-bar">
<div class="inc-fill" :class="leftover < 0 ? 'over' : ''" :style="{ width: budgetOfIncome * 100 + '%' }"></div>
</div>
<div class="inc-nums">
<span>수입 <b class="income">{{ won(incomeVal) }}</b></span>
<span>예산 <b>{{ won(totalBudget) }}</b></span>
<span :class="leftover < 0 ? 'expense' : 'income'">
{{ leftover >= 0 ? '여유' : '초과' }} <b>{{ won(Math.abs(leftover)) }}</b>
</span>
</div>
</div>
<p v-else class="inc-hint">예상 수입을 입력하면 설정한 예산과 비교해 여유 금액을 보여줍니다.</p>
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="statuses.length" class="status-list">
<li v-for="s in statuses" :key="s.id" class="status-row">
<div class="srow-top">
<span class="cat">{{ s.category }}<span v-if="s.fixed" class="fixed-badge">고정</span></span>
<span class="amounts">
<span :class="s.remaining < 0 ? 'neg' : ''">{{ won(s.spent) }}</span>
/ {{ won(s.monthlyBudget) }}
</span>
</div>
<div class="bar">
<div class="bar-fill" :class="barClass(s)" :style="{ width: Math.min(ratio(s), 100) + '%' }"></div>
</div>
<div class="srow-bottom">
<span class="ratio" :class="barClass(s)">{{ ratio(s) }}%</span>
<span class="remain">
{{ s.remaining >= 0 ? `잔여 ${won(s.remaining)}` : `초과 ${won(-s.remaining)}` }}
</span>
<span class="acts">
<IconBtn icon="edit" title="수정" size="sm" @click="openEdit(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="remove(s)" />
</span>
</div>
</li>
</ul>
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p>
<!-- 추가/수정 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="formOpen" class="modal-backdrop" @click.self="formOpen = false">
<div class="modal" role="dialog" aria-modal="true">
<button class="close" type="button" @click="formOpen = false">×</button>
<h2>예산 {{ editId ? '수정' : '추가' }}</h2>
<form class="budget-form" @submit.prevent="submit">
<label>카테고리
<select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option>
<template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select>
</label>
<div class="mode">
<label class="radio"><input type="radio" :value="false" v-model="form.fixed" /> 비고정(기간별 직접)</label>
<label class="radio"><input type="radio" :value="true" v-model="form.fixed" /> 고정(자동 환산)</label>
</div>
<!-- 고정 -->
<template v-if="form.fixed">
<div class="fixed-base">
<select v-model="form.baseUnit" :disabled="submitting">
<option value="DAY"></option>
<option value="WEEK"></option>
<option value="MONTH"></option>
</select>
<input v-model.number="form.baseAmount" type="number" min="0" placeholder="기준 금액(원)" :disabled="submitting" />
</div>
<p v-if="fixedPreview" class="preview">
환산 {{ won(fixedPreview.weekly) }} · {{ won(fixedPreview.monthly) }} · {{ won(fixedPreview.yearly) }}
<span class="note">(이번 {{ daysInMonth(year, month) }} / 올해 {{ daysInYear(year) }} 기준)</span>
</p>
</template>
<!-- 비고정 -->
<template v-else>
<label> 예산<input v-model.number="form.daily" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.weekly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<label> 예산<input v-model.number="form.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.budget {
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #c0392b;
color: #c0392b;
}
button.primary {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.mn-spacer {
flex: 1;
}
.mn-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.period {
font-size: 1.1rem;
font-weight: 600;
min-width: 8rem;
text-align: center;
}
/* 예상 수입 vs 예산 */
.income-panel {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 1.25rem;
}
.inc-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
.inc-input label {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.inc-input input {
flex: 1;
min-width: 0;
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.inc-compare {
margin-top: 0.7rem;
}
.inc-bar {
height: 8px;
background: var(--color-background-mute);
border-radius: 999px;
overflow: hidden;
}
.inc-fill {
height: 100%;
border-radius: 999px;
background: #2e7d32;
transition: width 0.3s;
}
.inc-fill.over {
background: #c0392b;
}
.inc-nums {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.4rem;
font-size: 0.85rem;
}
.inc-nums .income {
color: #2e7d32;
}
.inc-nums .expense {
color: #c0392b;
}
.inc-hint {
margin-top: 0.5rem;
font-size: 0.8rem;
opacity: 0.65;
}
.status-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.status-row {
padding: 0.85rem 0;
border-bottom: 1px solid var(--color-border);
}
.srow-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.4rem;
}
.cat {
font-weight: 600;
}
.fixed-badge {
margin-left: 0.4rem;
font-size: 0.7rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.3rem;
opacity: 0.8;
}
.amounts {
font-size: 0.9rem;
}
.amounts .neg {
color: #c0392b;
font-weight: 600;
}
.bar {
height: 8px;
background: var(--color-background-mute);
border-radius: 999px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s;
}
.bar-fill.ok {
background: #2e7d32;
}
.bar-fill.warn {
background: #e67e22;
}
.bar-fill.over {
background: #c0392b;
}
.srow-bottom {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.35rem;
font-size: 0.82rem;
}
.ratio.ok {
color: #2e7d32;
}
.ratio.warn {
color: #e67e22;
}
.ratio.over {
color: #c0392b;
font-weight: 700;
}
.remain {
opacity: 0.8;
}
.acts {
margin-left: auto;
display: flex;
gap: 0.35rem;
}
.acts button {
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.modal h2 {
margin-bottom: 1rem;
font-size: 1.2rem;
text-align: center;
}
.budget-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.budget-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.budget-form input,
.budget-form select {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.mode {
display: flex;
gap: 1rem;
font-size: 0.85rem;
padding: 0.25rem 0;
}
.radio {
flex-direction: row !important;
align-items: center;
gap: 0.3rem !important;
cursor: pointer;
}
.fixed-base {
display: flex;
gap: 0.5rem;
}
.fixed-base select {
width: 5rem;
}
.fixed-base input {
flex: 1;
}
.preview {
font-size: 0.82rem;
background: var(--color-background-soft);
border-radius: 4px;
padding: 0.5rem 0.6rem;
}
.note {
font-size: 0.78rem;
opacity: 0.6;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>