Skip to content

Commit 0a915ae

Browse files
SOIVclaude
andcommitted
feat(subscription): CSV/XLS 가져오기, 아이콘 시스템, UI 수정
- 구독 모듈 CSV·XLSX·XLS 가져오기 기능 추가 (preview → commit 2단계) - 한국어/영어 헤더 자동 인식, UTF-8 BOM·EUC-KR 자동 감지 - 모듈 아이콘 시스템 구현: module.json icon 필드 → 사이드바 표시 - subscription: 💳, ledger: 📒 - 구독 카드 '다음 결제' → '다음 결제일: ' 텍스트 수정 - Select 컴포넌트 line-height 누락으로 인한 Input과의 높이 불일치 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6aaa33a commit 0a915ae

11 files changed

Lines changed: 3534 additions & 2534 deletions

File tree

TODO.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
# TODO
22
> 우선 작업이 필요한 것은 ★을 표기
33
4-
- [ ] README Update (ko, en)
4+
- [x] README Update (ko, en)
55
- 작업 진행하면서 한번식 업데이트하기
66
- [x] 로드맵 확인 후 재 정렬
77
- [x]**로그 중 [fieldstack] 가 표시되는 로그들 수정하기**
88
- [ ] Home에서 설치된 모듈의 사각형 사이즈를 조정
99
- 가로로 좀 길개 만들어도 될 듯.
10-
- [ ] 일반 설정 UI 수정
10+
- [ ] 일반 설정 UI 수정
1111
- 가로로 조금 늘려서 좀 넓직하게 사용할 수 있도록 만들어줘야 될 것 같음
1212
- 그리고 상단 탭형으로 변경해서 볼 수 있도록
13-
- [ ]**사이드 탭에서 모듈의 아이콘이 📦으로만 표시되는 문제**
13+
- [x]**사이드 탭에서 모듈의 아이콘이 📦으로만 표시되는 문제**
1414
- 각 모듈의 아이콘이 있는데 그것으로 표시가 안됨
1515
- [ ] 가계부 모듈 작업 진행(2.1)
1616
- [ ] 프론트 수정(UI/UX) - 작업 진행 미정(차후 작업 예정)
17-
- [ ] 추가 기능 관련 계획 잡기
1817
- [x] 2.x.2 i18n 작업
18+
- [ ] 추가 기능 관련 계획 잡기
1919
- [ ] 나머지 추가 작업 진행
2020
- [ ] 구독 관리 모듈 작업(2.2)
21-
- [ ] CSV 가져오기 기능 수정
22-
- [ ] CSV 뿐만 아니라 xls 등 다른 확장자도 지원하도록 변경
21+
- [x] CSV 가져오기 기능 수정
22+
- [x] CSV 뿐만 아니라 xls 등 다른 확장자도 지원하도록 변경
2323
- [ ]**UI 수정**
24+
- [x] 다음 결제 -> "다음 결제일: " 으로 수정
2425
- [ ] 다음 결제일에 해당 되는 리스트를 왠쪽에 한 라인으로 배치
2526
- 결제 예정으로 표시되는 것들은 7일 이내 결제 예정인 것들만 표시
2627
- [ ] 버튼을 하나 만들어서 구독 개월수 비교 차트 추가
2728
- [ ] 구독 중인 것과 구독 중이 아닌 것을 구분하여 표시할 수 있도록 수정
28-
- [ ] 다음 결제 -> "다음 결제일: " 으로 수정
2929
- [ ] 활성 구독의 표시 부분을 추가로 표기
3030
- 전체, 일반, 프로모션(무료)를 구분하여 표기
3131
- 비활성 구독을 분리하여 표시
@@ -34,7 +34,7 @@
3434
- [x] 비고/메모란을 삭제
3535
- [ ] 구독 시작일에서 날짜 뒤에 ()가 표시되는 부분을 수정
3636
- Chromium 엔진의 자체 이슈라서 해결이 불가능 ([Issue #263320](https://issues.chromium.org/issues/40326106))
37-
- [ ]**구독 상세 패널 관련**
37+
- [x]**구독 상세 패널 관련**
3838
- [x] 누적 통계에서 환율이 적용되지 않아 8달러가 8원이 되는 문제
3939
- [x] 메모를 "비고/메모"로 수정
4040
- [x] 가격 변경뿐만 아니라 다른 히스토리도 추가할 수 있도록 수정

apps/api/src/loader/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface ModuleManifest {
2020
enabled: boolean;
2121
dependencies: string[];
2222
routes: ModuleRoutes;
23+
icon?: string;
2324
repository?: string;
2425
author?: ModuleAuthor | string;
2526
}
@@ -56,6 +57,7 @@ export function parseModuleJson(content: string): ModuleManifest {
5657
frontend: parsed.routes?.frontend ?? "",
5758
api: parsed.routes?.api ?? "",
5859
},
60+
...(parsed.icon !== undefined && { icon: parsed.icon }),
5961
...(parsed.repository !== undefined && { repository: parsed.repository }),
6062
...(parsed.author !== undefined && { author: parsed.author }),
6163
};

apps/api/src/routes/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function createCoreRouter(services: AppServices): Router {
8888
description: mod.manifest.description,
8989
basePath: mod.basePath,
9090
version: mod.manifest.version,
91+
icon: mod.manifest.icon,
9192
// user_modules 레코드 없으면 기본 활성
9293
enabled: userMap.has(mod.name) ? userMap.get(mod.name) : true,
9394
}));

