feat: 통계·가계부 UX 개선 + 계좌 탭 한 줄
CI / build (push) Failing after 14m57s

- 통계: 순자산 추이 주간(최근 3개월), 이번달 예상 지출(run-rate), 지난달 대비 지출
- 가계부: 내역→고정지출 바로 등록, 날짜별 접기/펴기(기본 접힘·오늘만 펼침·모두 토글)
- 계좌 관리 탭 한 줄(넘치면 가로 스크롤)
- 릴리스 노트 갱신(23~32)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-22 22:38:22 +09:00
parent 8cc9cbb383
commit b1f85cee52
5 changed files with 256 additions and 19 deletions
+36
View File
@@ -113,6 +113,42 @@
--- ---
# (이어진 작업 3) 현금 계좌 · 통계 · 가계부 UX (2026-06-22)
## 23. 상환/납부 — 연회비(카드)
- 카드 상환 시 **연회비**도 분리 입력(대상이 카드일 때만 노출). 원금=이체 · 이자=지출(분류 '이자') · **연회비=지출(분류 '연회비')**. 대출엔 연회비 입력 불가.
## 24. 현금 계좌
- **계좌 관리에 '현금' 유형 추가**(이름·초기 잔액만). 순자산 계산에 **자산으로 합산**(BANK와 동일 취급).
- **가계부 입력**: 현금 선택 시 등록된 현금 계좌를 고를 수 있고, 없으면 종전처럼 '미지정'으로 기록(하위호환).
- **이체에도 현금 지원**: 출금/입금 종류에 현금 포함 → **ATM 출금(은행→현금)·현금 입금(현금→은행)** 등 현금 계좌 이체 가능. 중복되던 별도 '현금' 라디오 제거(일반 계좌 종류로 통합).
## 25. 고정 지출 — '확인 필요(pending)'로 생성
- 자동 생성되는 고정지출 내역을 **'확인 필요'**로 만들어, 실제 결제·이체를 확인한 뒤 **'확인'으로 확정**(카드 알림과 동일 흐름). 메모 없으면 제목으로 채워 식별 쉽게. → "실제 처리 여부"를 사용자가 판단 가능.
## 26. 가계부 내역 → 고정지출 바로 등록
- 내역 **수정 모달에 「🔁 이 내역을 고정지출로 등록」** 버튼. 현재 항목의 종류·금액·분류·메모·계좌로 **매월** 고정지출 생성(일자=내역 날짜, 시작=다음날로 이번 회차 중복 방지). 주기는 고정지출 화면에서 변경.
## 27. 통계 — 순자산 추이 주간화
- 순자산 추이를 **최근 3개월·주간(월요일 기준 13주)**으로 변경. 백엔드 `weeklyWalletFlow` + `netWorthTrendWeekly` 추가, `/networth/trend?unit=WEEK&weeks=N`. x축 라벨은 격주 표기(겹침 방지).
## 28. 통계 — 이번달 예상 지출(월말 추정)
- 대시보드 '수입 대비 지출'에 **예상 지출** 표시: `현재까지 지출 ÷ 경과일 × 그 달 총일수`(run-rate). 이번 달에만 표시, 예산 대비 %도 함께.
## 29. 통계 — 지난달 대비 이번달 지출
- 같은 패널에 **「지난달 대비」** 행: `±차액 (±%)`(늘면 빨강·줄면 초록), 기준 '지난달 N원' 표기. 선택 월 기준으로 직전 달과 비교.
## 30. 가계부 — 날짜별 접기/펴기
- 내역이 많을 때를 위해 **날짜 그룹 접기/펴기**. 기본은 **접힘 + 오늘만 펼침**(날짜 요약 = 날짜·건수·일 합계), 헤더 탭으로 개별 토글, 상단 **「모두 펴기/접기」** 토글. 월 이동 시 초기화, 내역 편집 시엔 펼침 상태 유지.
## 31. 앱(안드로이드) — 키보드가 입력폼 가리던 문제
- 에지투에지 환경에서 **IME(키보드) 인셋을 콘텐츠 패딩에 반영** → 키보드가 뜨면 WebView가 키보드 위로 줄어들어 입력 모달이 가려지지 않음(`windowSoftInputMode=adjustResize` + 인셋 리스너). 모든 모달 입력 공통.
## 32. 계좌 관리 — 탭 한 줄
- 계좌 종류 탭(은행/현금/카드/대출/투자)이 두 줄로 줄바꿈되던 것 → **한 줄(넘치면 가로 스크롤)**. 버튼 글자 줄바꿈 방지.
---
## 운영 반영 시 체크 ## 운영 반영 시 체크
- 서버 `/opt/sb-backend/.env` - 서버 `/opt/sb-backend/.env`
- `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음). - `ACCOUNT_CRYPTO_KEY` — 계좌번호 암호화를 켤 때만(미설정 시 평문, 깨지지 않음).
+2 -2
View File
@@ -14,8 +14,8 @@ export const accountApi = {
categoryStats({ type, year, month } = {}) { categoryStats({ type, year, month } = {}) {
return http.get('/account/category-stats', { params: { type, year, month } }) return http.get('/account/category-stats', { params: { type, year, month } })
}, },
netWorthTrend({ months } = {}) { netWorthTrend({ months, weeks, unit } = {}) {
return http.get('/account/networth/trend', { params: { months } }) return http.get('/account/networth/trend', { params: { months, weeks, unit } })
}, },
create(payload) { create(payload) {
return http.post('/account/entries', payload) return http.post('/account/entries', payload)
+75 -9
View File
@@ -17,6 +17,7 @@ const budgetTotal = ref(0)
const spentTotal = ref(0) const spentTotal = ref(0)
const monthIncome = ref(0) const monthIncome = ref(0)
const monthExpense = ref(0) const monthExpense = ref(0)
const prevMonthExpense = ref(0) // 선택 월의 직전 달 지출 (대비용)
const barData = ref([]) // [{bucket, budget, expense}] const barData = ref([]) // [{bucket, budget, expense}]
const monthlyStats = ref([]) // MONTH unit of year (income/expense) const monthlyStats = ref([]) // MONTH unit of year (income/expense)
const catType = ref('EXPENSE') // 분류별 파이차트 구분 const catType = ref('EXPENSE') // 분류별 파이차트 구분
@@ -52,6 +53,25 @@ const donutColor = computed(() => {
const ieTotal = computed(() => monthIncome.value + monthExpense.value) const ieTotal = computed(() => monthIncome.value + monthExpense.value)
const incomePct = computed(() => (ieTotal.value > 0 ? (monthIncome.value / ieTotal.value) * 100 : 0)) const incomePct = computed(() => (ieTotal.value > 0 ? (monthIncome.value / ieTotal.value) * 100 : 0))
/* ===== 지난달 대비 이번달 지출 ===== */
const expenseDelta = computed(() => {
const prev = prevMonthExpense.value
const diff = monthExpense.value - prev
const pct = prev > 0 ? Math.round((diff / prev) * 1000) / 10 : null
return { prev, diff, pct }
})
/* ===== 이번달 예상 지출 (실제 지출 추세로 월말 추정, run-rate) =====
경과일까지 실제 지출을 일평균으로 환산해 그 달 총일수로 추정. 이번 달에만 표시. */
const expenseForecast = computed(() => {
const isCurrent = year.value === now.getFullYear() && month.value === now.getMonth() + 1
if (!isCurrent) return null // 지난/미래 달은 추정 불필요(이미 확정 또는 데이터 없음)
const totalDays = daysInMonth(year.value, month.value)
const elapsed = now.getDate()
const projected = elapsed > 0 ? Math.round((monthExpense.value / elapsed) * totalDays) : monthExpense.value
return { projected, elapsed, totalDays }
})
/* ===== 막대 차트 (기간별 예산 대비 지출) ===== */ /* ===== 막대 차트 (기간별 예산 대비 지출) ===== */
const bars = computed(() => { const bars = computed(() => {
const map = {} const map = {}
@@ -123,13 +143,27 @@ const trendView = computed(() => {
const max = Math.max(...vals, 0) const max = Math.max(...vals, 0)
const span = max - min || 1 const span = max - min || 1
const n = data.length const n = data.length
const pts = data.map((d, i) => ({ const weekly = data[0].month.length > 7 // 주간이면 'yyyy-MM-dd'
const pts = data.map((d, i) => {
let label, tip
if (weekly) {
const [, mm, dd] = d.month.split('-')
label = `${+mm}/${+dd}`
tip = `${+mm}/${+dd}`
} else {
label = d.month.slice(5) // MM
tip = d.month
}
return {
x: n === 1 ? 50 : (i / (n - 1)) * 100, x: n === 1 ? 50 : (i / (n - 1)) * 100,
y: 100 - ((d.netWorth - min) / span) * 100, y: 100 - ((d.netWorth - min) / span) * 100,
month: d.month, month: tip,
netWorth: d.netWorth, netWorth: d.netWorth,
label: d.month.slice(5), // MM label,
})) // 점이 많으면(주간) 축 라벨을 격주로만 표기해 겹침 방지
showLabel: n <= 8 || i % 2 === 0 || i === n - 1,
}
})
const line = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') const line = pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
const area = `${pts[0].x.toFixed(2)},100 ` + line + ` ${pts[n - 1].x.toFixed(2)},100` const area = `${pts[0].x.toFixed(2)},100 ` + line + ` ${pts[n - 1].x.toFixed(2)},100`
const zeroY = min < 0 && max > 0 ? 100 - ((0 - min) / span) * 100 : null const zeroY = min < 0 && max > 0 ? 100 - ((0 - min) / span) * 100 : null
@@ -160,7 +194,7 @@ function setCatType(t) {
} }
async function loadTrend() { async function loadTrend() {
try { try {
trendData.value = await accountApi.netWorthTrend({ months: 12 }) trendData.value = await accountApi.netWorthTrend({ unit: 'WEEK', weeks: 13 })
} catch { } catch {
trendData.value = [] trendData.value = []
} }
@@ -188,9 +222,12 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const [status, summary] = await Promise.all([ const pmY = month.value === 1 ? year.value - 1 : year.value
const pmM = month.value === 1 ? 12 : month.value - 1
const [status, summary, prevSummary] = await Promise.all([
accountApi.budgetStatus({ year: year.value, month: month.value }), accountApi.budgetStatus({ year: year.value, month: month.value }),
accountApi.summary({ year: year.value, month: month.value }), accountApi.summary({ year: year.value, month: month.value }),
accountApi.summary({ year: pmY, month: pmM }),
loadBar(), loadBar(),
loadCat(), loadCat(),
]) ])
@@ -198,6 +235,7 @@ async function load() {
spentTotal.value = status.reduce((s, x) => s + x.spent, 0) spentTotal.value = status.reduce((s, x) => s + x.spent, 0)
monthIncome.value = summary.totalIncome monthIncome.value = summary.totalIncome
monthExpense.value = summary.totalExpense monthExpense.value = summary.totalExpense
prevMonthExpense.value = prevSummary.totalExpense
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '불러오지 못했습니다.' error.value = e.response?.data?.message || '불러오지 못했습니다.'
} finally { } finally {
@@ -301,10 +339,23 @@ onMounted(async () => {
<div class="ie"> <div class="ie">
<div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div> <div class="ie-row"><span>수입</span><b class="income">{{ won(monthIncome) }}</b></div>
<div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div> <div class="ie-row"><span>지출</span><b class="expense">{{ won(monthExpense) }}</b></div>
<div v-if="expenseDelta.prev || monthExpense" class="ie-row compare">
<span>지난달 대비 <small>지난달 {{ won(expenseDelta.prev) }}</small></span>
<b :class="expenseDelta.diff > 0 ? 'expense' : 'income'">
{{ expenseDelta.diff >= 0 ? '+' : '' }}{{ won(expenseDelta.diff) }}<template v-if="expenseDelta.pct !== null"> ({{ expenseDelta.pct >= 0 ? '+' : '' }}{{ expenseDelta.pct }}%)</template>
</b>
</div>
<div v-if="expenseForecast" class="ie-row forecast">
<span>예상 지출 <small>월말 추정</small></span>
<b class="expense">{{ won(expenseForecast.projected) }}</b>
</div>
<div class="ie-bar"> <div class="ie-bar">
<div class="ie-fill income" :style="{ width: incomePct + '%' }"></div> <div class="ie-fill income" :style="{ width: incomePct + '%' }"></div>
</div> </div>
<div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div> <div class="ie-row net"><span>수지</span><b :class="monthIncome - monthExpense < 0 ? 'expense' : 'income'">{{ won(monthIncome - monthExpense) }}</b></div>
<p v-if="expenseForecast" class="forecast-note">
최근 추세 기준({{ expenseForecast.elapsed }}/{{ expenseForecast.totalDays }} · 현재 {{ won(monthExpense) }})<template v-if="budgetTotal > 0">, 예산 대비 {{ Math.round((expenseForecast.projected / budgetTotal) * 100) }}%</template>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -377,9 +428,9 @@ onMounted(async () => {
<p v-else class="empty">데이터가 없습니다.</p> <p v-else class="empty">데이터가 없습니다.</p>
</div> </div>
<!-- 순자산 추이 (최근 12개월) --> <!-- 순자산 추이 (최근 3개월, 주간) -->
<div class="panel"> <div class="panel">
<h2>순자산 추이 <span class="sub-note">최근 12개월</span></h2> <h2>순자산 추이 <span class="sub-note">최근 3개월 · 주간</span></h2>
<div v-if="trendView.pts.length" class="trend-wrap" @mouseleave="hideTrendTip"> <div v-if="trendView.pts.length" class="trend-wrap" @mouseleave="hideTrendTip">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="trend-svg"> <svg viewBox="0 0 100 100" preserveAspectRatio="none" class="trend-svg">
<line v-if="trendView.zeroY !== null" x1="0" :y1="trendView.zeroY" x2="100" :y2="trendView.zeroY" <line v-if="trendView.zeroY !== null" x1="0" :y1="trendView.zeroY" x2="100" :y2="trendView.zeroY"
@@ -394,7 +445,7 @@ onMounted(async () => {
@mouseenter="showTrendTip(p)" @mouseenter="showTrendTip(p)"
></div> ></div>
<div class="trend-labels"> <div class="trend-labels">
<span v-for="(p, i) in trendView.pts" :key="i" class="t-axis" :style="{ left: p.x + '%' }">{{ p.label }}</span> <span v-for="(p, i) in trendView.pts" :key="i" class="t-axis" :style="{ left: p.x + '%' }">{{ p.showLabel ? p.label : '' }}</span>
</div> </div>
<div v-if="trendTip.show" class="tooltip trend-tip" :style="{ left: trendTip.x + '%', top: trendTip.y + '%' }"> <div v-if="trendTip.show" class="tooltip trend-tip" :style="{ left: trendTip.x + '%', top: trendTip.y + '%' }">
<div class="t-label">{{ trendTip.month }}</div> <div class="t-label">{{ trendTip.month }}</div>
@@ -523,6 +574,21 @@ h2 {
padding: 0.2rem 0; padding: 0.2rem 0;
font-size: 0.9rem; font-size: 0.9rem;
} }
.ie-row.forecast {
opacity: 0.85;
}
.ie-row.forecast small,
.ie-row.compare small {
font-size: 0.72rem;
opacity: 0.6;
font-weight: 400;
margin-left: 0.25rem;
}
.forecast-note {
margin: 0.3rem 0 0;
font-size: 0.72rem;
opacity: 0.6;
}
.ie-row.net { .ie-row.net {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
margin-top: 0.3rem; margin-top: 0.3rem;
+131 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr' import { imageToBlob, parseReceiptText } from '@/utils/receiptOcr'
@@ -328,6 +328,28 @@ function dayLabel(d) {
return `${dt.getMonth() + 1}${dt.getDate()}일 (${wd})` return `${dt.getMonth() + 1}${dt.getDate()}일 (${wd})`
} }
// ===== 날짜별 접기/펴기 (기본 접힘, 오늘만 펼침) =====
const expandedDates = ref(new Set())
function isExpanded(date) {
return expandedDates.value.has(date)
}
function toggleDate(date) {
const s = new Set(expandedDates.value)
s.has(date) ? s.delete(date) : s.add(date)
expandedDates.value = s
}
const allExpanded = computed(() => {
const days = entriesByDay.value
return days.length > 0 && days.every((g) => expandedDates.value.has(g.date))
})
function toggleAll() {
expandedDates.value = allExpanded.value ? new Set() : new Set(entriesByDay.value.map((g) => g.date))
}
// 월이 바뀌면 모두 접고 오늘 날짜만 펼침 (현재 월일 때만 오늘이 목록에 존재)
watch([year, month], () => {
expandedDates.value = new Set([todayStr()])
}, { immediate: true })
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -511,6 +533,49 @@ async function remove(e) {
} }
} }
// 현재 수정 중인 내역을 '매월' 고정지출로 바로 등록 (주기·시작일은 고정지출 화면에서 변경)
async function registerAsRecurring() {
if (form.type === 'REPAYMENT') return // 고정지출은 수입/지출/이체만
const amount = Number(form.amount)
if (!amount || amount <= 0) {
formError.value = '금액을 올바르게 입력하세요.'
return
}
const title =
(form.memo || '').trim() ||
(form.category || '').trim() ||
(form.type === 'INCOME' ? '정기 수입' : form.type === 'TRANSFER' ? '정기 이체' : '고정 지출')
const base = form.entryDate || todayStr()
const [y, m, d] = base.split('-').map(Number)
const dom = d
// 이번 회차 중복 방지: 시작일을 내역 다음날로 → 다음 발생부터 생성
const start = new Date(y, m - 1, d + 1)
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
if (!confirm(`'${title}'\n매월 ${dom}일 · ${won(amount)} 고정지출로 등록할까요?\n(주기·시작일은 고정지출 화면에서 변경할 수 있어요)`)) return
submitting.value = true
try {
await accountApi.createRecurring({
title,
type: form.type,
amount,
category: form.type === 'TRANSFER' ? null : form.category || null,
memo: form.memo || null,
walletId: form.walletId || null,
toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
frequency: 'MONTHLY',
dayOfMonth: dom,
startDate: startStr,
active: true,
})
formOpen.value = false
alert('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
} catch (e) {
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.'
} finally {
submitting.value = false
}
}
onMounted(async () => { onMounted(async () => {
// 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이) // 진입 시 밀린 정기 거래를 먼저 반영한 뒤 목록을 불러온다 (중복 없이)
try { try {
@@ -596,15 +661,22 @@ onMounted(async () => {
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
<div v-else-if="entries.length" class="day-list"> <div v-else-if="entries.length" class="day-list">
<div class="list-tools">
<button type="button" class="toggle-all" @click="toggleAll">{{ allExpanded ? '모두 접기 ' : '모두 펴기 ' }}</button>
</div>
<section v-for="g in entriesByDay" :key="g.date" class="day-group"> <section v-for="g in entriesByDay" :key="g.date" class="day-group">
<div class="day-head"> <div class="day-head" @click="toggleDate(g.date)">
<span class="day-head-left">
<span class="day-toggle">{{ isExpanded(g.date) ? '▾' : '▸' }}</span>
<span class="day-date">{{ dayLabel(g.date) }}</span> <span class="day-date">{{ dayLabel(g.date) }}</span>
<span v-if="!isExpanded(g.date)" class="day-count">{{ g.items.length }}</span>
</span>
<span class="day-sums"> <span class="day-sums">
<span v-if="g.income" class="income">+{{ won(g.income) }}</span> <span v-if="g.income" class="income">+{{ won(g.income) }}</span>
<span v-if="g.expense" class="expense">-{{ won(g.expense) }}</span> <span v-if="g.expense" class="expense">-{{ won(g.expense) }}</span>
</span> </span>
</div> </div>
<ul class="day-items"> <ul v-show="isExpanded(g.date)" class="day-items">
<li v-for="e in g.items" :key="e.id" class="entry-item" :class="{ 'is-pending': e.pending }"> <li v-for="e in g.items" :key="e.id" class="entry-item" :class="{ 'is-pending': e.pending }">
<div class="ei-line1"> <div class="ei-line1">
<span class="ei-cat"> <span class="ei-cat">
@@ -773,6 +845,14 @@ onMounted(async () => {
<p v-if="formError" class="msg error">{{ formError }}</p> <p v-if="formError" class="msg error">{{ formError }}</p>
<button
v-if="editId && form.type !== 'REPAYMENT'"
type="button"
class="to-recurring"
:disabled="submitting"
@click="registerAsRecurring"
>🔁 내역을 고정지출로 등록</button>
<div class="buttons"> <div class="buttons">
<IconBtn icon="close" title="취소" @click="formOpen = false" /> <IconBtn icon="close" title="취소" @click="formOpen = false" />
<IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" /> <IconBtn icon="save" :title="editId ? '수정' : '등록'" variant="primary" type="submit" :disabled="submitting" />
@@ -924,6 +1004,21 @@ button.primary {
.day-group { .day-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.list-tools {
display: flex;
justify-content: flex-end;
margin-bottom: 0.4rem;
}
.toggle-all {
border: 1px solid var(--color-border);
background: transparent;
color: inherit;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
cursor: pointer;
opacity: 0.8;
}
.day-head { .day-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -931,11 +1026,27 @@ button.primary {
padding: 0.3rem 0.1rem; padding: 0.3rem 0.1rem;
border-bottom: 2px solid var(--color-border); border-bottom: 2px solid var(--color-border);
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
cursor: pointer;
user-select: none;
}
.day-head-left {
display: flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
}
.day-toggle {
font-size: 0.7rem;
opacity: 0.55;
} }
.day-date { .day-date {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
} }
.day-count {
font-size: 0.72rem;
opacity: 0.5;
}
.day-sums { .day-sums {
display: flex; display: flex;
gap: 0.7rem; gap: 0.7rem;
@@ -1348,6 +1459,23 @@ button.primary {
border-radius: 3px; border-radius: 3px;
opacity: 0.85; opacity: 0.85;
} }
.to-recurring {
display: block;
width: 100%;
margin-top: 0.6rem;
padding: 0.55rem;
font-size: 0.85rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
background: transparent;
border: 1px dashed hsla(160, 100%, 37%, 0.6);
border-radius: 8px;
cursor: pointer;
}
.to-recurring:disabled {
opacity: 0.5;
cursor: default;
}
.buttons { .buttons {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+8 -1
View File
@@ -547,6 +547,11 @@ button.primary {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* 한 줄 유지(넘치면 가로 스크롤), 스크롤바 숨김 */
}
.tabs::-webkit-scrollbar {
display: none;
} }
.tab-add { .tab-add {
flex-shrink: 0; flex-shrink: 0;
@@ -556,7 +561,9 @@ button.primary {
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
padding: 0.6rem 1rem; padding: 0.6rem 0.7rem;
white-space: nowrap; /* 버튼 안 글자가 줄바꿈되지 않도록(두 줄 방지) */
flex-shrink: 0;
} }
.tabs button.active { .tabs button.active {
border-bottom-color: hsla(160, 100%, 37%, 1); border-bottom-color: hsla(160, 100%, 37%, 1);