Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-12 14:16:25 +09:00
47 changed files with 6156 additions and 386 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

@@ -270,6 +270,7 @@ claudedocs/
| `[PLAN-2025-11-27] item-form-component-separation.md` | ItemForm 컴포넌트 분리 |
| `[IMPL-2025-11-27] realtime-sync-fixes.md` | 실시간 동기화 수정 |
| `[IMPL-2026-01-09] item-management-api-integration.md` | 품목관리 API 연동 |
| `[API-REQUEST-2026-02-12] dynamic-field-type-backend-spec.md` | 동적 필드 타입 확장 백엔드 API 스펙 |
| `NEXT-*.md` | 세션 체크포인트 (다수) |
| `API-*.md` | API 명세/요청 (다수) |
| `ANALYSIS-*.md` | 분석 노트 (다수) |
@@ -357,6 +358,7 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[IMPL-2026-02-11] favorites-feature.md` | 즐겨찾기 기능 (localStorage → 추후 API 전환) |
| `[IMPL-2026-01-07] ceo-dashboard-checklist.md` | 대표님 전용 대시보드 (11개 섹션) |
| `dashboard-integration-complete.md` | 대시보드 통합 완료 |
| `dashboard-cleanup-summary.md` | 정리 요약 |
@@ -483,6 +485,9 @@ claudedocs/
| `[IMPL-2026-02-05] formatter-commonization-plan.md` | formatter 공통화 계획 |
| `[IMPL] IntegratedDetailTemplate-checklist.md` | 통합 상세 템플릿 체크리스트 |
| `[REF] template-migration-status.md` | 템플릿 마이그레이션 현황 |
| **동적 필드 타입 확장** | |
| `[DESIGN-2026-02-11] dynamic-field-type-extension.md` | 동적 필드 타입 확장 설계서 (4-Level 구조) |
| `[IMPL-2026-02-11] dynamic-field-components.md` | 동적 필드 컴포넌트 구현 기획서 (Phase 1~3 완료) |
| **시스템 설계** | |
| `[PLAN-2026-01-16] layout-restructure.md` | 레이아웃 구조 변경 |
| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 동적 메뉴 갱신 시스템 |

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

@@ -0,0 +1,343 @@
# 동적 필드 타입 컴포넌트 — 프론트엔드 구현 기획서
> 작성일: 2026-02-11
> 설계 근거: `[DESIGN-2026-02-11] dynamic-field-type-extension.md`
> 상태: ✅ 프론트 구현 완료 — 백엔드 작업 대기
> 백엔드 스펙: `item-master/[API-REQUEST-2026-02-12] dynamic-field-type-backend-spec.md`
---
## 목적
현재 DynamicItemForm의 필드 타입이 6종(textbox, number, dropdown, checkbox, date, textarea)으로 제한되어 있어 제조/공사/유통/물류 등 다양한 산업의 품목관리 요구를 충족하지 못함.
**이 작업의 목표**:
- 8종의 신규 필드 컴포넌트를 미리 만들어둔다
- 범용 테이블 섹션(DynamicTableSection)을 만든다
- 백엔드가 `field_type` + `properties` config를 보내면 자동 렌더링되는 구조를 완성한다
- 백엔드 작업 전에도 mock props로 독립 테스트 가능하게 한다
**API 연동 시 동작 흐름**:
```
백엔드 DB: field_type = "reference", properties = { "source": "vendors" }
API 응답: GET /v1/item-master/pages/{id}/structure
프론트: DynamicFieldRenderer → switch("reference") → <ReferenceField />
ReferenceField가 properties.source 읽어서 /api/proxy/vendors 검색 API 호출
```
---
## 컴포넌트 구현 목록
### Phase 1: 핵심 컴포넌트
| # | 컴포넌트 | field_type | 상태 | 비고 |
|---|---------|-----------|------|------|
| 1-1 | ReferenceField | `reference` | ✅ 완료 | 다른 테이블 검색/선택 |
| 1-2 | MultiSelectField | `multi-select` | ✅ 완료 | 복수 선택 태그 |
| 1-3 | FileField | `file` | ✅ 완료 | 파일/이미지 업로드 |
| 1-4 | DynamicTableSection | (섹션) | ✅ 완료 | 범용 테이블 섹션 |
| 1-5 | TableCellRenderer | (내부) | ✅ 완료 | 테이블 셀 렌더러 |
| 1-6 | reference-sources.ts | (config) | ✅ 완료 | 참조 소스 프리셋 정의 |
| 1-7 | DynamicFieldRenderer 확장 | (수정) | ✅ 완료 | switch문 + 신규 import |
| 1-8 | 타입 정의 확장 | (수정) | ✅ 완료 | ItemFieldType 통합 + config 인터페이스 |
### Phase 2: 편의 컴포넌트
| # | 컴포넌트 | field_type | 상태 | 비고 |
|---|---------|-----------|------|------|
| 2-1 | CurrencyField | `currency` | ✅ 완료 | 통화 금액 (천단위 포맷) |
| 2-2 | UnitValueField | `unit-value` | ✅ 완료 | 값+단위 조합 (100mm) |
| 2-3 | RadioField | `radio` | ✅ 완료 | 라디오 버튼 그룹 |
| 2-4 | section-presets.ts | (config) | ✅ 완료 | 산업별 섹션 프리셋 JSON |
### Phase 3: 고급 컴포넌트
| # | 컴포넌트 | field_type | 상태 | 비고 |
|---|---------|-----------|------|------|
| 3-1 | ToggleField | `toggle` | ✅ 완료 | On/Off 스위치 |
| 3-2 | ComputedField | `computed` | ✅ 완료 | 계산 필드 (읽기전용) |
| 3-3 | 조건부 표시 연산자 확장 | (수정) | ✅ 완료 | in/not_in/greater_than 등 9종 |
---
## 각 컴포넌트 스펙
### 1-1. ReferenceField
**파일**: `DynamicItemForm/fields/ReferenceField.tsx`
**역할**: 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객, 현장, 차량 등)
**UI 구성**:
- 읽기전용 Input + 검색 버튼(돋보기 아이콘)
- 클릭 시 SearchableSelectionModal 열림
- 선택 후 displayField 값 표시
- X 버튼으로 선택 해제
**properties에서 읽는 값**:
```typescript
interface ReferenceConfig {
source: string; // "vendors" | "items" | "custom" 등
displayField?: string; // 기본 "name"
valueField?: string; // 기본 "id"
searchFields?: string[]; // 기본 ["name"]
searchApiUrl?: string; // source="custom"일 때 필수
columns?: Array<{ key: string; label: string; width?: string }>;
displayFormat?: string; // "{code} - {name}"
returnFields?: string[]; // ["id", "code", "name"]
}
```
**API 연동 시**:
- `REFERENCE_SOURCES[source]`에서 apiUrl 조회
- `GET {apiUrl}?search={query}&size=20` 호출
- 결과를 SearchableSelectionModal에 표시
**API 연동 전 (mock)**:
- props로 전달된 options 사용 또는
- 빈 상태에서 UI/UX만 확인
---
### 1-2. MultiSelectField
**파일**: `DynamicItemForm/fields/MultiSelectField.tsx`
**역할**: 여러 항목을 동시에 선택 (태그 칩 형태로 표시)
**UI 구성**:
- Combobox (검색 가능한 드롭다운)
- 선택된 항목은 칩(Chip/Badge)으로 표시
- 칩의 X 버튼으로 개별 해제
**properties에서 읽는 값**:
```typescript
interface MultiSelectConfig {
maxSelections?: number; // 최대 선택 수 (기본: 무제한)
allowCustom?: boolean; // 직접 입력 허용 (기본: false)
layout?: 'chips' | 'list'; // 기본: "chips"
}
```
**options**: 기존 dropdown과 동일 `[{label, value}]`
**저장값**: `string[]` (예: `["CUT", "BEND", "WELD"]`)
---
### 1-3. FileField
**파일**: `DynamicItemForm/fields/FileField.tsx`
**역할**: 파일/이미지 첨부
**UI 구성**:
- 파일 선택 버튼 ("파일 선택" 또는 드래그 앤 드롭 영역)
- 선택된 파일 목록 표시 (이름, 크기, 삭제 버튼)
- 이미지 파일일 경우 미리보기 썸네일
**properties에서 읽는 값**:
```typescript
interface FileConfig {
accept?: string; // ".pdf,.doc" (기본: "*")
maxSize?: number; // bytes (기본: 10MB = 10485760)
maxFiles?: number; // 기본: 1
preview?: boolean; // 이미지 미리보기 (기본: true)
category?: string; // 파일 카테고리 태그
}
```
**API 연동 시**: `POST /v1/files/upload` (multipart)
**API 연동 전**: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기
---
### 1-4. DynamicTableSection
**파일**: `DynamicItemForm/sections/DynamicTableSection.tsx`
**역할**: config 기반 범용 테이블 (공정, 품질검사, 구매처, 공정표, 배차 등)
**UI 구성**:
- 테이블 헤더 (columns config 기반)
- 행 추가/삭제 버튼
- 각 셀은 TableCellRenderer (= DynamicFieldRenderer 재사용)
- 요약행 (선택, summaryRow config)
- 빈 상태 메시지
**props**:
```typescript
interface DynamicTableSectionProps {
section: ItemSectionResponse;
tableConfig: TableConfig;
rows: Record<string, any>[];
onRowsChange: (rows: Record<string, any>[]) => void;
disabled?: boolean;
}
```
**tableConfig 구조**: 설계서 섹션 4.3 참조
**API 연동 시**: `GET/PUT /v1/items/{itemId}/section-data/{sectionId}`
**API 연동 전**: rows를 폼 상태(formData)에 로컬 관리
---
### 1-5. TableCellRenderer
**파일**: `DynamicItemForm/sections/TableCellRenderer.tsx`
**역할**: 테이블 셀 = DynamicFieldRenderer를 테이블 셀용 축소 모드로 래핑
**핵심**: column config → ItemFieldResponse 호환 객체로 변환 → DynamicFieldRenderer 호출
```typescript
function TableCellRenderer({ column, value, onChange, compact }) {
const fieldLike = columnToFieldResponse(column);
return <DynamicFieldRenderer field={fieldLike} value={value} onChange={onChange} />;
}
```
---
### 1-6. reference-sources.ts
**파일**: `DynamicItemForm/config/reference-sources.ts`
**역할**: reference 필드의 소스별 기본 설정 (API URL, 표시 필드, 검색 컬럼)
**내용**: 공통(vendors, items, customers, employees, warehouses) + 산업별(processes, sites, vehicles, stores 등)
**확장 방법**: 새 소스 추가 = 이 파일에 객체 1개 추가
---
### 2-1. CurrencyField
**파일**: `DynamicItemForm/fields/CurrencyField.tsx`
**역할**: 통화 금액 입력 (천단위 콤마, 통화 기호)
**UI**: Input + 통화기호(₩) prefix + 천단위 포맷
- 입력 중: 숫자만
- 포커스 아웃: "₩15,000" 포맷
**properties**: `{ currency, precision, showSymbol, allowNegative }`
**저장값**: `number` (포맷 없이)
---
### 2-2. UnitValueField
**파일**: `DynamicItemForm/fields/UnitValueField.tsx`
**역할**: 값 + 단위 조합 입력 (100mm, 50kg)
**UI**: Input(숫자) + Select(단위) 가로 배치
**properties**: `{ units, defaultUnit, precision }`
**저장값**: `{ value: number, unit: string }`
---
### 2-3. RadioField
**파일**: `DynamicItemForm/fields/RadioField.tsx`
**역할**: 라디오 버튼 그룹
**UI**: RadioGroup (수평/수직)
**properties**: `{ layout: "horizontal" | "vertical" }`
**options**: `[{label, value}]`
---
### 3-1. ToggleField
**파일**: `DynamicItemForm/fields/ToggleField.tsx`
**역할**: On/Off 토글 스위치
**UI**: Switch + 라벨
**properties**: `{ onLabel, offLabel, onValue, offValue }`
---
### 3-2. ComputedField
**파일**: `DynamicItemForm/fields/ComputedField.tsx`
**역할**: 다른 필드 기반 자동 계산 (읽기 전용)
**UI**: 읽기전용 표시 (배경색 구분, muted)
**properties**: `{ formula, dependsOn, format, precision }`
**동작**: `dependsOn` 필드 값이 변경될 때마다 formula 재계산
---
## 파일 구조
```
DynamicItemForm/
├── fields/
│ ├── DynamicFieldRenderer.tsx ← switch 확장
│ ├── TextField.tsx (기존)
│ ├── NumberField.tsx (기존)
│ ├── DropdownField.tsx (기존)
│ ├── CheckboxField.tsx (기존)
│ ├── DateField.tsx (기존)
│ ├── TextareaField.tsx (기존)
│ ├── ReferenceField.tsx ★ Phase 1
│ ├── MultiSelectField.tsx ★ Phase 1
│ ├── FileField.tsx ★ Phase 1
│ ├── CurrencyField.tsx ★ Phase 2
│ ├── UnitValueField.tsx ★ Phase 2
│ ├── RadioField.tsx ★ Phase 2
│ ├── ToggleField.tsx ★ Phase 3
│ └── ComputedField.tsx ★ Phase 3
├── sections/
│ ├── DynamicBOMSection.tsx (기존)
│ ├── DynamicTableSection.tsx ★ Phase 1
│ └── TableCellRenderer.tsx ★ Phase 1
├── config/
│ └── reference-sources.ts ★ Phase 1
├── presets/
│ └── section-presets.ts ★ Phase 2
├── hooks/ (기존, 변경 없음)
├── types.ts ← 타입 확장
└── index.tsx ← table 섹션 렌더링 추가
```
## 기존 코드 수정 범위
| 파일 | 수정 내용 | 줄 수 |
|------|----------|-------|
| `DynamicFieldRenderer.tsx` | switch case 8개 추가 + import | +20줄 |
| `types.ts` | ExtendedFieldType union + config 인터페이스 | +80줄 |
| `index.tsx` | 섹션 렌더링에 `case 'table'` 추가 | +15줄 |
| `item-master-api.ts` | field_type union 확장 | +3줄 |
**기존 컴포넌트 6개 + BOM + hooks 7개 = 변경 없음**
---
## 상태 범례
- ⬜ 대기
- 🔄 진행 중
- ✅ 완료
- ⏸️ 보류
---
**마지막 업데이트**: 2026-02-12 — Phase 1+2+3 전체 완료 (15/15 항목)

View File

@@ -0,0 +1,691 @@
# Component Registry
> Auto-generated: 2026-02-12T01:56:50.520Z
> Total: **501** components
## UI (53)
### ui (53)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| Accordion | accordion.tsx | none | | Y | 66 |
| AccountNumberInput | account-number-input.tsx | none | AccountNumberInputProps | Y | 95 |
| Alert | alert.tsx | none | VariantProps | | 59 |
| AlertDialog | alert-dialog.tsx | none | | Y | 158 |
| Badge | badge.tsx | none | VariantProps | | 47 |
| BusinessNumberInput | business-number-input.tsx | none | BusinessNumberInputProps | Y | 114 |
| Button | button.tsx | none | VariantProps | | 62 |
| Calendar | calendar.tsx | none | | Y | 138 |
| Card | card.tsx | none | | | 93 |
| CardNumberInput | card-number-input.tsx | none | CardNumberInputProps | Y | 95 |
| ChartWrapper | chart-wrapper.tsx | named | ChartWrapperProps | | 66 |
| Checkbox | checkbox.tsx | none | | Y | 33 |
| Collapsible | collapsible.tsx | none | | Y | 33 |
| Command | command.tsx | none | | Y | 177 |
| ConfirmDialog | confirm-dialog.tsx | both | ConfirmDialogProps | Y | 226 |
| CurrencyInput | currency-input.tsx | none | CurrencyInputProps | Y | 220 |
| DatePicker | date-picker.tsx | none | DatePickerProps | Y | 279 |
| Dialog | dialog.tsx | none | | Y | 137 |
| Drawer | drawer.tsx | none | | Y | 133 |
| DropdownMenu | dropdown-menu.tsx | none | | Y | 258 |
| EmptyState | empty-state.tsx | both | ButtonProps | Y | 227 |
| ErrorCard | error-card.tsx | named | ErrorCardProps | Y | 196 |
| ErrorMessage | error-message.tsx | named | ErrorMessageProps | | 38 |
| FileDropzone | file-dropzone.tsx | both | FileDropzoneProps | Y | 227 |
| FileInput | file-input.tsx | both | FileInputProps | Y | 226 |
| FileList | file-list.tsx | both | FileListProps | Y | 276 |
| ImageUpload | image-upload.tsx | both | ImageUploadProps | Y | 309 |
| Input | input.tsx | none | | | 22 |
| Label | label.tsx | none | | Y | 25 |
| LoadingSpinner | loading-spinner.tsx | named | LoadingSpinnerProps | | 114 |
| MultiSelectCombobox | multi-select-combobox.tsx | named | MultiSelectComboboxProps | Y | 128 |
| NumberInput | number-input.tsx | none | NumberInputProps | Y | 280 |
| PersonalNumberInput | personal-number-input.tsx | none | PersonalNumberInputProps | Y | 101 |
| PhoneInput | phone-input.tsx | none | PhoneInputProps | Y | 95 |
| Popover | popover.tsx | none | | Y | 53 |
| Progress | progress.tsx | none | | Y | 32 |
| QuantityInput | quantity-input.tsx | none | QuantityInputProps | Y | 271 |
| RadioGroup | radio-group.tsx | none | | Y | 46 |
| ScrollArea | scroll-area.tsx | none | | Y | 53 |
| SearchableSelect | searchable-select.tsx | named | SearchableSelectProps | Y | 219 |
| Select | select.tsx | none | | Y | 192 |
| Separator | separator.tsx | none | SeparatorProps | Y | 32 |
| Sheet | sheet.tsx | none | | Y | 146 |
| Skeleton | skeleton.tsx | none | SkeletonProps | Y | 679 |
| Slider | slider.tsx | none | | | 26 |
| StatusBadge | status-badge.tsx | both | StatusBadgeProps | Y | 123 |
| Switch | switch.tsx | none | | Y | 32 |
| Table | table.tsx | none | | | 117 |
| Tabs | tabs.tsx | none | | Y | 66 |
| Textarea | textarea.tsx | none | | | 25 |
| TimePicker | time-picker.tsx | none | TimePickerProps | Y | 191 |
| Tooltip | tooltip.tsx | none | | Y | 48 |
| VisuallyHidden | visually-hidden.tsx | none | | Y | 14 |
## ATOMS (3)
### atoms (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| BadgeSm | BadgeSm.tsx | named | BadgeSmProps | Y | 117 |
| ScrollableButtonGroup | ScrollableButtonGroup.tsx | named | ScrollableButtonGroupProps | | 53 |
| TabChip | TabChip.tsx | named | TabChipProps | Y | 72 |
## MOLECULES (8)
### molecules (8)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DateRangeSelector | DateRangeSelector.tsx | named | DateRangeSelectorProps | Y | 217 |
| FormField | FormField.tsx | named | FormFieldProps | | 296 |
| IconWithBadge | IconWithBadge.tsx | named | IconWithBadgeProps | Y | 51 |
| MobileFilter | MobileFilter.tsx | named | MobileFilterProps | Y | 335 |
| StandardDialog | StandardDialog.tsx | named | StandardDialogProps | Y | 219 |
| StatusBadge | StatusBadge.tsx | named | StatusBadgeProps | Y | 111 |
| TableActions | TableActions.tsx | named | TableActionsProps | Y | 89 |
| YearQuarterFilter | YearQuarterFilter.tsx | named | YearQuarterFilterProps | Y | 98 |
## ORGANISMS (12)
### organisms (12)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DataTable | DataTable.tsx | named | DataTableProps | Y | 363 |
| EmptyState | EmptyState.tsx | named | EmptyStateProps | Y | 38 |
| FormActions | FormActions.tsx | named | FormActionsProps | | 74 |
| FormFieldGrid | FormFieldGrid.tsx | named | FormFieldGridProps | | 35 |
| FormSection | FormSection.tsx | named | FormSectionProps | | 62 |
| MobileCard | MobileCard.tsx | named | InfoFieldProps | Y | 347 |
| PageHeader | PageHeader.tsx | named | PageHeaderProps | Y | 42 |
| PageLayout | PageLayout.tsx | named | PageLayoutProps | Y | 32 |
| ScreenVersionHistory | ScreenVersionHistory.tsx | named | ScreenVersionHistoryProps | Y | 75 |
| SearchableSelectionModal | SearchableSelectionModal.tsx | named | | Y | 253 |
| SearchFilter | SearchFilter.tsx | named | SearchFilterProps | Y | 58 |
| StatCards | StatCards.tsx | named | StatCardsProps | Y | 67 |
## COMMON (16)
### common (16)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AccessDenied | AccessDenied.tsx | named | AccessDeniedProps | Y | 68 |
| CalendarHeader | CalendarHeader.tsx | named | | Y | 148 |
| DayCell | DayCell.tsx | named | DayCellProps | Y | 93 |
| DayTimeView | DayTimeView.tsx | named | | Y | 167 |
| EditableTable | EditableTable.tsx | both | EditableTableProps | Y | 333 |
| EmptyPage | EmptyPage.tsx | named | EmptyPageProps | Y | 150 |
| MonthView | MonthView.tsx | named | WeekRowProps | Y | 264 |
| MorePopover | MorePopover.tsx | named | MorePopoverProps | Y | 45 |
| NoticePopupModal | NoticePopupModal.tsx | named | NoticePopupModalProps | Y | 171 |
| ParentMenuRedirect | ParentMenuRedirect.tsx | named | ParentMenuRedirectProps | Y | 82 |
| PermissionGuard | PermissionGuard.tsx | named | PermissionGuardProps | Y | 44 |
| ScheduleBar | ScheduleBar.tsx | named | ScheduleBarProps | Y | 96 |
| ScheduleCalendar | ScheduleCalendar.tsx | named | | Y | 194 |
| ServerErrorPage | ServerErrorPage.tsx | named | ServerErrorPageProps | Y | 140 |
| WeekTimeView | WeekTimeView.tsx | named | | Y | 217 |
| WeekView | WeekView.tsx | named | | Y | 211 |
## LAYOUT (3)
### layout (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| CommandMenuSearch | CommandMenuSearch.tsx | default | | Y | 199 |
| HeaderFavoritesBar | HeaderFavoritesBar.tsx | default | HeaderFavoritesBarProps | Y | 156 |
| Sidebar | Sidebar.tsx | default | SidebarProps | | 390 |
## DEV (2)
### dev (2)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DevFillProvider | DevFillContext.tsx | named | DevFillProviderProps | Y | 179 |
| DevToolbar | DevToolbar.tsx | both | | Y | 499 |
## DOMAIN (404)
### LanguageSelect.tsx (1)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| LanguageSelect | LanguageSelect.tsx | named | LanguageSelectProps | Y | 90 |
### ThemeSelect.tsx (1)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ThemeSelect | ThemeSelect.tsx | named | ThemeSelectProps | Y | 82 |
### accounting (19)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| BadDebtDetail | BadDebtDetail.tsx | named | BadDebtDetailProps | Y | 963 |
| BadDebtDetailClientV2 | BadDebtDetailClientV2.tsx | named | BadDebtDetailClientV2Props | Y | 136 |
| BillDetail | BillDetail.tsx | named | BillDetailProps | Y | 540 |
| BillManagementClient | BillManagementClient.tsx | named | BillManagementClientProps | Y | 522 |
| CardTransactionDetailClient | CardTransactionDetailClient.tsx | default | CardTransactionDetailClientProps | Y | 139 |
| CreditAnalysisDocument | CreditAnalysisDocument.tsx | named | CreditAnalysisDocumentProps | Y | 210 |
| CreditSignal | CreditSignal.tsx | named | CreditSignalProps | Y | 58 |
| DepositDetail | DepositDetail.tsx | named | DepositDetailProps | Y | 327 |
| DepositDetailClientV2 | DepositDetailClientV2.tsx | default | DepositDetailClientV2Props | Y | 144 |
| PurchaseDetail | PurchaseDetail.tsx | named | PurchaseDetailProps | Y | 697 |
| PurchaseDetailModal | PurchaseDetailModal.tsx | named | PurchaseDetailModalProps | Y | 402 |
| RiskRadarChart | RiskRadarChart.tsx | named | RiskRadarChartProps | Y | 95 |
| SalesDetail | SalesDetail.tsx | named | SalesDetailProps | Y | 579 |
| VendorDetail | VendorDetail.tsx | named | VendorDetailProps | Y | 684 |
| VendorDetailClient | VendorDetailClient.tsx | named | VendorDetailClientProps | Y | 586 |
| VendorLedgerDetail | VendorLedgerDetail.tsx | named | VendorLedgerDetailProps | Y | 386 |
| VendorManagementClient | VendorManagementClient.tsx | named | VendorManagementClientProps | Y | 574 |
| WithdrawalDetail | WithdrawalDetail.tsx | named | WithdrawalDetailProps | Y | 327 |
| WithdrawalDetailClientV2 | WithdrawalDetailClientV2.tsx | default | WithdrawalDetailClientV2Props | Y | 144 |
### approval (11)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ApprovalLineBox | ApprovalLineBox.tsx | named | ApprovalLineBoxProps | Y | 85 |
| ApprovalLineSection | ApprovalLineSection.tsx | named | ApprovalLineSectionProps | Y | 108 |
| BasicInfoSection | BasicInfoSection.tsx | named | BasicInfoSectionProps | Y | 81 |
| DocumentDetailModalV2 | DocumentDetailModalV2.tsx | named | | Y | 94 |
| ExpenseEstimateDocument | ExpenseEstimateDocument.tsx | named | ExpenseEstimateDocumentProps | Y | 130 |
| ExpenseEstimateForm | ExpenseEstimateForm.tsx | named | ExpenseEstimateFormProps | Y | 167 |
| ExpenseReportDocument | ExpenseReportDocument.tsx | named | ExpenseReportDocumentProps | Y | 138 |
| ExpenseReportForm | ExpenseReportForm.tsx | named | ExpenseReportFormProps | Y | 243 |
| ProposalDocument | ProposalDocument.tsx | named | ProposalDocumentProps | Y | 117 |
| ProposalForm | ProposalForm.tsx | named | ProposalFormProps | Y | 234 |
| ReferenceSection | ReferenceSection.tsx | named | ReferenceSectionProps | Y | 109 |
### attendance (2)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AttendanceComplete | AttendanceComplete.tsx | default | AttendanceCompleteProps | Y | 83 |
| GoogleMap | GoogleMap.tsx | default | GoogleMapProps | Y | 309 |
### auth (2)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| LoginPage | LoginPage.tsx | named | | Y | 301 |
| SignupPage | SignupPage.tsx | named | | Y | 763 |
### board (8)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| BoardDetail | BoardDetail.tsx | named | BoardDetailProps | Y | 120 |
| BoardDetailClientV2 | BoardDetailClientV2.tsx | named | BoardDetailClientV2Props | Y | 308 |
| BoardForm | BoardForm.tsx | named | BoardFormProps | Y | 271 |
| BoardListUnified | BoardListUnified.tsx | both | | Y | 372 |
| CommentItem | CommentItem.tsx | both | CommentItemProps | Y | 161 |
| DynamicBoardCreateForm | DynamicBoardCreateForm.tsx | named | DynamicBoardCreateFormProps | Y | 166 |
| DynamicBoardEditForm | DynamicBoardEditForm.tsx | named | DynamicBoardEditFormProps | Y | 253 |
| MenuBar | MenuBar.tsx | named | MenuBarProps | Y | 289 |
### business (97)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| BiddingDetailForm | BiddingDetailForm.tsx | default | BiddingDetailFormProps | Y | 533 |
| BiddingListClient | BiddingListClient.tsx | default | BiddingListClientProps | Y | 385 |
| CalendarSection | CalendarSection.tsx | named | CalendarSectionProps | Y | 421 |
| CardManagementSection | CardManagementSection.tsx | named | CardManagementSectionProps | Y | 71 |
| CategoryDialog | CategoryDialog.tsx | named | | Y | 89 |
| CEODashboard | CEODashboard.tsx | named | | Y | 407 |
| ConstructionDashboard | ConstructionDashboard.tsx | named | | Y | 19 |
| ConstructionDetailCard | ConstructionDetailCard.tsx | named | ConstructionDetailCardProps | Y | 83 |
| ConstructionDetailClient | ConstructionDetailClient.tsx | default | ConstructionDetailClientProps | Y | 732 |
| ConstructionMainDashboard | ConstructionMainDashboard.tsx | named | | Y | 196 |
| ConstructionManagementListClient | ConstructionManagementListClient.tsx | default | ConstructionManagementListClientProps | Y | 540 |
| ContractDetailForm | ContractDetailForm.tsx | default | ContractDetailFormProps | Y | 541 |
| ContractDocumentModal | ContractDocumentModal.tsx | named | ContractDocumentModalProps | Y | 64 |
| ContractInfoCard | ContractInfoCard.tsx | named | ContractInfoCardProps | Y | 169 |
| ContractInfoCard | ContractInfoCard.tsx | named | ContractInfoCardProps | Y | 58 |
| ContractListClient | ContractListClient.tsx | default | ContractListClientProps | Y | 399 |
| DailyReportSection | DailyReportSection.tsx | named | DailyReportSectionProps | Y | 37 |
| Dashboard | Dashboard.tsx | named | | Y | 33 |
| DashboardSettingsDialog | DashboardSettingsDialog.tsx | named | DashboardSettingsDialogProps | Y | 744 |
| DashboardSwitcher | DashboardSwitcher.tsx | named | | Y | 89 |
| DebtCollectionSection | DebtCollectionSection.tsx | named | DebtCollectionSectionProps | Y | 62 |
| DetailAccordion | DetailAccordion.tsx | default | DetailAccordionProps | Y | 199 |
| DetailCard | DetailCard.tsx | default | DetailCardProps | Y | 69 |
| DetailModal | DetailModal.tsx | named | DetailModalProps | Y | 763 |
| DirectConstructionContent | DirectConstructionContent.tsx | named | DirectConstructionContentProps | Y | 157 |
| DirectConstructionModal | DirectConstructionModal.tsx | named | DirectConstructionModalProps | Y | 60 |
| ElectronicApprovalModal | ElectronicApprovalModal.tsx | named | ElectronicApprovalModalProps | Y | 299 |
| ElectronicApprovalModal | ElectronicApprovalModal.tsx | none | | | 2 |
| EnhancedDailyReportSection | EnhancedSections.tsx | named | EnhancedDailyReportSectionProps | Y | 534 |
| EntertainmentSection | EntertainmentSection.tsx | named | EntertainmentSectionProps | Y | 53 |
| EstimateDetailForm | EstimateDetailForm.tsx | default | EstimateDetailFormProps | Y | 761 |
| EstimateDetailTableSection | EstimateDetailTableSection.tsx | named | EstimateDetailTableSectionProps | Y | 657 |
| EstimateDocumentContent | EstimateDocumentContent.tsx | named | EstimateDocumentContentProps | Y | 286 |
| EstimateDocumentModal | EstimateDocumentModal.tsx | named | EstimateDocumentModalProps | Y | 88 |
| EstimateInfoSection | EstimateInfoSection.tsx | named | EstimateInfoSectionProps | Y | 262 |
| EstimateListClient | EstimateListClient.tsx | default | EstimateListClientProps | Y | 376 |
| EstimateSummarySection | EstimateSummarySection.tsx | named | EstimateSummarySectionProps | Y | 182 |
| ExpenseDetailSection | ExpenseDetailSection.tsx | named | ExpenseDetailSectionProps | Y | 197 |
| HandoverReportDetailForm | HandoverReportDetailForm.tsx | default | HandoverReportDetailFormProps | Y | 694 |
| HandoverReportDocumentModal | HandoverReportDocumentModal.tsx | named | HandoverReportDocumentModalProps | Y | 236 |
| HandoverReportListClient | HandoverReportListClient.tsx | default | HandoverReportListClientProps | Y | 387 |
| IndirectConstructionContent | IndirectConstructionContent.tsx | named | IndirectConstructionContentProps | Y | 143 |
| IndirectConstructionModal | IndirectConstructionModal.tsx | named | IndirectConstructionModalProps | Y | 60 |
| IssueDetailForm | IssueDetailForm.tsx | default | IssueDetailFormProps | Y | 625 |
| IssueManagementListClient | IssueManagementListClient.tsx | default | IssueManagementListClientProps | Y | 514 |
| ItemDetailClient | ItemDetailClient.tsx | default | ItemDetailClientProps | Y | 487 |
| ItemManagementClient | ItemManagementClient.tsx | default | ItemManagementClientProps | Y | 618 |
| KanbanColumn | KanbanColumn.tsx | default | KanbanColumnProps | Y | 53 |
| LaborDetailClient | LaborDetailClient.tsx | default | LaborDetailClientProps | Y | 121 |
| LaborManagementClient | LaborManagementClient.tsx | default | LaborManagementClientProps | Y | 372 |
| MainDashboard | MainDashboard.tsx | named | | | 2652 |
| MonthlyExpenseSection | MonthlyExpenseSection.tsx | named | MonthlyExpenseSectionProps | Y | 38 |
| OrderDetailForm | OrderDetailForm.tsx | default | OrderDetailFormProps | Y | 276 |
| OrderDetailItemTable | OrderDetailItemTable.tsx | named | OrderDetailItemTableProps | Y | 445 |
| OrderDialogs | OrderDialogs.tsx | named | OrderDialogsProps | Y | 66 |
| OrderDocumentModal | OrderDocumentModal.tsx | named | OrderDocumentModalProps | Y | 311 |
| OrderInfoCard | OrderInfoCard.tsx | named | OrderInfoCardProps | Y | 143 |
| OrderManagementListClient | OrderManagementListClient.tsx | default | OrderManagementListClientProps | Y | 608 |
| OrderManagementUnified | OrderManagementUnified.tsx | both | OrderManagementUnifiedProps | Y | 641 |
| OrderMemoCard | OrderMemoCard.tsx | named | OrderMemoCardProps | Y | 29 |
| OrderScheduleCard | OrderScheduleCard.tsx | named | OrderScheduleCardProps | Y | 42 |
| PartnerForm | PartnerForm.tsx | default | PartnerFormProps | Y | 642 |
| PartnerListClient | PartnerListClient.tsx | default | PartnerListClientProps | Y | 335 |
| PhotoDocumentContent | PhotoDocumentContent.tsx | named | PhotoDocumentContentProps | Y | 130 |
| PhotoDocumentModal | PhotoDocumentModal.tsx | named | PhotoDocumentModalProps | Y | 60 |
| PhotoTable | PhotoTable.tsx | named | PhotoTableProps | Y | 153 |
| PriceAdjustmentSection | PriceAdjustmentSection.tsx | named | PriceAdjustmentSectionProps | Y | 150 |
| PricingDetailClient | PricingDetailClient.tsx | default | PricingDetailClientProps | Y | 135 |
| PricingListClient | PricingListClient.tsx | default | PricingListClientProps | Y | 477 |
| ProgressBillingDetailForm | ProgressBillingDetailForm.tsx | default | ProgressBillingDetailFormProps | Y | 193 |
| ProgressBillingInfoCard | ProgressBillingInfoCard.tsx | named | ProgressBillingInfoCardProps | Y | 78 |
| ProgressBillingItemTable | ProgressBillingItemTable.tsx | named | ProgressBillingItemTableProps | Y | 193 |
| ProgressBillingManagementListClient | ProgressBillingManagementListClient.tsx | default | ProgressBillingManagementListClientProps | Y | 343 |
| ProjectCard | ProjectCard.tsx | default | ProjectCardProps | Y | 89 |
| ProjectDetailClient | ProjectDetailClient.tsx | default | ProjectDetailClientProps | Y | 197 |
| ProjectEndDialog | ProjectEndDialog.tsx | default | ProjectEndDialogProps | Y | 192 |
| ProjectGanttChart | ProjectGanttChart.tsx | default | ProjectGanttChartProps | Y | 367 |
| ProjectKanbanBoard | ProjectKanbanBoard.tsx | default | ProjectKanbanBoardProps | Y | 244 |
| ProjectListClient | ProjectListClient.tsx | default | ProjectListClientProps | Y | 629 |
| ReceivableSection | ReceivableSection.tsx | named | ReceivableSectionProps | Y | 69 |
| ScheduleDetailModal | ScheduleDetailModal.tsx | named | ScheduleDetailModalProps | Y | 290 |
| SECTION_THEME_STYLES | components.tsx | named | | Y | 434 |
| SiteBriefingForm | SiteBriefingForm.tsx | default | SiteBriefingFormProps | Y | 957 |
| SiteBriefingListClient | SiteBriefingListClient.tsx | default | SiteBriefingListClientProps | Y | 362 |
| SiteDetailClientV2 | SiteDetailClientV2.tsx | both | SiteDetailClientV2Props | Y | 141 |
| SiteDetailForm | SiteDetailForm.tsx | default | SiteDetailFormProps | Y | 386 |
| SiteManagementListClient | SiteManagementListClient.tsx | default | SiteManagementListClientProps | Y | 338 |
| StageCard | StageCard.tsx | default | StageCardProps | Y | 89 |
| StatusBoardSection | StatusBoardSection.tsx | named | StatusBoardSectionProps | Y | 72 |
| StructureReviewDetailClientV2 | StructureReviewDetailClientV2.tsx | both | StructureReviewDetailClientV2Props | Y | 149 |
| StructureReviewDetailForm | StructureReviewDetailForm.tsx | default | StructureReviewDetailFormProps | Y | 390 |
| StructureReviewListClient | StructureReviewListClient.tsx | default | StructureReviewListClientProps | Y | 375 |
| TodayIssueSection | TodayIssueSection.tsx | named | TodayIssueSectionProps | Y | 453 |
| UtilityManagementListClient | UtilityManagementListClient.tsx | default | UtilityManagementListClientProps | Y | 395 |
| VatSection | VatSection.tsx | named | VatSectionProps | Y | 38 |
| WelfareSection | WelfareSection.tsx | named | WelfareSectionProps | Y | 53 |
| WorkerStatusListClient | WorkerStatusListClient.tsx | default | WorkerStatusListClientProps | Y | 416 |
### checklist-management (7)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ChecklistDetail | ChecklistDetail.tsx | named | ChecklistDetailProps | Y | 316 |
| ChecklistDetailClient | ChecklistDetailClient.tsx | named | ChecklistDetailClientProps | Y | 123 |
| ChecklistForm | ChecklistForm.tsx | named | ChecklistFormProps | Y | 173 |
| ChecklistListClient | ChecklistListClient.tsx | default | | Y | 520 |
| ItemDetail | ItemDetail.tsx | named | ItemDetailProps | Y | 224 |
| ItemDetailClient | ItemDetailClient.tsx | named | ItemDetailClientProps | Y | 111 |
| ItemForm | ItemForm.tsx | named | ItemFormProps | Y | 351 |
### clients (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ClientDetail | ClientDetail.tsx | named | ClientDetailProps | Y | 254 |
| ClientDetailClientV2 | ClientDetailClientV2.tsx | named | ClientDetailClientV2Props | Y | 253 |
| ClientRegistration | ClientRegistration.tsx | named | ClientRegistrationProps | Y | 468 |
### customer-center (9)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| EventDetail | EventDetail.tsx | both | EventDetailProps | Y | 102 |
| EventList | EventList.tsx | both | | Y | 261 |
| FAQList | FAQList.tsx | both | | Y | 172 |
| InquiryDetail | InquiryDetail.tsx | both | InquiryDetailProps | Y | 359 |
| InquiryDetailClientV2 | InquiryDetailClientV2.tsx | both | InquiryDetailClientV2Props | Y | 224 |
| InquiryForm | InquiryForm.tsx | both | InquiryFormProps | Y | 237 |
| InquiryList | InquiryList.tsx | both | | Y | 292 |
| NoticeDetail | NoticeDetail.tsx | both | NoticeDetailProps | Y | 102 |
| NoticeList | NoticeList.tsx | both | | Y | 227 |
### document-system (11)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ApprovalLine | ApprovalLine.tsx | named | ApprovalLineProps | Y | 170 |
| ConstructionApprovalTable | ConstructionApprovalTable.tsx | named | ConstructionApprovalTableProps | Y | 116 |
| DocumentContent | DocumentContent.tsx | named | DocumentContentProps | Y | 59 |
| DocumentHeader | DocumentHeader.tsx | named | DocumentHeaderProps | Y | 248 |
| DocumentToolbar | DocumentToolbar.tsx | named | DocumentToolbarProps | Y | 327 |
| DocumentViewer | DocumentViewer.tsx | named | | Y | 378 |
| InfoTable | InfoTable.tsx | named | InfoTableProps | Y | 95 |
| LotApprovalTable | LotApprovalTable.tsx | named | LotApprovalTableProps | Y | 122 |
| QualityApprovalTable | QualityApprovalTable.tsx | named | QualityApprovalTableProps | Y | 123 |
| SectionHeader | SectionHeader.tsx | named | SectionHeaderProps | Y | 46 |
| SignatureSection | SignatureSection.tsx | named | SignatureSectionProps | Y | 107 |
### hr (24)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AttendanceInfoDialog | AttendanceInfoDialog.tsx | named | | Y | 301 |
| CardDetail | CardDetail.tsx | named | CardDetailProps | Y | 132 |
| CardForm | CardForm.tsx | named | CardFormProps | Y | 246 |
| CardManagementUnified | CardManagementUnified.tsx | named | CardManagementUnifiedProps | Y | 267 |
| CSVUploadDialog | CSVUploadDialog.tsx | named | CSVUploadDialogProps | Y | 252 |
| CSVUploadPage | CSVUploadPage.tsx | named | CSVUploadPageProps | Y | 355 |
| DepartmentDialog | DepartmentDialog.tsx | named | | Y | 92 |
| DepartmentStats | DepartmentStats.tsx | named | | Y | 18 |
| DepartmentToolbar | DepartmentToolbar.tsx | named | | Y | 60 |
| DepartmentTree | DepartmentTree.tsx | named | | Y | 70 |
| DepartmentTreeItem | DepartmentTreeItem.tsx | named | | Y | 118 |
| EmployeeDetail | EmployeeDetail.tsx | named | EmployeeDetailProps | Y | 222 |
| EmployeeDialog | EmployeeDialog.tsx | named | | Y | 582 |
| EmployeeForm | EmployeeForm.tsx | named | EmployeeFormProps | Y | 1052 |
| EmployeeToolbar | EmployeeToolbar.tsx | named | EmployeeToolbarProps | Y | 82 |
| FieldSettingsDialog | FieldSettingsDialog.tsx | named | FieldSettingsDialogProps | Y | 259 |
| ReasonInfoDialog | ReasonInfoDialog.tsx | named | | Y | 140 |
| SalaryDetailDialog | SalaryDetailDialog.tsx | named | SalaryDetailDialogProps | Y | 420 |
| UserInviteDialog | UserInviteDialog.tsx | named | UserInviteDialogProps | Y | 116 |
| VacationAdjustDialog | VacationAdjustDialog.tsx | named | VacationAdjustDialogProps | Y | 225 |
| VacationGrantDialog | VacationGrantDialog.tsx | named | VacationGrantDialogProps | Y | 202 |
| VacationRegisterDialog | VacationRegisterDialog.tsx | named | VacationRegisterDialogProps | Y | 201 |
| VacationRequestDialog | VacationRequestDialog.tsx | named | VacationRequestDialogProps | Y | 208 |
| VacationTypeSettingsDialog | VacationTypeSettingsDialog.tsx | named | VacationTypeSettingsDialogProps | Y | 192 |
### items (65)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AssemblyPartForm | AssemblyPartForm.tsx | default | AssemblyPartFormProps | | 337 |
| AttributeTabContent | AttributeTabContent.tsx | named | AttributeTabContentProps | Y | 453 |
| BendingDiagramSection | BendingDiagramSection.tsx | default | BendingDiagramSectionProps | | 477 |
| BendingPartForm | BendingPartForm.tsx | default | BendingPartFormProps | | 304 |
| BOMManagementSection | BOMManagementSection.tsx | named | BOMManagementSectionProps | Y | 293 |
| BOMSection | BOMSection.tsx | default | BOMSectionProps | | 366 |
| CheckboxField | CheckboxField.tsx | named | | Y | 47 |
| ColumnDialog | ColumnDialog.tsx | named | ColumnDialogProps | Y | 124 |
| ColumnManageDialog | ColumnManageDialog.tsx | named | ColumnManageDialogProps | Y | 210 |
| ComputedField | ComputedField.tsx | named | | Y | 136 |
| ConditionalDisplayUI | ConditionalDisplayUI.tsx | named | ConditionalDisplayUIProps | | 349 |
| CurrencyField | CurrencyField.tsx | named | | Y | 127 |
| DateField | DateField.tsx | named | | Y | 45 |
| DraggableField | DraggableField.tsx | named | DraggableFieldProps | | 130 |
| DraggableSection | DraggableSection.tsx | named | DraggableSectionProps | | 140 |
| DrawingCanvas | DrawingCanvas.tsx | named | DrawingCanvasProps | Y | 404 |
| DropdownField | DropdownField.tsx | named | | Y | 141 |
| DuplicateCodeDialog | DuplicateCodeDialog.tsx | named | DuplicateCodeDialogProps | Y | 49 |
| DynamicBOMSection | DynamicBOMSection.tsx | default | DynamicBOMSectionProps | Y | 515 |
| DynamicFieldRenderer | DynamicFieldRenderer.tsx | named | | Y | 86 |
| DynamicTableSection | DynamicTableSection.tsx | default | DynamicTableSectionProps | Y | 200 |
| ErrorAlertDialog | ErrorAlertDialog.tsx | named | ErrorAlertDialogProps | Y | 51 |
| ErrorAlertProvider | ErrorAlertContext.tsx | named | ErrorAlertProviderProps | Y | 94 |
| FieldDialog | FieldDialog.tsx | named | FieldDialogProps | Y | 478 |
| FieldDrawer | FieldDrawer.tsx | named | FieldDrawerProps | Y | 682 |
| FileField | FileField.tsx | named | | Y | 200 |
| FileUpload | FileUpload.tsx | default | FileUploadProps | Y | 233 |
| FileUploadFields | FileUploadFields.tsx | named | FileUploadFieldsProps | Y | 240 |
| FormHeader | FormHeader.tsx | named | FormHeaderProps | Y | 31 |
| FormHeader | FormHeader.tsx | default | FormHeaderProps | | 62 |
| ImportFieldDialog | ImportFieldDialog.tsx | named | ImportFieldDialogProps | Y | 279 |
| ImportSectionDialog | ImportSectionDialog.tsx | named | ImportSectionDialogProps | Y | 221 |
| ItemDetailClient | ItemDetailClient.tsx | default | ItemDetailClientProps | Y | 638 |
| ItemDetailEdit | ItemDetailEdit.tsx | named | ItemDetailEditProps | Y | 390 |
| ItemDetailView | ItemDetailView.tsx | named | ItemDetailViewProps | Y | 275 |
| ItemFormContext | ItemFormContext.tsx | both | ItemFormProviderProps | Y | 77 |
| ItemListClient | ItemListClient.tsx | default | | Y | 607 |
| ItemMasterDataManagement | ItemMasterDataManagement.tsx | named | | Y | 1006 |
| ItemMasterDialogs | ItemMasterDialogs.tsx | named | ItemMasterDialogsProps | Y | 968 |
| ItemTypeSelect | ItemTypeSelect.tsx | default | ItemTypeSelectProps | Y | 76 |
| LoadTemplateDialog | LoadTemplateDialog.tsx | named | LoadTemplateDialogProps | Y | 103 |
| MasterFieldDialog | MasterFieldDialog.tsx | named | MasterFieldDialogProps | Y | 306 |
| MaterialForm | MaterialForm.tsx | default | MaterialFormProps | | 354 |
| MultiSelectField | MultiSelectField.tsx | named | | Y | 192 |
| NumberField | NumberField.tsx | named | | Y | 58 |
| OptionDialog | OptionDialog.tsx | named | OptionDialogProps | Y | 262 |
| PageDialog | PageDialog.tsx | named | PageDialogProps | Y | 107 |
| PartForm | PartForm.tsx | default | PartFormProps | | 273 |
| PathEditDialog | PathEditDialog.tsx | named | PathEditDialogProps | Y | 86 |
| ProductForm | ProductForm.tsx | both | ProductFormProps | | 307 |
| PurchasedPartForm | PurchasedPartForm.tsx | default | PurchasedPartFormProps | | 336 |
| RadioField | RadioField.tsx | named | | Y | 92 |
| ReferenceField | ReferenceField.tsx | named | | Y | 168 |
| SectionDialog | SectionDialog.tsx | named | SectionDialogProps | Y | 335 |
| SectionsTab | SectionsTab.tsx | named | SectionsTabProps | Y | 363 |
| SectionTemplateDialog | SectionTemplateDialog.tsx | named | SectionTemplateDialogProps | Y | 180 |
| TableCellRenderer | TableCellRenderer.tsx | named | TableCellRendererProps | Y | 85 |
| TabManagementDialogs | TabManagementDialogs.tsx | named | TabManagementDialogsProps | Y | 409 |
| TemplateFieldDialog | TemplateFieldDialog.tsx | named | TemplateFieldDialogProps | Y | 392 |
| TextareaField | TextareaField.tsx | named | | Y | 51 |
| TextField | TextField.tsx | named | | Y | 48 |
| ToggleField | ToggleField.tsx | named | | Y | 62 |
| UnitValueField | UnitValueField.tsx | named | | Y | 129 |
| ValidationAlert | ValidationAlert.tsx | named | ValidationAlertProps | Y | 42 |
| ValidationAlert | ValidationAlert.tsx | default | ValidationAlertProps | | 50 |
### material (13)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ImportInspectionInputModal | ImportInspectionInputModal.tsx | named | ImportInspectionInputModalProps | Y | 798 |
| InspectionCreate | InspectionCreate.tsx | named | Props | Y | 364 |
| InventoryAdjustmentDialog | InventoryAdjustmentDialog.tsx | named | Props | Y | 236 |
| ReceivingDetail | ReceivingDetail.tsx | named | Props | Y | 921 |
| ReceivingList | ReceivingList.tsx | named | | Y | 467 |
| ReceivingProcessDialog | ReceivingProcessDialog.tsx | named | Props | Y | 238 |
| ReceivingReceiptContent | ReceivingReceiptContent.tsx | named | ReceivingReceiptContentProps | Y | 132 |
| ReceivingReceiptDialog | ReceivingReceiptDialog.tsx | named | Props | Y | 46 |
| StockAuditModal | StockAuditModal.tsx | named | StockAuditModalProps | Y | 237 |
| StockStatusDetail | StockStatusDetail.tsx | named | StockStatusDetailProps | Y | 313 |
| StockStatusList | StockStatusList.tsx | named | | Y | 473 |
| SuccessDialog | SuccessDialog.tsx | named | Props | Y | 49 |
| SupplierSearchModal | SupplierSearchModal.tsx | named | SupplierSearchModalProps | Y | 161 |
### orders (10)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| ContractDocument | ContractDocument.tsx | named | ContractDocumentProps | Y | 246 |
| ItemAddDialog | ItemAddDialog.tsx | named | ItemAddDialogProps | Y | 317 |
| OrderDocumentModal | OrderDocumentModal.tsx | named | OrderDocumentModalProps | Y | 207 |
| OrderRegistration | OrderRegistration.tsx | named | OrderRegistrationProps | Y | 1087 |
| OrderSalesDetailEdit | OrderSalesDetailEdit.tsx | named | OrderSalesDetailEditProps | Y | 735 |
| OrderSalesDetailView | OrderSalesDetailView.tsx | named | OrderSalesDetailViewProps | Y | 824 |
| PurchaseOrderDocument | PurchaseOrderDocument.tsx | named | PurchaseOrderDocumentProps | Y | 223 |
| QuotationSelectDialog | QuotationSelectDialog.tsx | named | QuotationSelectDialogProps | Y | 114 |
| SalesOrderDocument | SalesOrderDocument.tsx | named | SalesOrderDocumentProps | Y | 638 |
| TransactionDocument | TransactionDocument.tsx | named | TransactionDocumentProps | Y | 226 |
### outbound (11)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DeliveryConfirmation | DeliveryConfirmation.tsx | named | DeliveryConfirmationProps | Y | 18 |
| ShipmentCreate | ShipmentCreate.tsx | named | | Y | 772 |
| ShipmentDetail | ShipmentDetail.tsx | named | ShipmentDetailProps | Y | 671 |
| ShipmentEdit | ShipmentEdit.tsx | named | ShipmentEditProps | Y | 791 |
| ShipmentList | ShipmentList.tsx | named | | Y | 399 |
| ShipmentOrderDocument | ShipmentOrderDocument.tsx | named | ShipmentOrderDocumentProps | Y | 647 |
| ShippingSlip | ShippingSlip.tsx | named | ShippingSlipProps | Y | 18 |
| TransactionStatement | TransactionStatement.tsx | named | TransactionStatementProps | Y | 154 |
| VehicleDispatchDetail | VehicleDispatchDetail.tsx | named | VehicleDispatchDetailProps | Y | 181 |
| VehicleDispatchEdit | VehicleDispatchEdit.tsx | named | VehicleDispatchEditProps | Y | 399 |
| VehicleDispatchList | VehicleDispatchList.tsx | named | | Y | 331 |
### pricing (5)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| PricingFinalizeDialog | PricingFinalizeDialog.tsx | both | PricingFinalizeDialogProps | Y | 95 |
| PricingFormClient | PricingFormClient.tsx | both | PricingFormClientProps | Y | 780 |
| PricingHistoryDialog | PricingHistoryDialog.tsx | both | PricingHistoryDialogProps | Y | 170 |
| PricingListClient | PricingListClient.tsx | both | PricingListClientProps | Y | 387 |
| PricingRevisionDialog | PricingRevisionDialog.tsx | both | PricingRevisionDialogProps | Y | 95 |
### pricing-distribution (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| PriceDistributionDetail | PriceDistributionDetail.tsx | both | Props | Y | 539 |
| PriceDistributionDocumentModal | PriceDistributionDocumentModal.tsx | both | Props | Y | 158 |
| PriceDistributionList | PriceDistributionList.tsx | both | | Y | 328 |
### pricing-table-management (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| PricingTableDetailClient | PricingTableDetailClient.tsx | named | PricingTableDetailClientProps | Y | 93 |
| PricingTableForm | PricingTableForm.tsx | named | PricingTableFormProps | Y | 486 |
| PricingTableListClient | PricingTableListClient.tsx | default | | Y | 381 |
### process-management (12)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| InspectionPreviewModal | InspectionPreviewModal.tsx | named | InspectionPreviewModalProps | Y | 265 |
| InspectionSettingModal | InspectionSettingModal.tsx | named | InspectionSettingModalProps | Y | 294 |
| ProcessDetail | ProcessDetail.tsx | named | ProcessDetailProps | Y | 451 |
| ProcessDetailClientV2 | ProcessDetailClientV2.tsx | named | ProcessDetailClientV2Props | Y | 137 |
| ProcessForm | ProcessForm.tsx | named | ProcessFormProps | Y | 829 |
| ProcessListClient | ProcessListClient.tsx | default | ProcessListClientProps | Y | 546 |
| ProcessWorkLogContent | ProcessWorkLogContent.tsx | named | ProcessWorkLogContentProps | Y | 136 |
| ProcessWorkLogPreviewModal | ProcessWorkLogPreviewModal.tsx | named | ProcessWorkLogPreviewModalProps | Y | 45 |
| RuleModal | RuleModal.tsx | named | RuleModalProps | Y | 352 |
| StepDetail | StepDetail.tsx | named | StepDetailProps | Y | 212 |
| StepDetailClient | StepDetailClient.tsx | named | StepDetailClientProps | Y | 115 |
| StepForm | StepForm.tsx | named | StepFormProps | Y | 397 |
### production (31)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AssigneeSelectModal | AssigneeSelectModal.tsx | named | AssigneeSelectModalProps | Y | 317 |
| BendingInspectionContent | BendingInspectionContent.tsx | named | BendingInspectionContentProps | Y | 490 |
| BendingWipInspectionContent | BendingWipInspectionContent.tsx | named | BendingWipInspectionContentProps | Y | 304 |
| BendingWorkLogContent | BendingWorkLogContent.tsx | named | BendingWorkLogContentProps | Y | 194 |
| CompletionConfirmDialog | CompletionConfirmDialog.tsx | named | CompletionConfirmDialogProps | Y | 64 |
| CompletionToast | CompletionToast.tsx | named | CompletionToastProps | Y | 28 |
| InspectionCheckbox | inspection-shared.tsx | named | | Y | 282 |
| InspectionInputModal | InspectionInputModal.tsx | named | InspectionInputModalProps | Y | 978 |
| InspectionReportModal | InspectionReportModal.tsx | named | InspectionReportModalProps | Y | 409 |
| IssueReportModal | IssueReportModal.tsx | named | IssueReportModalProps | Y | 178 |
| MaterialInputModal | MaterialInputModal.tsx | named | MaterialInputModalProps | Y | 333 |
| ProcessDetailSection | ProcessDetailSection.tsx | named | ProcessDetailSectionProps | Y | 392 |
| SalesOrderSelectModal | SalesOrderSelectModal.tsx | named | SalesOrderSelectModalProps | Y | 102 |
| ScreenInspectionContent | ScreenInspectionContent.tsx | named | ScreenInspectionContentProps | Y | 310 |
| ScreenWorkLogContent | ScreenWorkLogContent.tsx | named | ScreenWorkLogContentProps | Y | 201 |
| SlatInspectionContent | SlatInspectionContent.tsx | named | SlatInspectionContentProps | Y | 297 |
| SlatJointBarInspectionContent | SlatJointBarInspectionContent.tsx | named | SlatJointBarInspectionContentProps | Y | 311 |
| SlatWorkLogContent | SlatWorkLogContent.tsx | named | SlatWorkLogContentProps | Y | 198 |
| TemplateInspectionContent | TemplateInspectionContent.tsx | named | TemplateInspectionContentProps | Y | 719 |
| WipProductionModal | WipProductionModal.tsx | named | WipProductionModalProps | Y | 272 |
| WorkCard | WorkCard.tsx | named | WorkCardProps | Y | 188 |
| WorkCompletionResultDialog | WorkCompletionResultDialog.tsx | named | WorkCompletionResultDialogProps | Y | 85 |
| WorkItemCard | WorkItemCard.tsx | named | WorkItemCardProps | Y | 382 |
| WorkLogContent | WorkLogContent.tsx | named | WorkLogContentProps | Y | 195 |
| WorkLogModal | WorkLogModal.tsx | named | WorkLogModalProps | Y | 152 |
| WorkOrderCreate | WorkOrderCreate.tsx | named | | Y | 545 |
| WorkOrderDetail | WorkOrderDetail.tsx | named | WorkOrderDetailProps | Y | 656 |
| WorkOrderEdit | WorkOrderEdit.tsx | named | WorkOrderEditProps | Y | 656 |
| WorkOrderList | WorkOrderList.tsx | named | | Y | 460 |
| WorkOrderListPanel | WorkOrderListPanel.tsx | named | WorkOrderListPanelProps | Y | 132 |
| WorkResultList | WorkResultList.tsx | named | | Y | 374 |
### quality (11)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| InspectionCreate | InspectionCreate.tsx | named | | Y | 695 |
| InspectionDetail | InspectionDetail.tsx | named | InspectionDetailProps | Y | 1126 |
| InspectionList | InspectionList.tsx | named | | Y | 388 |
| InspectionReportDocument | InspectionReportDocument.tsx | named | InspectionReportDocumentProps | Y | 416 |
| InspectionReportModal | InspectionReportModal.tsx | named | InspectionReportModalProps | Y | 170 |
| InspectionRequestDocument | InspectionRequestDocument.tsx | named | InspectionRequestDocumentProps | Y | 258 |
| InspectionRequestModal | InspectionRequestModal.tsx | named | InspectionRequestModalProps | Y | 40 |
| MemoModal | MemoModal.tsx | named | MemoModalProps | Y | 92 |
| OrderSelectModal | OrderSelectModal.tsx | named | OrderSelectModalProps | Y | 111 |
| PerformanceReportList | PerformanceReportList.tsx | named | | Y | 604 |
| ProductInspectionInputModal | ProductInspectionInputModal.tsx | named | ProductInspectionInputModalProps | Y | 486 |
### quotes (15)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DiscountModal | DiscountModal.tsx | named | DiscountModalProps | Y | 232 |
| FormulaViewModal | FormulaViewModal.tsx | named | FormulaViewModalProps | Y | 316 |
| ItemSearchModal | ItemSearchModal.tsx | named | ItemSearchModalProps | Y | 114 |
| LocationDetailPanel | LocationDetailPanel.tsx | named | LocationDetailPanelProps | Y | 827 |
| LocationEditModal | LocationEditModal.tsx | named | LocationEditModalProps | Y | 283 |
| LocationListPanel | LocationListPanel.tsx | named | LocationListPanelProps | Y | 575 |
| PurchaseOrderDocument | PurchaseOrderDocument.tsx | named | PurchaseOrderDocumentProps | | 265 |
| QuoteDocument | QuoteDocument.tsx | named | QuoteDocumentProps | | 409 |
| QuoteFooterBar | QuoteFooterBar.tsx | named | QuoteFooterBarProps | Y | 236 |
| QuoteManagementClient | QuoteManagementClient.tsx | named | QuoteManagementClientProps | Y | 713 |
| QuotePreviewContent | QuotePreviewContent.tsx | named | QuotePreviewContentProps | Y | 434 |
| QuotePreviewModal | QuotePreviewModal.tsx | named | QuotePreviewModalProps | Y | 132 |
| QuoteRegistration | QuoteRegistration.tsx | named | QuoteRegistrationProps | Y | 1023 |
| QuoteSummaryPanel | QuoteSummaryPanel.tsx | named | QuoteSummaryPanelProps | Y | 277 |
| QuoteTransactionModal | QuoteTransactionModal.tsx | named | QuoteTransactionModalProps | Y | 324 |
### settings (16)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| AccountDetail | AccountDetail.tsx | named | AccountDetailProps | Y | 356 |
| AccountDetail | AccountDetail.tsx | named | AccountDetailProps | Y | 369 |
| AddCompanyDialog | AddCompanyDialog.tsx | named | AddCompanyDialogProps | Y | 149 |
| ItemSettingsDialog | ItemSettingsDialog.tsx | named | ItemSettingsDialogProps | Y | 336 |
| PaymentHistoryClient | PaymentHistoryClient.tsx | named | PaymentHistoryClientProps | Y | 255 |
| PermissionDetail | PermissionDetail.tsx | named | PermissionDetailProps | Y | 456 |
| PermissionDetailClient | PermissionDetailClient.tsx | named | PermissionDetailClientProps | Y | 700 |
| PermissionDialog | PermissionDialog.tsx | named | | Y | 109 |
| PopupDetail | PopupDetail.tsx | both | PopupDetailProps | Y | 125 |
| PopupDetailClientV2 | PopupDetailClientV2.tsx | named | PopupDetailClientV2Props | Y | 199 |
| PopupForm | PopupForm.tsx | both | PopupFormProps | Y | 319 |
| PopupList | PopupList.tsx | both | PopupListProps | Y | 198 |
| RankDialog | RankDialog.tsx | named | | Y | 89 |
| SubscriptionClient | SubscriptionClient.tsx | named | SubscriptionClientProps | Y | 242 |
| SubscriptionManagement | SubscriptionManagement.tsx | named | SubscriptionManagementProps | Y | 250 |
| TitleDialog | TitleDialog.tsx | named | | Y | 90 |
### templates (11)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| DetailActions | DetailActions.tsx | both | DetailActionsProps | Y | 172 |
| DetailField | DetailField.tsx | both | DetailFieldProps | Y | 91 |
| DetailFieldSkeleton | DetailFieldSkeleton.tsx | both | DetailFieldSkeletonProps | Y | 48 |
| DetailGrid | DetailGrid.tsx | both | DetailGridProps | Y | 63 |
| DetailGridSkeleton | DetailGridSkeleton.tsx | both | DetailGridSkeletonProps | Y | 61 |
| DetailSection | DetailSection.tsx | both | DetailSectionProps | Y | 97 |
| DetailSectionSkeleton | DetailSectionSkeleton.tsx | both | DetailSectionSkeletonProps | Y | 53 |
| DetailSectionSkeleton | skeletons.tsx | both | DetailFieldSkeletonProps | Y | 183 |
| FieldInput | FieldInput.tsx | both | FieldInputProps | Y | 408 |
| FieldRenderer | FieldRenderer.tsx | named | FieldRendererProps | Y | 390 |
| IntegratedListTemplateV2 | IntegratedListTemplateV2.tsx | named | IntegratedListTemplateV2Props | Y | 1087 |
### vehicle-management (3)
| Component | File | Export | Props | Client | Lines |
|-----------|------|--------|-------|--------|-------|
| Config | config.tsx | none | | Y | 431 |
| Config | config.tsx | none | | Y | 479 |
| Config | config.tsx | none | | Y | 266 |

View File

@@ -0,0 +1,122 @@
# 즐겨찾기(Favorites) 기능 구현
> 2026-02-11 | 상태: 완료 (localStorage 기반) | 추후: API 전환 예정
## 개요
사이드바 메뉴에 별표(즐겨찾기) 기능을 추가하여 사용자가 자주 쓰는 메뉴를 헤더에 동적으로 표시.
기존 하드코딩된 종합분석/품질인정심사 버튼을 제거하고 사용자 선택 기반으로 전환.
## 파일 구조
| 파일 | 작업 | 설명 |
|------|------|------|
| `src/stores/favoritesStore.ts` | NEW | Zustand persist 스토어 |
| `src/lib/utils/menuTransform.ts` | MODIFY | reverseIconMap, getIconName, DEFAULT_FAVORITES 추가 |
| `src/components/layout/HeaderFavoritesBar.tsx` | NEW | 헤더 즐겨찾기 바 (반응형) |
| `src/components/layout/Sidebar.tsx` | MODIFY | leaf 메뉴에 별표 토글 추가 |
| `src/layouts/AuthenticatedLayout.tsx` | MODIFY | 하드코딩 버튼 → HeaderFavoritesBar 교체 |
## 핵심 동작
### 즐겨찾기 등록/해제
- 사이드바 leaf 메뉴(children 없는 항목) hover 시 별표 아이콘 표시
- 이미 즐겨찾기인 항목은 항상 노란 별표 표시
- 별표 클릭으로 토글 (메뉴 클릭과 분리 - `e.stopPropagation()`)
- sidebar collapsed 상태에서는 별표 숨김
### 헤더 표시 (반응형)
| 조건 | 표시 방식 |
|------|----------|
| 데스크톱 (1024px+), 1~8개 | 아이콘 버튼 + Tooltip |
| 데스크톱 (1024px+), 9~10개 | ★ 드롭다운 |
| 태블릿 (768~1024px) | ★ 드롭다운 |
| 모바일 (<768px) | 드롭다운 |
### 제한
- 최대 **10개**
- 초과 토스트: `즐겨찾기는 최대 10개까지 등록할 수 있습니다.`
### 저장
- **현재**: localStorage (`sam-favorites-{userId}` )
- Zustand persist 사용, 사용자별 분리 저장
- 기본값 없음 (사용자가 직접 추가한 것만 표시)
## 주요 구현 상세
### favoritesStore.ts
```typescript
interface FavoriteItem {
id: string; // 메뉴 id
label: string; // 메뉴 라벨
iconName: string; // 아이콘 문자열 (iconMap 키)
path: string; // 라우트 경로
addedAt: number; // 추가 시각 (정렬용)
}
// toggleFavorite 반환값으로 토스트 제어
type ToggleResult = 'added' | 'removed' | 'max_reached';
```
### menuTransform.ts 확장
- `reverseIconMap`: iconMap을 뒤집어 `LucideIcon → string` 조회
- `getIconName(icon)`: 아이콘 컴포넌트 문자열 이름 변환
- `DEFAULT_FAVORITES`: 종합분석 + 품질인정심사 (현재 미사용, 참고용 보관)
### button 중첩 방지
별표 아이콘은 메뉴 `<button>` 바깥에 배치 (`<div className="group/row">` 래퍼).
HTML 규격상 `<button>` 안에 `<button>` 불가 hydration 에러 유발.
```
<div class="flex items-center group/row">
<button>메뉴 항목</button> ← 메뉴 클릭
<button>★</button> ← 별표 클릭 (형제 관계)
</div>
```
## API 전환 계획
현재 localStorage 기반이라 기기/브라우저별로 동기화되지 않음.
추후 API 준비되면 스토어 내부만 교체하면 .
### 변경 범위
- `favoritesStore.ts` 수정 (컴포넌트 수정 불필요)
### 예상 API 엔드포인트
```
GET /api/v1/favorites → 목록 조회
POST /api/v1/favorites → 추가
DELETE /api/v1/favorites/{menuId} → 삭제
```
### 전환 방식
```typescript
// Before (localStorage)
toggleFavorite: (item) => {
// Zustand set()으로 localStorage 저장
}
// After (API)
toggleFavorite: async (item) => {
const exists = get().favorites.some(f => f.id === item.id);
if (exists) {
await fetch(`/api/v1/favorites/${item.id}`, { method: 'DELETE' });
} else {
await fetch('/api/v1/favorites', { method: 'POST', body: JSON.stringify(item) });
}
// 성공 후 로컬 상태 업데이트
}
```
### 초기 로딩
```typescript
// AuthenticatedLayout useEffect에서
const res = await fetch('/api/v1/favorites');
const data = await res.json();
useFavoritesStore.getState().setFavorites(data);
```
### 주의사항
- API 실패 localStorage fallback 고려
- 낙관적 업데이트(optimistic update) UX 저하 방지
- 서버 응답 전에 UI 먼저 반영 실패 롤백

View File

@@ -125,3 +125,56 @@ const html = clonedElement.outerHTML;
3. canvas drawImage → toDataURL (로컬 이미지 fallback)
4. 실패 시 원본 src 유지 (graceful degradation)
```
---
## 미해결: 도해 이미지 데이터 파이프라인 (2026-02-09 확인)
### 현재 상황
PDF 변환(파이프 끝)은 수정 완료했으나, **데이터 주입(파이프 시작)이 연결되지 않은 상태**.
```
[백엔드 설정 사이트] [sam-api] [프론트엔드]
도해 이미지 업로드 → inspection_setting? → inspectionSetting
검사기준 설정 (❌ 미구현) schematicImage
DocumentViewer
PDF (base64 변환 ✅)
```
### 백엔드 API 확인 결과
| 항목 | 상태 |
|------|------|
| `ProcessStep` 테이블에 `inspection_setting` 컬럼 | ❌ 없음 |
| `ApiProcessStep``inspectionSetting` 매핑 | ❌ 없음 |
| 검사 설정 위치 | Process 레벨 `DocumentTemplate`으로 최근(2/10~11) 이동 |
| 도해 이미지 URL 필드 | ❌ 백엔드에 미정의 |
### 프론트 vs 백엔드 구조 불일치
| 항목 | 프론트엔드 기대 | 백엔드 실제 |
|------|----------------|-------------|
| 위치 | `step.inspectionSetting` | `process.documentTemplate` |
| 구조 | `{ standardName, schematicImage, appearance, dimension }` | DocumentTemplate (sections, fields) |
| 이미지 | `schematicImage` 직접 필드 | 없음 |
### 프론트 누락 코드 (합치고 나서 수정 필요)
**`src/components/process-management/actions.ts`**:
- `ApiProcessStep` 인터페이스에 `inspection_setting` 필드 미정의 (line 582)
- `transformStepApiToFrontend()`에서 `inspectionSetting` 매핑 안 함 (line 599)
### 해결 방향
백엔드 머지 후 아래 중 하나로 진행:
| 옵션 | 내용 | 수정 위치 |
|------|------|----------|
| A | ProcessStep에 `inspection_setting` JSON 컬럼 추가 | 백엔드 |
| B | API에서 DocumentTemplate → InspectionSetting 변환하여 내려주기 | 백엔드 |
| C | 프론트에서 `process.documentTemplate` 직접 사용하도록 변경 | 프론트 |
**백엔드 담당자와 구조 협의 필요.**

