Skip to content

Commit edb35c7

Browse files
SOIVclaude
andcommitted
feat(ledger): 예산 설정·CSV 가져오기·영수증 첨부 + 모듈 서브 네비 인프라
[Ledger 기능] - 카테고리별 예산 한도 (002_budget.sql, budget_limit 필드, 차트 뷰 예산 바) - CSV 가져오기 (csv-import.ts: RFC4180 파서, EUC-KR 감지, 은행·카드사 포맷 자동 감지, 중복 판정, 2단계 모달 — 미리보기 → 열 매핑 → 가져오기) - 영수증 첨부 (003_receipt.sql, receipt_path 필드, 상세 패널 업로드·보기·삭제) - 카테고리 인라인 편집 시 예산 입력 필드 추가 - routes: POST/GET/DELETE /entries/:id/receipt, POST /import/preview·commit [모듈 서브 네비 인프라] - moduleConfig.ts: 모듈별 서브 라우트 정의 레지스트리 - main.tsx: 해시 서브라우트 파싱 (ledger/xxx → base=ledger, sub=xxx) - AppShell: 활성 모듈 아래 서브 항목 들여쓰기 렌더 (접힘 시 숨김) - shell.css: .shell-subnav-* 스타일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1c41b82 commit edb35c7

14 files changed

Lines changed: 2956 additions & 115 deletions

File tree

apps/web/src/components/AppShell.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { type ReactNode, useState, useEffect } from "react";
22
import "../styles/shell.css";
33

44
import { apiFetch } from "../lib/apiFetch";
5+
import type { ModuleSubRoute } from "../moduleConfig";
56

67
// 코어 라우트 + 설치된 모듈 이름도 RouteKey에 포함 (가계부: "ledger" 등)
78
export type CoreRouteKey = "login" | "forgot-password" | "home" | "marketplace" | "admin" | "change-password";
89
export type RouteKey = CoreRouteKey | string;
910

