Skip to content

Commit 8152b38

Browse files
SOIVclaude
andcommitted
feat(ledger): CSV 내보내기 및 카테고리·결제수단 관리 UI 추가
- GET /api/ledger/entries/export — BOM 포함 UTF-8 CSV 내보내기 (Excel 호환) - 카테고리·결제수단 관리 모달 추가 (탭 전환, 추가/삭제) - LedgerView 헤더에 CSV 내보내기 버튼 및 관리 버튼 추가 - loadMeta useCallback으로 분리하여 관리 모달과 공유 - vite.config.ts: @fieldstack/* 패키지를 dist 대신 src 직접 참조로 변경 (Vite export* 체인 캐싱 문제 해결, tsc 빌드 없이 즉시 반영) - ledger.css: --fs-* 변수명을 실제 global.css 기준(--border, --text 등)으로 전면 교체 - CLAUDE.md: CSS 변수명 규칙 명시 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dce44a7 commit 8152b38

7 files changed

Lines changed: 660 additions & 68 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,29 @@ TypeScript 계약(`ControlDescriptor`, `CONTROL_DESCRIPTORS`)만 선언되어
8080

8181
### 스타일
8282

83-
- CSS 커스텀 프로퍼티 기반 다크 모드 디자인 토큰 (`apps/web/src/styles/`).
84-
- Primary `#3B82F6`, success `#10B981`, warning `#F59E0B`, danger `#EF4444`.
83+
- CSS 커스텀 프로퍼티 기반 다크 모드 디자인 토큰 (`apps/web/src/styles/global.css`).
8584
- 현재 Tailwind 미적용 (계획 중). 현재는 각 View별 CSS 파일 직접 작성.
8685

86+
**CSS 변수명 — 반드시 `global.css` 기준을 사용할 것. `--fs-*` 접두사는 존재하지 않음.**
87+
88+
| 용도 | 변수명 |
89+
|------|--------|
90+
| 배경 (기본) | `--bg` |
91+
| 배경 (카드/서피스) | `--bg-surface` |
92+
| 배경 (보조/elevated) | `--bg-elevated` |
93+
| 배경 (호버) | `--bg-hover` |
94+
| 테두리 | `--border` |
95+
| 테두리 (subtle) | `--border-subtle` |
96+
| 텍스트 (기본) | `--text` |
97+
| 텍스트 (보조) | `--text-muted` |
98+
| 텍스트 (희미) | `--text-faint` |
99+
| 강조색 | `--primary` / `--accent` |
100+
| 성공 | `--ok` |
101+
| 경고 | `--warn` |
102+
| 오류/위험 | `--err` |
103+
104+
> ⚠️ `--fs-border`, `--fs-text-primary`, `--fs-bg-secondary``--fs-` 접두사 변수는 정의되어 있지 않아 항상 빈 값으로 처리됨. 절대 사용 금지.
105+
87106
---
88107

89108
## Code Conventions

apps/web/vite.config.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1010
// alias로 각 export 경로를 명시한다.
1111
// 주의: 서브패스(controls/styles)를 베이스(controls)보다 먼저 선언해야
1212
// prefix 매칭 순서가 올바르게 동작한다.
13-
const WEB_NODE_MODULES = path.resolve(__dirname, "node_modules");
13+
// 모노레포 내부 패키지는 dist 대신 src를 직접 참조한다.
14+
// Vite는 TypeScript를 네이티브로 처리하므로 빌드 단계가 불필요하고,
15+
// 소스 변경이 즉시 HMR에 반영되며 dist 캐싱 문제가 발생하지 않는다.
16+
const PACKAGES = path.resolve(__dirname, "../../packages");
1417

1518
export default defineConfig({
1619
resolve: {
1720
alias: {
18-
"@fieldstack/controls/styles": path.join(WEB_NODE_MODULES, "@fieldstack/controls/src/styles/index.css"),
19-
"@fieldstack/controls": path.join(WEB_NODE_MODULES, "@fieldstack/controls/dist/index.js"),
20-
"@fieldstack/core/browser": path.join(WEB_NODE_MODULES, "@fieldstack/core/dist/browser.js"),
21+
"@fieldstack/controls/styles": path.join(PACKAGES, "controls/src/styles/index.css"),
22+
"@fieldstack/controls": path.join(PACKAGES, "controls/src/index.ts"),
23+
"@fieldstack/core/browser": path.join(PACKAGES, "core/src/browser.ts"),
2124
},
2225
},
2326
server: {

modules/ledger/backend/routes.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
CreateEntrySchema,
88
CreatePaymentMethodSchema,
99
EntryListQuerySchema,
10+
ExportQuerySchema,
1011
SummaryQuerySchema,
1112
UpdateEntrySchema,
1213
validateBody,
@@ -163,6 +164,31 @@ export function createLedgerRouter(
163164
}
164165
});
165166

167+
/** GET /api/ledger/entries/export?year=2026&month=4&type=expense */
168+
router.get('/entries/export', auth, validateQuery(ExportQuerySchema), async (req, res) => {
169+
try {
170+
type Q = {
171+
year?: number;
172+
month?: number;
173+
type?: 'income' | 'expense';
174+
categoryId?: string;
175+
};
176+
const query = (req as Request & { validatedQuery: Q }).validatedQuery;
177+
const csv = await service.exportEntriesCsv(req.auth!.userId, query);
178+
179+
const now = new Date();
180+
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
181+
const filename = `ledger-${dateStr}.csv`;
182+
183+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
184+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
185+
// UTF-8 BOM — Excel에서 한글 깨짐 방지
186+
res.send('\uFEFF' + csv);
187+
} catch (err) {
188+
res.status(500).json({ success: false, error: (err as Error).message });
189+
}
190+
});
191+
166192
/** GET /api/ledger/entries/:id */
167193
router.get('/entries/:id', auth, async (req, res) => {
168194
try {

modules/ledger/backend/service.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,88 @@ export class LedgerService {
349349
return rows.length > 0;
350350
}
351351

352+
async exportEntriesCsv(
353+
userId: string,
354+
opts: {
355+
year?: number;
356+
month?: number;
357+
type?: 'income' | 'expense';
358+
categoryId?: string;
359+
},
360+
): Promise<string> {
361+
const conditions: string[] = ['e.user_id = $1'];
362+
const params: unknown[] = [userId];
363+
let idx = 2;
364+
365+
if (opts.year !== undefined && opts.month !== undefined) {
366+
const from = `${opts.year}-${String(opts.month).padStart(2, '0')}-01`;
367+
const toYear = opts.month === 12 ? opts.year + 1 : opts.year;
368+
const toMonth = opts.month === 12 ? 1 : opts.month + 1;
369+
const to = `${toYear}-${String(toMonth).padStart(2, '0')}-01`;
370+
conditions.push(`e.date >= $${idx} AND e.date < $${idx + 1}`);
371+
params.push(from, to);
372+
idx += 2;
373+
} else if (opts.year !== undefined) {
374+
const from = `${opts.year}-01-01`;
375+
const to = `${opts.year + 1}-01-01`;
376+
conditions.push(`e.date >= $${idx} AND e.date < $${idx + 1}`);
377+
params.push(from, to);
378+
idx += 2;
379+
}
380+
381+
if (opts.type) {
382+
conditions.push(`e.type = $${idx}`);
383+
params.push(opts.type);
384+
idx++;
385+
}
386+
387+
if (opts.categoryId) {
388+
conditions.push(`e.category_id = $${idx}`);
389+
params.push(opts.categoryId);
390+
}
391+
392+
const where = conditions.join(' AND ');
393+
const rows = await this.db.query<EntryRow>(
394+
`SELECT
395+
e.*,
396+
c.name AS category_name,
397+
pm.name AS payment_method_name
398+
FROM ledger_entries e
399+
LEFT JOIN ledger_categories c ON c.id = e.category_id
400+
LEFT JOIN ledger_payment_methods pm ON pm.id = e.payment_method_id
401+
WHERE ${where}
402+
ORDER BY e.date DESC, e.created_at DESC`,
403+
params,
404+
);
405+
406+
const entries = rows.map(rowToEntry);
407+
408+
// CSV 헤더
409+
const header = ['날짜', '유형', '금액', '카테고리', '내용', '결제수단', '메모', '태그'].join(',');
410+
411+
const escapeCell = (val: string | null | undefined): string => {
412+
if (val == null) return '';
413+
// 쉼표·큰따옴표·줄바꿈이 있으면 큰따옴표로 감싸기
414+
if (/[",\n\r]/.test(val)) return `"${val.replace(/"/g, '""')}"`;
415+
return val;
416+
};
417+
418+
const lines = entries.map((e) =>
419+
[
420+
escapeCell(e.date),
421+
escapeCell(e.type === 'income' ? '수입' : '지출'),
422+
String(e.amount),
423+
escapeCell(e.categoryName),
424+
escapeCell(e.description),
425+
escapeCell(e.paymentMethodName),
426+
escapeCell(e.notes),
427+
escapeCell(e.tags.join('|')),
428+
].join(','),
429+
);
430+
431+
return [header, ...lines].join('\r\n');
432+
}
433+
352434
// ── 통계 ──────────────────────────────────────────────────────
353435

354436
async getSummary(userId: string, year: number, month: number): Promise<LedgerSummary> {

modules/ledger/backend/validation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export const SummaryQuerySchema = z.object({
4848
month: z.coerce.number().int().min(1).max(12),
4949
});
5050

51+
export const ExportQuerySchema = z.object({
52+
year: z.coerce.number().int().min(2000).max(2100).optional(),
53+
month: z.coerce.number().int().min(1).max(12).optional(),
54+
type: z.enum(['income', 'expense']).optional(),
55+
categoryId: z.string().uuid().optional(),
56+
});
57+
5158
// ── 미들웨어 헬퍼 ─────────────────────────────────────────────
5259

5360
export function validateBody<T>(schema: z.ZodSchema<T>) {

0 commit comments

Comments
 (0)