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
|
## User Environment
|
||||||
**Priority**: 🟢
|
**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,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getComponentSource } from './actions';
|
import { getComponentSource, type RegistryData, type ComponentEntry } from './actions';
|
||||||
import { UI_PREVIEWS } from './previews';
|
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 {
|
interface ComponentRegistryClientProps {
|
||||||
registry: RegistryData;
|
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'
|
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Card Header */}
|
{/* Card Header */}
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={handleToggle}
|
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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -207,7 +183,7 @@ function ComponentCard({
|
|||||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Content */}
|
{/* Expanded Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
@@ -406,10 +382,10 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
|||||||
return counts;
|
return counts;
|
||||||
}, [registry.components]);
|
}, [registry.components]);
|
||||||
|
|
||||||
// Count of previewable components
|
// Count of previewable components (all tiers)
|
||||||
const previewCount = useMemo(() => {
|
const previewCount = useMemo(() => {
|
||||||
return registry.components.filter(
|
return registry.components.filter(
|
||||||
(c) => c.tier === 'ui' && UI_PREVIEWS[c.fileName]
|
(c) => !!UI_PREVIEWS[c.fileName]
|
||||||
).length;
|
).length;
|
||||||
}, [registry.components]);
|
}, [registry.components]);
|
||||||
|
|
||||||
@@ -430,8 +406,8 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">
|
<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>로 갱신
|
스캔: {generatedDate} · 새로고침으로 갱신 (실시간 스캔)
|
||||||
· 카드 클릭: 소스코드 보기 · UI 프리뷰: {previewCount}개
|
· 카드 클릭: 소스코드 보기 · 프리뷰: {previewCount}개
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -513,7 +489,7 @@ export default function ComponentRegistryClient({ registry }: ComponentRegistryC
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
|
||||||
<p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join, relative, basename, extname } from 'node:path';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 소스코드 조회 (기존)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export async function getComponentSource(filePath: string): Promise<{ source: string | null; error: string | null }> {
|
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')) {
|
if (!filePath.startsWith('src/components/') || !filePath.endsWith('.tsx')) {
|
||||||
return { source: null, error: 'Invalid file path' };
|
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' };
|
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';
|
'use client';
|
||||||
|
|
||||||
import registry from '@/generated/component-registry.json';
|
import { useEffect, useState } from 'react';
|
||||||
import ComponentRegistryClient, { type RegistryData } from './ComponentRegistryClient';
|
import { scanComponentRegistry, type RegistryData } from './actions';
|
||||||
|
import ComponentRegistryClient from './ComponentRegistryClient';
|
||||||
|
|
||||||
export default function ComponentRegistryPage() {
|
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 { YearQuarterFilter } from '@/components/molecules/YearQuarterFilter';
|
||||||
import { MobileFilter } from '@/components/molecules/MobileFilter';
|
import { MobileFilter } from '@/components/molecules/MobileFilter';
|
||||||
// Organisms
|
// 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 { FormActions } from '@/components/organisms/FormActions';
|
||||||
import { FormSection } from '@/components/organisms/FormSection';
|
import { FormSection } from '@/components/organisms/FormSection';
|
||||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||||
@@ -1087,7 +1087,7 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
|||||||
label: 'Variants',
|
label: 'Variants',
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<OrganismEmptyState icon={Inbox} title="데이터 없음" description="등록된 항목이 없습니다." />
|
<OrganismEmptyState icon={<Inbox className="w-8 h-8" />} message="데이터 없음" description="등록된 항목이 없습니다." />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface StandardDialogProps {
|
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 { TableActions } from "./TableActions";
|
||||||
export type { TableAction } from "./TableActions";
|
export type { TableAction } from "./TableActions";
|
||||||
|
|
||||||
export { StandardDialog, ConfirmDialog, FormDialog } from "./StandardDialog";
|
export { StandardDialog } from "./StandardDialog";
|
||||||
export type { StandardDialogProps, ConfirmDialogProps, FormDialogProps } from "./StandardDialog";
|
export type { StandardDialogProps } from "./StandardDialog";
|
||||||
|
|
||||||
export { YearQuarterFilter } from "./YearQuarterFilter";
|
export { YearQuarterFilter } from "./YearQuarterFilter";
|
||||||
export type { Quarter } 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 type { Column, CellType } from "./DataTable";
|
||||||
export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
|
export { MobileCard, ListMobileCard, InfoField } from "./MobileCard";
|
||||||
export type { MobileCardProps, InfoFieldProps } 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 { ScreenVersionHistory } from "./ScreenVersionHistory";
|
||||||
export { SearchableSelectionModal } from "./SearchableSelectionModal";
|
export { SearchableSelectionModal } from "./SearchableSelectionModal";
|
||||||
export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } 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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { AccountNumberInput } from '@/components/ui/account-number-input';
|
||||||
import { ImageUpload } from '@/components/ui/image-upload';
|
import { ImageUpload } from '@/components/ui/image-upload';
|
||||||
import { FileInput } from '@/components/ui/file-input';
|
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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="companyName">회사명</Label>
|
label="회사명"
|
||||||
<Input
|
value={formData.companyName}
|
||||||
id="companyName"
|
onChange={(value) => handleChange('companyName', value)}
|
||||||
value={formData.companyName}
|
placeholder="회사명"
|
||||||
onChange={(e) => handleChange('companyName', e.target.value)}
|
disabled={!isEditMode}
|
||||||
placeholder="회사명"
|
/>
|
||||||
disabled={!isEditMode}
|
<FormField
|
||||||
/>
|
label="대표자명"
|
||||||
</div>
|
value={formData.representativeName}
|
||||||
<div className="space-y-2">
|
onChange={(value) => handleChange('representativeName', value)}
|
||||||
<Label htmlFor="representativeName">대표자명</Label>
|
placeholder="대표자명"
|
||||||
<Input
|
disabled={!isEditMode}
|
||||||
id="representativeName"
|
/>
|
||||||
value={formData.representativeName}
|
|
||||||
onChange={(e) => handleChange('representativeName', e.target.value)}
|
|
||||||
placeholder="대표자명"
|
|
||||||
disabled={!isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 업태 / 업종 */}
|
{/* 업태 / 업종 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="businessType">업태</Label>
|
label="업태"
|
||||||
<Input
|
value={formData.businessType}
|
||||||
id="businessType"
|
onChange={(value) => handleChange('businessType', value)}
|
||||||
value={formData.businessType}
|
placeholder="업태명"
|
||||||
onChange={(e) => handleChange('businessType', e.target.value)}
|
disabled={!isEditMode}
|
||||||
placeholder="업태명"
|
/>
|
||||||
disabled={!isEditMode}
|
<FormField
|
||||||
/>
|
label="업종"
|
||||||
</div>
|
value={formData.businessCategory}
|
||||||
<div className="space-y-2">
|
onChange={(value) => handleChange('businessCategory', value)}
|
||||||
<Label htmlFor="businessCategory">업종</Label>
|
placeholder="업종명"
|
||||||
<Input
|
disabled={!isEditMode}
|
||||||
id="businessCategory"
|
/>
|
||||||
value={formData.businessCategory}
|
|
||||||
onChange={(e) => handleChange('businessCategory', e.target.value)}
|
|
||||||
placeholder="업종명"
|
|
||||||
disabled={!isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 주소 */}
|
{/* 주소 */}
|
||||||
@@ -290,28 +278,20 @@ export function CompanyInfoManagement() {
|
|||||||
|
|
||||||
{/* 이메일 / 세금계산서 이메일 */}
|
{/* 이메일 / 세금계산서 이메일 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="email">이메일 (아이디)</Label>
|
label="이메일 (아이디)"
|
||||||
<Input
|
value={formData.email}
|
||||||
id="email"
|
onChange={(value) => handleChange('email', value)}
|
||||||
type="email"
|
placeholder="abc@email.com"
|
||||||
value={formData.email}
|
disabled={!isEditMode}
|
||||||
onChange={(e) => handleChange('email', e.target.value)}
|
/>
|
||||||
placeholder="abc@email.com"
|
<FormField
|
||||||
disabled={!isEditMode}
|
label="세금계산서 이메일"
|
||||||
/>
|
value={formData.taxInvoiceEmail}
|
||||||
</div>
|
onChange={(value) => handleChange('taxInvoiceEmail', value)}
|
||||||
<div className="space-y-2">
|
placeholder="abc@email.com"
|
||||||
<Label htmlFor="taxInvoiceEmail">세금계산서 이메일</Label>
|
disabled={!isEditMode}
|
||||||
<Input
|
/>
|
||||||
id="taxInvoiceEmail"
|
|
||||||
type="email"
|
|
||||||
value={formData.taxInvoiceEmail}
|
|
||||||
onChange={(e) => handleChange('taxInvoiceEmail', e.target.value)}
|
|
||||||
placeholder="abc@email.com"
|
|
||||||
disabled={!isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */}
|
{/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */}
|
||||||
@@ -352,16 +332,14 @@ export function CompanyInfoManagement() {
|
|||||||
placeholder="파일을 선택하세요"
|
placeholder="파일을 선택하세요"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="businessNumber">사업자등록번호</Label>
|
label="사업자등록번호"
|
||||||
<BusinessNumberInput
|
type="businessNumber"
|
||||||
id="businessNumber"
|
value={formData.businessNumber}
|
||||||
value={formData.businessNumber}
|
onChange={(value) => handleChange('businessNumber', value)}
|
||||||
onChange={(value) => handleChange('businessNumber', value)}
|
placeholder="123-12-12345"
|
||||||
placeholder="123-12-12345"
|
disabled={!isEditMode}
|
||||||
disabled={!isEditMode}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -374,16 +352,13 @@ export function CompanyInfoManagement() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* 결제 은행 / 계좌 */}
|
{/* 결제 은행 / 계좌 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="paymentBank">결제 은행</Label>
|
label="결제 은행"
|
||||||
<Input
|
value={formData.paymentBank}
|
||||||
id="paymentBank"
|
onChange={(value) => handleChange('paymentBank', value)}
|
||||||
value={formData.paymentBank}
|
placeholder="은행명"
|
||||||
onChange={(e) => handleChange('paymentBank', e.target.value)}
|
disabled={!isEditMode}
|
||||||
placeholder="은행명"
|
/>
|
||||||
disabled={!isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="paymentAccount">계좌</Label>
|
<Label htmlFor="paymentAccount">계좌</Label>
|
||||||
<AccountNumberInput
|
<AccountNumberInput
|
||||||
@@ -398,16 +373,13 @@ export function CompanyInfoManagement() {
|
|||||||
|
|
||||||
{/* 예금주 / 결제일 */}
|
{/* 예금주 / 결제일 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="paymentAccountHolder">예금주</Label>
|
label="예금주"
|
||||||
<Input
|
value={formData.paymentAccountHolder}
|
||||||
id="paymentAccountHolder"
|
onChange={(value) => handleChange('paymentAccountHolder', value)}
|
||||||
value={formData.paymentAccountHolder}
|
placeholder="예금주명"
|
||||||
onChange={(e) => handleChange('paymentAccountHolder', e.target.value)}
|
disabled={!isEditMode}
|
||||||
placeholder="예금주명"
|
/>
|
||||||
disabled={!isEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="paymentDay">결제일</Label>
|
<Label htmlFor="paymentDay">결제일</Label>
|
||||||
{isEditMode ? (
|
{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');
|
const script = document.createElement('script');
|
||||||
script.src =
|
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.async = true;
|
||||||
script.onload = () => setIsScriptLoaded(true);
|
script.onload = () => setIsScriptLoaded(true);
|
||||||
document.head.appendChild(script);
|
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('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
intlResponse.headers.set('Content-Security-Policy', [
|
intlResponse.headers.set('Content-Security-Policy', [
|
||||||
"default-src 'self'",
|
"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'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob: https:",
|
"img-src 'self' data: blob: https:",
|
||||||
"font-src 'self' data: https://fonts.gstatic.com",
|
"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'",
|
"frame-ancestors 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
|
|||||||
Reference in New Issue
Block a user