-
-
{{ TABS.find((t) => t.key === form.type)?.label }} {{ editId ? '수정' : '추가' }}
-
-
+
+
+
@@ -893,43 +906,6 @@ button.primary {
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: 360px;
- 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;
-}
.wallet-form {
display: flex;
flex-direction: column;
@@ -949,21 +925,45 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
+/* 배지형 선택 */
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+.field-label {
+ font-size: 0.82rem;
+ opacity: 0.7;
+}
+.chip-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+.chip {
+ padding: 0.28rem 0.75rem;
+ border: 1.5px solid var(--color-border);
+ border-radius: 100px;
+ background: var(--color-background-soft);
+ color: var(--color-text);
+ font-size: 0.82rem;
+ font-weight: 500;
+ cursor: pointer;
+ line-height: 1.4;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+.chip:disabled { opacity: 0.45; cursor: not-allowed; }
+.chip.active {
+ border-color: hsla(160, 100%, 37%, 1);
+ background: hsla(160, 100%, 37%, 1);
+ color: #fff;
+}
.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;
-}
-
@media (max-width: 768px) {
.wallet {
max-width: 100%;
diff --git a/src/views/account/BudgetView.vue b/src/views/account/BudgetView.vue
index 90d9074..79e1c00 100644
--- a/src/views/account/BudgetView.vue
+++ b/src/views/account/BudgetView.vue
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
+import AppModal from '@/components/ui/AppModal.vue'
+import CategoryPicker from '@/components/ui/CategoryPicker.vue'
const dialog = useDialog()
@@ -39,26 +41,6 @@ async function loadCategories() {
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) {
@@ -273,10 +255,97 @@ 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(() => {
load()
loadCategories()
loadIncome()
+ loadOnetime()
})
@@ -349,70 +418,126 @@ onMounted(() => {
설정된 예산이 없습니다. "예산 추가"로 시작하세요.
-
-
-
-
-
-
-
예산 {{ editId ? '수정' : '추가' }}
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- 환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
- (이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)
-
-
+
+
+
+
+
예산 방식
+
+
+
-
-
+
+
+
+
+
+
+
+
+ 환산 — 주 {{ won(fixedPreview.weekly) }} · 월 {{ won(fixedPreview.monthly) }} · 년 {{ won(fixedPreview.yearly) }}
+ (이번 달 {{ daysInMonth(year, month) }}일 / 올해 {{ daysInYear(year) }}일 기준)
+
+
+
+
+
+
+
+
+
+ 예산 대비 지출은 '월 예산' 기준으로 비교됩니다.
+
+
+ {{ formError }}
+
+
+
+
+
+
+
@@ -647,43 +772,6 @@ button.primary {
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;
@@ -703,17 +791,42 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
-.mode {
+/* 배지형 선택 */
+.field {
display: flex;
- gap: 1rem;
- font-size: 0.85rem;
- padding: 0.25rem 0;
+ flex-direction: column;
+ gap: 0.3rem;
}
-.radio {
- flex-direction: row !important;
- align-items: center;
- gap: 0.3rem !important;
+.field-label {
+ font-size: 0.82rem;
+ opacity: 0.7;
+}
+.field-hint {
+ font-size: 0.75rem;
+ opacity: 0.7;
+}
+.chip-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+.chip {
+ padding: 0.28rem 0.75rem;
+ border: 1.5px solid var(--color-border);
+ border-radius: 100px;
+ background: var(--color-background-soft);
+ color: var(--color-text);
+ font-size: 0.82rem;
+ font-weight: 500;
cursor: pointer;
+ line-height: 1.4;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+.chip:disabled { opacity: 0.45; cursor: not-allowed; }
+.chip.active {
+ border-color: hsla(160, 100%, 37%, 1);
+ background: hsla(160, 100%, 37%, 1);
+ color: #fff;
}
.fixed-base {
display: flex;
@@ -741,12 +854,92 @@ button.primary {
gap: 0.5rem;
margin-top: 0.5rem;
}
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity 0.18s ease;
+/* 1회성 예산 */
+.onetime-section {
+ margin-top: 2rem;
+ border-top: 2px solid var(--color-border);
+ padding-top: 1.25rem;
}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
+.onetime-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+.onetime-title {
+ font-size: 1rem;
+ font-weight: 700;
+}
+.onetime-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.onetime-row {
+ padding: 0.85rem 0;
+ border-bottom: 1px solid var(--color-border);
+}
+.ot-top {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin-bottom: 0.4rem;
+}
+.ot-name {
+ font-weight: 600;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ot-status {
+ font-size: 0.7rem;
+ border-radius: 3px;
+ padding: 0.05rem 0.35rem;
+ border: 1px solid;
+ white-space: nowrap;
+}
+.ot-status.active { color: #2e7d32; border-color: #2e7d32; }
+.ot-status.upcoming { color: #1565c0; border-color: #1565c0; }
+.ot-status.ended { opacity: 0.5; }
+.ot-amounts {
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+.ot-amounts .neg { color: #c0392b; font-weight: 600; }
+.ot-sep { opacity: 0.5; margin: 0 0.2rem; }
+.ot-bottom {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-top: 0.35rem;
+ font-size: 0.8rem;
+ flex-wrap: wrap;
+}
+.ot-date { opacity: 0.65; }
+.ot-cat {
+ background: var(--color-background-mute);
+ border-radius: 3px;
+ padding: 0.05rem 0.35rem;
+ font-size: 0.75rem;
+}
+.ot-remain { opacity: 0.85; }
+.ot-remain.neg { color: #c0392b; opacity: 1; }
+.ot-empty { opacity: 0.6; }
+
+/* 날짜 범위 입력 */
+.date-range {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.4rem;
+}
+.date-range label {
+ flex: 1;
+}
+.range-sep {
+ padding-bottom: 0.55rem;
+ opacity: 0.6;
+ font-size: 0.9rem;
}
diff --git a/src/views/account/CategoryView.vue b/src/views/account/CategoryView.vue
index c0ab56f..ef7f118 100644
--- a/src/views/account/CategoryView.vue
+++ b/src/views/account/CategoryView.vue
@@ -1,19 +1,23 @@
@@ -204,55 +121,34 @@ onBeforeUnmount(destroySortables)
-
- ≡ 손잡이를 끌어 순서를 바꾸세요 — 대분류는 소분류와 함께, 소분류는 그 대분류 안에서만 이동. 끝나면 완료.
- 순서변경을 누르면 분류 순서를 바꿀 수 있어요. (평소엔 스크롤 중 실수 방지로 잠겨 있어요)
+ ⠿ 손잡이를 끌어 순서를 바꾸세요. 끝나면 완료.
+ ✏ 이름 수정 · ✕ 삭제 · 대분류 클릭 시 소분류 펼침
{{ error }}
불러오는 중...
-
-
+
+
+
🗂️
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없어요
기본 분류를 불러오면 식비·교통 등이 한 번에 추가됩니다.
-
+
@@ -275,41 +171,14 @@ onBeforeUnmount(destroySortables)
max-width: 560px;
margin: 0 auto;
}
-.head {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-.head-actions {
- display: flex;
- gap: 0.5rem;
-}
-h1 {
- font-size: 1.5rem;
-}
.hint {
font-size: 0.85rem;
opacity: 0.7;
margin: 0.5rem 0 1rem;
}
-input {
- padding: 0.5rem 0.7rem;
- border: 1px solid var(--color-border);
- border-radius: 4px;
- background: var(--color-background-soft);
- color: var(--color-text);
-}
-button {
- padding: 0.45rem 0.85rem;
- border: 1px solid var(--color-border);
- border-radius: 4px;
- background: var(--color-background-soft);
- color: var(--color-text);
- cursor: pointer;
-}
-button.danger {
- border-color: #c0392b;
- color: #c0392b;
+.hint.sm {
+ font-size: 0.78rem;
+ margin: 0 0 0.75rem;
}
.tabs-row {
display: flex;
@@ -317,11 +186,30 @@ button.danger {
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
- flex-wrap: wrap; /* 좁으면 액션 버튼이 아래 줄로 — 탭 텍스트가 깨지지 않게 */
+ flex-wrap: wrap;
}
-.tabs-row .tabs {
+.tabs-row .tabs { flex: none; }
+.tabs {
+ display: flex;
+ gap: 0.25rem;
+ border-bottom: 1px solid var(--color-border);
margin-bottom: 0;
- flex: none; /* 탭은 줄어들지 않음 */
+}
+.tabs button {
+ border: 0;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ background: transparent;
+ padding: 0.6rem 1rem;
+ white-space: nowrap;
+ cursor: pointer;
+ color: var(--color-text);
+ font-size: 0.9rem;
+}
+.tabs button.active {
+ border-bottom-color: hsla(160, 100%, 37%, 1);
+ color: hsla(160, 100%, 37%, 1);
+ font-weight: 600;
}
.tabs-actions {
display: flex;
@@ -330,150 +218,29 @@ button.danger {
flex-wrap: wrap;
justify-content: flex-end;
}
+.text-btn {
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--color-background-soft);
+ color: var(--color-text);
+ padding: 0.4rem 0.75rem;
+ font-size: 0.82rem;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.text-btn:hover { background: var(--color-background-mute); }
.reorder-btn.active {
color: #fff;
background: hsla(160, 100%, 37%, 1);
- border-radius: 6px;
-}
-.tabs {
- display: flex;
- gap: 0.25rem;
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 1rem;
-}
-.tabs button {
- border: 0;
- border-bottom: 2px solid transparent;
- border-radius: 0;
- background: transparent;
- padding: 0.6rem 1rem;
- white-space: nowrap; /* '지출 분류' 두 줄로 깨짐 방지 */
-}
-.tabs button.active {
- border-bottom-color: hsla(160, 100%, 37%, 1);
- color: hsla(160, 100%, 37%, 1);
- font-weight: 600;
-}
-.new-cat {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- margin-bottom: 1rem;
-}
-.nc-line {
- display: flex;
- gap: 0.5rem;
-}
-.nc-line input {
- flex: 1;
- min-width: 0;
-}
-.major-list {
- list-style: none;
- padding-left: 0;
- margin: 0;
-}
-.major-block {
- border: 1px solid var(--color-border);
- border-radius: 8px;
- margin-bottom: 0.5rem;
- overflow: hidden;
-}
-.major-row {
- background: var(--color-background-soft);
- border-bottom: 0 !important;
-}
-.sub-list {
- list-style: none;
- padding: 0 0 0 0.4rem;
- margin: 0;
-}
-.sub-item:last-child {
- border-bottom: 0;
-}
-.cat-row {
- display: flex;
- flex-direction: column;
- gap: 0.35rem;
- padding: 0.5rem 0.5rem;
- border-bottom: 1px solid var(--color-border);
- background: var(--color-background);
-}
-.cat-main {
- display: flex;
- gap: 0.4rem;
- align-items: center;
-}
-.cat-parent {
- display: flex;
- align-items: center;
- gap: 0.4rem;
- padding-left: 1.4rem;
- font-size: 0.8rem;
- opacity: 0.85;
-}
-.cat-parent .pl {
- flex: 0 0 auto;
- opacity: 0.6;
-}
-.cat-parent .parent-sel {
- flex: 1;
- min-width: 0;
-}
-.drag-handle {
- cursor: grab;
- user-select: none;
- opacity: 0.5;
- font-size: 1.1rem;
- padding: 0 0.2rem;
- touch-action: none;
-}
-.drag-handle:active {
- cursor: grabbing;
-}
-.cat-name {
- flex: 1;
- min-width: 0;
-}
-.cat-row.child {
- padding-left: 1.2rem;
-}
-.cat-row.child .cat-name {
- background: var(--color-background-soft);
-}
-.sub-mark {
- opacity: 0.45;
-}
-
-.hint.sm {
- font-size: 0.78rem;
- margin: 0 0 0.75rem;
-}
-.msg {
- margin: 0.75rem 0;
-}
-.msg.error {
- color: #c0392b;
-}
-/* 신규 유저 빈 상태 */
-.empty-state {
- text-align: center;
- padding: 2.2rem 1rem 2rem;
-}
-.empty-emoji {
- font-size: 2.6rem;
-}
-.empty-title {
- margin-top: 0.6rem;
- font-size: 1.05rem;
- font-weight: 700;
- color: var(--color-heading);
-}
-.empty-desc {
- margin-top: 0.3rem;
- font-size: 0.88rem;
- opacity: 0.7;
+ border-color: hsla(160, 100%, 37%, 1);
}
+.msg { margin: 0.75rem 0; font-size: 0.9rem; }
+.msg.error { color: #c0392b; }
+/* 빈 상태 */
+.empty-state { text-align: center; padding: 2.2rem 1rem 2rem; }
+.empty-emoji { font-size: 2.6rem; }
+.empty-title { margin-top: 0.6rem; font-size: 1.05rem; font-weight: 700; color: var(--color-heading); }
+.empty-desc { margin-top: 0.3rem; font-size: 0.88rem; opacity: 0.7; }
.empty-cta {
margin-top: 1.1rem;
padding: 0.7rem 1.6rem;
@@ -485,17 +252,11 @@ button.danger {
font-weight: 700;
cursor: pointer;
}
-.empty-cta:hover {
- background: hsla(160, 100%, 32%, 1);
-}
-/* 인앱 확인 모달 */
+.empty-cta:hover { background: hsla(160, 100%, 32%, 1); }
+/* 확인 모달 */
.modal-backdrop {
- position: fixed;
- inset: 0;
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
+ position: fixed; inset: 0; z-index: 1000;
+ display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
@@ -508,27 +269,9 @@ button.danger {
border: 1px solid var(--color-border);
border-radius: 12px;
}
-.modal h2 {
- font-size: 1.05rem;
- margin: 0 0 0.6rem;
-}
-.confirm-body {
- margin: 0 0 1rem;
- font-size: 0.9rem;
- line-height: 1.5;
- white-space: pre-line;
-}
-.buttons {
- display: flex;
- justify-content: flex-end;
- gap: 0.5rem;
-}
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity 0.15s;
-}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
-}
+.modal h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
+.confirm-body { margin: 0 0 1rem; font-size: 0.9rem; line-height: 1.5; white-space: pre-line; }
+.buttons { display: flex; justify-content: flex-end; gap: 0.5rem; }
+.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
+.fade-enter-from, .fade-leave-to { opacity: 0; }
diff --git a/src/views/account/InvestPortfolio.vue b/src/views/account/InvestPortfolio.vue
index 37129e3..32d68d5 100644
--- a/src/views/account/InvestPortfolio.vue
+++ b/src/views/account/InvestPortfolio.vue
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
+import AppModal from '@/components/ui/AppModal.vue'
const props = defineProps({ walletId: { type: [Number, String], required: true } })
const emit = defineEmits(['changed'])
@@ -339,93 +340,69 @@ onMounted(async () => {
-
-
-
-
-
-
종목 {{ holdingEditId ? '수정' : '추가' }}
-
-
+
+
+
-
-
-
-
-
-
-
{{ tradeHolding?.name }} {{ tradeEditId ? '매매 수정' : (tForm.tradeType === 'SELL' ? '매도' : '매수') }}
-
-
+
+
+
+
-
-
-
-
-
-
-
{{ allTradesHolding?.name }} 매매내역
-
- -
- {{ tDate(t.tradeDate) }}
- {{ t.tradeType === 'SELL' ? '매도' : '매수' }}
- {{ shares(t.quantity) }}주 × {{ won(t.price) }}
- 수수료 {{ won(t.fee) }}
- {{ won(t.amount) }}
-
-
-
-
-
-
-
-
+
+
+
+ -
+ {{ tDate(t.tradeDate) }}
+ {{ t.tradeType === 'SELL' ? '매도' : '매수' }}
+ {{ shares(t.quantity) }}주 × {{ won(t.price) }}
+ 수수료 {{ won(t.fee) }}
+ {{ won(t.amount) }}
+
+
+
+
+
@@ -608,16 +585,7 @@ onMounted(async () => {
.more-trades:hover {
border-color: var(--color-border-hover);
}
-/* 전체보기 모달: 매매목록 아래 매수 모달이 위에 오도록 한 단계 낮게 */
-.modal-backdrop.trades-backdrop {
- z-index: 1090;
-}
-.trades-modal {
- max-width: 420px;
-}
.modal-trades {
- max-height: 60vh;
- overflow-y: auto;
margin-top: 0.5rem;
}
.trade-list {
@@ -662,44 +630,6 @@ onMounted(async () => {
line-height: 1;
}
-/* 모달 (계좌 관리와 동일 톤) */
-.modal-backdrop {
- position: fixed;
- inset: 0;
- z-index: 1100;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.5);
- padding: 1rem;
-}
-.modal {
- position: relative;
- width: 100%;
- max-width: 340px;
- 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;
- cursor: pointer;
-}
-.modal h2 {
- margin-bottom: 1rem;
- font-size: 1.1rem;
- text-align: center;
-}
.pf-form {
display: flex;
flex-direction: column;
@@ -741,15 +671,6 @@ onMounted(async () => {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity 0.18s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
-}
-
/* ===== 모바일: 매매이력 2줄, 글자 세로쪼개짐 방지 ===== */
@media (max-width: 768px) {
.hold-row {
diff --git a/src/views/account/RecurringView.vue b/src/views/account/RecurringView.vue
index 97b41e6..7ac781f 100644
--- a/src/views/account/RecurringView.vue
+++ b/src/views/account/RecurringView.vue
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { accountApi } from '@/api/accountApi'
import { useDialog } from '@/composables/dialog'
import IconBtn from '@/components/ui/IconBtn.vue'
+import AppModal from '@/components/ui/AppModal.vue'
+import CategoryPicker from '@/components/ui/CategoryPicker.vue'
const dialog = useDialog()
@@ -36,30 +38,25 @@ const submitting = ref(false)
const formError = ref(null)
const DOW = ['', '월', '화', '수', '목', '금', '토', '일']
-const categoryOptions = computed(() => {
- const names = categories.value.filter((c) => c.type === form.type).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 === form.type)
- 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 TYPES = [
+ { value: 'EXPENSE', label: '지출', cls: 'chip-expense' },
+ { value: 'INCOME', label: '수입', cls: 'chip-income' },
+ { value: 'TRANSFER', label: '이체', cls: 'chip-transfer' },
+]
+const FREQS = [
+ { value: 'DAILY', label: '매일' },
+ { value: 'WEEKLY', label: '매주' },
+ { value: 'MONTHLY', label: '매월' },
+ { value: 'YEARLY', label: '매년' },
+]
+
+// 계좌/카드 종류
const WALLET_KINDS = [
- { value: 'BANK', label: '계좌' },
- { value: 'CASH', label: '현금' },
- { value: 'CARD', label: '카드' },
+ { value: 'BANK', label: '계좌' },
+ { value: 'CASH', label: '현금' },
+ { value: 'CARD', label: '카드' },
+ { value: 'MINUS', label: '마이너스' },
]
const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
@@ -67,16 +64,11 @@ function walletKindOf(id) {
const w = wallets.value.find((x) => x.id === id)
return w ? w.type : ''
}
-function onWalletKindChange() {
- form.walletId = ''
-}
-function onToWalletKindChange() {
- form.toWalletId = ''
-}
+function onWalletKindChange() { form.walletId = '' }
+function onToWalletKindChange() { form.toWalletId = '' }
+function onTypeChange() { form.category = '' }
-function won(n) {
- return (n ?? 0).toLocaleString('ko-KR')
-}
+function won(n) { return (n ?? 0).toLocaleString('ko-KR') }
function typeLabel(t) {
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
}
@@ -87,18 +79,16 @@ function freqLabel(r) {
return `매년 ${r.monthOfYear}/${r.dayOfMonth}`
}
-// 결제일 정렬 키 (작을수록 먼저): 매일<매주(요일)<매월(일) / 매년(월·일)
function payKey(r) {
if (r.frequency === 'DAILY') return 0
if (r.frequency === 'WEEKLY') return r.dayOfWeek || 0
if (r.frequency === 'YEARLY') return (r.monthOfYear || 0) * 100 + (r.dayOfMonth || 0)
- return r.dayOfMonth || 0 // MONTHLY
+ return r.dayOfMonth || 0
}
-// 월간/년간 분리 → 카테고리별 그룹 → 결제일순 정렬 → 소계·합계 (활성 건만 합산, 중지 건은 표시만)
const PERIODS = [
{ key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] },
- { key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
+ { key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
]
const grouped = computed(() =>
PERIODS.map((p) => {
@@ -111,7 +101,6 @@ const grouped = computed(() =>
catMap[cat].minKey = Math.min(catMap[cat].minKey, payKey(r))
if (r.active) catMap[cat].subtotal += r.amount || 0
}
- // 카테고리 안 항목은 결제일순, 카테고리도 가장 이른 결제일순으로 정렬
const cats = Object.values(catMap)
cats.forEach((c) => c.items.sort((a, b) => payKey(a) - payKey(b)))
cats.sort((a, b) => a.minKey - b.minKey || a.category.localeCompare(b.category, 'ko'))
@@ -119,6 +108,7 @@ const grouped = computed(() =>
return { ...p, cats, total, count: items.length }
}).filter((g) => g.count > 0),
)
+
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
@@ -157,7 +147,8 @@ function openCreate() {
editId.value = null
Object.assign(form, {
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
- walletKind: '', walletId: '', toWalletKind: '', toWalletId: '', frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
+ walletKind: '', walletId: '', toWalletKind: '', toWalletId: '',
+ frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1,
monthOfYear: 1, startDate: todayStr(), endDate: '', active: true,
})
formError.value = null
@@ -168,7 +159,8 @@ function openEdit(r) {
Object.assign(form, {
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
- toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '', frequency: r.frequency,
+ toWalletKind: walletKindOf(r.toWalletId), toWalletId: r.toWalletId || '',
+ frequency: r.frequency,
dayOfMonth: r.dayOfMonth || 1, dayOfWeek: r.dayOfWeek || 1, monthOfYear: r.monthOfYear || 1,
startDate: r.startDate, endDate: r.endDate || '', active: r.active,
})
@@ -178,22 +170,13 @@ function openEdit(r) {
async function submit() {
formError.value = null
- if (!form.title.trim()) {
- formError.value = '이름을 입력하세요.'
- return
- }
- if (!form.amount || form.amount <= 0) {
- formError.value = '금액을 입력하세요.'
- return
- }
+ if (!form.title.trim()) { formError.value = '이름을 입력하세요.'; return }
+ if (!form.amount || form.amount <= 0) { formError.value = '금액을 입력하세요.'; return }
if (form.type === 'TRANSFER' && (!form.walletId || !form.toWalletId || form.walletId === form.toWalletId)) {
formError.value = '이체는 서로 다른 출금/입금 계좌를 선택하세요.'
return
}
- if (!form.startDate) {
- formError.value = '시작일을 입력하세요.'
- return
- }
+ if (!form.startDate) { formError.value = '시작일을 입력하세요.'; return }
submitting.value = true
const payload = {
title: form.title.trim(),
@@ -238,7 +221,7 @@ onMounted(load)
- 등록한 주기에 맞춰 가계부 진입 시 자동으로 ‘확인 필요’ 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 뒤 ‘확인’을 눌러 확정하세요.
+ 등록한 주기에 맞춰 가계부 진입 시 자동으로 '확인 필요' 내역이 생성됩니다(중복 없이). 가계부에서 실제 결제·이체를 확인한 뒤 '확인'을 눌러 확정하세요.
@@ -281,108 +264,114 @@ onMounted(load)
등록된 고정 지출가 없습니다.
-
-
-
-
-
-
고정 지출 {{ editId ? '수정' : '추가' }}
+
+
+
+
-
-
+
+
+
+
+
+
{{ form.type === 'TRANSFER' ? '출금 계좌' : '계좌/카드' }}
+
+
+
+
+
+
+
+
+
입금 계좌
+
+
+
+
+
+
+
+
+ 분류
+
+
+
+
+
+
+
+
주기
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formError }}
+
+
+
+
+
+
+
@@ -391,20 +380,6 @@ onMounted(load)
max-width: 640px;
margin: 0 auto;
}
-.head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 0.5rem;
-}
-.head-actions {
- display: flex;
- gap: 0.5rem;
-}
-h1 {
- font-size: 1.5rem;
-}
.hint {
font-size: 0.85rem;
opacity: 0.7;
@@ -424,24 +399,12 @@ button {
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);
-}
-.recur-groups {
- margin-top: 0.5rem;
-}
-.period-group {
- margin-bottom: 1.5rem;
-}
+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); }
+
+.recur-groups { margin-top: 0.5rem; }
+.period-group { margin-bottom: 1.5rem; }
.period-head {
display: flex;
align-items: baseline;
@@ -450,18 +413,9 @@ button.primary {
border-bottom: 2px solid var(--color-border);
margin-bottom: 0.5rem;
}
-.period-head h2 {
- font-size: 1.1rem;
- font-weight: 700;
-}
-.period-total {
- font-size: 0.95rem;
- font-weight: 700;
- color: hsla(160, 100%, 37%, 1);
-}
-.cat-group {
- margin-bottom: 0.6rem;
-}
+.period-head h2 { font-size: 1.1rem; font-weight: 700; }
+.period-total { font-size: 0.95rem; font-weight: 700; color: hsla(160, 100%, 37%, 1); }
+.cat-group { margin-bottom: 0.6rem; }
.cat-head {
display: flex;
align-items: baseline;
@@ -470,20 +424,9 @@ button.primary {
background: var(--color-background-soft);
border-radius: 4px;
}
-.cat-name {
- font-size: 0.88rem;
- font-weight: 600;
-}
-.cat-subtotal {
- font-size: 0.82rem;
- font-weight: 600;
- opacity: 0.8;
-}
-.recur-list {
- list-style: none;
- padding-left: 0;
- margin: 0;
-}
+.cat-name { font-size: 0.88rem; font-weight: 600; }
+.cat-subtotal { font-size: 0.82rem; font-weight: 600; opacity: 0.8; }
+.recur-list { list-style: none; padding-left: 0; margin: 0; }
.recur-row {
display: flex;
align-items: flex-start;
@@ -492,104 +435,27 @@ button.primary {
padding: 0.7rem 0;
border-bottom: 1px solid var(--color-border);
}
-.info {
- flex: 1;
- min-width: 0;
-}
-.recur-row.inactive {
- opacity: 0.55;
-}
-.line1 {
- display: flex;
- align-items: center;
- gap: 0.4rem;
-}
-.title {
- font-weight: 600;
-}
+.info { flex: 1; min-width: 0; }
+.recur-row.inactive { opacity: 0.55; }
+.line1 { display: flex; align-items: center; gap: 0.4rem; }
+.title { font-weight: 600; }
.type-badge {
font-size: 0.72rem;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.05rem 0.35rem;
}
-.type-badge.income {
- color: #2e7d32;
- border-color: #2e7d32;
-}
-.type-badge.expense {
- color: #c0392b;
- border-color: #c0392b;
-}
-.off {
- font-size: 0.72rem;
- opacity: 0.6;
-}
-.sub {
- font-size: 0.83rem;
- opacity: 0.75;
- margin-top: 0.15rem;
-}
-.next {
- font-size: 0.78rem;
- opacity: 0.6;
- margin-top: 0.1rem;
-}
-.actions {
- display: flex;
- gap: 0.4rem;
- flex-shrink: 0;
-}
-.actions button {
- padding: 0.2rem 0.55rem;
- font-size: 0.82rem;
-}
-.msg {
- margin: 0.75rem 0;
-}
-.msg.error {
- color: #c0392b;
-}
+.type-badge.income { color: #2e7d32; border-color: #2e7d32; }
+.type-badge.expense { color: #c0392b; border-color: #c0392b; }
+.off { font-size: 0.72rem; opacity: 0.6; }
+.sub { font-size: 0.83rem; opacity: 0.75; margin-top: 0.15rem; }
+.next { font-size: 0.78rem; opacity: 0.6; margin-top: 0.1rem; }
+.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
+.actions button { padding: 0.2rem 0.55rem; font-size: 0.82rem; }
+.msg { margin: 0.75rem 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: 360px;
- max-height: 90vh;
- overflow-y: auto;
- 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;
-}
+/* 폼 */
.recur-form {
display: flex;
flex-direction: column;
@@ -601,11 +467,7 @@ button.primary {
gap: 0.25rem;
font-size: 0.85rem;
}
-.recur-form label.row {
- flex-direction: row;
- align-items: center;
- gap: 0.4rem;
-}
+.recur-form label.row { flex-direction: row; align-items: center; gap: 0.4rem; }
.recur-form input,
.recur-form select {
padding: 0.5rem 0.7rem;
@@ -614,66 +476,45 @@ button.primary {
background: var(--color-background-soft);
color: var(--color-text);
}
-.recur-form label.row input {
- padding: 0;
-}
-/* 계좌/카드 라디오 선택 */
+.recur-form label.row input { padding: 0; }
+
+/* 배지형 선택 */
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
-.field-row {
+.field-label { font-size: 0.82rem; opacity: 0.7; }
+.type-chips,
+.wallet-chips {
display: flex;
- align-items: center;
- gap: 0.9rem;
-}
-.field-label {
- font-size: 0.85rem;
- flex-shrink: 0;
-}
-.wallet-radios {
- display: flex;
- flex: 1;
- min-width: 0;
flex-wrap: wrap;
- gap: 0.4rem 0.9rem;
- padding: 0.1rem 0;
+ gap: 0.35rem;
}
-.wallet-radios .radio {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 0.3rem;
- font-size: 0.85rem;
+.chip {
+ padding: 0.28rem 0.75rem;
+ border: 1.5px solid var(--color-border);
+ border-radius: 100px;
+ background: var(--color-background-soft);
+ color: var(--color-text);
+ font-size: 0.82rem;
+ font-weight: 500;
cursor: pointer;
+ line-height: 1.4;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
}
-.wallet-radios .radio input {
- padding: 0;
- width: auto;
- margin: 0;
-}
-.r-issuer {
- margin-left: 0.2rem;
- font-size: 0.72rem;
- opacity: 0.6;
-}
-.empty-hint {
- font-size: 0.78rem;
- opacity: 0.6;
-}
+.chip:disabled { opacity: 0.45; cursor: not-allowed; }
+/* 기본 active (계좌/주기) */
+.chip.active { border-color: hsla(160, 100%, 37%, 1); background: hsla(160, 100%, 37%, 1); color: #fff; }
+/* 구분 배지 type별 색상 */
+.chip-expense.active { border-color: #c0392b; background: #c0392b; }
+.chip-income.active { border-color: #2e7d32; background: #2e7d32; }
+.chip-transfer.active { border-color: #1565c0; background: #1565c0; }
+
.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;
-}