feat(budget): 예산 추가를 '월 예산'만으로 단순화 + 1회성 예산 제거
Deploy / deploy (push) Failing after 12m24s

- 예산 폼: 고정/비고정 방식·일/주/년 예산 입력 제거 → 월 예산 단일 입력
- 1회성 예산 섹션·모달·관련 로직 전체 제거(백엔드 엔드포인트는 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-09 22:14:05 +09:00
parent d63c6997c9
commit 28712a026d
+10 -243
View File
@@ -21,13 +21,7 @@ const formOpen = ref(false)
const editId = ref(null) const editId = ref(null)
const form = reactive({ const form = reactive({
category: '', category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null, monthly: null,
yearly: null,
}) })
const submitting = ref(false) const submitting = ref(false)
const formError = ref(null) const formError = ref(null)
@@ -46,28 +40,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 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) { function ratio(s) {
if (!s.monthlyBudget) return 0 if (!s.monthlyBudget) return 0
return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999) return Math.min(Math.round((s.spent / s.monthlyBudget) * 100), 999)
@@ -178,16 +150,7 @@ async function copyFromPrevMonth() {
function openCreate() { function openCreate() {
editId.value = null editId.value = null
Object.assign(form, { Object.assign(form, { category: '', monthly: null })
category: '',
fixed: false,
baseUnit: 'DAY',
baseAmount: null,
daily: null,
weekly: null,
monthly: null,
yearly: null,
})
formError.value = null formError.value = null
formOpen.value = true formOpen.value = true
} }
@@ -195,16 +158,7 @@ function openEdit(s) {
const b = budgetsRaw.value.find((x) => x.id === s.id) const b = budgetsRaw.value.find((x) => x.id === s.id)
if (!b) return if (!b) return
editId.value = b.id editId.value = b.id
Object.assign(form, { Object.assign(form, { category: b.category, monthly: b.monthly })
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 formError.value = null
formOpen.value = true formOpen.value = true
} }
@@ -215,22 +169,17 @@ async function submit() {
formError.value = '카테고리를 입력하세요.' formError.value = '카테고리를 입력하세요.'
return return
} }
let payload if (!form.monthly || form.monthly <= 0) {
if (form.fixed) { formError.value = '월 예산 금액을 입력하세요.'
if (!form.baseAmount || form.baseAmount <= 0) {
formError.value = '기준 금액을 입력하세요.'
return return
} }
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) } const payload = {
} else {
payload = {
category: form.category.trim(), category: form.category.trim(),
fixed: false, fixed: false,
daily: form.daily != null ? Number(form.daily) : null, daily: null,
weekly: form.weekly != null ? Number(form.weekly) : null, weekly: null,
monthly: form.monthly != null ? Number(form.monthly) : null, monthly: Number(form.monthly),
yearly: form.yearly != null ? Number(form.yearly) : null, yearly: null,
}
} }
submitting.value = true submitting.value = true
try { try {
@@ -255,97 +204,10 @@ async function remove(s) {
} }
} }
// ===== 1회성 예산 =====
const onetimes = ref([])
const onetimeFormOpen = ref(false)
const onetimeEditId = ref(null)
const onetimeForm = reactive({ title: '', category: '', amount: null, startDate: '', endDate: '' })
const onetimeSubmitting = ref(false)
const onetimeFormError = ref(null)
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function loadOnetime() {
try { onetimes.value = await accountApi.onetimeBudgets() }
catch { onetimes.value = [] }
}
function statusLabel(s) {
return s === 'UPCOMING' ? '예정' : s === 'ACTIVE' ? '진행' : '종료'
}
function onetimeRatio(o) {
if (!o.amount) return 0
return Math.min(Math.round((o.spent / o.amount) * 100), 999)
}
function onetimeBarClass(o) {
const r = o.amount ? o.spent / o.amount : 0
return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok'
}
function openOnetimeCreate() {
onetimeEditId.value = null
Object.assign(onetimeForm, { title: '', category: '', amount: null, startDate: todayStr(), endDate: '' })
onetimeFormError.value = null
onetimeFormOpen.value = true
}
function openOnetimeEdit(o) {
onetimeEditId.value = o.id
Object.assign(onetimeForm, {
title: o.title,
category: o.category || '',
amount: o.amount,
startDate: o.startDate,
endDate: o.endDate,
})
onetimeFormError.value = null
onetimeFormOpen.value = true
}
async function submitOnetime() {
onetimeFormError.value = null
if (!onetimeForm.title.trim()) { onetimeFormError.value = '제목을 입력하세요.'; return }
if (!onetimeForm.amount || onetimeForm.amount <= 0) { onetimeFormError.value = '금액을 입력하세요.'; return }
if (!onetimeForm.startDate) { onetimeFormError.value = '시작일을 입력하세요.'; return }
if (!onetimeForm.endDate) { onetimeFormError.value = '종료일을 입력하세요.'; return }
if (onetimeForm.startDate > onetimeForm.endDate) { onetimeFormError.value = '종료일이 시작일보다 빠를 수 없습니다.'; return }
const payload = {
title: onetimeForm.title.trim(),
category: onetimeForm.category || null,
amount: Number(onetimeForm.amount),
startDate: onetimeForm.startDate,
endDate: onetimeForm.endDate,
}
onetimeSubmitting.value = true
try {
if (onetimeEditId.value) await accountApi.updateOnetimeBudget(onetimeEditId.value, payload)
else await accountApi.createOnetimeBudget(payload)
onetimeFormOpen.value = false
await loadOnetime()
} catch (e) {
onetimeFormError.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
onetimeSubmitting.value = false
}
}
async function removeOnetime(o) {
if (!(await dialog.confirm(`'${o.title}' 1회성 예산을 삭제할까요?`, { title: '삭제', danger: true }))) return
try {
await accountApi.removeOnetimeBudget(o.id)
await loadOnetime()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
}
onMounted(() => { onMounted(() => {
load() load()
loadCategories() loadCategories()
loadIncome() loadIncome()
loadOnetime()
}) })
</script> </script>
@@ -418,71 +280,6 @@ onMounted(() => {
</ul> </ul>
<p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p> <p v-else-if="!loading" class="msg">설정된 예산이 없습니다. "예산 추가" 시작하세요.</p>
<!-- 1회성 예산 섹션 -->
<div class="onetime-section">
<div class="onetime-header">
<span class="onetime-title">1회성 예산</span>
<IconBtn icon="plus" title="1회성 예산 추가" variant="primary" size="sm" @click="openOnetimeCreate" />
</div>
<ul v-if="onetimes.length" class="onetime-list">
<li v-for="o in onetimes" :key="o.id" class="onetime-row">
<div class="ot-top">
<span class="ot-name">{{ o.title }}</span>
<span class="ot-status" :class="o.status.toLowerCase()">{{ statusLabel(o.status) }}</span>
<span class="ot-amounts">
<span :class="o.remaining < 0 ? 'neg' : ''">{{ won(o.spent) }}</span>
<span class="ot-sep">/</span>
{{ won(o.amount) }}
</span>
</div>
<div class="bar">
<div class="bar-fill" :class="onetimeBarClass(o)" :style="{ width: Math.min(onetimeRatio(o), 100) + '%' }"></div>
</div>
<div class="ot-bottom">
<span class="ot-date">{{ o.startDate }} ~ {{ o.endDate }}</span>
<span v-if="o.category" class="ot-cat">{{ o.category }}</span>
<span class="ot-remain" :class="o.remaining < 0 ? 'neg' : ''">
{{ o.remaining >= 0 ? `잔여 ${won(o.remaining)}` : `초과 ${won(-o.remaining)}` }}
</span>
<span class="acts">
<IconBtn icon="edit" title="수정" size="sm" @click="openOnetimeEdit(o)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeOnetime(o)" />
</span>
</div>
</li>
</ul>
<p v-else class="msg ot-empty">등록된 1회성 예산이 없습니다.</p>
</div>
<!-- 1회성 예산 추가/수정 모달 -->
<AppModal v-model="onetimeFormOpen" :title="`1회성 예산 ${onetimeEditId ? '수정' : '추가'}`">
<form class="budget-form" @submit.prevent="submitOnetime">
<label>제목<input v-model="onetimeForm.title" type="text" placeholder="예: 7월 여행, 추석 준비" :disabled="onetimeSubmitting" /></label>
<div class="field">
<span class="field-label">카테고리 <span class="field-hint">(선택 비우면 전체 지출)</span></span>
<CategoryPicker
v-model="onetimeForm.category"
type="EXPENSE"
:categories="categories"
:disabled="onetimeSubmitting"
@category-added="loadCategories"
/>
</div>
<label>예산 금액<input v-model.number="onetimeForm.amount" type="number" min="1" placeholder="원" :disabled="onetimeSubmitting" /></label>
<div class="date-range">
<label>시작일<input v-model="onetimeForm.startDate" type="date" :disabled="onetimeSubmitting" /></label>
<span class="range-sep">~</span>
<label>종료일<input v-model="onetimeForm.endDate" type="date" :disabled="onetimeSubmitting" /></label>
</div>
<p v-if="onetimeFormError" class="msg error">{{ onetimeFormError }}</p>
<div class="buttons">
<IconBtn icon="close" title="취소" @click="onetimeFormOpen = false" />
<IconBtn icon="save" :title="onetimeEditId ? '수정' : '등록'" variant="primary" type="submit" :disabled="onetimeSubmitting" />
</div>
</form>
</AppModal>
<!-- 추가/수정 모달 (월별) --> <!-- 추가/수정 모달 (월별) -->
<AppModal v-model="formOpen" :title="`예산 ${editId ? '수정' : '추가'}`"> <AppModal v-model="formOpen" :title="`예산 ${editId ? '수정' : '추가'}`">
<form class="budget-form" @submit.prevent="submit"> <form class="budget-form" @submit.prevent="submit">
@@ -497,38 +294,8 @@ onMounted(() => {
/> />
</div> </div>
<div class="field">
<span class="field-label">예산 방식</span>
<div class="chip-group">
<button type="button" class="chip" :class="{ active: !form.fixed }" :disabled="submitting" @click="form.fixed = false">비고정 (기간별 직접)</button>
<button type="button" class="chip" :class="{ active: form.fixed }" :disabled="submitting" @click="form.fixed = true">고정 (자동 환산)</button>
</div>
</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.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>
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
</template>
<p v-if="formError" class="msg error">{{ formError }}</p> <p v-if="formError" class="msg error">{{ formError }}</p>