View File

@@ -0,0 +1,390 @@
# 동적 필드 타입 확장 — 백엔드 API 스펙
> 작성일: 2026-02-12
> 프론트 구현 완료: `[IMPL-2026-02-11] dynamic-field-components.md`
> 설계 근거: `[DESIGN-2026-02-11] dynamic-field-type-extension.md`
---
## 요약
프론트엔드에서 **14종 필드 타입** + **table 섹션 타입** + **조건부 표시 연산자 9종**을 렌더링할 준비가 완료되었습니다.
백엔드에서 해당 `field_type``properties` JSON을 DB에 저장하고 API로 반환하면, 프론트에서 추가 작업 없이 자동 렌더링됩니다.
---
## 1. field_type 확장
### 현재 (6종)
```
textbox | number | dropdown | checkbox | date | textarea
```
### 추가 필요 (8종)
```
reference | multi-select | file | currency | unit-value | radio | toggle | computed
```
### 전체 (14종)
```sql
-- item_fields.field_type 컬럼의 허용값
ENUM('textbox', 'number', 'dropdown', 'checkbox', 'date', 'textarea',
'reference', 'multi-select', 'file',
'currency', 'unit-value', 'radio',
'toggle', 'computed')
```
### 영향 범위
- `item_fields` 테이블의 `field_type` 컬럼
- `item_master_fields` 테이블의 `field_type` 컬럼 (deprecated지만 아직 사용 중이면)
- 필드 생성/수정 API의 validation rule
---
## 2. 각 field_type별 properties JSON 스펙
모든 설정은 기존 `item_fields.properties` JSON 컬럼에 저장합니다.
### 2-1. reference (다른 테이블 검색/선택)
```jsonc
{
"source": "vendors", // 필수. 프리셋: vendors|items|customers|employees|warehouses|processes|equipment|sites|vehicles
"displayField": "name", // 선택. 기본 "name"
"valueField": "id", // 선택. 기본 "id"
"searchFields": ["name"], // 선택. 검색 대상 필드
"searchApiUrl": "/api/...", // source="custom"일 때만 필수
"columns": [ // 선택. 검색 모달 표시 컬럼
{ "key": "code", "label": "코드", "width": "100px" },
{ "key": "name", "label": "이름" }
],
"displayFormat": "{code} - {name}", // 선택. 선택 후 표시 포맷
"returnFields": ["id", "code", "name"] // 선택. 프론트에 반환할 필드
}
```
**저장값**: `string` (선택한 항목의 valueField 값, 기본 id)
**프론트 동작**: source 프리셋이 있으면 `/api/proxy/{source}?search={query}` 호출. 없으면 searchApiUrl 사용.
---
### 2-2. multi-select (복수 선택 태그)
```jsonc
{
"maxSelections": 5, // 선택. 최대 선택 수 (기본: 무제한)
"allowCustom": false, // 선택. 직접 입력 허용 (기본: false)
"layout": "chips" // 선택. "chips" | "list" (기본: "chips")
}
```
**options**: 기존 dropdown과 동일 `[{label, value}]``item_fields.options` 컬럼 사용
**저장값**: `string[]` JSON (예: `["CUT","BEND","WELD"]`)
---
### 2-3. file (파일/이미지 첨부)
```jsonc
{
"accept": ".pdf,.doc,.jpg,.png", // 선택. 허용 확장자 (기본: "*")
"maxSize": 10485760, // 선택. 최대 파일 크기 bytes (기본: 10MB)
"maxFiles": 3, // 선택. 최대 파일 수 (기본: 1)
"preview": true, // 선택. 이미지 미리보기 (기본: true)
"category": "drawing" // 선택. 파일 카테고리 태그
}
```
**저장값**: 파일 업로드 후 반환된 URL 또는 file ID
**필요 API**: `POST /v1/files/upload` (multipart) — 이미 있으면 그대로 사용
---
### 2-4. currency (통화 금액)
```jsonc
{
"currency": "KRW", // 선택. 기본 "KRW". 지원: KRW|USD|EUR|JPY|CNY|GBP
"precision": 0, // 선택. 소수점 자리 (기본: 0)
"showSymbol": true, // 선택. 통화 기호 표시 (기본: true)
"allowNegative": false // 선택. 음수 허용 (기본: false)
}
```
**저장값**: `number` (포맷 없는 원시 숫자)
---
### 2-5. unit-value (값 + 단위 조합)
```jsonc
{
"units": [ // 필수. 단위 목록
{ "label": "mm", "value": "mm" },
{ "label": "cm", "value": "cm" },
{ "label": "m", "value": "m" }
],
"defaultUnit": "mm", // 선택. 기본 단위
"precision": 2 // 선택. 소수점 자리
}
```
**저장값**: `{"value": 100, "unit": "mm"}` JSON
---
### 2-6. radio (라디오 버튼 그룹)
```jsonc
{
"layout": "horizontal" // 선택. "horizontal" | "vertical" (기본: "vertical")
}
```
**options**: 기존 dropdown과 동일 `[{label, value}]``item_fields.options` 컬럼 사용
**저장값**: `string` (선택한 value)
---
### 2-7. toggle (On/Off 스위치)
```jsonc
{
"onLabel": "사용", // 선택. 기본 "ON"
"offLabel": "미사용", // 선택. 기본 "OFF"
"onValue": "Y", // 선택. 기본 "true"
"offValue": "N" // 선택. 기본 "false"
}
```
**저장값**: `string` (onValue 또는 offValue)
---
### 2-8. computed (계산 필드, 읽기전용)
```jsonc
{
"formula": "unit_price * quantity", // 필수. 수식 (field_key 참조)
"dependsOn": ["unit_price", "quantity"], // 필수. 의존 필드 field_key 목록
"format": "currency", // 선택. "currency" | "number" | "percent"
"precision": 0 // 선택. 소수점 자리
}
```
**저장값**: `number` (프론트에서 자동 계산, 백엔드 저장 시에도 계산 검증 권장)
**지원 연산**: `+`, `-`, `*`, `/`, `(`, `)` — field_key를 변수로 사용
---
## 3. section type 확장
### 현재
```sql
item_sections.type: ENUM('fields', 'bom')
```
### 추가 필요
```sql
item_sections.type: ENUM('fields', 'bom', 'table')
```
### table 섹션의 properties JSON
`item_sections.properties` 컬럼 (신규 또는 기존 description 등과 함께):
```jsonc
{
"tableConfig": {
"columns": [
{
"key": "process_name", // 컬럼 키
"label": "공정명", // 컬럼 헤더
"fieldType": "reference", // 셀 입력 타입 (ItemFieldType)
"width": "150px", // 선택
"isRequired": true, // 선택
"isReadonly": false, // 선택
"options": [...], // dropdown/radio/multi-select용
"properties": { // 해당 필드 타입의 properties
"source": "processes"
}
},
{
"key": "cycle_time",
"label": "사이클타임(초)",
"fieldType": "number",
"width": "100px"
}
],
"minRows": 1, // 선택. 최소 행 수
"maxRows": 30, // 선택. 최대 행 수
"summaryRow": [ // 선택. 합계행
{ "columnKey": "cycle_time", "type": "sum" },
{ "columnKey": "setup_time", "type": "avg" }
]
}
}
```
**summaryRow type**: `"sum"` | `"avg"` | `"count"` | `"label"`
### table 섹션 데이터 저장/조회 API
```
GET /v1/items/{itemId}/section-data/{sectionId}
PUT /v1/items/{itemId}/section-data/{sectionId}
```
**요청/응답 body**:
```jsonc
{
"rows": [
{ "process_name": "절단", "cycle_time": 30, "setup_time": 10 },
{ "process_name": "절곡", "cycle_time": 45, "setup_time": 15 }
]
}
```
> 이미 유사한 API가 있으면 그대로 사용. 없으면 신규 생성 필요.
---
## 4. display_condition 연산자 확장
### 현재
```jsonc
// item_fields.display_condition
{
"fieldConditions": [
{
"fieldKey": "item_type",
"expectedValue": "FG", // 정확히 일치 (equals만)
"targetFieldIds": ["10", "11"]
}
]
}
```
### 확장 (operator 필드 추가)
```jsonc
{
"fieldConditions": [
{
"fieldKey": "item_type",
"operator": "in", // 신규: 연산자
"expectedValue": "FG,PT", // in/not_in은 콤마 구분
"targetFieldIds": ["10", "11"]
},
{
"fieldKey": "quantity",
"operator": "greater_than",
"expectedValue": "100",
"targetSectionIds": ["5"]
}
]
}
```
### 지원 연산자 목록
| operator | 설명 | expectedValue 형식 | 예시 |
|----------|------|-------------------|------|
| `equals` | 정확히 일치 (기본값) | 단일값 | `"FG"` |
| `not_equals` | 불일치 | 단일값 | `"RM"` |
| `in` | 목록 중 하나 일치 | 콤마 구분 | `"FG,PT,SM"` |
| `not_in` | 목록 중 어느 것에도 불일치 | 콤마 구분 | `"RM,CS"` |
| `greater_than` | 초과 (숫자) | 숫자 문자열 | `"100"` |
| `less_than` | 미만 (숫자) | 숫자 문자열 | `"0"` |
| `gte` | 이상 (숫자) | 숫자 문자열 | `"50"` |
| `lte` | 이하 (숫자) | 숫자 문자열 | `"1000"` |
| `contains` | 부분 문자열 포함 | 문자열 | `"강판"` |
**하위호환**: `operator`가 없으면 프론트에서 `equals`로 처리. 기존 데이터 마이그레이션 불필요.
---
## 5. 기존 API 변경 없음 (확인)
다음 API들은 이미 `field_type``properties`를 통과시키므로 **변경 불필요**:
| API | 용도 | 비고 |
|-----|------|------|
| `GET /v1/item-master/pages/{id}/structure` | 페이지 구조 조회 | field_type, properties 이미 반환 |
| `POST /v1/item-master/fields` | 필드 생성 | field_type, properties 이미 수신 |
| `PUT /v1/item-master/fields/{id}` | 필드 수정 | 동일 |
| `GET /v1/item-master/init` | 초기화 조회 | field_type 이미 반환 |
**변경이 필요한 부분**:
1. `field_type` validation rule에 8종 추가
2. `item_sections.type``'table'` 추가
3. (선택) table 섹션 데이터 저장/조회 API 신규
---
## 6. 작업 우선순위 제안
### 즉시 (프론트와 바로 연동 가능)
| # | 작업 | 난이도 | 설명 |
|---|------|--------|------|
| 1 | field_type 허용값 8종 추가 | 낮음 | DB ENUM 또는 validation 수정만 |
| 2 | display_condition operator 저장 허용 | 낮음 | JSON이므로 스키마 변경 없음 |
### 다음 단계
| # | 작업 | 난이도 | 설명 |
|---|------|--------|------|
| 3 | section type 'table' 추가 | 중간 | DB + API validation |
| 4 | table 섹션 데이터 API | 중간 | 신규 엔드포인트 (CRUD) |
| 5 | reference 소스별 검색 API | 높음 | 각 소스(vendors, items 등)의 검색 API 표준화 |
### 나중 (필요 시)
| # | 작업 | 난이도 | 설명 |
|---|------|--------|------|
| 6 | 파일 업로드 API | 높음 | `POST /v1/files/upload` (이미 있으면 불필요) |
| 7 | computed 필드 서버사이드 검증 | 중간 | 저장 시 수식 재계산하여 값 검증 |
---
## 7. 테스트 방법
백엔드 작업 완료 후 확인 순서:
1. **페이지 빌더** (`/dev/page-builder`)에서 신규 field_type으로 필드 추가
2. **품목 등록** 페이지에서 해당 필드가 올바르게 렌더링되는지 확인
3. **저장/조회** 사이클 테스트 (값이 properties 형식에 맞게 저장/복원되는지)
---
## 부록: 프론트 파일 매핑
| field_type | 프론트 컴포넌트 | 파일 |
|------------|---------------|------|
| textbox | TextField | `DynamicItemForm/fields/TextField.tsx` |
| number | NumberField | `DynamicItemForm/fields/NumberField.tsx` |
| dropdown | DropdownField | `DynamicItemForm/fields/DropdownField.tsx` |
| checkbox | CheckboxField | `DynamicItemForm/fields/CheckboxField.tsx` |
| date | DateField | `DynamicItemForm/fields/DateField.tsx` |
| textarea | TextareaField | `DynamicItemForm/fields/TextareaField.tsx` |
| **reference** | **ReferenceField** | `DynamicItemForm/fields/ReferenceField.tsx` |
| **multi-select** | **MultiSelectField** | `DynamicItemForm/fields/MultiSelectField.tsx` |
| **file** | **FileField** | `DynamicItemForm/fields/FileField.tsx` |
| **currency** | **CurrencyField** | `DynamicItemForm/fields/CurrencyField.tsx` |
| **unit-value** | **UnitValueField** | `DynamicItemForm/fields/UnitValueField.tsx` |
| **radio** | **RadioField** | `DynamicItemForm/fields/RadioField.tsx` |
| **toggle** | **ToggleField** | `DynamicItemForm/fields/ToggleField.tsx` |
| **computed** | **ComputedField** | `DynamicItemForm/fields/ComputedField.tsx` |
| section type | 프론트 컴포넌트 | 파일 |
|-------------|---------------|------|
| fields | (기존 필드 렌더링) | `DynamicItemForm/index.tsx` |
| bom | DynamicBOMSection | `DynamicItemForm/sections/DynamicBOMSection.tsx` |
| **table** | **DynamicTableSection** | `DynamicItemForm/sections/DynamicTableSection.tsx` |