1011
interface AppShellProps {
1112
route: RouteKey;
13+
/** 현재 모듈 내 서브 라우트 ("" = 기본, "import" 등) */
14+
subRoute?: string;
15+
/** 모듈명 → 서브 라우트 목록 매핑 */
16+
moduleSubNav?: Record<string, ModuleSubRoute[]>;
1217
isAdmin: boolean;
1318
currentUser: { email: string } | null;
1419
notice: string;
@@ -42,6 +47,8 @@ function handleNavKeyDown(e: React.KeyboardEvent<HTMLUListElement>) {
4247

4348
export function AppShell({
4449
route,
50+
subRoute = "",
51+
moduleSubNav = {},
4552
isAdmin,
4653
currentUser,
4754
notice,
@@ -161,20 +168,50 @@ export function AppShell({
161168
<p className="shell-nav-label" aria-hidden="true">Modules</p>
162169
<ul className="shell-nav-list" aria-label="설치된 모듈" onKeyDown={handleNavKeyDown}>
163170
{sidebarModules.length > 0 ? (
164-
sidebarModules.map((mod) => (
165-
<li key={mod.name}>
166-
<button
167-
type="button"
168-
className="shell-nav-item"
169-
data-label={mod.displayName || mod.name}
170-
aria-current={route === mod.name ? "page" : undefined}
171-
onClick={() => { window.location.hash = mod.name; closeMobileMenu(); }}
172-
>
173-
<span className="shell-nav-icon" aria-hidden="true">📦</span>
174-
<span className="shell-nav-text">{mod.displayName || mod.name}</span>
175-
</button>
176-
</li>
177-
))
171+
sidebarModules.map((mod) => {
172+
const isActive = route === mod.name;
173+
const subItems = moduleSubNav[mod.name];
174+
return (
175+
<li key={mod.name}>
176+
{/* 모듈 루트 버튼 */}
177+
<button
178+
type="button"
179+
className="shell-nav-item"
180+
data-label={mod.displayName || mod.name}
181+
aria-current={isActive && !subRoute ? "page" : undefined}
182+
onClick={() => { window.location.hash = mod.name; closeMobileMenu(); }}
183+
>
184+
<span className="shell-nav-icon" aria-hidden="true">📦</span>
185+
<span className="shell-nav-text">{mod.displayName || mod.name}</span>
186+
</button>
187+
188+
{/* 서브 네비게이션 — 해당 모듈이 활성화됐을 때만 표시 */}
189+
{isActive && subItems && subItems.length > 0 && (
190+
<ul className="shell-subnav-list" aria-label={`${mod.displayName} 하위 메뉴`}>
191+
{subItems.map((sub) => {
192+
const hash = sub.key ? `${mod.name}/${sub.key}` : mod.name;
193+
const isSubActive = subRoute === sub.key;
194+
return (
195+
<li key={sub.key}>
196+
<button
197+
type="button"
198+
className="shell-subnav-item"
199+
aria-current={isSubActive ? "page" : undefined}
200+
onClick={() => { window.location.hash = hash; closeMobileMenu(); }}
201+
>
202+
{sub.icon && (
203+
<span className="shell-subnav-icon" aria-hidden="true">{sub.icon}</span>
204+
)}
205+
<span className="shell-subnav-text">{sub.label}</span>
206+
</button>
207+
</li>
208+
);
209+
})}
210+
</ul>
211+
)}
212+
</li>
213+
);
214+
})
178215
) : (
179216
<li className="shell-nav-empty">모듈 없음</li>
180217
)}

apps/web/src/main.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ChangePasswordView } from "./views/ChangePasswordView";
1919
import { ForgotPasswordView } from "./views/ForgotPasswordView";
2020
import { SetupWizardView } from "./views/SetupWizardView";
2121
import { LedgerView } from "../../../modules/ledger/frontend/LedgerView";
22+
import { MODULE_SUB_NAV } from "./moduleConfig";
2223

2324
// ─── Helpers ──────────────────────────────────────────────────
2425

@@ -27,14 +28,23 @@ const CORE_ROUTES = ["login", "forgot-password", "home", "marketplace", "admin",
2728
// 모듈 라우트 — module.json name 기준 (서버 레지스트리와 일치)
2829
const MODULE_ROUTES: string[] = ["ledger"];
2930

31+
/** 해시에서 베이스 라우트만 추출 ("ledger/import" → "ledger") */
3032
function getRouteFromHash(rawHash: string): RouteKey {
3133
const hash = rawHash.replace("#", "");
32-
if (hash === "settings") return "home";
33-
if ((CORE_ROUTES as readonly string[]).includes(hash)) return hash as RouteKey;
34-
if (MODULE_ROUTES.includes(hash)) return hash as RouteKey;
34+
const base = hash.split("/")[0] ?? hash;
35+
if (base === "settings") return "home";
36+
if ((CORE_ROUTES as readonly string[]).includes(base)) return base as RouteKey;
37+
if (MODULE_ROUTES.includes(base)) return base as RouteKey;
3538
return "login";
3639
}
3740

41+
/** 해시에서 서브 라우트만 추출 ("ledger/import" → "import", "ledger" → "") */
42+
function getSubRouteFromHash(rawHash: string): string {
43+
const hash = rawHash.replace("#", "");
44+
const parts = hash.split("/");
45+
return parts.length > 1 ? parts.slice(1).join("/") : "";
46+
}
47+
3848
// ─── Theme ────────────────────────────────────────────────────
3949
type ThemeSetting = "light" | "dark" | "system";
4050

@@ -158,11 +168,14 @@ function App() {
158168
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
159169
const [notice, setNotice] = useState("");
160170
const [route, setRoute] = useState<RouteKey>(() => getRouteFromHash(window.location.hash));
171+
const [subRoute, setSubRoute] = useState<string>(() => getSubRouteFromHash(window.location.hash));
161172

162173
useEffect(() => {
163174
const handleHashChange = () => {
164175
const next = getRouteFromHash(window.location.hash);
176+
const nextSub = getSubRouteFromHash(window.location.hash);
165177
setRoute(next);
178+
setSubRoute(nextSub);
166179
// 비인증 상태에서 app route로 hash 변경 시 redirect 대상 갱신
167180
const appRoutes: RouteKey[] = ["home", "marketplace", "admin"];
168181
if (sessionStorage.getItem(SS.auth) !== "true" && (appRoutes as string[]).includes(next)) {
@@ -463,6 +476,8 @@ function App() {
463476
)}
464477
<AppShell
465478
route={effectiveRoute}
479+
subRoute={subRoute}
480+
moduleSubNav={MODULE_SUB_NAV}
466481
isAdmin={isAdmin}
467482
currentUser={currentUser}
468483
notice={notice}
@@ -487,7 +502,7 @@ function App() {
487502
/>
488503
)}
489504
{/* ── 모듈 뷰 ────────────────────────────────────── */}
490-
{effectiveRoute === "ledger" && <LedgerView />}
505+
{effectiveRoute === "ledger" && <LedgerView subRoute={subRoute} />}
491506
{isSettingsOpen && (
492507
<SettingsView
493508
theme={theme}

apps/web/src/moduleConfig.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* 모듈별 사이드바 서브 네비게이션 정의
3+
*
4+
* key: "" → 모듈 기본 뷰 (#ledger)
5+
* key: "xxx" → 서브 뷰 (#ledger/xxx)
6+
*
7+
* 새 모듈을 추가할 때 여기에 항목을 등록한다.
8+
*/
9+
10+
export interface ModuleSubRoute {
11+
key: string;
12+
label: string;
13+
icon?: string;
14+
}
15+
16+
export const MODULE_SUB_NAV: Record<string, ModuleSubRoute[]> = {
17+
// 서브 네비 항목은 추후 계획 후 등록
18+
};

apps/web/src/styles/shell.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,83 @@
137137
opacity: 0.45;
138138
}
139139

140+
/* ── 서브 네비게이션 ──────────────────────────────────────────── */
141+
142+
.shell-subnav-list {
143+
list-style: none;
144+
margin: 2px 0 4px 0;
145+
padding: 0;
146+
display: flex;
147+
flex-direction: column;
148+
gap: 1px;
149+
}
150+
151+
.shell-subnav-item {
152+
width: 100%;
153+
display: flex;
154+
align-items: center;
155+
gap: 6px;
156+
/* 모듈 버튼보다 들여쓰기 + 작은 높이 */
157+
padding: 5px 8px 5px 28px;
158+
border: none;
159+
border-radius: 5px;
160+
background: transparent;
161+
color: var(--text-faint);
162+
font-size: 12px;
163+
font-weight: 500;
164+
font-family: inherit;
165+
text-align: left;
166+
cursor: pointer;
167+
transition: background 100ms ease, color 100ms ease;
168+
position: relative;
169+
}
170+
171+
/* 왼쪽 연결선 */
172+
.shell-subnav-item::before {
173+
content: "";
174+
position: absolute;
175+
left: 17px;
176+
top: 50%;
177+
transform: translateY(-50%);
178+
width: 6px;
179+
height: 1px;
180+
background: var(--border-subtle);
181+
}
182+
183+
.shell-subnav-item:hover {
184+
background: var(--bg-hover);
185+
color: var(--text-muted);
186+
}
187+
188+
.shell-subnav-item[aria-current="page"] {
189+
background: var(--accent-subtle);
190+
color: var(--accent-hover);
191+
font-weight: 600;
192+
}
193+
194+
.shell-subnav-item[aria-current="page"]::before {
195+
background: var(--accent-hover);
196+
}
197+
198+
.shell-subnav-icon {
199+
font-size: 11px;
200+
width: 13px;
201+
text-align: center;
202+
flex-shrink: 0;
203+
opacity: 0.7;
204+
}
205+
206+
.shell-subnav-text {
207+
white-space: nowrap;
208+
overflow: hidden;
209+
text-overflow: ellipsis;
210+
}
211+
212+
/* 사이드바 접힘 상태: 서브 네비 숨김 */
213+
.shell[data-sidebar-collapsed] .shell-subnav-list {
214+
display: none;
215+
}
216+
140217
/* Empty state for module list */
141218
.shell-nav-empty {
142219
padding: 5px 8px;

docs/v2_FINANCIAL-LEDGER/roadmap/01-development-plan.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# 단계별 개발 계획
22

3-
> 📌 **프로젝트 상태:** 2026-04-16 기준 **Phase 1.95 진행 중** (1.95.3 Setup UI 구현 완료, 1.95.4 대기).
3+
> 📌 **프로젝트 상태:** 2026-04-19 기준 **Phase 2.1 Ledger 기능 완료 (테스트 제외)**.
44
> - Phase 1.5 전 항목 완료 (Core UI Shell / 로그인 / 홈 / 설정 / 관리자).
55
> - Phase 1.9 완료 (API 서버 + DB + 인증 백엔드 + 공유 링크).
6-
> - Phase 1.95.1 모드 전환 시스템 완료 (`installed.lock` / `fieldstack.config.json` 기반).
7-
> - Phase 1.95.2 Setup 백엔드 API 완료 (Docker/systemd/native 런타임 감지·프로비저닝, SSE 스트리밍, IP 배너 출력).
8-
> - Phase 1.95.3 Setup UI 완료 (4단계 설치 마법사 — Welcome / Config / Progress / Complete).
6+
> - Phase 1.95 전 항목 완료 (모드 전환·Setup 백엔드 API·Setup UI·초기화 UI).
7+
> - Phase 2 사전 작업 완료 (ModuleRegistry·모듈 관리 API·Admin UI 연동·유저별 모듈 활성화).
8+
> - Phase 2.1 Ledger 백엔드 완료 (CRUD·통계·CSV export/import·카테고리·결제수단·예산·영수증 첨부 API).
9+
> - Phase 2.1 Ledger 프론트엔드 완료 (목록·폼·요약 카드·카테고리·결제수단 관리·상세 패널·SVG 차트·예산 현황·CSV import 2단계 모달·영수증 첨부). 미완료: 테스트.
910
1011
## 개요
1112

@@ -532,11 +533,11 @@ Chrome 확장 프로그램의 "새로고침" 방식과 동일하게:
532533

533534
**Frontend:**
534535
- [x] 목록 페이지 (DataTable, 월 네비게이션, 필터 탭)
535-
- [ ] 상세 페이지
536+
- [x] 상세 패널 (우측 슬라이드 드로어 — 금액·카테고리·메모·태그·등록일·수정·삭제 액션)
536537
- [x] 생성/수정 폼 (모달)
537538
- [x] 통계 대시보드 (월별 수입·지출·잔액 카드)
538539
- [x] 카테고리·결제수단 관리 UI (탭 모달 — 추가/삭제)
539-
- [ ] 차트 시각화 (recharts)
540+
- [x] 차트 시각화 (SVG 도넛 차트 — 수입·지출 비교 바 + 카테고리별 도넛, 라이브러리 불필요)
540541
- [ ] 테스트
541542

542543
**기능:**
@@ -545,9 +546,9 @@ Chrome 확장 프로그램의 "새로고침" 방식과 동일하게:
545546
- [x] 결제 수단 관리 (CRUD API + 관리 UI)
546547
- [x] 월별/연도별 통계
547548
- [x] CSV 내보내기 (`GET /api/ledger/entries/export`, BOM 포함 UTF-8, 프론트 다운로드 버튼 포함)
548-
- [ ] 카테고리별 예산 설정 (`ledger_categories.budget_limit` 컬럼 추가 `002_budget.sql`)
549-
- [ ] CSV 가져오기 (은행·카드사 CSV 포맷 자동 감지·매핑·중복 검증 `docs/modules/91-ledger-csv-import.md` 설계 완료)
550-
- [ ] 영수증 첨부 (선택)
549+
- [x] 카테고리별 예산 설정 (`ledger_categories.budget_limit` 컬럼 — `002_budget.sql`, 관리 UI·차트 뷰 예산 바)
550+
- [x] CSV 가져오기 (은행·카드사 포맷 자동 감지·매핑·중복 감지·2단계 모달 `csv-import.ts`)
551+
- [x] 영수증 첨부 (`ledger_entries.receipt_path``003_receipt.sql`, 상세 패널 업로드/삭제)
551552
- [ ] 사업자 관련 (세무 메타데이터 — `docs/modules/04-tax-management.md` 초안 완료, 세무사 검증 후 착수)
552553

553554
#### 2.2 Subscription Module (구독 관리)

0 commit comments

Comments
 (0)