@@ -11,11 +11,12 @@ import {
1111} from '@dnd-kit/core'
1212import { SortableContext , useSortable , verticalListSortingStrategy } from '@dnd-kit/sortable'
1313import { CSS } from '@dnd-kit/utilities'
14+ import { showSuccessSnackbar } from '@repo/ui/components/ui/snackbar'
1415import { apiFetch , ensureAuthenticated , logout , markSkipLoginRefreshOnce } from '../lib/auth'
1516import { clearSelectedLedgerId , getSelectedLedgerId } from '../lib/ledger'
1617import { 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'
1920type CategoryType = 'INCOME' | 'EXPENSE'
2021type AccountType = 'ASSET' | 'LIABILITY'
2122type 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+
94102type 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+
101116async 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}
0 commit comments