View File

@@ -11,7 +11,8 @@
"lint": "eslint",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --h 1eaded"
"test:e2e:headed": "playwright test --h 1eaded",
"gen:components": "node scripts/gen-component-registry.mjs"
},
"dependencies": {
"@capacitor/app": "^8.0.0",

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env node
/**
* Component Registry Generator
*
* src/components/ 하위 .tsx 파일을 스캔하여
* - src/generated/component-registry.json (페이지용 데이터)
* - claudedocs/components/_registry.md (에디터/git 이력용)
* 두 가지 출력을 생성합니다.
*
* 사용: node scripts/gen-component-registry.mjs
*/
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { join, relative, basename, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SRC_COMPONENTS = join(ROOT, 'src', 'components');
const OUT_JSON = join(ROOT, 'src', 'generated', 'component-registry.json');
const OUT_MD = join(ROOT, 'claudedocs', 'components', '_registry.md');
// Tier mapping by directory name
const TIER_MAP = {
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 files (useXxx.ts only, not .tsx)
];
function shouldSkip(fileName) {
if (SKIP_FILES.has(fileName)) return true;
return SKIP_PATTERNS.some(p => p.test(fileName));
}
function getTier(relPath) {
const firstDir = relPath.split('/')[0];
return TIER_MAP[firstDir] || 'domain';
}
function getCategory(relPath) {
const parts = relPath.split('/');
// category = first directory under components/
return parts[0];
}
function getSubcategory(relPath) {
const parts = relPath.split('/');
// subcategory = second directory if exists
return parts.length > 2 ? parts[1] : null;
}
function extractComponentInfo(content, fileName) {
const isClient = /^['"]use client['"];?/m.test(content);
// Extract component name from exports
let name = null;
let exportType = 'none';
// Check default export: export default function Foo / export default Foo
const defaultFuncMatch = content.match(/export\s+default\s+function\s+([A-Z]\w*)/);
const defaultConstMatch = content.match(/export\s+default\s+([A-Z]\w*)/);
// Check named exports: export function Foo / export const Foo
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 !== undefined);
// Remove default export name from named list if it also appears
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]; // Use first named export as component name
}
// Fallback: derive name from file name
if (!name) {
const base = basename(fileName, extname(fileName));
name = base
.split(/[-_]/)
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
.join('');
}
// Props detection
const hasProps = /(?:interface|type)\s+\w*Props/.test(content);
const propsNameMatch = content.match(/(?:interface|type)\s+(\w*Props)/);
const propsName = propsNameMatch ? propsNameMatch[1] : null;
// Line count
const lineCount = content.split('\n').length;
return { name, exportType, hasProps, propsName, isClientComponent: isClient, lineCount };
}
async function scanDirectory(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const results = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...await scanDirectory(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.tsx')) {
if (shouldSkip(entry.name)) continue;
const relPath = relative(SRC_COMPONENTS, 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) {
const map = new Map();
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));
}
function generateMarkdown(registry) {
const now = registry.generatedAt;
const lines = [
`# Component Registry`,
``,
`> Auto-generated: ${now} `,
`> Total: **${registry.totalCount}** components`,
``,
];
// Group by tier, then category
const byTier = new Map();
for (const comp of registry.components) {
if (!byTier.has(comp.tier)) byTier.set(comp.tier, new Map());
const tierMap = byTier.get(comp.tier);
if (!tierMap.has(comp.category)) tierMap.set(comp.category, []);
tierMap.get(comp.category).push(comp);
}
const tierOrder = ['ui', 'atoms', 'molecules', 'organisms', 'common', 'layout', 'dev', 'domain'];
for (const tier of tierOrder) {
const categories = byTier.get(tier);
if (!categories) continue;
const tierCount = [...categories.values()].reduce((s, arr) => s + arr.length, 0);
lines.push(`## ${tier.toUpperCase()} (${tierCount})`);
lines.push(``);
for (const [category, comps] of [...categories.entries()].sort()) {
lines.push(`### ${category} (${comps.length})`);
lines.push(``);
lines.push(`| Component | File | Export | Props | Client | Lines |`);
lines.push(`|-----------|------|--------|-------|--------|-------|`);
for (const c of comps.sort((a, b) => a.name.localeCompare(b.name))) {
const client = c.isClientComponent ? 'Y' : '';
const props = c.hasProps ? (c.propsName || 'Y') : '';
lines.push(`| ${c.name} | ${c.fileName} | ${c.exportType} | ${props} | ${client} | ${c.lineCount} |`);
}
lines.push(``);
}
}
return lines.join('\n');
}
async function main() {
console.log('Scanning src/components/...');
const components = await scanDirectory(SRC_COMPONENTS);
components.sort((a, b) => a.tier.localeCompare(b.tier) || a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
const registry = {
generatedAt: new Date().toISOString(),
totalCount: components.length,
categories: buildCategories(components),
components,
};
// Ensure output directories
await mkdir(dirname(OUT_JSON), { recursive: true });
await mkdir(dirname(OUT_MD), { recursive: true });
// Write JSON
await writeFile(OUT_JSON, JSON.stringify(registry, null, 2), 'utf-8');
console.log(` JSON: ${relative(ROOT, OUT_JSON)} (${registry.totalCount} components)`);
// Write Markdown
const md = generateMarkdown(registry);
await writeFile(OUT_MD, md, 'utf-8');
console.log(` MD: ${relative(ROOT, OUT_MD)}`);
// Summary
console.log(`\nSummary by tier:`);
const tierCounts = {};
for (const c of components) {
tierCounts[c.tier] = (tierCounts[c.tier] || 0) + 1;
}
for (const [tier, count] of Object.entries(tierCounts).sort()) {
console.log(` ${tier}: ${count}`);
}
console.log(`\nDone!`);
}
main().catch(err => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,498 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import {
Search,
Copy,
Check,
ChevronDown,
ChevronRight,
Layers,
FileCode,
Code,
Eye,
Loader2,
X,
} from 'lucide-react';
import { getComponentSource, type RegistryData, type ComponentEntry } from './actions';
import { UI_PREVIEWS } from './previews';
interface ComponentRegistryClientProps {
registry: RegistryData;
}
const TIER_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200' },
layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200' },
dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200' },
};
const ALL_TIERS = ['전체', 'ui', 'atoms', 'molecules', 'organisms', 'common', 'layout', 'dev', 'domain'];
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title={label}
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
function TierBadge({ tier }: { tier: string }) {
const config = TIER_CONFIG[tier] || TIER_CONFIG.domain;
return (
<span className={`text-xs px-1.5 py-0.5 rounded ${config.bg} ${config.color} ${config.border} border`}>
{config.label}
</span>
);
}
function SourceCodeViewer({ source }: { source: string }) {
const lines = source.split('\n');
return (
<div className="relative">
<div className="absolute top-2 right-2 z-10">
<CopyButton text={source} label="소스코드 전체 복사" />
</div>
<pre className="bg-gray-950 text-gray-300 rounded-lg p-4 text-xs overflow-x-auto max-h-[500px] overflow-y-auto leading-relaxed">
<code>
{lines.map((line, i) => (
<div key={i} className="flex">
<span className="text-gray-600 select-none w-10 text-right pr-3 shrink-0">
{i + 1}
</span>
<span className="flex-1 whitespace-pre">{line}</span>
</div>
))}
</code>
</pre>
</div>
);
}
function ComponentCard({
comp,
isExpanded,
onToggle,
}: {
comp: ComponentEntry;
isExpanded: boolean;
onToggle: () => void;
}) {
const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview');
const [source, setSource] = useState<string | null>(null);
const [isLoadingSource, setIsLoadingSource] = useState(false);
const importPath = '@/' + comp.filePath.replace(/^src\//, '').replace(/\.tsx$/, '');
const hasPreview = !!UI_PREVIEWS[comp.fileName];
const handleToggle = useCallback(async () => {
if (!isExpanded) {
// Opening: load source code
if (!source && !isLoadingSource) {
setIsLoadingSource(true);
const result = await getComponentSource(comp.filePath);
if (result.source) setSource(result.source);
setIsLoadingSource(false);
}
if (hasPreview) {
setActiveTab('preview');
} else {
setActiveTab('code');
}
}
onToggle();
}, [isExpanded, source, isLoadingSource, comp.filePath, hasPreview, onToggle]);
const handleTabChange = useCallback(async (tab: 'preview' | 'code') => {
setActiveTab(tab);
if (tab === 'code' && !source && !isLoadingSource) {
setIsLoadingSource(true);
const result = await getComponentSource(comp.filePath);
if (result.source) setSource(result.source);
setIsLoadingSource(false);
}
}, [source, isLoadingSource, comp.filePath]);
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border transition-all ${
isExpanded
? 'border-blue-400 dark:border-blue-500 shadow-md'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm'
}`}>
{/* Card Header */}
<div
role="button"
tabIndex={0}
onClick={handleToggle}
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">
<span className="font-medium text-gray-900 dark:text-white">
{comp.name}
</span>
{comp.isClientComponent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
client
</span>
)}
{comp.hasProps && (
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200">
{comp.propsName || 'Props'}
</span>
)}
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">
{comp.exportType}
</span>
{hasPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
Preview
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1 truncate">
{comp.filePath}
</p>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
<span className="text-xs text-gray-400">{comp.lineCount}L</span>
<CopyButton text={importPath} label={`복사: ${importPath}`} />
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700">
{/* Tab Buttons */}
<div className="flex items-center gap-1 px-3 pt-2">
{hasPreview && (
<button
onClick={() => handleTabChange('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
activeTab === 'preview'
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleTabChange('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
activeTab === 'code'
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Code className="w-3.5 h-3.5" />
</button>
</div>
{/* Tab Content */}
<div className="p-3">
{activeTab === 'preview' && hasPreview && (
<div className="space-y-4">
{UI_PREVIEWS[comp.fileName]!.map((preview) => (
<div key={preview.label}>
<p className="text-xs font-medium text-gray-500 mb-2">{preview.label}</p>
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
{preview.render()}
</div>
</div>
))}
</div>
)}
{activeTab === 'code' && (
<>
{isLoadingSource && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-gray-400 mr-2" />
<span className="text-sm text-gray-500"> ...</span>
</div>
)}
{source && <SourceCodeViewer source={source} />}
{!isLoadingSource && !source && (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</>
)}
</div>
</div>
)}
</div>
);
}
function CategorySection({
category,
components,
tier,
expandedCard,
onCardToggle,
}: {
category: string;
components: ComponentEntry[];
tier: string;
expandedCard: string | null;
onCardToggle: (filePath: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
// Group by subcategory
const groups = useMemo(() => {
const map = new Map<string, ComponentEntry[]>();
for (const comp of components) {
const key = comp.subcategory || '__root__';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(comp);
}
return map;
}, [components]);
return (
<div className="mb-4">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full text-left mb-2"
>
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
)}
<TierBadge tier={tier} />
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{category}
</h3>
<span className="text-sm text-gray-500 ml-auto">
{components.length}
</span>
</button>
{expanded && (
<div className="pl-6 space-y-3">
{[...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([sub, comps]) => (
<div key={sub}>
{sub !== '__root__' && (
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1.5 pl-1">
{sub}
</p>
)}
<div className="grid gap-1.5">
{comps.map((comp) => (
<ComponentCard
key={comp.filePath}
comp={comp}
isExpanded={expandedCard === comp.filePath}
onToggle={() => onCardToggle(comp.filePath)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) {
const [searchTerm, setSearchTerm] = useState('');
const [activeTier, setActiveTier] = useState('전체');
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const handleCardToggle = useCallback((filePath: string) => {
setExpandedCard((prev) => (prev === filePath ? null : filePath));
}, []);
const filtered = useMemo(() => {
let comps = registry.components;
if (activeTier !== '전체') {
comps = comps.filter((c) => c.tier === activeTier);
}
if (searchTerm) {
const q = searchTerm.toLowerCase();
comps = comps.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.fileName.toLowerCase().includes(q) ||
c.filePath.toLowerCase().includes(q) ||
(c.propsName && c.propsName.toLowerCase().includes(q))
);
}
return comps;
}, [registry.components, activeTier, searchTerm]);
// Group filtered components by category
const groupedByCategory = useMemo(() => {
const map = new Map<string, { tier: string; components: ComponentEntry[] }>();
for (const comp of filtered) {
if (!map.has(comp.category)) {
map.set(comp.category, { tier: comp.tier, components: [] });
}
map.get(comp.category)!.components.push(comp);
}
return [...map.entries()].sort(([, a], [, b]) => {
const tierOrder = ALL_TIERS.indexOf(a.tier) - ALL_TIERS.indexOf(b.tier);
if (tierOrder !== 0) return tierOrder;
return a.tier.localeCompare(b.tier);
});
}, [filtered]);
// Tier counts for chips
const tierCounts = useMemo(() => {
const counts: Record<string, number> = { '전체': registry.components.length };
for (const comp of registry.components) {
counts[comp.tier] = (counts[comp.tier] || 0) + 1;
}
return counts;
}, [registry.components]);
// Count of previewable components (all tiers)
const previewCount = useMemo(() => {
return registry.components.filter(
(c) => !!UI_PREVIEWS[c.fileName]
).length;
}, [registry.components]);
const generatedDate = new Date(registry.generatedAt).toLocaleString('ko-KR');
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-6 h-6 text-blue-500" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Component Registry
</h1>
<span className="text-sm text-gray-500 ml-2">
{registry.totalCount}
</span>
</div>
<p className="text-xs text-gray-400">
: {generatedDate} &middot; ( )
&middot; 클릭: 소스코드 &middot; : {previewCount}
</p>
</div>
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="컴포넌트명, 파일명, 경로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Tier Filter Chips */}
<div className="flex flex-wrap gap-2 mb-6">
{ALL_TIERS.map((tier) => {
const isActive = activeTier === tier;
const count = tierCounts[tier] || 0;
if (tier !== '전체' && count === 0) return null;
return (
<button
key={tier}
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:border-blue-400'
}`}
>
{tier === '전체' ? '전체' : TIER_CONFIG[tier]?.label || tier}
<span className={`ml-1.5 text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Results count */}
{(searchTerm || activeTier !== '전체') && (
<p className="text-sm text-gray-500 mb-4">
{filtered.length}
</p>
)}
{/* Component List */}
<div className="space-y-2">
{groupedByCategory.map(([category, { tier, components }]) => (
<CategorySection
key={category}
category={category}
tier={tier}
components={components}
expandedCard={expandedCard}
onCardToggle={handleCardToggle}
/>
))}
</div>
{/* Empty State */}
{filtered.length === 0 && (
<div className="text-center py-16">
<FileCode className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500"> .</p>
</div>
)}
{/* 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/components/**/*.tsx</code>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
'use server';
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 }> {
if (!filePath.startsWith('src/components/') || !filePath.endsWith('.tsx')) {
return { source: null, error: 'Invalid file path' };
}
try {
const fullPath = join(process.cwd(), filePath);
const source = await readFile(fullPath, 'utf-8');
return { source, error: null };
} catch {
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

@@ -0,0 +1,37 @@
'use client';
import { useEffect, useState } from 'react';
import { scanComponentRegistry, type RegistryData } from './actions';
import ComponentRegistryClient from './ComponentRegistryClient';
export default function ComponentRegistryPage() {
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} />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,8 @@ import {
Building2, // 거래처
// 자재 아이콘
PackageCheck, // 입고
// Dev 도구 아이콘
Layers, // 컴포넌트 레지스트리
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -193,6 +195,15 @@ export function DevToolbar() {
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
DEV MODE
</Badge>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-yellow-700 hover:bg-yellow-200"
onClick={() => handleNavigate('/dev/component-registry')}
title="컴포넌트 레지스트리"
>
<Layers className="w-3.5 h-3.5" />
</Button>
{detectedPage && (
<span className="text-sm text-yellow-700">
: <strong>{detectedPage.label}</strong>

View File

@@ -23,7 +23,6 @@ import {
UniversalListPage,
type UniversalListConfig,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/UniversalListPage';
@@ -250,19 +249,6 @@ export function AttendanceManagement() {
},
], [stats]);
// 탭 옵션 (mergedRecords 기반)
const tabs: TabOption[] = useMemo(() => [
{ value: 'all', label: '전체', count: mergedRecords.length, color: 'gray' },
{ value: 'notYetIn', label: '미출근', count: stats.notYetInCount, color: 'gray' },
{ value: 'onTime', label: '정시 출근', count: stats.onTimeCount, color: 'green' },
{ value: 'late', label: '지각', count: stats.lateCount, color: 'yellow' },
{ value: 'absent', label: '결근', count: stats.absentCount, color: 'red' },
{ value: 'vacation', label: '휴가', count: stats.vacationCount, color: 'blue' },
{ value: 'businessTrip', label: '출장', count: mergedRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' },
{ value: 'fieldWork', label: '외근', count: mergedRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' },
{ value: 'overtime', label: '연장근무', count: mergedRecords.filter(r => r.status === 'overtime').length, color: 'indigo' },
], [mergedRecords.length, stats]);
// 테이블 컬럼 정의
const tableColumns = useMemo(() => [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
@@ -441,8 +427,6 @@ export function AttendanceManagement() {
columns: tableColumns,
tabs: tabs,
defaultTab: activeTab,
filterConfig: filterConfig,
initialFilters: filterValues,
@@ -480,63 +464,11 @@ export function AttendanceManagement() {
searchPlaceholder: '이름, 부서 검색...',
// 엑셀 다운로드 설정 (fetchAllUrl로 전체 데이터 조회)
// 엑셀 다운로드 설정 (프론트 mergedRecords 사용 - 미출근 직원 포함)
excelDownload: {
columns: excelColumns,
filename: '근태현황',
sheetName: '근태',
fetchAllUrl: '/api/proxy/attendances',
fetchAllParams: () => {
const params: Record<string, string> = {};
if (startDate) params.date_from = startDate;
if (endDate) params.date_to = endDate;
return params;
},
mapResponse: (result: unknown) => {
const res = result as { data?: { data?: Record<string, unknown>[] } };
const items = res.data?.data ?? [];
return items.map((item) => {
const user = item.user as Record<string, unknown> | undefined;
const profiles = (user?.tenant_profiles ?? []) as Record<string, unknown>[];
const profile = profiles[0] as Record<string, unknown> | undefined;
const dept = profile?.department as Record<string, unknown> | undefined;
const legacyProfile = user?.tenant_profile as Record<string, unknown> | undefined;
const legacyDept = legacyProfile?.department as Record<string, unknown> | undefined;
const jsonDetails = (item.json_details ?? {}) as Record<string, unknown>;
const breakMins = item.break_minutes as number | null;
const overtimeMins = jsonDetails.overtime_minutes as number | undefined;
return {
id: String(item.id),
employeeId: String(item.user_id),
employeeName: (user?.name ?? '') as string,
department: (dept?.name ?? legacyDept?.name ?? '') as string,
position: (profile?.position_key ?? '') as string,
rank: ((legacyProfile?.rank ?? '') as string),
baseDate: item.base_date as string,
checkIn: (item.check_in ?? jsonDetails.check_in ?? null) as string | null,
checkOut: (item.check_out ?? jsonDetails.check_out ?? null) as string | null,
breakTime: breakMins != null
? `${Math.floor(breakMins / 60)}:${(breakMins % 60).toString().padStart(2, '0')}`
: (jsonDetails.break_time as string || null),
overtimeHours: overtimeMins
? (() => {
const h = Math.floor(overtimeMins / 60);
const m = overtimeMins % 60;
if (h > 0 && m > 0) return `${h}시간 ${m}`;
if (h > 0) return `${h}시간`;
return `${m}`;
})()
: null,
workMinutes: (jsonDetails.work_minutes || null) as number | null,
reason: (jsonDetails.reason || null) as AttendanceRecord['reason'],
status: item.status as string,
remarks: (item.remarks ?? null) as string | null,
createdAt: item.created_at as string,
updatedAt: item.updated_at as string,
} as AttendanceRecord;
});
},
},
itemsPerPage: itemsPerPage,
@@ -551,11 +483,6 @@ export function AttendanceManagement() {
);
},
tabFilter: (item, activeTab) => {
if (activeTab === 'all') return true;
return item.status === activeTab;
},
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
let filtered = items;
@@ -700,8 +627,6 @@ export function AttendanceManagement() {
}), [
mergedRecords,
tableColumns,
tabs,
activeTab,
filterConfig,
filterValues,
statCards,

View File

@@ -0,0 +1,112 @@
/**
* Reference 필드 소스 프리셋
*
* ReferenceField가 properties.source 값을 키로 조회하여
* API URL, 표시 필드, 검색 컬럼 등을 자동 결정
*
* 확장: 새 소스 추가 = 이 파일에 객체 1개 추가
*/
import type { ReferenceSourcePreset } from '../types';
export const REFERENCE_SOURCES: Record<string, ReferenceSourcePreset> = {
// ===== 공통 =====
vendors: {
apiUrl: '/api/proxy/vendors',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '거래처 코드', width: '120px' },
{ key: 'name', label: '거래처명' },
{ key: 'contact_person', label: '담당자', width: '100px' },
],
},
items: {
apiUrl: '/api/proxy/items',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '품목코드', width: '120px' },
{ key: 'name', label: '품목명' },
{ key: 'spec', label: '규격', width: '120px' },
],
},
customers: {
apiUrl: '/api/proxy/customers',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '고객 코드', width: '120px' },
{ key: 'name', label: '고객명' },
],
},
employees: {
apiUrl: '/api/proxy/employees',
displayField: 'name',
valueField: 'id',
searchFields: ['name'],
columns: [
{ key: 'name', label: '직원명' },
{ key: 'department', label: '부서', width: '120px' },
],
},
warehouses: {
apiUrl: '/api/proxy/warehouses',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '창고코드', width: '120px' },
{ key: 'name', label: '창고명' },
],
},
// ===== 제조 =====
processes: {
apiUrl: '/api/proxy/processes',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '공정코드', width: '120px' },
{ key: 'name', label: '공정명' },
],
},
equipment: {
apiUrl: '/api/proxy/equipment',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'code'],
columns: [
{ key: 'code', label: '설비코드', width: '120px' },
{ key: 'name', label: '설비명' },
],
},
// ===== 공사 =====
sites: {
apiUrl: '/api/proxy/construction-sites',
displayField: 'name',
valueField: 'id',
searchFields: ['name', 'address'],
columns: [
{ key: 'name', label: '현장명' },
{ key: 'address', label: '주소' },
],
},
// ===== 물류 =====
vehicles: {
apiUrl: '/api/proxy/vehicles',
displayField: 'plate_number',
valueField: 'id',
searchFields: ['plate_number', 'driver_name'],
columns: [
{ key: 'plate_number', label: '차량번호', width: '120px' },
{ key: 'driver_name', label: '기사명' },
],
},
};

View File

@@ -0,0 +1,135 @@
/**
* 계산 필드 (읽기전용)
* 다른 필드 값 기반 자동 계산, formula 평가
*
* properties: { formula, dependsOn, format, precision }
* 저장값: 계산 결과 number (자동 업데이트)
*/
'use client';
import { useEffect, useRef } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import type { DynamicFieldRendererProps, ComputedConfig, DynamicFormData } from '../types';
/**
* 안전한 수식 평가기
* 지원 연산: +, -, *, /, (, )
* 변수: field_key 이름으로 참조
*/
function evaluateFormula(
formula: string,
variables: Record<string, number>
): number | null {
try {
// 변수를 숫자로 치환 (긴 키부터 치환하여 부분 매칭 방지)
let expression = formula;
const sortedKeys = Object.keys(variables).sort((a, b) => b.length - a.length);
for (const key of sortedKeys) {
expression = expression.replace(
new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
String(variables[key])
);
}
// 숫자, 연산자, 괄호, 공백만 허용 (안전성 검사)
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
return null;
}
// Function 생성자로 안전하게 평가
const result = new Function(`"use strict"; return (${expression})`)();
if (typeof result !== 'number' || !isFinite(result)) {
return null;
}
return result;
} catch {
return null;
}
}
function formatResult(
value: number,
format?: string,
precision?: number
): string {
const p = precision ?? 2;
switch (format) {
case 'currency': {
const formatted = value.toFixed(p);
const [intPart, decPart] = formatted.split('.');
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decPart ? `\u20A9${withCommas}.${decPart}` : `\u20A9${withCommas}`;
}
case 'percent':
return `${value.toFixed(p)}%`;
case 'number':
default:
return value.toFixed(p);
}
}
export function ComputedField({
field,
value,
onChange,
error,
disabled: _disabled,
formData,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as ComputedConfig;
const { formula, dependsOn, format, precision } = config;
const prevResultRef = useRef<number | null>(null);
// dependsOn 필드 값이 변경될 때마다 재계산
useEffect(() => {
if (!formula || !dependsOn || !formData) return;
// 의존 필드 값 수집
const variables: Record<string, number> = {};
for (const dep of dependsOn) {
const depValue = formData[dep];
variables[dep] = depValue !== null && depValue !== undefined ? Number(depValue) || 0 : 0;
}
const result = evaluateFormula(formula, variables);
// 이전 결과와 다를 때만 업데이트 (무한 루프 방지)
if (result !== null && result !== prevResultRef.current) {
prevResultRef.current = result;
onChange(result);
}
}, [formula, dependsOn, formData, onChange]);
const displayValue = value !== null && value !== undefined
? formatResult(Number(value), format, precision)
: '-';
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<Input
id={fieldKey}
value={displayValue}
readOnly
disabled
className="bg-muted text-muted-foreground cursor-default"
/>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,126 @@
/**
* 통화 금액 입력 필드
* 천단위 콤마 포맷, 통화 기호 prefix 지원
*
* properties: { currency, precision, showSymbol, allowNegative }
* 저장값: number (포맷 없이)
*/
'use client';
import { useState, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { DynamicFieldRendererProps, CurrencyConfig } from '../types';
const CURRENCY_SYMBOLS: Record<string, string> = {
KRW: '\u20A9',
USD: '$',
EUR: '\u20AC',
JPY: '\u00A5',
CNY: '\u00A5',
GBP: '\u00A3',
};
function formatCurrency(num: number, precision: number): string {
const fixed = num.toFixed(precision);
const [intPart, decPart] = fixed.split('.');
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return decPart !== undefined ? `${formatted}.${decPart}` : formatted;
}
function parseCurrency(str: string): number {
const cleaned = str.replace(/[^0-9.\-]/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
export function CurrencyField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as CurrencyConfig;
const currency = config.currency || 'KRW';
const precision = config.precision ?? 0;
const showSymbol = config.showSymbol !== false;
const allowNegative = config.allowNegative === true;
const symbol = CURRENCY_SYMBOLS[currency] || currency;
const numericValue = value !== null && value !== undefined ? Number(value) : null;
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState(
numericValue !== null ? String(numericValue) : ''
);
const handleFocus = useCallback(() => {
setIsFocused(true);
setInputValue(numericValue !== null ? String(numericValue) : '');
}, [numericValue]);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (inputValue === '' || inputValue === '-') {
onChange(null);
return;
}
const parsed = parseCurrency(inputValue);
const final = allowNegative ? parsed : Math.abs(parsed);
onChange(final);
}, [inputValue, onChange, allowNegative]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
// 숫자, 점, 마이너스만 허용
const pattern = allowNegative ? /[^0-9.\-]/g : /[^0-9.]/g;
const cleaned = raw.replace(pattern, '');
setInputValue(cleaned);
}, [allowNegative]);
const displayValue = isFocused
? inputValue
: numericValue !== null
? formatCurrency(numericValue, precision)
: '';
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="relative">
{showSymbol && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground pointer-events-none">
{symbol}
</span>
)}
<Input
id={fieldKey}
type="text"
inputMode="decimal"
placeholder={field.placeholder || '0'}
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={`${showSymbol ? 'pl-8' : ''} text-right ${error ? 'border-red-500' : ''}`}
/>
</div>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -1,6 +1,9 @@
/**
* 동적 필드 렌더러
* field_type에 따라 적절한 필드 컴포넌트를 선택하여 렌더링
*
* 기본 6종 + 확장 8종 = 14종 지원
* 알 수 없는 타입은 TextField로 폴백
*/
'use client';
@@ -11,12 +14,24 @@ import { DropdownField } from './DropdownField';
import { CheckboxField } from './CheckboxField';
import { DateField } from './DateField';
import { TextareaField } from './TextareaField';
// Phase 1: 핵심 컴포넌트
import { ReferenceField } from './ReferenceField';
import { MultiSelectField } from './MultiSelectField';
import { FileField } from './FileField';
// Phase 2: 편의 컴포넌트
import { CurrencyField } from './CurrencyField';
import { UnitValueField } from './UnitValueField';
import { RadioField } from './RadioField';
// Phase 3: 고급 컴포넌트
import { ToggleField } from './ToggleField';
import { ComputedField } from './ComputedField';
import type { DynamicFieldRendererProps } from '../types';
export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
const { field } = props;
switch (field.field_type) {
// 기본 6종
case 'textbox':
return <TextField {...props} />;
@@ -35,6 +50,33 @@ export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
case 'textarea':
return <TextareaField {...props} />;
// Phase 1: 핵심 타입
case 'reference':
return <ReferenceField {...props} />;
case 'multi-select':
return <MultiSelectField {...props} />;
case 'file':
return <FileField {...props} />;
// Phase 2: 편의 타입
case 'currency':
return <CurrencyField {...props} />;
case 'unit-value':
return <UnitValueField {...props} />;
case 'radio':
return <RadioField {...props} />;
// Phase 3: 고급 타입
case 'toggle':
return <ToggleField {...props} />;
case 'computed':
return <ComputedField {...props} />;
default:
// 알 수 없는 타입은 텍스트 필드로 폴백
console.warn(`Unknown field type: ${field.field_type}, falling back to TextField`);

View File

@@ -0,0 +1,199 @@
/**
* File 필드 컴포넌트
* 파일/이미지 첨부 (드래그 앤 드롭 + 파일 선택)
*
* API 연동 전: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기
* API 연동 시: POST /v1/files/upload (multipart)
*/
'use client';
import { useState, useRef, useCallback } from 'react';
import { Upload, X, FileIcon, ImageIcon } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import type { DynamicFieldRendererProps, FileConfig } from '../types';
interface LocalFile {
file: File;
previewUrl?: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
export function FileField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const fileInputRef = useRef<HTMLInputElement>(null);
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const config = (field.properties || {}) as FileConfig;
const accept = config.accept || '*';
const maxSize = config.maxSize || 10485760; // 10MB
const maxFiles = config.maxFiles || 1;
const showPreview = config.preview !== false;
// 파일 추가
const addFiles = useCallback((newFiles: FileList | File[]) => {
const files = Array.from(newFiles);
const validFiles: LocalFile[] = [];
for (const file of files) {
if (localFiles.length + validFiles.length >= maxFiles) break;
if (file.size > maxSize) continue;
const previewUrl = showPreview && isImageFile(file)
? URL.createObjectURL(file)
: undefined;
validFiles.push({ file, previewUrl });
}
if (validFiles.length === 0) return;
const updated = [...localFiles, ...validFiles];
setLocalFiles(updated);
// 단일 파일이면 File 객체, 복수면 배열 이름을 저장
onChange(maxFiles === 1
? updated[0]?.file.name || null
: updated.map(f => f.file.name)
);
}, [localFiles, maxFiles, maxSize, showPreview, onChange]);
// 파일 제거
const removeFile = (index: number) => {
const file = localFiles[index];
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
const updated = localFiles.filter((_, i) => i !== index);
setLocalFiles(updated);
onChange(updated.length === 0 ? null : (
maxFiles === 1 ? updated[0]?.file.name : updated.map(f => f.file.name)
));
};
// 드래그 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragOver(true);
};
const handleDragLeave = () => setIsDragOver(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (!disabled && e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
};
const canAddMore = localFiles.length < maxFiles;
// value가 이미 있으면 (서버에서 받은 파일명) 기존 파일 표시
const existingFileName = typeof value === 'string' && value && localFiles.length === 0 ? value : null;
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
{/* 드래그 앤 드롭 영역 */}
{canAddMore && !disabled && (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`mt-1 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer ${
isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
} ${error ? 'border-red-500' : ''}`}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(maxSize)}
{accept !== '*' && ` (${accept})`}
</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
id={fieldKey}
accept={accept}
multiple={maxFiles > 1}
className="hidden"
onChange={(e) => e.target.files && addFiles(e.target.files)}
/>
{/* 기존 파일 (서버에서 받은) */}
{existingFileName && (
<div className="mt-2 flex items-center gap-2 rounded-md border p-2">
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate flex-1">{existingFileName}</span>
</div>
)}
{/* 선택된 파일 목록 */}
{localFiles.length > 0 && (
<div className="mt-2 space-y-2">
{localFiles.map((lf, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border p-2">
{/* 이미지 미리보기 */}
{lf.previewUrl ? (
<img
src={lf.previewUrl}
alt={lf.file.name}
className="h-10 w-10 rounded object-cover shrink-0"
/>
) : (
isImageFile(lf.file)
? <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
: <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{lf.file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(lf.file.size)}</p>
</div>
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeFile(index)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,191 @@
/**
* MultiSelect 필드 컴포넌트
* 여러 항목을 동시에 선택 (태그 칩 형태로 표시)
*
* 저장값: string[] (예: ["CUT", "BEND", "WELD"])
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import type { DynamicFieldRendererProps, MultiSelectConfig } from '../types';
// 옵션 정규화 (DropdownField와 동일 로직)
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
if (!rawOptions) return [];
if (typeof rawOptions === 'string') {
return rawOptions.split(',').map(o => {
const trimmed = o.trim();
return { label: trimmed, value: trimmed };
});
}
if (Array.isArray(rawOptions)) {
return rawOptions.map(item => {
if (typeof item === 'object' && item !== null && 'value' in item) {
return {
label: String((item as Record<string, unknown>).label || (item as Record<string, unknown>).value),
value: String((item as Record<string, unknown>).value),
};
}
const str = String(item);
return { label: str, value: str };
});
}
return [];
}
export function MultiSelectField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const config = (field.properties || {}) as MultiSelectConfig;
const maxSelections = config.maxSelections;
const allowCustom = config.allowCustom ?? false;
// 현재 선택된 값 배열
const selectedValues: string[] = Array.isArray(value) ? (value as string[]) : [];
// 옵션 목록
const options = normalizeOptions(field.options);
const filteredOptions = options.filter(
opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()) &&
!selectedValues.includes(opt.value)
);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 항목 추가
const addValue = (val: string) => {
if (maxSelections && selectedValues.length >= maxSelections) return;
const newValues = [...selectedValues, val];
onChange(newValues);
setSearchTerm('');
};
// 항목 제거
const removeValue = (val: string) => {
onChange(selectedValues.filter(v => v !== val));
};
// 커스텀 입력 추가 (Enter)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchTerm.trim()) {
e.preventDefault();
if (allowCustom && !selectedValues.includes(searchTerm.trim())) {
addValue(searchTerm.trim());
} else {
// 기존 옵션에서 일치하는 것 선택
const match = filteredOptions[0];
if (match) addValue(match.value);
}
}
if (e.key === 'Backspace' && !searchTerm && selectedValues.length > 0) {
removeValue(selectedValues[selectedValues.length - 1]);
}
};
// 옵션 라벨 조회
const getLabel = (val: string) => {
const opt = options.find(o => o.value === val);
return opt ? opt.label : val;
};
const isAtLimit = maxSelections ? selectedValues.length >= maxSelections : false;
return (
<div ref={containerRef}>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
{maxSelections && (
<span className="text-muted-foreground font-normal ml-1">
({selectedValues.length}/{maxSelections})
</span>
)}
</Label>
<div
className={`min-h-10 flex flex-wrap items-center gap-1 rounded-md border bg-background px-3 py-2 text-sm cursor-text ${
error ? 'border-red-500' : 'border-input'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && setIsOpen(true)}
>
{/* 선택된 칩들 */}
{selectedValues.map(val => (
<Badge key={val} variant="secondary" className="gap-1 pr-1">
{getLabel(val)}
{!disabled && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeValue(val); }}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))}
{/* 검색 입력 */}
{!disabled && !isAtLimit && (
<Input
id={fieldKey}
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selectedValues.length === 0 ? (field.placeholder || '선택하세요') : ''}
className="border-0 shadow-none p-0 h-6 min-w-[60px] flex-1 focus-visible:ring-0"
/>
)}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground ml-auto" />
</div>
{/* 드롭다운 */}
{isOpen && filteredOptions.length > 0 && (
<div className="relative">
<div className="absolute z-50 w-full mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md">
{filteredOptions.map(opt => (
<button
key={opt.value}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => { addValue(opt.value); setIsOpen(false); }}
>
{opt.label}
</button>
))}
</div>
</div>
)}
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
/**
* 라디오 버튼 그룹 필드
* 수평/수직 레이아웃 지원
*
* properties: { layout: "horizontal" | "vertical" }
* options: [{label, value}]
*/
'use client';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import type { DynamicFieldRendererProps } from '../types';
// 옵션을 {label, value} 형태로 정규화
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
if (!rawOptions) return [];
if (typeof rawOptions === 'string') {
return rawOptions.split(',').map(o => {
const trimmed = o.trim();
return { label: trimmed, value: trimmed };
});
}
if (Array.isArray(rawOptions)) {
return rawOptions.map(item => {
if (typeof item === 'object' && item !== null && 'value' in item) {
return {
label: String(item.label || item.value),
value: String(item.value),
};
}
const str = String(item);
return { label: str, value: str };
});
}
return [];
}
export function RadioField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const layout = (field.properties?.layout as string) || 'vertical';
const options = normalizeOptions(field.options);
const stringValue = value !== null && value !== undefined ? String(value) : '';
return (
<div>
<Label>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<RadioGroup
value={stringValue}
onValueChange={onChange}
disabled={disabled}
className={`mt-2 ${layout === 'horizontal' ? 'flex flex-wrap gap-4' : 'grid gap-2'}`}
>
{options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem
value={option.value}
id={`${fieldKey}-${option.value}`}
/>
<Label
htmlFor={`${fieldKey}-${option.value}`}
className="font-normal cursor-pointer"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
/**
* Reference 필드 컴포넌트
* 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객 등)
*
* API 연동 시: REFERENCE_SOURCES[source]에서 apiUrl 조회 → 검색 API 호출
* API 연동 전: props로 전달된 options 사용 또는 빈 상태에서 UI 확인
*/
'use client';
import { useState, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
import { REFERENCE_SOURCES } from '../config/reference-sources';
import type { DynamicFieldRendererProps, ReferenceConfig } from '../types';
interface ReferenceItem {
[key: string]: unknown;
}
export function ReferenceField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const [isModalOpen, setIsModalOpen] = useState(false);
const [displayValue, setDisplayValue] = useState<string>('');
// properties에서 config 추출
const config = (field.properties || {}) as ReferenceConfig;
const source = config.source || '';
const displayField = config.displayField || 'name';
const valueField = config.valueField || 'id';
const displayFormat = config.displayFormat;
// 소스 프리셋 조회
const preset = source ? REFERENCE_SOURCES[source] : null;
const apiUrl = config.searchApiUrl || preset?.apiUrl || '';
const columns = config.columns || preset?.columns || [
{ key: 'name', label: '이름' },
];
// 표시값 포맷팅
const formatDisplay = useCallback((item: ReferenceItem): string => {
if (displayFormat) {
return displayFormat.replace(/\{(\w+)\}/g, (_, key) =>
String(item[key] || '')
);
}
return String(item[displayField] || '');
}, [displayFormat, displayField]);
// 검색 API 호출
const fetchData = useCallback(async (query: string): Promise<ReferenceItem[]> => {
if (!apiUrl) return [];
try {
const separator = apiUrl.includes('?') ? '&' : '?';
const url = `${apiUrl}${separator}search=${encodeURIComponent(query)}&size=20`;
const response = await fetch(url);
const result = await response.json();
// 다양한 응답 구조 대응
if (Array.isArray(result)) return result;
if (result.data?.data) return result.data.data;
if (Array.isArray(result.data)) return result.data;
return [];
} catch {
return [];
}
}, [apiUrl]);
// 선택 핸들러
const handleSelect = (item: ReferenceItem) => {
const selectedValue = item[valueField];
onChange(selectedValue as string | number);
setDisplayValue(formatDisplay(item));
setIsModalOpen(false);
};
// 선택 해제
const handleClear = () => {
onChange(null);
setDisplayValue('');
};
// 현재 표시값
const currentDisplay = displayValue || (value ? String(value) : '');
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id={fieldKey}
value={currentDisplay}
placeholder={field.placeholder || `${field.field_name}을(를) 선택하세요`}
readOnly
disabled={disabled}
className={`pr-8 ${error ? 'border-red-500' : ''}`}
/>
{currentDisplay && !disabled && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled || !apiUrl}
onClick={() => setIsModalOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
</div>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
{/* 검색 모달 */}
<SearchableSelectionModal<ReferenceItem>
mode="single"
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={`${field.field_name} 검색`}
searchPlaceholder="검색어를 입력하세요"
fetchData={fetchData}
keyExtractor={(item) => String(item[valueField])}
renderItem={(item) => (
<div className="flex items-center gap-4 px-3 py-2">
{columns.map((col) => (
<span
key={col.key}
className="text-sm"
style={col.width ? { width: col.width, flexShrink: 0 } : { flex: 1 }}
>
{String(item[col.key] || '')}
</span>
))}
</div>
)}
onSelect={handleSelect}
searchMode="debounce"
loadOnOpen
/>
</div>
);
}

View File

@@ -0,0 +1,61 @@
/**
* On/Off 토글 스위치 필드
*
* properties: { onLabel, offLabel, onValue, offValue }
* 저장값: onValue/offValue (기본: "true"/"false")
*/
'use client';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { DynamicFieldRendererProps, ToggleConfig } from '../types';
export function ToggleField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as ToggleConfig;
const onLabel = config.onLabel || 'ON';
const offLabel = config.offLabel || 'OFF';
const onValue = config.onValue || 'true';
const offValue = config.offValue || 'false';
const isChecked = value === onValue || value === true || value === 'true';
const handleChange = (checked: boolean) => {
onChange(checked ? onValue : offValue);
};
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex items-center gap-3 mt-2">
<Switch
id={fieldKey}
checked={isChecked}
onCheckedChange={handleChange}
disabled={disabled}
/>
<span className="text-sm text-muted-foreground">
{isChecked ? onLabel : offLabel}
</span>
</div>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
/**
* 값+단위 조합 입력 필드
* Input(숫자) + Select(단위) 가로 배치
*
* properties: { units, defaultUnit, precision }
* 저장값: { value: number, unit: string }
*/
'use client';
import { useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { DynamicFieldRendererProps, UnitValueConfig } from '../types';
interface UnitValueData {
value: number | null;
unit: string;
}
function parseUnitValue(raw: unknown, defaultUnit: string): UnitValueData {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>;
return {
value: obj.value !== null && obj.value !== undefined ? Number(obj.value) : null,
unit: typeof obj.unit === 'string' ? obj.unit : defaultUnit,
};
}
// 숫자만 들어온 경우
if (typeof raw === 'number') {
return { value: raw, unit: defaultUnit };
}
return { value: null, unit: defaultUnit };
}
export function UnitValueField({
field,
value,
onChange,
error,
disabled,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const config = (field.properties || {}) as UnitValueConfig;
const units = config.units || [];
const defaultUnit = config.defaultUnit || (units.length > 0 ? units[0].value : '');
const precision = config.precision;
const data = parseUnitValue(value, defaultUnit);
const handleValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '' || raw === '-') {
onChange({ value: null, unit: data.unit });
return;
}
const num = parseFloat(raw);
if (!isNaN(num)) {
const final = precision !== undefined
? Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision)
: num;
onChange({ value: final, unit: data.unit });
}
}, [data.unit, precision, onChange]);
const handleUnitChange = useCallback((newUnit: string) => {
onChange({ value: data.value, unit: newUnit });
}, [data.value, onChange]);
return (
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<div className="flex gap-2">
<Input
id={fieldKey}
type="number"
placeholder={field.placeholder || '값 입력'}
value={data.value !== null ? String(data.value) : ''}
onChange={handleValueChange}
disabled={disabled}
step={precision !== undefined ? Math.pow(10, -precision) : 'any'}
className={`flex-1 ${error ? 'border-red-500' : ''}`}
/>
{units.length > 0 ? (
<Select
key={`${fieldKey}-unit-${data.unit}`}
value={data.unit}
onValueChange={handleUnitChange}
disabled={disabled}
>
<SelectTrigger className="w-24 shrink-0">
<SelectValue placeholder="단위" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.value} value={u.value}>
{u.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="flex items-center text-sm text-muted-foreground px-2">
{data.unit || '-'}
</span>
)}
</div>
{error && (
<p className="text-xs text-red-500 mt-1">{error}</p>
)}
{!error && field.description && (
<p className="text-xs text-muted-foreground mt-1">
* {field.description}
</p>
)}
</div>
);
}

