@@ -330,6 +369,35 @@ onMounted(load)
font-weight: 600;
opacity: 0.8;
}
+.pf-head-btns {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+.refresh-btn {
+ padding: 0.25rem 0.6rem;
+ font-size: 0.78rem;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ background: var(--color-background-soft);
+ color: var(--color-text);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.refresh-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+.pf-refresh-msg {
+ font-size: 0.78rem;
+ opacity: 0.7;
+ margin: 0 0 0.4rem;
+}
+.pf-form-hint {
+ font-size: 0.76rem;
+ opacity: 0.6;
+ margin: -0.2rem 0 0;
+}
.pf-add {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
@@ -389,6 +457,12 @@ onMounted(load)
font-size: 0.76rem;
opacity: 0.7;
}
+.h-sub-qty {
+ font-weight: 600;
+}
+.h-sub-price {
+ margin-top: 1px;
+}
.noprice {
color: #e67e22;
}
diff --git a/src/views/account/RecurringView.vue b/src/views/account/RecurringView.vue
index 22f82bd..d9a3f79 100644
--- a/src/views/account/RecurringView.vue
+++ b/src/views/account/RecurringView.vue
@@ -20,7 +20,9 @@ const form = reactive({
amount: null,
category: '',
memo: '',
+ walletKind: '',
walletId: '',
+ toWalletKind: '',
toWalletId: '',
frequency: 'MONTHLY',
dayOfMonth: 1,
@@ -39,6 +41,23 @@ const categoryOptions = computed(() => {
if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names
})
+// 계좌/카드 — 종류(계좌/카드)를 라디오로 고르고, 그 종류만 셀렉트로 노출
+const WALLET_KINDS = [
+ { value: 'BANK', label: '계좌' },
+ { value: 'CARD', label: '카드' },
+]
+const walletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.walletKind))
+const toWalletsOfKind = computed(() => wallets.value.filter((w) => w.type === form.toWalletKind))
+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 won(n) {
return (n ?? 0).toLocaleString('ko-KR')
@@ -52,6 +71,39 @@ function freqLabel(r) {
if (r.frequency === 'MONTHLY') return `매월 ${r.dayOfMonth}일`
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
+}
+
+// 월간/년간 분리 → 카테고리별 그룹 → 결제일순 정렬 → 소계·합계 (활성 건만 합산, 중지 건은 표시만)
+const PERIODS = [
+ { key: 'MONTH', label: '월간 거래', freqs: ['DAILY', 'WEEKLY', 'MONTHLY'] },
+ { key: 'YEAR', label: '년간 거래', freqs: ['YEARLY'] },
+]
+const grouped = computed(() =>
+ PERIODS.map((p) => {
+ const items = recurrings.value.filter((r) => p.freqs.includes(r.frequency))
+ const catMap = {}
+ for (const r of items) {
+ const cat = r.type === 'TRANSFER' ? '이체' : r.category || '미분류'
+ if (!catMap[cat]) catMap[cat] = { category: cat, items: [], subtotal: 0, minKey: Infinity }
+ catMap[cat].items.push(r)
+ 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'))
+ const total = items.filter((r) => r.active).reduce((s, r) => s + (r.amount || 0), 0)
+ 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')}`
@@ -79,7 +131,7 @@ async function load() {
async function runNow() {
try {
const res = await accountApi.runRecurrings()
- alert(`${res.generated}건의 정기 거래가 반영되었습니다.`)
+ alert(`${res.generated}건의 고정 지출가 반영되었습니다.`)
await load()
} catch (e) {
alert(e.response?.data?.message || '반영에 실패했습니다.')
@@ -90,7 +142,7 @@ function openCreate() {
editId.value = null
Object.assign(form, {
title: '', type: 'EXPENSE', amount: null, category: '', memo: '',
- walletId: '', 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
@@ -100,7 +152,8 @@ function openEdit(r) {
editId.value = r.id
Object.assign(form, {
title: r.title, type: r.type, amount: r.amount, category: r.category || '', memo: r.memo || '',
- walletId: r.walletId || '', toWalletId: r.toWalletId || '', frequency: r.frequency,
+ walletKind: walletKindOf(r.walletId), walletId: r.walletId || '',
+ 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,
})
@@ -156,7 +209,7 @@ async function submit() {
}
async function remove(r) {
- if (!confirm(`'${r.title}' 정기 거래를 삭제할까요? (이미 생성된 내역은 유지됩니다)`)) return
+ if (!confirm(`'${r.title}' 고정 지출를 삭제할까요? (이미 생성된 내역은 유지됩니다)`)) return
try {
await accountApi.removeRecurring(r.id)
await load()
@@ -171,11 +224,11 @@ onMounted(load)
등록한 주기에 맞춰 가계부 진입 시 자동으로 내역이 생성됩니다. (중복 없이)
@@ -183,26 +236,38 @@ onMounted(load)
{{ error }}
불러오는 중...
-
- -
-
-
- {{ r.title }}
- {{ typeLabel(r.type) }}
- 중지
-
-
- {{ freqLabel(r) }} · {{ won(r.amount) }} · {{ r.category }}
-
-
다음 {{ r.nextDate }}
+
+
+
+
{{ g.label }}
+ 합계 {{ won(g.total) }}
-
-
-
+
+
+ {{ c.category }}
+ 소계 {{ won(c.subtotal) }}
+
+
+ -
+
+
+ {{ r.title }}
+ {{ typeLabel(r.type) }}
+ 중지
+
+
{{ freqLabel(r) }} · {{ won(r.amount) }}
+
다음 {{ r.nextDate }}
+
+
+
+
+
+
+
-
-
-
등록된 정기 거래가 없습니다.
+
+
+ 등록된 고정 지출가 없습니다.
@@ -210,7 +275,7 @@ onMounted(load)
-
정기 거래 {{ editId ? '수정' : '추가' }}
+
고정 지출 {{ editId ? '수정' : '추가' }}
-
+
-
+
diff --git a/src/views/board/BoardListView.vue b/src/views/board/BoardListView.vue
index 54c28c5..d09a562 100644
--- a/src/views/board/BoardListView.vue
+++ b/src/views/board/BoardListView.vue
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi'
import { formatRelative } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
+import { boardLabel, DEFAULT_BOARD } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
@@ -11,6 +12,10 @@ const router = useRouter()
const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
+// 현재 게시판(카테고리)
+const category = computed(() => route.params.category || DEFAULT_BOARD)
+const boardTitle = computed(() => boardLabel(category.value))
+
const posts = ref([])
const tags = ref([])
const loading = ref(false)
@@ -38,6 +43,7 @@ async function load() {
error.value = null
try {
const res = await boardApi.list({
+ category: category.value,
page: page.value,
size: size.value,
tag: activeTag.value,
@@ -107,14 +113,9 @@ function clearSearch() {
pushQuery({ page: 1, tag: activeTag.value })
}
-// 목록 표시 순번: 전체건수 기준 내림차순 (최신글이 큰 번호)
-function rowNumber(index) {
- return totalElements.value - (page.value - 1) * size.value - index
-}
-
-// 쿼리가 바뀔 때마다(검색·페이지 이동·뒤로가기 포함) 상태 동기화 후 재조회
+// 경로(게시판 전환)·쿼리(검색·페이지·뒤로가기)가 바뀌면 상태 동기화 후 재조회
watch(
- () => route.query,
+ () => route.fullPath,
() => {
syncFromQuery()
load()
@@ -128,8 +129,8 @@ onMounted(loadTags)
- 자유게시판
-
+ {{ boardTitle }}
+
@@ -155,7 +156,6 @@ onMounted(loadTags)
- | 번호 |
제목 |
작성자 |
조회 |
@@ -163,8 +163,7 @@ onMounted(loadTags)
-
- | {{ rowNumber(index) }} |
+
|
🚫 관리자에 의해 제한된 게시글입니다.
@@ -291,7 +290,6 @@ button:disabled {
.post-table tbody tr:hover {
background: var(--color-background-soft);
}
-.col-id,
.col-num {
width: 60px;
text-align: center;
@@ -440,20 +438,14 @@ button:disabled {
font-size: 0.8rem;
opacity: 0.7;
}
- /* 제목(2번째 셀): 첫 줄 전체 */
- .post-table td:nth-child(2) {
+ /* 제목(첫 번째 셀): 첫 줄 전체 */
+ .post-table td:nth-child(1) {
order: 0;
width: 100% !important;
font-size: 0.97rem;
opacity: 1;
font-weight: 500;
}
- .post-table .col-id {
- order: 1;
- }
- .post-table .col-id::before {
- content: '#';
- }
.post-table .col-author {
order: 2;
}
diff --git a/src/views/board/BoardWriteView.vue b/src/views/board/BoardWriteView.vue
index e33d3ad..277a6d6 100644
--- a/src/views/board/BoardWriteView.vue
+++ b/src/views/board/BoardWriteView.vue
@@ -4,10 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi'
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
import IconBtn from '@/components/ui/IconBtn.vue'
+import { DEFAULT_BOARD } from '@/constants/boards'
const route = useRoute()
const router = useRouter()
+const category = route.params.category || DEFAULT_BOARD
const editId = route.params.id || null
const isEdit = computed(() => !!editId)
@@ -62,13 +64,14 @@ async function submit() {
const payload = {
title: title.value.trim(),
content: content.value,
+ category,
tagIds: selectedTagIds.value,
}
try {
const saved = isEdit.value
? await boardApi.update(editId, payload)
: await boardApi.create(payload)
- router.replace(`/board/${saved.id}`)
+ router.replace(`/board/${category}/${saved.id}`)
} catch (e) {
error.value = e.response?.data?.message || '저장에 실패했습니다.'
} finally {
@@ -77,8 +80,8 @@ async function submit() {
}
function cancel() {
- if (isEdit.value) router.replace(`/board/${editId}`)
- else router.replace('/board')
+ if (isEdit.value) router.replace(`/board/${category}/${editId}`)
+ else router.replace(`/board/${category}`)
}
onMounted(async () => {
|