Skip to content

Commit 189ec78

Browse files
authored
[기능추가] [4-6][4-7] 설정 기본/모바일 네비게이션 UI (#116)
* 설정 기본 설정 UI 추가 * 설정 모바일 하위 화면 뒤로가기 헤더 추가 * 리뷰 반영: 기본 설정 goal null 처리
1 parent 58fcf44 commit 189ec78

File tree

2 files changed

+227
-22
lines changed

2 files changed

+227
-22
lines changed

apps/web/src/routes/settings.tsx

Lines changed: 217 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import {
1111
} from '@dnd-kit/core'
1212
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
1313
import { CSS } from '@dnd-kit/utilities'
14+
import { showSuccessSnackbar } from '@repo/ui/components/ui/snackbar'
1415
import { apiFetch, ensureAuthenticated, logout, markSkipLoginRefreshOnce } from '../lib/auth'
1516
import { clearSelectedLedgerId, getSelectedLedgerId } from '../lib/ledger'
1617
import { getThemeMode, setThemeMode, type ThemeMode } from '../lib/theme'
1718

18-
type SettingsSection = 'CATEGORY' | 'ACCOUNT' | 'BUDGET' | 'SCHEDULE' | 'INSTALLMENT' | 'APP'
19+
type SettingsSection = 'CATEGORY' | 'ACCOUNT' | 'BUDGET' | 'SCHEDULE' | 'INSTALLMENT' | 'BASIC' | 'APP'
1920
type CategoryType = 'INCOME' | 'EXPENSE'
2021
type AccountType = 'ASSET' | 'LIABILITY'
2122
type ScheduleWeekend = 'FORWARD' | 'BACKWARD'
@@ -91,13 +92,27 @@ type Installment = {
9192
}
9293
}
9394

95+
type LedgerDetail = {
96+
id: number
97+
bookName: string
98+
goal: string | null
99+
fiscalStartDay: number
100+
}
101+
94102
type WrappedResponse<T> =
95103
| T[]
96104
| {
97105
success?: boolean
98106
data?: T[]
99107
}
100108

109+
type WrappedSingleResponse<T> =
110+
| T
111+
| {
112+
success?: boolean
113+
data?: T
114+
}
115+
101116
async function ensureAuthenticatedAndLedgerSelected() {
102117
const isAuthenticated = await ensureAuthenticated()
103118

@@ -178,9 +193,15 @@ function SettingsPage() {
178193
}
179194

180195
const [section, setSection] = useState<SettingsSection>('CATEGORY')
196+
const [isMobileDetailOpen, setIsMobileDetailOpen] = useState(false)
181197
const [errorMessage, setErrorMessage] = useState<string | null>(null)
182198
const [themeMode, setThemeModeState] = useState<ThemeMode>('system')
183199
const [isLoggingOut, setIsLoggingOut] = useState(false)
200+
const [basicLoading, setBasicLoading] = useState(false)
201+
const [basicSubmitting, setBasicSubmitting] = useState(false)
202+
const [basicBookName, setBasicBookName] = useState('')
203+
const [basicGoal, setBasicGoal] = useState('')
204+
const [basicFiscalStartDay, setBasicFiscalStartDay] = useState('1')
184205

185206
// category states
186207
const [categoryType, setCategoryType] = useState<CategoryType>('EXPENSE')
@@ -334,6 +355,80 @@ function SettingsPage() {
334355
}
335356
}
336357