apps/web/src/components/AppShell.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface SidebarModule {
2929
displayName: string;
3030
basePath: string;
3131
enabled: boolean;
32+
icon?: string;
3233
}
3334

3435
// nav list 내 ArrowUp/ArrowDown 키보드 탐색
@@ -183,7 +184,7 @@ export function AppShell({
183184
aria-current={isActive && !subRoute ? "page" : undefined}
184185
onClick={() => { window.location.hash = mod.name; closeMobileMenu(); }}
185186
>
186-
<span className="shell-nav-icon" aria-hidden="true">📦</span>
187+
<span className="shell-nav-icon" aria-hidden="true">{mod.icon ?? '📦'}</span>
187188
<span className="shell-nav-text">{t(mod.displayName, { defaultValue: mod.name })}</span>
188189
</button>
189190

modules/ledger/module.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"description": "ledger:description",
66
"enabled": true,
77
"dependencies": [],
8+
"icon": "📒",
89
"repository": "",
910
"author": {
1011
"name": "",
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* 구독 데이터 가져오기 — CSV / XLSX / XLS 파싱
3+
*
4+
* 지원 포맷:
5+
* - .csv (UTF-8 BOM, EUC-KR 자동 감지)
6+
* - .xlsx / .xls (SheetJS)
7+
*
8+
* 컬럼 헤더는 한국어·영어 모두 인식.
9+
*/
10+
11+
import * as XLSX from 'xlsx';
12+
13+
export type BillingCycle = 'monthly' | 'yearly';
14+
export type Currency = 'KRW' | 'USD' | 'EUR' | 'JPY' | 'GBP';
15+
16+
export interface ImportRow {
17+
serviceName: string;
18+
currentAmount: number;
19+
currency: Currency;
20+
billingCycle: BillingCycle;
21+
billingDay: number;
22+
startedAt?: string;
23+
category?: string;
24+
url?: string;
25+
}
26+
27+
export interface ImportError {
28+
row: number;
29+
message: string;
30+
}
31+
32+
export interface ImportResult {
33+
rows: ImportRow[];
34+
errors: ImportError[];
35+
}
36+
37+
// ── 헤더 매핑 ──────────────────────────────────────────────────
38+
39+
const FIELD_ALIASES: Record<keyof ImportRow, string[]> = {
40+
serviceName: ['서비스명', '서비스', 'servicename', 'service', 'name'],
41+
currentAmount: ['금액', '가격', 'amount', 'price', 'currentamount'],
42+
currency: ['통화', 'currency'],
43+
billingCycle: ['결제주기', '주기', 'billingcycle', 'cycle'],
44+
billingDay: ['결제일', '결제 일', 'billingday', 'day'],
45+
startedAt: ['구독시작일', '시작일', 'startedat', 'started', 'start'],
46+
category: ['카테고리', 'category'],
47+
url: ['url', 'URL', '주소'],
48+
};
49+
50+
function findCol(headers: string[], field: keyof ImportRow): string | undefined {
51+
const aliases = FIELD_ALIASES[field];
52+
const lower = headers.map((h) => h.trim().toLowerCase().replace(/\s/g, ''));
53+
for (const alias of aliases) {
54+
const idx = lower.indexOf(alias.toLowerCase().replace(/\s/g, ''));
55+
if (idx !== -1) return headers[idx];
56+
}
57+
return undefined;
58+
}
59+
60+
// ── 값 파싱 ────────────────────────────────────────────────────
61+
62+
function normalizeCurrency(raw: string): Currency {
63+
const v = raw.trim().toUpperCase();
64+
const valid: Currency[] = ['KRW', 'USD', 'EUR', 'JPY', 'GBP'];
65+
return valid.includes(v as Currency) ? (v as Currency) : 'KRW';
66+
}
67+
68+
function normalizeCycle(raw: string): BillingCycle {
69+
const v = raw.trim().toLowerCase();
70+
if (v === 'yearly' || v === '연간' || v === 'annual') return 'yearly';
71+
return 'monthly';
72+
}
73+
74+
function parseAmount(raw: string | number): number | null {
75+
if (typeof raw === 'number') return isFinite(raw) ? raw : null;
76+
const s = String(raw).trim().replace(/,/g, '');
77+
const n = parseFloat(s);
78+
return isNaN(n) ? null : n;
79+
}
80+
81+
function parseDay(raw: string | number): number | null {
82+
const n = typeof raw === 'number' ? raw : parseInt(String(raw).trim());
83+
if (isNaN(n) || n < 1 || n > 31) return null;
84+
return n;
85+
}
86+
87+
function parseDate(raw: string | number | undefined): string | undefined {
88+
if (raw === undefined || raw === null || raw === '') return undefined;
89+
const s = String(raw).trim().slice(0, 10).replace(/[./]/g, '-');
90+
const compact = String(raw).trim().replace(/\D/g, '').slice(0, 8);
91+
if (compact.length === 8) {
92+
return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`;
93+
}
94+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
95+
return undefined;
96+
}
97+
98+
// ── CSV 디코딩 ─────────────────────────────────────────────────
99+
100+
function decodeBuffer(buf: Buffer): string {
101+
if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
102+
return buf.slice(3).toString('utf-8');
103+
}
104+
const asUtf8 = buf.toString('utf-8');
105+
if (!asUtf8.includes('�')) return asUtf8;
106+
try {
107+
const decoder = new TextDecoder('euc-kr', { fatal: true });
108+
return decoder.decode(buf);
109+
} catch {
110+
return asUtf8;
111+
}
112+
}
113+
114+
// ── 메인 파서 ─────────────────────────────────────────────────
115+
116+
export function parseSubscriptionFile(buf: Buffer, ext: string): ImportResult {
117+
let rows: Record<string, unknown>[];
118+
119+
if (ext === 'csv') {
120+
const text = decodeBuffer(buf);
121+
const wb = XLSX.read(text, { type: 'string' });
122+
const ws = wb.Sheets[wb.SheetNames[0]!]!;
123+
rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' });
124+
} else {
125+
const wb = XLSX.read(buf, { type: 'buffer' });
126+
const ws = wb.Sheets[wb.SheetNames[0]!]!;
127+
rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' });
128+
}
129+
130+
if (rows.length === 0) return { rows: [], errors: [] };
131+
132+
const headers = Object.keys(rows[0]!);
133+
const colMap: Partial<Record<keyof ImportRow, string>> = {};
134+
for (const field of Object.keys(FIELD_ALIASES) as (keyof ImportRow)[]) {
135+
const found = findCol(headers, field);
136+
if (found) colMap[field] = found;
137+
}
138+
139+
const result: ImportResult = { rows: [], errors: [] };
140+
141+
rows.forEach((raw, idx) => {
142+
const rowNum = idx + 2; // 헤더 행은 1행
143+
144+
const nameRaw = String(raw[colMap.serviceName ?? ''] ?? '').trim();
145+
if (!nameRaw) {
146+
result.errors.push({ row: rowNum, message: '서비스명이 비어 있습니다.' });
147+
return;
148+
}
149+
150+
const amountRaw = raw[colMap.currentAmount ?? ''];
151+
const amount = parseAmount(amountRaw as string | number);
152+
if (amount === null || amount < 0) {
153+
result.errors.push({ row: rowNum, message: `금액 파싱 실패: "${amountRaw}"` });
154+
return;
155+
}
156+
157+
const dayRaw = raw[colMap.billingDay ?? ''];
158+
const day = colMap.billingDay ? parseDay(dayRaw as string | number) : 1;
159+
if (day === null) {
160+
result.errors.push({ row: rowNum, message: `결제일 파싱 실패: "${dayRaw}"` });
161+
return;
162+
}
163+
164+
const currency = colMap.currency
165+
? normalizeCurrency(String(raw[colMap.currency] ?? 'KRW'))
166+
: 'KRW';
167+
168+
const cycle = colMap.billingCycle
169+
? normalizeCycle(String(raw[colMap.billingCycle] ?? 'monthly'))
170+
: 'monthly';
171+
172+
const startedAt = parseDate(
173+
colMap.startedAt ? (raw[colMap.startedAt] as string | undefined) : undefined,
174+
);
175+
176+
const category = colMap.category
177+
? String(raw[colMap.category] ?? '').trim() || undefined
178+
: undefined;
179+
180+
const rawUrl = colMap.url ? String(raw[colMap.url] ?? '').trim() : '';
181+
const url = rawUrl.match(/^https?:\/\//) ? rawUrl : undefined;
182+
183+
result.rows.push({
184+
serviceName: nameRaw,
185+
currentAmount: amount,
186+
currency,
187+
billingCycle: cycle,
188+
billingDay: day,
189+
startedAt,
190+
category,
191+
url,
192+
});
193+
});
194+
195+
return result;
196+
}

modules/subscription/backend/routes.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Router } from 'express';
1+
import express, { Router } from 'express';
22
import type { NextFunction, Request, Response } from 'express';
33

44
import type { SubscriptionService } from './service.js';
@@ -8,6 +8,7 @@ import {
88
createSubscriptionSchema,
99
updateSubscriptionSchema,
1010
} from './validation.js';
11+
import { parseSubscriptionFile } from './file-import.js';
1112

1213
type JwtManager = {
1314
verifyAccessToken(token: string): Promise<{ userId: string; email: string }>;
@@ -41,6 +42,7 @@ export function createSubscriptionRouter(
4142
): Router {
4243
const router = Router();
4344
const auth = makeAuth(jwtManager);
45+
const rawBody = express.raw({ type: '*/*', limit: '10mb' });
4446

4547
// ── 구독 목록 / 생성 ──────────────────────────────────────────
4648
router.get('/services', auth, async (req, res) => {
@@ -227,5 +229,68 @@ export function createSubscriptionRouter(
227229
}
228230
});
229231

232+
// ── 파일 가져오기 ─────────────────────────────────────────────
233+
234+
/**
235+
* POST /api/subscription/import/preview
236+
* Body: raw file bytes (CSV / XLSX / XLS)
237+
* Header: X-Filename — 확장자 판별에 사용 (예: subscriptions.csv)
238+
*/
239+
router.post('/import/preview', auth, rawBody, async (req, res) => {
240+
try {
241+
const buf = req.body as Buffer;
242+
if (!Buffer.isBuffer(buf) || buf.length === 0) {
243+
res.status(400).json({ success: false, error: '파일 데이터가 없습니다.' });
244+
return;
245+
}
246+
247+
const filename = decodeURIComponent(
248+
(req.headers['x-filename'] as string | undefined) ?? 'import.csv',
249+
);
250+
const ext = filename.split('.').pop()?.toLowerCase() ?? 'csv';
251+
252+
const result = parseSubscriptionFile(buf, ext);
253+
res.json({ success: true, data: result });
254+
} catch (err) {
255+
res.status(500).json({ success: false, error: (err as Error).message });
256+
}
257+
});
258+
259+
/**
260+
* POST /api/subscription/import/commit
261+
* Body: raw file bytes
262+
* Header: X-Filename — 확장자 판별
263+
*/
264+
router.post('/import/commit', auth, rawBody, async (req, res) => {
265+
try {
266+
const userId = (req as AuthRequest).userId;
267+
const buf = req.body as Buffer;
268+
if (!Buffer.isBuffer(buf) || buf.length === 0) {
269+
res.status(400).json({ success: false, error: '파일 데이터가 없습니다.' });
270+
return;
271+
}
272+
273+
const filename = decodeURIComponent(
274+
(req.headers['x-filename'] as string | undefined) ?? 'import.csv',
275+
);
276+
const ext = filename.split('.').pop()?.toLowerCase() ?? 'csv';
277+
278+
const { rows, errors } = parseSubscriptionFile(buf, ext);
279+
280+
let imported = 0;
281+
for (const row of rows) {
282+
const parsed = createSubscriptionSchema.safeParse(row);
283+
if (parsed.success) {
284+
await service.create(userId, parsed.data);
285+
imported++;
286+
}
287+
}
288+
289+
res.json({ success: true, data: { imported, skipped: rows.length - imported, errors } });
290+
} catch (err) {
291+
res.status(500).json({ success: false, error: (err as Error).message });
292+
}
293+
});
294+
230295
return router;
231296
}

0 commit comments

Comments
 (0)