refactor(WEB): 컴포넌트 레지스트리 개선 및 미사용 코드 정리

- component-registry를 파일 시스템 기반 동적 스캔으로 전환 (정적 JSON 삭제)
- 미사용 컴포넌트 삭제 (EmptyState, StandardDialog)
- 회사정보 관리 페이지 개선
- 컴포넌트 계층 정의 가이드 문서 추가
- middleware 및 useDaumPostcode 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-12 14:15:09 +09:00
parent 020d74f36c
commit 8d685109d3
14 changed files with 523 additions and 7005 deletions

View File

@@ -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**: 🟢

View 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 |

View File

@@ -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} &middot; <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-xs">npm run gen:components</code>
&middot; 클릭: 소스코드 &middot; UI : {previewCount}
: {generatedDate} &middot; ( )
&middot; 클릭: 소스코드 &middot; : {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>

View File

@@ -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,
};
}

View File

@@ -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} />;
}

View File

@@ -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>
),
},

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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

View File

@@ -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);

View File

@@ -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'",