View File

@@ -4,4 +4,15 @@ export { DropdownField } from './DropdownField';
export { CheckboxField } from './CheckboxField';
export { DateField } from './DateField';
export { TextareaField } from './TextareaField';
// Phase 1: 핵심 컴포넌트
export { ReferenceField } from './ReferenceField';
export { MultiSelectField } from './MultiSelectField';
export { FileField } from './FileField';
// Phase 2: 편의 컴포넌트
export { CurrencyField } from './CurrencyField';
export { UnitValueField } from './UnitValueField';
export { RadioField } from './RadioField';
// Phase 3: 고급 컴포넌트
export { ToggleField } from './ToggleField';
export { ComputedField } from './ComputedField';
export { DynamicFieldRenderer } from './DynamicFieldRenderer';

View File

@@ -12,7 +12,50 @@ import type {
DynamicFormData,
DisplayCondition,
FieldConditionConfig,
ConditionOperator,
} from '../types';
import type { DynamicFieldValue } from '../types';
/**
* 조건 연산자 평가
* operator가 없으면 'equals' (하위호환)
*/
function evaluateCondition(
currentValue: DynamicFieldValue,
operator: ConditionOperator,
expectedValue: string
): boolean {
const strValue = currentValue !== null && currentValue !== undefined ? String(currentValue) : '';
const numValue = Number(currentValue);
const numExpected = Number(expectedValue);
switch (operator) {
case 'equals':
return strValue === expectedValue;
case 'not_equals':
return strValue !== expectedValue;
case 'in': {
const list = expectedValue.split(',').map(s => s.trim());
return list.includes(strValue);
}
case 'not_in': {
const list = expectedValue.split(',').map(s => s.trim());
return !list.includes(strValue);
}
case 'greater_than':
return !isNaN(numValue) && !isNaN(numExpected) && numValue > numExpected;
case 'less_than':
return !isNaN(numValue) && !isNaN(numExpected) && numValue < numExpected;
case 'gte':
return !isNaN(numValue) && !isNaN(numExpected) && numValue >= numExpected;
case 'lte':
return !isNaN(numValue) && !isNaN(numExpected) && numValue <= numExpected;
case 'contains':
return strValue.includes(expectedValue);
default:
return strValue === expectedValue;
}
}
interface ConditionalDisplayResult {
/** 섹션이 표시되어야 하는지 확인 */
@@ -88,8 +131,7 @@ export function useConditionalDisplay(
// fieldConditions 배열 순회
if (condition.fieldConditions) {
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
// 현재 값과 기대값이 일치하는지 확인
const isMatch = String(currentValue) === fc.expectedValue;
const isMatch = evaluateCondition(currentValue, fc.operator || 'equals', fc.expectedValue);
if (isMatch) {
// 일치하면 타겟 섹션/필드 활성화

View File

@@ -683,6 +683,7 @@ export default function DynamicItemForm({
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
formData={formData}
/>
);
})}
@@ -737,6 +738,7 @@ export default function DynamicItemForm({
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
formData={formData}
/>
{/* 규격 필드 바로 다음에 품목코드 자동생성 필드 표시 (절곡부품 제외) */}
{isSpecField && hasAutoItemCode && !isBendingPart && (
@@ -887,6 +889,7 @@ export default function DynamicItemForm({
error={errors[fieldKey]}
disabled={isSubmitting}
unitOptions={unitOptions}
formData={formData}
/>
);
})}

View File

@@ -0,0 +1,253 @@
/**
* 산업별 섹션 프리셋
* 백엔드 DB에 저장할 section config의 프론트엔드 프리셋 정의
*
* 사용: 관리자가 페이지 구조 설계 시 프리셋 선택 → properties JSON으로 저장
* 확장: 새 산업/섹션 = 이 파일에 객체 추가
*/
import type { TableConfig } from '../types';
export interface SectionPreset {
id: string;
name: string;
description: string;
industry: string;
sectionType: 'fields' | 'table';
tableConfig?: TableConfig;
fields?: Array<{
field_key: string;
field_name: string;
field_type: string;
options?: Array<{ label: string; value: string }>;
properties?: Record<string, unknown>;
is_required?: boolean;
}>;
}
// ============================================
// 제조업 프리셋
// ============================================
export const MANUFACTURING_PROCESS_TABLE: SectionPreset = {
id: 'mfg-process',
name: '공정 정보',
description: '제조 공정 순서 및 상세 정보',
industry: '제조',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'process_name', label: '공정명', fieldType: 'reference', width: '150px', isRequired: true, properties: { source: 'processes' } },
{ key: 'equipment', label: '설비', fieldType: 'reference', width: '130px', properties: { source: 'equipment' } },
{ key: 'cycle_time', label: '사이클타임(초)', fieldType: 'number', width: '100px' },
{ key: 'setup_time', label: '셋업시간(분)', fieldType: 'number', width: '100px' },
{ key: 'note', label: '비고', fieldType: 'textbox' },
],
minRows: 1,
maxRows: 30,
summaryRow: [
{ columnKey: 'cycle_time', type: 'sum' },
{ columnKey: 'setup_time', type: 'sum' },
],
},
};
export const MANUFACTURING_QC_TABLE: SectionPreset = {
id: 'mfg-qc',
name: '품질검사 항목',
description: '검사 항목 및 기준값',
industry: '제조',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'inspection_item', label: '검사항목', fieldType: 'textbox', width: '150px', isRequired: true },
{ key: 'method', label: '검사방법', fieldType: 'dropdown', width: '120px', options: [
{ label: '육안검사', value: 'visual' },
{ label: '치수검사', value: 'dimension' },
{ label: '기능검사', value: 'functional' },
{ label: '파괴검사', value: 'destructive' },
]},
{ key: 'standard_value', label: '기준값', fieldType: 'textbox', width: '100px' },
{ key: 'tolerance_min', label: '하한', fieldType: 'number', width: '80px' },
{ key: 'tolerance_max', label: '상한', fieldType: 'number', width: '80px' },
{ key: 'unit', label: '단위', fieldType: 'textbox', width: '60px' },
],
minRows: 1,
maxRows: 50,
},
};
export const MANUFACTURING_VENDOR_TABLE: SectionPreset = {
id: 'mfg-vendor',
name: '구매처 정보',
description: '원자재/부품 구매처 목록',
industry: '제조',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'vendor', label: '거래처', fieldType: 'reference', width: '150px', isRequired: true, properties: { source: 'vendors' } },
{ key: 'unit_price', label: '단가', fieldType: 'number', width: '100px' },
{ key: 'lead_time', label: '리드타임(일)', fieldType: 'number', width: '90px' },
{ key: 'is_primary', label: '주거래처', fieldType: 'checkbox', width: '80px' },
{ key: 'note', label: '비고', fieldType: 'textbox' },
],
maxRows: 10,
},
};
// ============================================
// 건설/공사업 프리셋
// ============================================
export const CONSTRUCTION_SPEC_FIELDS: SectionPreset = {
id: 'const-spec',
name: '시방 정보',
description: '자재 시방 및 규격 상세',
industry: '건설',
sectionType: 'fields',
fields: [
{ field_key: 'material_grade', field_name: '자재 등급', field_type: 'dropdown', options: [
{ label: 'SS400', value: 'SS400' },
{ label: 'STS304', value: 'STS304' },
{ label: 'STS316', value: 'STS316' },
{ label: 'AL6061', value: 'AL6061' },
]},
{ field_key: 'surface_treatment', field_name: '표면처리', field_type: 'dropdown', options: [
{ label: '무처리', value: 'none' },
{ label: '도금', value: 'plating' },
{ label: '도장', value: 'painting' },
{ label: '아노다이징', value: 'anodizing' },
]},
{ field_key: 'fire_rating', field_name: '내화등급', field_type: 'textbox' },
{ field_key: 'installation_site', field_name: '설치현장', field_type: 'reference', properties: { source: 'sites' } },
],
};
export const CONSTRUCTION_SCHEDULE_TABLE: SectionPreset = {
id: 'const-schedule',
name: '공정표',
description: '시공 일정 및 공정 정보',
industry: '건설',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'work_name', label: '작업명', fieldType: 'textbox', width: '150px', isRequired: true },
{ key: 'start_date', label: '시작일', fieldType: 'date', width: '120px' },
{ key: 'end_date', label: '종료일', fieldType: 'date', width: '120px' },
{ key: 'worker_count', label: '작업인원', fieldType: 'number', width: '80px' },
{ key: 'status', label: '상태', fieldType: 'dropdown', width: '100px', options: [
{ label: '대기', value: 'pending' },
{ label: '진행중', value: 'in_progress' },
{ label: '완료', value: 'completed' },
]},
],
minRows: 1,
maxRows: 100,
},
};
// ============================================
// 유통업 프리셋
// ============================================
export const DISTRIBUTION_LOGISTICS_TABLE: SectionPreset = {
id: 'dist-logistics',
name: '물류 정보',
description: '보관 및 배송 조건',
industry: '유통',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'warehouse', label: '창고', fieldType: 'reference', width: '130px', isRequired: true, properties: { source: 'warehouses' } },
{ key: 'location', label: '로케이션', fieldType: 'textbox', width: '100px' },
{ key: 'stock_qty', label: '재고수량', fieldType: 'number', width: '90px' },
{ key: 'min_stock', label: '안전재고', fieldType: 'number', width: '90px' },
{ key: 'max_stock', label: '최대재고', fieldType: 'number', width: '90px' },
],
maxRows: 20,
summaryRow: [
{ columnKey: 'stock_qty', type: 'sum' },
],
},
};
export const DISTRIBUTION_PRODUCT_FIELDS: SectionPreset = {
id: 'dist-product',
name: '상품 정보',
description: '유통 상품 속성',
industry: '유통',
sectionType: 'fields',
fields: [
{ field_key: 'barcode', field_name: '바코드', field_type: 'textbox', is_required: true },
{ field_key: 'shelf_life', field_name: '유통기한(일)', field_type: 'number' },
{ field_key: 'storage_temp', field_name: '보관온도', field_type: 'dropdown', options: [
{ label: '상온', value: 'room' },
{ label: '냉장 (0~10\u00B0C)', value: 'cold' },
{ label: '냉동 (-18\u00B0C 이하)', value: 'frozen' },
]},
{ field_key: 'origin_country', field_name: '원산지', field_type: 'textbox' },
],
};
// ============================================
// 물류업 프리셋
// ============================================
export const LOGISTICS_VEHICLE_TABLE: SectionPreset = {
id: 'log-vehicle',
name: '배차 정보',
description: '운송 차량 배정 목록',
industry: '물류',
sectionType: 'table',
tableConfig: {
columns: [
{ key: 'vehicle', label: '차량', fieldType: 'reference', width: '130px', isRequired: true, properties: { source: 'vehicles' } },
{ key: 'driver', label: '기사', fieldType: 'reference', width: '100px', properties: { source: 'employees' } },
{ key: 'load_weight', label: '적재중량(kg)', fieldType: 'number', width: '100px' },
{ key: 'delivery_date', label: '배송일', fieldType: 'date', width: '120px' },
{ key: 'status', label: '상태', fieldType: 'dropdown', width: '90px', options: [
{ label: '배정', value: 'assigned' },
{ label: '운행중', value: 'in_transit' },
{ label: '완료', value: 'delivered' },
]},
],
maxRows: 50,
summaryRow: [
{ columnKey: 'load_weight', type: 'sum' },
],
},
};
// ============================================
// 전체 프리셋 레지스트리
// ============================================
export const SECTION_PRESETS: Record<string, SectionPreset> = {
// 제조
'mfg-process': MANUFACTURING_PROCESS_TABLE,
'mfg-qc': MANUFACTURING_QC_TABLE,
'mfg-vendor': MANUFACTURING_VENDOR_TABLE,
// 건설
'const-spec': CONSTRUCTION_SPEC_FIELDS,
'const-schedule': CONSTRUCTION_SCHEDULE_TABLE,
// 유통
'dist-logistics': DISTRIBUTION_LOGISTICS_TABLE,
'dist-product': DISTRIBUTION_PRODUCT_FIELDS,
// 물류
'log-vehicle': LOGISTICS_VEHICLE_TABLE,
};
/**
* 산업별 프리셋 필터
*/
export function getPresetsByIndustry(industry: string): SectionPreset[] {
return Object.values(SECTION_PRESETS).filter(p => p.industry === industry);
}
/**
* 사용 가능한 산업 목록
*/
export function getAvailableIndustries(): string[] {
const industries = new Set(Object.values(SECTION_PRESETS).map(p => p.industry));
return Array.from(industries);
}

