refactor(WEB): 컴포넌트 레지스트리 개선 및 미사용 코드 정리
- component-registry를 파일 시스템 기반 동적 스캔으로 전환 (정적 JSON 삭제) - 미사용 컴포넌트 삭제 (EmptyState, StandardDialog) - 회사정보 관리 페이지 개선 - 컴포넌트 계층 정의 가이드 문서 추가 - middleware 및 useDaumPostcode 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
CLAUDE.md
49
CLAUDE.md
@@ -369,6 +369,55 @@ export async function getItems(params: SearchParams) {
|
||||
|
||||
---
|
||||
|
||||
## FormField 사용 규칙 (신규 폼 필수)
|
||||
**Priority**: 🟡
|
||||
|
||||
### 적용 범위
|
||||
- **신규 폼**: `Label + Input` 수동 조합 대신 `FormField` molecule 필수 사용
|
||||
- **기존 폼**: 건드리지 않음 (해당 파일 수정 시에만 선택적 전환)
|
||||
|
||||
### 사용 패턴
|
||||
```typescript
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
|
||||
// ✅ 올바른 패턴 - FormField 사용
|
||||
<FormField
|
||||
label="회사명"
|
||||
value={formData.companyName}
|
||||
onChange={(value) => handleChange('companyName', value)}
|
||||
placeholder="회사명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
|
||||
// ❌ 잘못된 패턴 - Label + Input 수동 조합
|
||||
<div className="space-y-2">
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleChange('companyName', e.target.value)}
|
||||
placeholder="회사명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### FormField 지원 타입
|
||||
| type | 설명 | 대체 컴포넌트 |
|
||||
|------|------|---------------|
|
||||
| `text` (기본값) | 일반 텍스트 입력 | Label + Input |
|
||||
| `number` | 숫자 입력 | Label + Input[type=number] |
|
||||
| `email` | 이메일 입력 | Label + Input[type=email] |
|
||||
| `tel` | 전화번호 (자동 포맷) | Label + PhoneInput |
|
||||
| `businessNumber` | 사업자등록번호 (자동 포맷) | Label + BusinessNumberInput |
|
||||
| `textarea` | 여러 줄 텍스트 | Label + Textarea |
|
||||
|
||||
### FormField로 대체하지 않는 경우
|
||||
- **특수 컴포넌트 필드**: Select, DatePicker, ImageUpload, FileInput, AccountNumberInput 등
|
||||
- **복합 레이아웃 필드**: 주소 검색(버튼+입력), 다중 입력 조합
|
||||
- **커스텀 인터랙션**: 편집/읽기 모드가 다른 컴포넌트(예: 결제일 Select↔Input 전환)
|
||||
|
||||
---
|
||||
|
||||
## User Environment
|
||||
**Priority**: 🟢
|
||||
|
||||
|
||||
153
claudedocs/architecture/[GUIDE] component-tier-definition.md
Normal file
153
claudedocs/architecture/[GUIDE] component-tier-definition.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Component Tier 정의
|
||||
|
||||
> SAM 프로젝트의 컴포넌트 계층(tier) 기준 정의.
|
||||
> 새 컴포넌트 작성 시 어디에 배치할지 판단하는 기준 문서.
|
||||
|
||||
## Tier 구조 요약
|
||||
|
||||
```
|
||||
ui 원시 빌딩블록 (HTML 래퍼, 단일 기능)
|
||||
↓ 조합
|
||||
atoms 최소 단위 UI 조각 (ui 1~2개 조합)
|
||||
↓ 조합
|
||||
molecules 의미 있는 UI 패턴 (atoms/ui 여러 개 조합)
|
||||
↓ 조합
|
||||
organisms 페이지 섹션 단위 (molecules/atoms 조합, 레이아웃 포함)
|
||||
↓ 사용
|
||||
domain 도메인별 비즈니스 컴포넌트 (organisms/molecules 사용)
|
||||
```
|
||||
|
||||
## Tier별 정의
|
||||
|
||||
### ui (원시 빌딩블록)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/ui/` |
|
||||
| 역할 | HTML 요소를 감싼 최소 단위. 스타일링 + 접근성만 담당 |
|
||||
| 특징 | 비즈니스 로직 없음, 범용적, Radix UI 래퍼 포함 |
|
||||
| 예시 | Button, Input, Select, Badge, Dialog, DatePicker, EmptyState |
|
||||
| 판단 기준 | "이 컴포넌트가 다른 프로젝트에 그대로 복사해도 동작하는가?" → Yes면 ui |
|
||||
|
||||
### atoms (최소 UI 조각)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/atoms/` |
|
||||
| 역할 | ui 1~2개를 조합한 작은 패턴. 단일 목적 |
|
||||
| 특징 | props 2~5개, 상태 관리 최소 |
|
||||
| 예시 | BadgeSm, TabChip, ScrollableButtonGroup |
|
||||
| 판단 기준 | "ui 하나로는 부족하지만, 독립적인 의미 단위인가?" → Yes면 atoms |
|
||||
|
||||
### molecules (의미 있는 UI 패턴)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/molecules/` |
|
||||
| 역할 | atoms/ui 여러 개를 조합하여 하나의 기능 패턴을 구성 |
|
||||
| 특징 | Label + Input + Error 같은 조합, 내부 상태 가능 |
|
||||
| 예시 | FormField, StatusBadge, DateRangeSelector, StandardDialog, TableActions |
|
||||
| 판단 기준 | "여러 ui/atoms의 조합이고, 재사용 가능한 패턴인가?" → Yes면 molecules |
|
||||
|
||||
### organisms (페이지 섹션)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/organisms/` |
|
||||
| 역할 | 페이지의 독립적인 섹션. molecules/atoms를 조합하여 레이아웃 포함 |
|
||||
| 특징 | 데이터 테이블, 검색 필터, 폼 섹션 등 페이지 구성 단위 |
|
||||
| 예시 | DataTable, PageHeader, StatCards, FormSection, SearchableSelectionModal |
|
||||
| 판단 기준 | "페이지에서 하나의 영역으로 독립 가능한가?" → Yes면 organisms |
|
||||
|
||||
### common (공용 페이지/레이아웃)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/common/` |
|
||||
| 역할 | 에러 페이지, 권한 없음 페이지 등 전역 공통 화면 |
|
||||
| 특징 | 라우터 사용, 전체 페이지 레이아웃 |
|
||||
| 예시 | AccessDenied, EmptyPage, ServerErrorPage |
|
||||
| 판단 기준 | "전체 화면을 차지하는 공통 페이지인가?" → Yes면 common |
|
||||
|
||||
### layout (레이아웃 구조)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/layout/` |
|
||||
| 역할 | 앱 전체 레이아웃 골격 (사이드바, 헤더, 네비게이션) |
|
||||
| 예시 | AuthenticatedLayout, Sidebar, TopNav |
|
||||
|
||||
### dev (개발 도구)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/dev/` |
|
||||
| 역할 | 개발 환경 전용 도구 (프로덕션 미포함) |
|
||||
| 예시 | DevToolbar |
|
||||
|
||||
### domain (도메인 비즈니스)
|
||||
| 항목 | 기준 |
|
||||
|------|------|
|
||||
| 위치 | `src/components/{도메인명}/` (hr, sales, accounting 등) |
|
||||
| 역할 | 특정 도메인의 비즈니스 로직이 포함된 컴포넌트 |
|
||||
| 특징 | API 호출, 도메인 타입, 비즈니스 규칙 포함 |
|
||||
| 예시 | EmployeeManagement, OrderRegistration, BillDetail |
|
||||
| 판단 기준 | "특정 도메인에서만 사용되는가?" → Yes면 domain |
|
||||
|
||||
## 자주 혼동되는 케이스
|
||||
|
||||
| 상황 | 올바른 tier | 이유 |
|
||||
|------|-------------|------|
|
||||
| EmptyState (프리셋/variant 있음) | **ui** | 범용 빌딩블록, 비즈니스 로직 없음 |
|
||||
| StatusBadge (icon/dot/색상 커스텀) | **molecules** | Badge + BadgeSm 조합, DataTable 연동 |
|
||||
| ConfirmDialog (삭제/저장 확인) | **ui** | AlertDialog 래퍼, 범용적 |
|
||||
| StandardDialog (범용 컨테이너) | **molecules** | Dialog + Header + Footer 조합 패턴 |
|
||||
| DataTable (정렬/페이지네이션/선택) | **organisms** | 페이지 섹션 단위, 다수 하위 컴포넌트 |
|
||||
| SearchableSelectionModal | **organisms** | 검색+선택 완결 기능, 독립 섹션 |
|
||||
|
||||
## 중복 방지 규칙
|
||||
|
||||
1. **새 컴포넌트 작성 전**: 같은 이름/기능이 다른 tier에 이미 있는지 확인
|
||||
2. **ui에 이미 있으면**: molecules/organisms에 동일 컴포넌트 만들지 않음. 필요하면 ui를 확장
|
||||
3. **re-export 허용**: organisms/index.ts에서 ui 컴포넌트를 re-export 가능 (편의성)
|
||||
4. **확인(Confirm) 다이얼로그**: `ui/confirm-dialog.tsx` 하나만 사용 (52개 파일 사용 중)
|
||||
|
||||
## StatusBadge 역할 구분
|
||||
|
||||
이름이 같지만 tier와 용도가 다른 두 컴포넌트. **둘 다 유지**.
|
||||
|
||||
### `ui/status-badge.tsx` — 범용 상태 배지
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| import | `import { StatusBadge } from '@/components/ui/status-badge'` |
|
||||
| 용도 | `createStatusConfig`와 연동하는 **config 기반** 상태 표시 |
|
||||
| API | `children` 또는 `status + config` 자동 라벨/스타일 |
|
||||
| 특화 기능 | `mode` (badge/text), `ConfiguredStatusBadge` 제네릭 |
|
||||
| 사용 예시 | 템플릿/유틸과 연동하는 범용 상태 표시 |
|
||||
|
||||
```tsx
|
||||
// config 기반 사용
|
||||
<StatusBadge status="pending" config={APPROVAL_STATUS_CONFIG} />
|
||||
|
||||
// children 기반 사용
|
||||
<StatusBadge variant="success">완료</StatusBadge>
|
||||
```
|
||||
|
||||
### `molecules/StatusBadge.tsx` — DataTable 특화 배지
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| import | `import { StatusBadge } from '@/components/molecules/StatusBadge'` |
|
||||
| 용도 | DataTable 셀에서 상태를 **아이콘/도트와 함께** 표시 |
|
||||
| API | `label` 필수, `variant`로 색상 지정 |
|
||||
| 특화 기능 | `icon` (LucideIcon), `showDot`, 커스텀 `bgColor/textColor/borderColor` |
|
||||
| 기반 | Badge + BadgeSm 조합 (size="sm"일 때 BadgeSm으로 자동 전환) |
|
||||
|
||||
```tsx
|
||||
// DataTable 셀 렌더링
|
||||
<StatusBadge label="승인완료" variant="success" showDot />
|
||||
<StatusBadge label="긴급" variant="danger" icon={AlertCircle} />
|
||||
```
|
||||
|
||||
### 선택 기준
|
||||
|
||||
| 상황 | 사용할 컴포넌트 |
|
||||
|------|----------------|
|
||||
| `createStatusConfig` 결과와 연동 | **ui** StatusBadge |
|
||||
| DataTable 컬럼 셀 렌더링 | **molecules** StatusBadge |
|
||||
| 아이콘이나 도트가 필요한 배지 | **molecules** StatusBadge |
|
||||
| 단순 텍스트 상태 표시 (badge/text 모드) | **ui** StatusBadge |
|
||||
@@ -14,36 +14,9 @@ import {
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getComponentSource } from './actions';
|
||||
import { getComponentSource, type RegistryData, type ComponentEntry } from './actions';
|
||||
import { UI_PREVIEWS } from './previews';
|
||||
|
||||
interface ComponentEntry {
|
||||
name: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
tier: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
exportType: 'default' | 'named' | 'both' | 'none';
|
||||
hasProps: boolean;
|
||||
propsName: string | null;
|
||||
isClientComponent: boolean;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
interface CategorySummary {
|
||||
tier: string;
|
||||
category: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RegistryData {
|
||||
generatedAt: string;
|
||||
totalCount: number;
|
||||
categories: CategorySummary[];
|
||||
components: ComponentEntry[];
|
||||
}
|
||||
|
||||
interface ComponentRegistryClientProps {
|
||||
registry: RegistryData;
|
||||
}
|
||||
@@ -166,9 +139,12 @@ function ComponentCard({
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm'
|
||||
}`}>
|
||||
{/* Card Header */}
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleToggle}
|
||||
className="flex items-center justify-between p-3 w-full text-left"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); } }}
|
||||
className="flex items-center justify-between p-3 w-full text-left cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -207,7 +183,7 @@ function ComponentCard({
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
@@ -406,10 +382,10 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
||||
return counts;
|
||||
}, [registry.components]);
|
||||
|
||||
// Count of previewable components
|
||||
// Count of previewable components (all tiers)
|
||||
const previewCount = useMemo(() => {
|
||||
return registry.components.filter(
|
||||
(c) => c.tier === 'ui' && UI_PREVIEWS[c.fileName]
|
||||
(c) => !!UI_PREVIEWS[c.fileName]
|
||||
).length;
|
||||
}, [registry.components]);
|
||||
|
||||
@@ -430,8 +406,8 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
생성: {generatedDate} · <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-xs">npm run gen:components</code>로 갱신
|
||||
· 카드 클릭: 소스코드 보기 · UI 프리뷰: {previewCount}개
|
||||
스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔)
|
||||
· 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -513,7 +489,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
데이터 소스: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">src/generated/component-registry.json</code>
|
||||
실시간 스캔: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">src/components/**/*.tsx</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join, relative, basename, extname } from 'node:path';
|
||||
|
||||
// ============================================================
|
||||
// 소스코드 조회 (기존)
|
||||
// ============================================================
|
||||
|
||||
export async function getComponentSource(filePath: string): Promise<{ source: string | null; error: string | null }> {
|
||||
// filePath는 "src/components/ui/button.tsx" 형태
|
||||
if (!filePath.startsWith('src/components/') || !filePath.endsWith('.tsx')) {
|
||||
return { source: null, error: 'Invalid file path' };
|
||||
}
|
||||
@@ -17,3 +20,200 @@ export async function getComponentSource(filePath: string): Promise<{ source: st
|
||||
return { source: null, error: 'File not found' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 컴포넌트 레지스트리 실시간 스캔
|
||||
// ============================================================
|
||||
|
||||
export interface ComponentEntry {
|
||||
name: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
tier: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
exportType: 'default' | 'named' | 'both' | 'none';
|
||||
hasProps: boolean;
|
||||
propsName: string | null;
|
||||
isClientComponent: boolean;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
interface CategorySummary {
|
||||
tier: string;
|
||||
category: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RegistryData {
|
||||
generatedAt: string;
|
||||
totalCount: number;
|
||||
categories: CategorySummary[];
|
||||
components: ComponentEntry[];
|
||||
}
|
||||
|
||||
// Tier mapping
|
||||
const TIER_MAP: Record<string, string> = {
|
||||
ui: 'ui',
|
||||
atoms: 'atoms',
|
||||
molecules: 'molecules',
|
||||
organisms: 'organisms',
|
||||
common: 'common',
|
||||
layout: 'layout',
|
||||
dev: 'dev',
|
||||
};
|
||||
|
||||
// Skip patterns
|
||||
const SKIP_FILES = new Set([
|
||||
'index.ts', 'index.tsx', 'actions.ts', 'actions.tsx',
|
||||
'types.ts', 'types.tsx', 'utils.ts', 'utils.tsx',
|
||||
'constants.ts', 'constants.tsx', 'schema.ts', 'schema.tsx',
|
||||
]);
|
||||
|
||||
const SKIP_PATTERNS = [
|
||||
/\.test\./,
|
||||
/\.spec\./,
|
||||
/\.stories\./,
|
||||
/^use[A-Z].*\.ts$/, // hooks (useXxx.ts only, not .tsx)
|
||||
];
|
||||
|
||||
function shouldSkip(fileName: string): boolean {
|
||||
if (SKIP_FILES.has(fileName)) return true;
|
||||
return SKIP_PATTERNS.some(p => p.test(fileName));
|
||||
}
|
||||
|
||||
function getTier(relPath: string): string {
|
||||
const firstDir = relPath.split('/')[0];
|
||||
return TIER_MAP[firstDir] || 'domain';
|
||||
}
|
||||
|
||||
function getCategory(relPath: string): string {
|
||||
return relPath.split('/')[0];
|
||||
}
|
||||
|
||||
function getSubcategory(relPath: string): string | null {
|
||||
const parts = relPath.split('/');
|
||||
return parts.length > 2 ? parts[1] : null;
|
||||
}
|
||||
|
||||
function extractComponentInfo(content: string, fileName: string) {
|
||||
const isClient = /^['"]use client['"];?/m.test(content);
|
||||
|
||||
let name: string | null = null;
|
||||
let exportType: 'default' | 'named' | 'both' | 'none' = 'none';
|
||||
|
||||
const defaultFuncMatch = content.match(/export\s+default\s+function\s+([A-Z]\w*)/);
|
||||
const defaultConstMatch = content.match(/export\s+default\s+([A-Z]\w*)/);
|
||||
const namedFuncMatches = [...content.matchAll(/export\s+(?:async\s+)?function\s+([A-Z]\w*)/g)];
|
||||
const namedConstMatches = [...content.matchAll(/export\s+(?:const|let)\s+([A-Z]\w*)/g)];
|
||||
|
||||
const hasDefault = !!(defaultFuncMatch || defaultConstMatch);
|
||||
const namedExports = [
|
||||
...namedFuncMatches.map(m => m[1]),
|
||||
...namedConstMatches.map(m => m[1]),
|
||||
].filter((n): n is string => n !== undefined);
|
||||
|
||||
if (defaultFuncMatch) {
|
||||
name = defaultFuncMatch[1];
|
||||
} else if (defaultConstMatch) {
|
||||
name = defaultConstMatch[1];
|
||||
}
|
||||
|
||||
const hasNamed = namedExports.length > 0;
|
||||
|
||||
if (hasDefault && hasNamed) {
|
||||
exportType = 'both';
|
||||
} else if (hasDefault) {
|
||||
exportType = 'default';
|
||||
} else if (hasNamed) {
|
||||
exportType = 'named';
|
||||
name = namedExports[0];
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
const base = basename(fileName, extname(fileName));
|
||||
name = base
|
||||
.split(/[-_]/)
|
||||
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
const hasProps = /(?:interface|type)\s+\w*Props/.test(content);
|
||||
const propsNameMatch = content.match(/(?:interface|type)\s+(\w*Props)/);
|
||||
const propsName = propsNameMatch ? propsNameMatch[1] : null;
|
||||
|
||||
const lineCount = content.split('\n').length;
|
||||
|
||||
return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount };
|
||||
}
|
||||
|
||||
async function scanDirectory(dir: string, componentsRoot: string): Promise<ComponentEntry[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const results: ComponentEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await scanDirectory(fullPath, componentsRoot));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.tsx')) {
|
||||
if (shouldSkip(entry.name)) continue;
|
||||
|
||||
const relPath = relative(componentsRoot, fullPath);
|
||||
const content = await readFile(fullPath, 'utf-8');
|
||||
const info = extractComponentInfo(content, entry.name);
|
||||
|
||||
results.push({
|
||||
name: info.name,
|
||||
fileName: entry.name,
|
||||
filePath: `src/components/${relPath}`,
|
||||
tier: getTier(relPath),
|
||||
category: getCategory(relPath),
|
||||
subcategory: getSubcategory(relPath),
|
||||
exportType: info.exportType,
|
||||
hasProps: info.hasProps,
|
||||
propsName: info.propsName,
|
||||
isClientComponent: info.isClientComponent,
|
||||
lineCount: info.lineCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildCategories(components: ComponentEntry[]): CategorySummary[] {
|
||||
const map = new Map<string, CategorySummary>();
|
||||
for (const comp of components) {
|
||||
const key = `${comp.tier}::${comp.category}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { tier: comp.tier, category: comp.category, count: 0 });
|
||||
}
|
||||
map.get(key)!.count++;
|
||||
}
|
||||
return [...map.values()].sort((a, b) =>
|
||||
a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 레지스트리 실시간 스캔
|
||||
*
|
||||
* src/components/ 하위 .tsx 파일을 스캔하여 레지스트리 데이터를 반환합니다.
|
||||
* 페이지 방문 시마다 최신 상태를 반영합니다.
|
||||
*/
|
||||
export async function scanComponentRegistry(): Promise<RegistryData> {
|
||||
const componentsRoot = join(process.cwd(), 'src', 'components');
|
||||
|
||||
const components = await scanDirectory(componentsRoot, componentsRoot);
|
||||
components.sort((a, b) =>
|
||||
a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalCount: components.length,
|
||||
categories: buildCategories(components),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import registry from '@/generated/component-registry.json';
|
||||
import ComponentRegistryClient, { type RegistryData } from './ComponentRegistryClient';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { scanComponentRegistry, type RegistryData } from './actions';
|
||||
import ComponentRegistryClient from './ComponentRegistryClient';
|
||||
|
||||
export default function ComponentRegistryPage() {
|
||||
return <ComponentRegistryClient registry={registry as unknown as RegistryData} />;
|
||||
const [registry, setRegistry] = useState<RegistryData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
scanComponentRegistry()
|
||||
.then(setRegistry)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">컴포넌트 스캔 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!registry) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<p className="text-sm text-red-500">스캔에 실패했습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ComponentRegistryClient registry={registry} />;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { YearQuarterFilter } from '@/components/molecules/YearQuarterFilter';
|
||||
import { MobileFilter } from '@/components/molecules/MobileFilter';
|
||||
// Organisms
|
||||
import { EmptyState as OrganismEmptyState } from '@/components/organisms/EmptyState';
|
||||
import { EmptyState as OrganismEmptyState } from '@/components/ui/empty-state';
|
||||
import { FormActions } from '@/components/organisms/FormActions';
|
||||
import { FormSection } from '@/components/organisms/FormSection';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
@@ -1087,7 +1087,7 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
label: 'Variants',
|
||||
render: () => (
|
||||
<div className="flex gap-4">
|
||||
<OrganismEmptyState icon={Inbox} title="데이터 없음" description="등록된 항목이 없습니다." />
|
||||
<OrganismEmptyState icon={<Inbox className="w-8 h-8" />} message="데이터 없음" description="등록된 항목이 없습니다." />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface StandardDialogProps {
|
||||
@@ -84,136 +83,3 @@ export function StandardDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인 다이얼로그
|
||||
*/
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmText = "확인",
|
||||
cancelText = "취소",
|
||||
confirmVariant = "default",
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-4"></div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 다이얼로그
|
||||
*/
|
||||
export interface FormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
saveText?: string;
|
||||
cancelText?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onSave,
|
||||
onCancel,
|
||||
saveText = "저장",
|
||||
cancelText = "취소",
|
||||
size = "md",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: FormDialogProps) {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn(sizeClasses[size])}>
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-4 overflow-y-auto max-h-[60vh]">{children}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || disabled}>
|
||||
{saveText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ export { IconWithBadge } from "./IconWithBadge";
|
||||
export { TableActions } from "./TableActions";
|
||||
export type { TableAction } from "./TableActions";
|
||||
|
||||
export { StandardDialog, ConfirmDialog, FormDialog } from "./StandardDialog";
|
||||
export type { StandardDialogProps, ConfirmDialogProps, FormDialogProps } from "./StandardDialog";
|
||||
export { StandardDialog } from "./StandardDialog";
|
||||
export type { StandardDialogProps } from "./StandardDialog";
|
||||
|
||||
export { YearQuarterFilter } from "./YearQuarterFilter";
|
||||
export type { Quarter } from "./YearQuarterFilter";
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
{Icon && (
|
||||
<div className="mb-4 p-4 bg-muted rounded-full">
|
||||
<Icon className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export { DataTable } from "./DataTable";
|
||||
export type { Column, CellType } from "./DataTable";
|
||||
export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
|
||||
export type { MobileCardProps, InfoFieldProps } from "./MobileCard";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { EmptyState, TableEmptyState } from "@/components/ui/empty-state";
|
||||
export { ScreenVersionHistory } from "./ScreenVersionHistory";
|
||||
export { SearchableSelectionModal } from "./SearchableSelectionModal";
|
||||
export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } from "./SearchableSelectionModal";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Building2, Plus, Save, X, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { AccountNumberInput } from '@/components/ui/account-number-input';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import { FileInput } from '@/components/ui/file-input';
|
||||
@@ -213,50 +213,38 @@ export function CompanyInfoManagement() {
|
||||
|
||||
{/* 회사명 / 대표자명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">회사명</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleChange('companyName', e.target.value)}
|
||||
placeholder="회사명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="representativeName">대표자명</Label>
|
||||
<Input
|
||||
id="representativeName"
|
||||
value={formData.representativeName}
|
||||
onChange={(e) => handleChange('representativeName', e.target.value)}
|
||||
placeholder="대표자명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="회사명"
|
||||
value={formData.companyName}
|
||||
onChange={(value) => handleChange('companyName', value)}
|
||||
placeholder="회사명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
<FormField
|
||||
label="대표자명"
|
||||
value={formData.representativeName}
|
||||
onChange={(value) => handleChange('representativeName', value)}
|
||||
placeholder="대표자명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 업태 / 업종 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessType">업태</Label>
|
||||
<Input
|
||||
id="businessType"
|
||||
value={formData.businessType}
|
||||
onChange={(e) => handleChange('businessType', e.target.value)}
|
||||
placeholder="업태명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessCategory">업종</Label>
|
||||
<Input
|
||||
id="businessCategory"
|
||||
value={formData.businessCategory}
|
||||
onChange={(e) => handleChange('businessCategory', e.target.value)}
|
||||
placeholder="업종명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="업태"
|
||||
value={formData.businessType}
|
||||
onChange={(value) => handleChange('businessType', value)}
|
||||
placeholder="업태명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
<FormField
|
||||
label="업종"
|
||||
value={formData.businessCategory}
|
||||
onChange={(value) => handleChange('businessCategory', value)}
|
||||
placeholder="업종명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주소 */}
|
||||
@@ -290,28 +278,20 @@ export function CompanyInfoManagement() {
|
||||
|
||||
{/* 이메일 / 세금계산서 이메일 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일 (아이디)</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="abc@email.com"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="taxInvoiceEmail">세금계산서 이메일</Label>
|
||||
<Input
|
||||
id="taxInvoiceEmail"
|
||||
type="email"
|
||||
value={formData.taxInvoiceEmail}
|
||||
onChange={(e) => handleChange('taxInvoiceEmail', e.target.value)}
|
||||
placeholder="abc@email.com"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="이메일 (아이디)"
|
||||
value={formData.email}
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
placeholder="abc@email.com"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
<FormField
|
||||
label="세금계산서 이메일"
|
||||
value={formData.taxInvoiceEmail}
|
||||
onChange={(value) => handleChange('taxInvoiceEmail', value)}
|
||||
placeholder="abc@email.com"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */}
|
||||
@@ -352,16 +332,14 @@ export function CompanyInfoManagement() {
|
||||
placeholder="파일을 선택하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="businessNumber">사업자등록번호</Label>
|
||||
<BusinessNumberInput
|
||||
id="businessNumber"
|
||||
value={formData.businessNumber}
|
||||
onChange={(value) => handleChange('businessNumber', value)}
|
||||
placeholder="123-12-12345"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="사업자등록번호"
|
||||
type="businessNumber"
|
||||
value={formData.businessNumber}
|
||||
onChange={(value) => handleChange('businessNumber', value)}
|
||||
placeholder="123-12-12345"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -374,16 +352,13 @@ export function CompanyInfoManagement() {
|
||||
<CardContent className="space-y-6">
|
||||
{/* 결제 은행 / 계좌 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentBank">결제 은행</Label>
|
||||
<Input
|
||||
id="paymentBank"
|
||||
value={formData.paymentBank}
|
||||
onChange={(e) => handleChange('paymentBank', e.target.value)}
|
||||
placeholder="은행명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="결제 은행"
|
||||
value={formData.paymentBank}
|
||||
onChange={(value) => handleChange('paymentBank', value)}
|
||||
placeholder="은행명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentAccount">계좌</Label>
|
||||
<AccountNumberInput
|
||||
@@ -398,16 +373,13 @@ export function CompanyInfoManagement() {
|
||||
|
||||
{/* 예금주 / 결제일 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentAccountHolder">예금주</Label>
|
||||
<Input
|
||||
id="paymentAccountHolder"
|
||||
value={formData.paymentAccountHolder}
|
||||
onChange={(e) => handleChange('paymentAccountHolder', e.target.value)}
|
||||
placeholder="예금주명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="예금주"
|
||||
value={formData.paymentAccountHolder}
|
||||
onChange={(value) => handleChange('paymentAccountHolder', value)}
|
||||
placeholder="예금주명"
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentDay">결제일</Label>
|
||||
{isEditMode ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ export function useDaumPostcode(options?: UseDaumPostcodeOptions) {
|
||||
// 스크립트 추가
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
|
||||
'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
|
||||
script.async = true;
|
||||
script.onload = () => setIsScriptLoaded(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
@@ -300,11 +300,12 @@ export async function middleware(request: NextRequest) {
|
||||
intlResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
intlResponse.headers.set('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"connect-src 'self' https://maps.googleapis.com",
|
||||
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net",
|
||||
"frame-src *.daum.net *.daumcdn.net",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
Reference in New Issue
Block a user