- 예산 폼: 고정/비고정 방식·일/주/년 예산 입력 제거 → 월 예산 단일 입력 - 1회성 예산 섹션·모달·관련 로직 전체 제거(백엔드 엔드포인트는 유지) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
return
|
||||||
formError.value = '기준 금액을 입력하세요.'
|
}
|
||||||
return
|
const payload = {
|
||||||
}
|
category: form.category.trim(),
|
||||||
payload = { category: form.category.trim(), fixed: true, baseUnit: form.baseUnit, baseAmount: Number(form.baseAmount) }
|
fixed: false,
|
||||||
} else {
|
daily: null,
|
||||||
payload = {
|
weekly: null,
|
||||||
category: form.category.trim(),
|
monthly: Number(form.monthly),
|
||||||
fixed: false,
|
yearly: null,
|
||||||
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
|
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">
|
<label>월 예산<input v-model.number="form.monthly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
||||||
<span class="field-label">예산 방식</span>
|
<p class="note">설정한 월 예산 기준으로 이번 달 지출과 비교됩니다.</p>
|
||||||
<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.yearly" type="number" min="0" placeholder="원" :disabled="submitting" /></label>
|
|
||||||
<p class="note">예산 대비 지출은 '월 예산' 기준으로 비교됩니다.</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p v-if="formError" class="msg error">{{ formError }}</p>
|
<p v-if="formError" class="msg error">{{ formError }}</p>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user