358+
const fetchBasicSettings = async () => {
359+
if (!ledgerId) return
360+
361+
setBasicLoading(true)
362+
setErrorMessage(null)
363+
364+
try {
365+
const response = await apiFetch(`/api/ledgers/${ledgerId}`)
366+
if (!response.ok) throw new Error(await getApiErrorMessage(response, '기본 설정 조회 실패'))
367+
368+
const payload = (await response.json()) as WrappedSingleResponse<LedgerDetail>
369+
const ledger = (payload && typeof payload === 'object' && 'data' in payload
370+
? payload.data
371+
: payload) as LedgerDetail | undefined
372+
373+
if (!ledger) {
374+
throw new Error('기본 설정 데이터를 확인할 수 없습니다.')
375+
}
376+
377+
setBasicBookName(ledger.bookName)
378+
setBasicGoal(ledger.goal ?? '')
379+
setBasicFiscalStartDay(String(ledger.fiscalStartDay))
380+
} catch (error) {
381+
setErrorMessage(error instanceof Error ? error.message : '기본 설정 조회 중 오류가 발생했습니다.')
382+
} finally {
383+
setBasicLoading(false)
384+
}
385+
}
386+
387+
const updateBasicSettings = async (event: FormEvent<HTMLFormElement>) => {
388+
event.preventDefault()
389+
if (!ledgerId) return
390+
391+
const bookName = basicBookName.trim()
392+
if (!bookName) {
393+
setErrorMessage('가계부 이름을 입력해주세요.')
394+
return
395+
}
396+
397+
const fiscalStartDay = Number(basicFiscalStartDay)
398+
if (!Number.isInteger(fiscalStartDay) || fiscalStartDay < 1 || fiscalStartDay > 28) {
399+
setErrorMessage('회계기준일은 1~28 사이의 정수여야 합니다.')
400+
return
401+
}
402+
403+
setBasicSubmitting(true)
404+
setErrorMessage(null)
405+
406+
try {
407+
const goal = basicGoal.trim()
408+
const payload: { bookName: string; fiscalStartDay: number; goal: string | null } = {
409+
bookName,
410+
fiscalStartDay,
411+
goal: goal === '' ? null : goal,
412+
}
413+
414+
const response = await apiFetch(`/api/ledgers/${ledgerId}`, {
415+
method: 'PATCH',
416+
headers: { 'Content-Type': 'application/json' },
417+
body: JSON.stringify(payload),
418+
})
419+
if (!response.ok) throw new Error(await getApiErrorMessage(response, '기본 설정 저장 실패'))
420+
421+
showSuccessSnackbar('기본 설정이 저장되었습니다.', {
422+
description: '가계부 이름, 목표, 회계기준일이 업데이트되었어요.',
423+
})
424+
await fetchBasicSettings()
425+
} catch (error) {
426+
setErrorMessage(error instanceof Error ? error.message : '기본 설정 저장 중 오류가 발생했습니다.')
427+
} finally {
428+
setBasicSubmitting(false)
429+
}
430+
}
431+
337432
useEffect(() => {
338433
void fetchCategories()
339434
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -363,6 +458,12 @@ function SettingsPage() {
363458
// eslint-disable-next-line react-hooks/exhaustive-deps
364459
}, [section, ledgerId])
365460

461+
useEffect(() => {
462+
if (section !== 'BASIC') return
463+
void fetchBasicSettings()
464+
// eslint-disable-next-line react-hooks/exhaustive-deps
465+
}, [section, ledgerId])
466+
366467
useEffect(() => {
367468
setThemeModeState(getThemeMode())
368469
}, [])
@@ -436,6 +537,16 @@ function SettingsPage() {
436537
{ value: 'light', label: '라이트', description: '항상 밝은 테마를 사용합니다.' },
437538
{ value: 'dark', label: '다크', description: '항상 어두운 테마를 사용합니다.' },
438539
]
540+
const settingSectionItems: Array<{ key: SettingsSection; label: string }> = [
541+
{ key: 'CATEGORY', label: '카테고리' },
542+
{ key: 'ACCOUNT', label: '계좌' },
543+
{ key: 'BUDGET', label: '예산' },
544+
{ key: 'SCHEDULE', label: '반복 일정' },
545+
{ key: 'INSTALLMENT', label: '할부' },
546+
{ key: 'BASIC', label: '기본 설정' },
547+
{ key: 'APP', label: '앱 설정' },
548+
]
549+
const currentSectionLabel = settingSectionItems.find((item) => item.key === section)?.label ?? '설정'
439550

440551
const scheduleCategoryNameMap = useMemo(
441552
() => new Map<number, string>(categories.map((category) => [category.id, category.minor])),
@@ -1294,20 +1405,34 @@ function SettingsPage() {
12941405
<div className="space-y-2">
12951406
<h1 className="text-2xl font-bold">설정</h1>
12961407
<p className="text-sm text-muted-foreground">
1297-
카테고리, 계좌, 예산, 반복 일정, 할부, 앱 설정을 관리할 수 있습니다.
1408+
카테고리, 계좌, 예산, 반복 일정, 할부, 기본 설정, 앱 설정을 관리할 수 있습니다.
12981409
</p>
12991410
</div>
13001411

1301-
<section className="rounded-xl border bg-card p-4 shadow-sm">
1302-
<div className="flex gap-2">
1303-
{([
1304-
{ key: 'CATEGORY', label: '카테고리' },
1305-
{ key: 'ACCOUNT', label: '계좌' },
1306-
{ key: 'BUDGET', label: '예산' },
1307-
{ key: 'SCHEDULE', label: '반복 일정' },
1308-
{ key: 'INSTALLMENT', label: '할부' },
1309-
{ key: 'APP', label: '앱 설정' },
1310-
] as const).map((item) => (
1412+
{!isMobileDetailOpen ? (
1413+
<section className="rounded-xl border bg-card p-4 shadow-sm md:hidden">
1414+
<div className="space-y-2">
1415+
{settingSectionItems.map((item) => (
1416+
<button
1417+
key={item.key}
1418+
type="button"
1419+
className="flex w-full items-center justify-between rounded-md border bg-background px-3 py-3 text-left text-sm font-medium hover:bg-accent"
1420+
onClick={() => {
1421+
setSection(item.key)
1422+
setIsMobileDetailOpen(true)
1423+
}}
1424+
>
1425+
<span>{item.label}</span>
1426+
<span className="text-muted-foreground"></span>
1427+
</button>
1428+
))}
1429+
</div>
1430+
</section>
1431+
) : null}
1432+
1433+
<section className="hidden rounded-xl border bg-card p-4 shadow-sm md:block">
1434+
<div className="flex flex-wrap gap-2">
1435+
{settingSectionItems.map((item) => (
13111436
<button
13121437
key={item.key}
13131438
type="button"
@@ -1324,9 +1449,26 @@ function SettingsPage() {
13241449
</div>
13251450
</section>
13261451

1327-
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
1452+
{isMobileDetailOpen ? (
1453+
<section className="rounded-xl border bg-card p-3 shadow-sm md:hidden">
1454+
<div className="flex items-center gap-2">
1455+
<button
1456+
type="button"
1457+
className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md border bg-background text-lg leading-none hover:bg-accent"
1458+
onClick={() => setIsMobileDetailOpen(false)}
1459+
aria-label="설정 메인으로 돌아가기"
1460+
>
1461+
1462+
</button>
1463+
<h2 className="text-base font-semibold">{currentSectionLabel}</h2>
1464+
</div>
1465+
</section>
1466+
) : null}
1467+
1468+
<div className={isMobileDetailOpen ? '' : 'hidden md:block'}>
1469+
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
13281470

1329-
{section === 'BUDGET' ? (
1471+
{section === 'BUDGET' ? (
13301472
<>
13311473
{/* 월 선택 헤더 */}
13321474
<section className="rounded-xl border bg-card p-4 shadow-sm">
@@ -2056,6 +2198,66 @@ function SettingsPage() {
20562198
)}
20572199
</section>
20582200
</>
2201+
) : section === 'BASIC' ? (
2202+
<>
2203+
<section className="rounded-xl border bg-card p-4 shadow-sm">
2204+
{basicLoading ? (
2205+
<p className="text-sm text-muted-foreground">기본 설정 불러오는 중...</p>
2206+
) : (
2207+
<form className="grid gap-3 md:grid-cols-2" onSubmit={updateBasicSettings}>
2208+
<label className="md:col-span-2 flex flex-col gap-1 text-sm">
2209+
<span className="text-xs text-muted-foreground">가계부 이름</span>
2210+
<input
2211+
className="rounded-md border bg-background px-3 py-2 text-sm"
2212+
value={basicBookName}
2213+
onChange={(event) => setBasicBookName(event.target.value)}
2214+
placeholder="예: 우리집 가계부"
2215+
maxLength={100}
2216+
/>
2217+
</label>
2218+
2219+
<label className="md:col-span-2 flex flex-col gap-1 text-sm">
2220+
<span className="text-xs text-muted-foreground">목표 문구</span>
2221+
<input
2222+
className="rounded-md border bg-background px-3 py-2 text-sm"
2223+
value={basicGoal}
2224+
onChange={(event) => setBasicGoal(event.target.value)}
2225+
placeholder="예: 월 50만원 저축"
2226+
maxLength={200}
2227+
/>
2228+
</label>
2229+
2230+
<label className="flex flex-col gap-1 text-sm">
2231+
<span className="text-xs text-muted-foreground">회계기준일 (1~28)</span>
2232+
<input
2233+
className="rounded-md border bg-background px-3 py-2 text-sm"
2234+
type="number"
2235+
min={1}
2236+
max={28}
2237+
step={1}
2238+
value={basicFiscalStartDay}
2239+
onChange={(event) => setBasicFiscalStartDay(event.target.value)}
2240+
placeholder="1"
2241+
/>
2242+
</label>
2243+
2244+
<p className="flex items-end text-xs text-muted-foreground">
2245+
예: 25일로 설정하면 매월 25일 ~ 다음 달 24일을 한 달로 집계합니다.
2246+
</p>
2247+
2248+
<div className="md:col-span-2">
2249+
<button
2250+
type="submit"
2251+
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
2252+
disabled={basicSubmitting || !basicBookName.trim()}
2253+
>
2254+
{basicSubmitting ? '저장 중...' : '기본 설정 저장'}
2255+
</button>
2256+
</div>
2257+
</form>
2258+
)}
2259+
</section>
2260+
</>
20592261
) : section === 'APP' ? (
20602262
<>
20612263
<section className="rounded-xl border bg-card p-4 shadow-sm">
@@ -2249,6 +2451,7 @@ function SettingsPage() {
22492451
</section>
22502452
</>
22512453
)}
2454+
</div>
22522455
</div>
22532456
)
22542457
}

memories/task.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -557,25 +557,27 @@
557557
- **의존성:** 1-7
558558

559559
### [4-6] 설정 — 기본 설정 UI
560-
- **상태:** 대기
560+
- **상태:** 완료
561561
- **범위:** `apps/web`
562562
- **설명:** 설정 > 기본 설정 하위 화면. 가계부 이름, 목표 문구, 회계기준일(1~28) 수정 폼. PATCH API 호출.
563+
- **진행 메모 (2026-03-31):** `apps/web/src/routes/settings.tsx``기본 설정` 섹션을 추가해 `GET /api/ledgers/:ledgerId`로 현재 값을 로드하고 `PATCH /api/ledgers/:ledgerId`로 저장하도록 연결. 회계기준일은 프론트에서 1~28 범위를 검증하고, 저장 성공 시 성공 스낵바를 노출하도록 반영.
563564
- **참조:** screen-design §6-11, db-schema §2
564565
- **완료 조건:**
565-
- 가계부 이름·목표·회계기준일 수정 동작
566-
- 회계기준일 1~28 범위 검증
567-
- 저장 시 성공 스낵바
566+
- 가계부 이름·목표·회계기준일 수정 동작
567+
- 회계기준일 1~28 범위 검증
568+
- 저장 시 성공 스낵바
568569
- **의존성:** 1-5, 1-8
569570

570571
### [4-7] 설정 하위 화면 뒤로가기 헤더
571-
- **상태:** 대기
572+
- **상태:** 완료
572573
- **범위:** `apps/web`
573574
- **설명:** 모바일에서 설정 하위 화면(카테고리, 계좌, 반복 일정, 예산, 할부, 기본 설정, 앱 설정) 진입 시 상단에 ← 아이콘 + 화면명 헤더 표시. 데스크톱에서는 마스터-디테일 레이아웃이므로 미적용.
575+
- **진행 메모 (2026-03-31):** `apps/web/src/routes/settings.tsx`에 모바일 전용 설정 메인 메뉴(리스트)와 하위 화면 상태를 추가하고, 하위 화면 진입 시 ← 버튼 + 화면명 헤더를 노출하도록 반영. 뒤로가기 버튼으로 모바일 설정 메인으로 복귀되며, 데스크톱(`md`)에서는 기존 섹션 탭과 콘텐츠만 노출되도록 분기했다.
574576
- **참조:** screen-design §2 설정, §6-11
575577
- **완료 조건:**
576-
- 모바일에서 모든 설정 하위 화면에 뒤로가기 헤더 표시
577-
- ← 탭 시 설정 메인으로 복귀
578-
- 데스크톱에서는 뒤로가기 헤더 미노출
578+
- 모바일에서 모든 설정 하위 화면에 뒤로가기 헤더 표시
579+
- ← 탭 시 설정 메인으로 복귀
580+
- 데스크톱에서는 뒤로가기 헤더 미노출
579581
- **의존성:** 1-7
580582

581583
### [4-8] 모바일 반응형 최적화

0 commit comments

Comments
 (0)