View File

@@ -0,0 +1,199 @@
/**
* DynamicTableSection
* config 기반 범용 테이블 섹션 (공정, 품질검사, 구매처, 공정표, 배차 등)
*
* API 연동 시: GET/PUT /v1/items/{itemId}/section-data/{sectionId}
* API 연동 전: rows를 폼 상태(formData)에 로컬 관리
*/
'use client';
import { useCallback } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { TableCellRenderer } from './TableCellRenderer';
import type { ItemSectionResponse } from '@/types/item-master-api';
import type { TableConfig } from '../types';
export interface DynamicTableSectionProps {
section: {
section: ItemSectionResponse;
orderNo: number;
};
tableConfig: TableConfig;
rows: Record<string, unknown>[];
onRowsChange: (rows: Record<string, unknown>[]) => void;
disabled?: boolean;
}
export default function DynamicTableSection({
section,
tableConfig,
rows,
onRowsChange,
disabled,
}: DynamicTableSectionProps) {
const { columns, minRows, maxRows, summaryRow } = tableConfig;
// 행 추가
const handleAddRow = useCallback(() => {
if (maxRows && rows.length >= maxRows) return;
const newRow: Record<string, unknown> = {};
columns.forEach(col => {
newRow[col.key] = null;
});
onRowsChange([...rows, newRow]);
}, [rows, columns, maxRows, onRowsChange]);
// 행 삭제
const handleRemoveRow = useCallback((index: number) => {
if (minRows && rows.length <= minRows) return;
onRowsChange(rows.filter((_, i) => i !== index));
}, [rows, minRows, onRowsChange]);
// 셀 값 변경
const handleCellChange = useCallback((rowIndex: number, columnKey: string, value: unknown) => {
const updated = rows.map((row, i) =>
i === rowIndex ? { ...row, [columnKey]: value } : row
);
onRowsChange(updated);
}, [rows, onRowsChange]);
// 요약행 계산
const computeSummary = (columnKey: string, type: string): string | number => {
const values = rows.map(r => Number(r[columnKey]) || 0);
switch (type) {
case 'sum': return values.reduce((a, b) => a + b, 0);
case 'avg': return values.length > 0
? Math.round(values.reduce((a, b) => a + b, 0) / values.length * 100) / 100
: 0;
case 'count': return rows.length;
default: return '';
}
};
const canAdd = !maxRows || rows.length < maxRows;
const canRemove = !minRows || rows.length > minRows;
return (
<Card>
<CardHeader className="pb-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base font-medium">
{section.section.title}
</CardTitle>
{section.section.description && (
<p className="text-xs text-muted-foreground mt-1">
{section.section.description}
</p>
)}
</div>
{!disabled && canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
. &quot; &quot; .
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">#</TableHead>
{columns.map(col => (
<TableHead
key={col.key}
style={col.width ? { width: col.width } : undefined}
>
{col.label}
{col.isRequired && <span className="text-red-500"> *</span>}
</TableHead>
))}
{!disabled && <TableHead className="w-12" />}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell className="text-center text-muted-foreground text-sm">
{rowIndex + 1}
</TableCell>
{columns.map(col => (
<TableCell key={col.key} className="p-1">
<TableCellRenderer
column={col}
value={row[col.key] as any}
onChange={(val) => handleCellChange(rowIndex, col.key, val)}
disabled={disabled}
rowIndex={rowIndex}
/>
</TableCell>
))}
{!disabled && (
<TableCell className="p-1 text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={!canRemove}
onClick={() => handleRemoveRow(rowIndex)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</TableCell>
)}
</TableRow>
))}
{/* 요약행 */}
{summaryRow && summaryRow.length > 0 && (
<TableRow className="bg-muted/50 font-medium">
<TableCell className="text-center text-sm"></TableCell>
{columns.map(col => {
const summary = summaryRow.find(s => s.columnKey === col.key);
return (
<TableCell key={col.key} className="p-2 text-sm">
{summary
? summary.type === 'label'
? summary.label || ''
: computeSummary(col.key, summary.type)
: ''}
</TableCell>
);
})}
{!disabled && <TableCell />}
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
/**
* TableCellRenderer
* 테이블 셀 = DynamicFieldRenderer를 테이블 셀용 축소 모드로 래핑
*
* column config → ItemFieldResponse 호환 객체로 변환 → DynamicFieldRenderer 호출
*/
'use client';
import { DynamicFieldRenderer } from '../fields/DynamicFieldRenderer';
import type { DynamicFieldValue } from '../types';
import type { TableColumnConfig } from '../types';
import type { ItemFieldResponse } from '@/types/item-master-api';
interface TableCellRendererProps {
column: TableColumnConfig;
value: DynamicFieldValue;
onChange: (value: DynamicFieldValue) => void;
disabled?: boolean;
rowIndex: number;
}
/**
* column config를 ItemFieldResponse 호환 객체로 변환
*/
function columnToFieldResponse(column: TableColumnConfig, rowIndex: number): ItemFieldResponse {
return {
id: 0,
tenant_id: 0,
group_id: null,
section_id: null,
field_name: column.label,
field_key: `table_${column.key}_${rowIndex}`,
field_type: (column.fieldType || 'textbox') as ItemFieldResponse['field_type'],
order_no: 0,
is_required: column.isRequired ?? false,
placeholder: null,
default_value: null,
display_condition: null,
validation_rules: null,
options: column.options || null,
properties: (column.properties as Record<string, any>) || null,
category: null,
description: null,
is_common: false,
is_locked: false,
locked_by: null,
locked_at: null,
created_by: null,
updated_by: null,
created_at: '',
updated_at: '',
};
}
export function TableCellRenderer({
column,
value,
onChange,
disabled,
rowIndex,
}: TableCellRendererProps) {
const fieldLike = columnToFieldResponse(column, rowIndex);
// 읽기 전용 컬럼
if (column.isReadonly) {
return (
<span className="text-sm px-2 py-1 block truncate">
{value !== null && value !== undefined ? String(value) : '-'}
</span>
);
}
return (
<div className="[&_label]:hidden [&_p]:hidden">
<DynamicFieldRenderer
field={fieldLike}
value={value}
onChange={onChange}
disabled={disabled}
/>
</div>
);
}

View File

@@ -1,2 +1,5 @@
export { default as DynamicBOMSection } from './DynamicBOMSection';
export type { DynamicBOMSectionProps } from './DynamicBOMSection';
export type { DynamicBOMSectionProps } from './DynamicBOMSection';
export { default as DynamicTableSection } from './DynamicTableSection';
export type { DynamicTableSectionProps } from './DynamicTableSection';
export { TableCellRenderer } from './TableCellRenderer';

View File

@@ -20,12 +20,33 @@ export type { ItemType } from '@/types/item';
// 조건부 표시 타입
// ============================================
/**
* 조건부 표시 연산자
* - equals: 기본값 (하위호환), 정확히 일치
* - not_equals: 불일치
* - in: expectedValue를 콤마 구분 목록으로 해석, 그 중 하나에 일치
* - not_in: 콤마 구분 목록 중 어느 것에도 불일치
* - greater_than / less_than / gte / lte: 숫자 비교
* - contains: 부분 문자열 포함
*/
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'in'
| 'not_in'
| 'greater_than'
| 'less_than'
| 'gte'
| 'lte'
| 'contains';
/**
* 조건부 표시 설정 - 개별 조건
*/
export interface FieldConditionConfig {
fieldKey: string; // 조건을 가진 필드의 키
expectedValue: string; // 이 값일 때
operator?: ConditionOperator; // 기본: "equals" (하위호환)
expectedValue: string; // 이 값일 때 (in/not_in은 콤마 구분)
targetFieldIds?: string[]; // 이 필드들 표시
targetSectionIds?: string[]; // 이 섹션들 표시
}
@@ -172,6 +193,8 @@ export interface DynamicFieldRendererProps {
error?: string;
disabled?: boolean;
unitOptions?: SimpleUnitOption[];
/** ComputedField에서 다른 필드 값 참조용 */
formData?: DynamicFormData;
}
@@ -207,6 +230,122 @@ export interface UseDynamicFormStateResult {
resetForm: (initialData?: DynamicFormData) => void;
}
// ============================================
// 확장 필드 config 타입 (Phase 1~3)
// ============================================
/**
* Reference 필드 properties config
*/
export interface ReferenceConfig {
source: string; // "vendors" | "items" | "customers" 등
displayField?: string; // 기본 "name"
valueField?: string; // 기본 "id"
searchFields?: string[]; // 기본 ["name"]
searchApiUrl?: string; // source="custom"일 때 필수
columns?: Array<{ key: string; label: string; width?: string }>;
displayFormat?: string; // "{code} - {name}"
returnFields?: string[]; // ["id", "code", "name"]
}
/**
* MultiSelect 필드 properties config
*/
export interface MultiSelectConfig {
maxSelections?: number; // 최대 선택 수 (기본: 무제한)
allowCustom?: boolean; // 직접 입력 허용 (기본: false)
layout?: 'chips' | 'list'; // 기본: "chips"
}
/**
* File 필드 properties config
*/
export interface FileConfig {
accept?: string; // ".pdf,.doc" (기본: "*")
maxSize?: number; // bytes (기본: 10MB = 10485760)
maxFiles?: number; // 기본: 1
preview?: boolean; // 이미지 미리보기 (기본: true)
category?: string; // 파일 카테고리 태그
}
/**
* Currency 필드 properties config
*/
export interface CurrencyConfig {
currency?: string; // 기본: "KRW"
precision?: number; // 기본: 0
showSymbol?: boolean; // 기본: true
allowNegative?: boolean; // 기본: false
}
/**
* UnitValue 필드 properties config
*/
export interface UnitValueConfig {
units: Array<{ label: string; value: string }>;
defaultUnit?: string;
precision?: number;
}
/**
* Toggle 필드 properties config
*/
export interface ToggleConfig {
onLabel?: string; // 기본: "ON"
offLabel?: string; // 기본: "OFF"
onValue?: string; // 기본: "true"
offValue?: string; // 기본: "false"
}
/**
* Computed 필드 properties config
*/
export interface ComputedConfig {
formula: string; // "price * quantity"
dependsOn: string[]; // ["price", "quantity"]
format?: string; // "currency" | "number" | "percent"
precision?: number;
}
/**
* Reference 소스 프리셋 엔트리
*/
export interface ReferenceSourcePreset {
apiUrl: string;
displayField: string;
valueField: string;
searchFields: string[];
columns: Array<{ key: string; label: string; width?: string }>;
}
/**
* 테이블 컬럼 config
*/
export interface TableColumnConfig {
key: string;
label: string;
width?: string;
fieldType: string; // ItemFieldType
options?: Array<{ label: string; value: string }>;
properties?: Record<string, unknown>;
isRequired?: boolean;
isReadonly?: boolean;
}
/**
* 테이블 섹션 config (properties에서 읽음)
*/
export interface TableConfig {
columns: TableColumnConfig[];
minRows?: number;
maxRows?: number;
summaryRow?: Array<{
columnKey: string;
type: 'sum' | 'avg' | 'count' | 'label';
label?: string;
}>;
}
// ============================================
// 유틸리티 타입
// ============================================

View File

@@ -8,10 +8,11 @@
*/
import type { ItemField, FieldDisplayCondition } from '@/contexts/ItemMasterContext';
import type { ItemFieldType } from '@/types/item-master-api';
// ===== Types =====
export type FieldType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
export type FieldType = ItemFieldType;
export interface FieldFormData {
name: string;

View File

@@ -8,11 +8,12 @@
*/
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import type { ItemFieldType } from '@/types/item-master-api';
import { fieldService, type SingleFieldValidation } from './fieldService';
// ===== Types =====
export type MasterFieldType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
export type MasterFieldType = ItemFieldType;
export type AttributeType = 'custom' | 'unit' | 'material' | 'surface';
export interface MasterFieldFormData {

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 ? (

View File

@@ -671,30 +671,12 @@ export function UniversalListPage<T>({
toast.success(`${selectedData.length}건 다운로드 완료`);
}, [config.excelDownload, effectiveSelectedItems, rawData, getItemId]);
// 엑셀 다운로드 버튼 렌더링
// 엑셀 전체 다운로드 버튼 (헤더 영역)
const renderExcelDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
return null;
}
const { enableSelectedDownload = true } = config.excelDownload;
// 선택 항목이 있고 선택 다운로드가 활성화된 경우
if (enableSelectedDownload && effectiveSelectedItems.size > 0) {
return (
<Button
variant="outline"
size="sm"
onClick={handleSelectedExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
({effectiveSelectedItems.size})
</Button>
);
}
// 전체 다운로드
return (
<Button
variant="outline"
@@ -711,7 +693,30 @@ export function UniversalListPage<T>({
{isExcelDownloading ? '다운로드 중...' : '엑셀 다운로드'}
</Button>
);
}, [config.excelDownload, effectiveSelectedItems.size, isExcelDownloading, handleExcelDownload, handleSelectedExcelDownload]);
}, [config.excelDownload, canExport, isExcelDownloading, handleExcelDownload]);
// 엑셀 선택 다운로드 버튼 (selectionActions 영역 - "전체 N건 / N개 항목 선택됨" 뒤)
const renderExcelSelectedDownloadButton = useMemo(() => {
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
return null;
}
const { enableSelectedDownload = true } = config.excelDownload;
if (!enableSelectedDownload || effectiveSelectedItems.size === 0) {
return null;
}
return (
<Button
variant="outline"
size="sm"
onClick={handleSelectedExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
({effectiveSelectedItems.size})
</Button>
);
}, [config.excelDownload, canExport, effectiveSelectedItems.size, handleSelectedExcelDownload]);
// ===== 정렬 핸들러 =====
const handleSort = useCallback((key: string) => {
@@ -952,11 +957,16 @@ export function UniversalListPage<T>({
onToggleSelectAll={toggleSelectAll}
getItemId={effectiveGetItemId}
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
selectionActions={config.selectionActions?.({
selectedItems: effectiveSelectedItems,
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
onRefresh: fetchData,
})}
selectionActions={
<>
{renderExcelSelectedDownloadButton}
{config.selectionActions?.({
selectedItems: effectiveSelectedItems,
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
onRefresh: fetchData,
})}
</>
}
// 표시 옵션
showCheckbox={config.showCheckbox}
showRowNumber={config.showRowNumber}

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

View File

@@ -16,7 +16,8 @@ export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
export type SectionType = 'BASIC' | 'BOM' | 'CUSTOM';
/** 필드 타입 */
export type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
import type { ItemFieldType } from '@/types/item-master-api';
export type FieldType = ItemFieldType;
/** 부품 유형 */
export type PartType = 'ASSEMBLY' | 'BENDING' | 'PURCHASED';

View File

@@ -200,6 +200,27 @@ export interface LinkSectionRequest {
// 필드 관리
// ============================================
/**
* 필드 타입 정의
* 기본 6종 + 확장 8종 = 14종
* 2026-02-11: 동적 필드 타입 확장 (Phase 1~3)
*/
export type ItemFieldType =
// 기본 타입 (기존)
| 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'
// Phase 1: 핵심 타입
| 'reference' | 'multi-select' | 'file'
// Phase 2: 편의 타입
| 'currency' | 'unit-value' | 'radio'
// Phase 3: 고급 타입
| 'toggle' | 'computed';
/**
* 섹션 타입 정의
* 2026-02-11: 'table' 타입 추가 (범용 테이블 섹션)
*/
export type ItemSectionType = 'fields' | 'bom' | 'table';
/**
* 필드 생성/수정 요청
* POST /v1/item-master/sections/{sectionId}/fields
@@ -208,7 +229,7 @@ export interface LinkSectionRequest {
export interface ItemFieldRequest {
field_name: string;
field_key?: string | null; // 2025-11-28: 필드 키 (영문, 숫자, 언더스코어만 허용, 영문으로 시작)
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
field_type: ItemFieldType;
section_id?: number | null;
master_field_id?: number | null;
order_no?: number;
@@ -235,7 +256,7 @@ export interface ItemFieldResponse {
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
field_name: string;
field_key: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력})
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
field_type: ItemFieldType;
order_no: number;
is_required: boolean;
placeholder: string | null;
@@ -280,7 +301,7 @@ export interface IndependentFieldRequest {
group_id?: number;
field_name: string;
field_key?: string; // 2025-11-28: 필드 키 (영문, 숫자, 언더스코어만 허용, 영문으로 시작)
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
field_type: ItemFieldType;
is_required?: boolean;
default_value?: string;
placeholder?: string;
@@ -524,7 +545,7 @@ export interface SectionTemplateResponse {
*/
export interface MasterFieldRequest {
field_name: string;
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
field_type: ItemFieldType;
category?: string;
description?: string;
is_common?: boolean;
@@ -544,7 +565,7 @@ export interface MasterFieldResponse {
tenant_id: number;
field_name: string;
field_key: string | null; // 2025-11-28: 필드 키 추가 (형식: {ID}_{사용자입력})
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
field_type: ItemFieldType;
category: string | null;
description: string | null;
is_common: boolean;

View File

@@ -3,6 +3,8 @@
* ItemMasterContext에서 분리됨 (2026-01-06)
*/
import type { ItemFieldType } from './item-master-api';
// ===== 기본 타입 =====
// 전개도 상세 정보
@@ -244,7 +246,7 @@ export interface ItemMasterField {
tenant_id: number;
field_name: string;
field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력})
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일
field_type: ItemFieldType; // API와 동일
category: string | null;
description: string | null;
is_common: boolean; // 공통 필드 여부
@@ -280,7 +282,7 @@ export interface ItemField {
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
field_name: string; // 항목명 (name → field_name)
field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성)
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입
field_type: ItemFieldType; // 필드 타입
order_no: number; // 항목 순서 (order → order_no, required)
is_required: boolean; // 필수 여부
placeholder?: string | null; // 플레이스홀더