diff --git a/claudedocs/[PLAN] ceo-dashboard-refactoring.md b/claudedocs/[PLAN] ceo-dashboard-refactoring.md new file mode 100644 index 00000000..77f688c4 --- /dev/null +++ b/claudedocs/[PLAN] ceo-dashboard-refactoring.md @@ -0,0 +1,331 @@ +# CEO 대시보드 리팩토링 계획 + +> 작성일: 2026-01-10 +> 대상 파일: `src/components/business/CEODashboard/` +> 목표: 파일 분리 + 모바일(344px) 대응 + +--- + +## 1. 현재 상태 분석 + +### 1.1 파일 구조 + +``` +CEODashboard/ +├── CEODashboard.tsx # 1,648줄 ⚠️ 분리 필요 +├── components.tsx # 312줄 ✅ 적정 +├── types.ts # ~100줄 ✅ 적정 +├── sections/ +│ ├── index.ts +│ ├── TodayIssueSection.tsx # 73줄 ✅ +│ ├── DailyReportSection.tsx # 37줄 ✅ +│ ├── MonthlyExpenseSection.tsx # 38줄 ✅ +│ ├── CardManagementSection.tsx # ~50줄 ✅ +│ ├── EntertainmentSection.tsx # ~50줄 ✅ +│ ├── WelfareSection.tsx # ~50줄 ✅ +│ ├── ReceivableSection.tsx # ~50줄 ✅ +│ ├── DebtCollectionSection.tsx # ~50줄 ✅ +│ ├── VatSection.tsx # ~50줄 ✅ +│ └── CalendarSection.tsx # ~100줄 ✅ +├── modals/ +│ ├── ScheduleDetailModal.tsx # ~200줄 ✅ +│ └── DetailModal.tsx # ~300줄 ✅ +└── dialogs/ + └── DashboardSettingsDialog.tsx # ~200줄 ✅ +``` + +### 1.2 CEODashboard.tsx 내부 분석 (1,648줄) + +| 줄 범위 | 내용 | 줄 수 | 분리 대상 | +|---------|------|-------|----------| +| 1-26 | imports | 26 | - | +| 27-370 | mockData 객체 | **344** | ✅ 분리 | +| 371-748 | handleMonthlyExpenseCardClick (모달 config) | **378** | ✅ 분리 | +| 749-1019 | handleCardManagementCardClick (모달 config) | **271** | ✅ 분리 | +| 1020-1247 | handleEntertainmentCardClick (모달 config) | **228** | ✅ 분리 | +| 1248-1375 | handleWelfareCardClick (모달 config) | **128** | ✅ 분리 | +| 1376-1465 | handleVatClick (모달 config) | **90** | ✅ 분리 | +| 1466-1509 | 캘린더 관련 핸들러 | 44 | - | +| 1510-1648 | 컴포넌트 렌더링 | 139 | - | + +**분리 대상 총합**: ~1,439줄 (87%) +**분리 후 예상**: ~210줄 + +--- + +## 2. 분리 계획 + +### 2.1 목표 구조 + +``` +CEODashboard/ +├── CEODashboard.tsx # ~250줄 (컴포넌트 + 핸들러) +├── components.tsx # 312줄 (유지) +├── types.ts # ~100줄 (유지) +├── mockData.ts # 🆕 ~350줄 (목데이터) +├── modalConfigs/ # 🆕 모달 설정 분리 +│ ├── index.ts +│ ├── monthlyExpenseConfigs.ts # ~380줄 +│ ├── cardManagementConfigs.ts # ~280줄 +│ ├── entertainmentConfigs.ts # ~230줄 +│ ├── welfareConfigs.ts # ~130줄 +│ └── vatConfigs.ts # ~100줄 +├── sections/ # (유지) +├── modals/ # (유지) +└── dialogs/ # (유지) +``` + +### 2.2 분리 파일 상세 + +#### A. mockData.ts (신규) + +```typescript +// mockData.ts +import type { CEODashboardData } from './types'; + +export const mockData: CEODashboardData = { + todayIssue: [...], + dailyReport: {...}, + monthlyExpense: {...}, + cardManagement: {...}, + entertainment: {...}, + welfare: {...}, + receivable: {...}, + debtCollection: {...}, + vat: {...}, + calendarSchedules: [...], +}; +``` + +#### B. modalConfigs/index.ts (신규) + +```typescript +// modalConfigs/index.ts +export { getMonthlyExpenseModalConfig } from './monthlyExpenseConfigs'; +export { getCardManagementModalConfig } from './cardManagementConfigs'; +export { getEntertainmentModalConfig } from './entertainmentConfigs'; +export { getWelfareModalConfig } from './welfareConfigs'; +export { getVatModalConfig } from './vatConfigs'; +``` + +#### C. 개별 모달 config 파일 예시 + +```typescript +// modalConfigs/monthlyExpenseConfigs.ts +import type { DetailModalConfig } from '../types'; + +export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null { + const configs: Record = { + me1: { title: '당월 매입 상세', ... }, + me2: { title: '당월 카드 상세', ... }, + me3: { title: '당월 발행어음 상세', ... }, + me4: { title: '당월 지출 예상 상세', ... }, + }; + return configs[cardId] || null; +} +``` + +#### D. CEODashboard.tsx (리팩토링 후) + +```typescript +// CEODashboard.tsx (리팩토링 후 ~250줄) +import { mockData } from './mockData'; +import { + getMonthlyExpenseModalConfig, + getCardManagementModalConfig, + getEntertainmentModalConfig, + getWelfareModalConfig, + getVatModalConfig, +} from './modalConfigs'; + +export function CEODashboard() { + // 상태 관리 + const [data] = useState(mockData); + const [detailModalConfig, setDetailModalConfig] = useState(null); + // ... + + // 간소화된 핸들러 + const handleMonthlyExpenseCardClick = useCallback((cardId: string) => { + const config = getMonthlyExpenseModalConfig(cardId); + if (config) { + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } + }, []); + + // 렌더링 + return (...); +} +``` + +--- + +## 3. 모바일 대응 계획 + +### 3.1 적용 대상 컴포넌트 + +| 컴포넌트 | 현재 상태 | 변경 필요 | +|----------|----------|----------| +| TodayIssueSection | `grid-cols-2 md:grid-cols-4` | ✅ `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` | +| DailyReportSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| MonthlyExpenseSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| CardManagementSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| EntertainmentSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| WelfareSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| ReceivableSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| DebtCollectionSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| VatSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 | +| AmountCardItem (공통) | 고정 텍스트 크기 | ✅ 반응형 텍스트 | +| IssueCardItem (공통) | 고정 텍스트 크기 | ✅ 반응형 텍스트 | +| PageHeader | 가로 배치 | ✅ 세로/가로 반응형 | + +### 3.2 components.tsx 변경 사항 + +#### AmountCardItem + +```tsx +// Before +

+ {formatCardAmount(card.amount)} +

+ +// After +

+ {formatCardAmount(card.amount)} +

+

+ {card.label} +

+``` + +#### IssueCardItem + +```tsx +// Before +

+ {typeof count === 'number' ? `${count}건` : count} +

+ +// After +

+ {typeof count === 'number' ? `${count}건` : count} +

+``` + +### 3.3 섹션 공통 변경 + +```tsx +// Before (모든 섹션) +
+ +// After +
+``` + +### 3.4 CardContent 패딩 + +```tsx +// Before + + +// After + +``` + +--- + +## 4. 실행 계획 + +### Phase 1: 파일 분리 (예상 30분) + +- [ ] **1.1** `mockData.ts` 생성 및 데이터 이동 +- [ ] **1.2** `modalConfigs/` 폴더 생성 +- [ ] **1.3** `monthlyExpenseConfigs.ts` 생성 +- [ ] **1.4** `cardManagementConfigs.ts` 생성 +- [ ] **1.5** `entertainmentConfigs.ts` 생성 +- [ ] **1.6** `welfareConfigs.ts` 생성 +- [ ] **1.7** `vatConfigs.ts` 생성 +- [ ] **1.8** `modalConfigs/index.ts` 생성 +- [ ] **1.9** `CEODashboard.tsx` 리팩토링 +- [ ] **1.10** import 정리 및 동작 확인 + +### Phase 2: 모바일 대응 (예상 30분) + +- [ ] **2.1** `components.tsx` - AmountCardItem 반응형 적용 +- [ ] **2.2** `components.tsx` - IssueCardItem 반응형 적용 +- [ ] **2.3** `sections/*.tsx` - 그리드 반응형 적용 (일괄) +- [ ] **2.4** `sections/*.tsx` - CardContent 패딩 반응형 적용 +- [ ] **2.5** PageHeader 반응형 확인 +- [ ] **2.6** 344px 테스트 및 미세 조정 + +### Phase 3: 검증 (예상 15분) + +- [ ] **3.1** 빌드 확인 요청 +- [ ] **3.2** 데스크탑(1280px) 동작 확인 +- [ ] **3.3** 태블릿(768px) 동작 확인 +- [ ] **3.4** 모바일(375px) 동작 확인 +- [ ] **3.5** Galaxy Fold(344px) 동작 확인 + +--- + +## 5. 예상 결과 + +### 5.1 파일 크기 변화 + +| 파일 | Before | After | +|------|--------|-------| +| CEODashboard.tsx | 1,648줄 | ~250줄 | +| mockData.ts | - | ~350줄 | +| modalConfigs/*.ts | - | ~1,100줄 (5개 파일) | + +### 5.2 장점 + +1. **유지보수성**: 각 파일이 단일 책임 원칙 준수 +2. **재사용성**: 모달 config를 다른 곳에서 재사용 가능 +3. **확장성**: 새 모달 추가 시 별도 파일로 분리 +4. **가독성**: 핵심 로직만 CEODashboard.tsx에 유지 +5. **API 전환 용이**: mockData.ts만 교체하면 됨 + +### 5.3 모바일 개선 효과 + +| 항목 | Before (344px) | After (344px) | +|------|----------------|---------------| +| 카드 배치 | 2열 (160px/카드) | 1열 (320px/카드) | +| 금액 표시 | 잘림 가능 | 완전 표시 | +| 라벨 표시 | 잘림 가능 | 줄바꿈/truncate | +| 패딩 | 과다 (24px) | 적정 (12px) | + +--- + +## 6. 참고 문서 + +- **모바일 대응 가이드**: `claudedocs/guides/[GUIDE] mobile-responsive-patterns.md` +- **기존 테스트 계획**: `claudedocs/[PLAN] mobile-overflow-testing.md` + +--- + +## 7. 의사결정 사항 + +### Q1: mockData를 별도 파일로? +- **결정**: ✅ 분리 +- **이유**: 향후 API 연동 시 교체 용이 + +### Q2: 모달 config를 폴더로? +- **결정**: ✅ 폴더로 분리 +- **이유**: 각 config가 100줄 이상, 단일 파일은 여전히 큼 + +### Q3: 모바일에서 1열 vs 2열? +- **결정**: 344px 이하 1열, 375px 이상 2열 +- **이유**: Galaxy Fold 160px 카드는 너무 좁음 + +--- + +## 8. 시작 조건 + +- [x] 계획서 작성 완료 +- [x] 모바일 가이드 작성 완료 +- [ ] 사용자 승인 + +--- + +> **다음 단계**: 계획 승인 후 Phase 1 (파일 분리) 시작 \ No newline at end of file diff --git a/claudedocs/guides/[GUIDE] mobile-responsive-patterns.md b/claudedocs/guides/[GUIDE] mobile-responsive-patterns.md new file mode 100644 index 00000000..f2fd8f99 --- /dev/null +++ b/claudedocs/guides/[GUIDE] mobile-responsive-patterns.md @@ -0,0 +1,538 @@ +# 모바일 반응형 패턴 가이드 + +> 작성일: 2026-01-10 +> 적용 범위: SAM 프로젝트 전체 +> 주요 대상 기기: Galaxy Z Fold 5 (접힌 상태 344px) + +--- + +## 1. 브레이크포인트 정의 + +### 1.1 Tailwind 기본 브레이크포인트 + +| 접두사 | 최소 너비 | 대상 기기 | +|--------|----------|----------| +| (기본) | 0px | Galaxy Fold 접힌 (344px) | +| `xs` | 375px | iPhone SE, 소형 모바일 | +| `sm` | 640px | 대형 모바일, 소형 태블릿 | +| `md` | 768px | 태블릿 | +| `lg` | 1024px | 소형 데스크탑 | +| `xl` | 1280px | 데스크탑 | +| `2xl` | 1536px | 대형 데스크탑 | + +### 1.2 커스텀 브레이크포인트 (tailwind.config.js) + +```javascript +// tailwind.config.js +module.exports = { + theme: { + screens: { + 'xs': '375px', // iPhone SE + 'sm': '640px', + 'md': '768px', + 'lg': '1024px', + 'xl': '1280px', + '2xl': '1536px', + // Galaxy Fold 전용 (선택적) + 'fold': '344px', + }, + }, +} +``` + +### 1.3 주요 테스트 뷰포트 + +| 기기 | 너비 | 높이 | 우선순위 | +|------|------|------|----------| +| Galaxy Z Fold 5 (접힌) | **344px** | 882px | 🔴 필수 | +| iPhone SE | 375px | 667px | 🔴 필수 | +| iPhone 14 Pro | 393px | 852px | 🟡 권장 | +| iPad Mini | 768px | 1024px | 🟡 권장 | +| Desktop | 1280px+ | - | 🟢 기본 | + +--- + +## 2. 공통 패턴별 해결책 + +### 2.1 그리드 레이아웃 + +#### 문제 +344px에서 `grid-cols-2`는 각 항목이 ~160px로 좁아져 텍스트 오버플로우 발생 + +#### 해결 패턴 + +**패턴 A: 1열 → 2열 → 4열 (권장)** +```tsx +// Before +
+ +// After - 344px에서 1열 +
+``` + +**패턴 B: 최소 너비 보장** +```tsx +// 카드 최소 너비 보장 + 자동 열 조정 +
+``` + +**패턴 C: Flex Wrap (항목 수 가변적일 때)** +```tsx +
+
+ {/* 카드 내용 */} +
+
+``` + +#### 적용 기준 +| 카드 개수 | 권장 패턴 | +|-----------|----------| +| 1-2개 | `grid-cols-1 xs:grid-cols-2` | +| 3-4개 | `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` | +| 5개+ | `grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4` | + +--- + +### 2.2 테이블 반응형 + +#### 문제 +테이블이 344px 화면에서 가로 스크롤 없이 표시 불가 + +#### 해결 패턴 + +**패턴 A: 가로 스크롤 (기본)** +```tsx +
+ + {/* 테이블 내용 */} +
+
+``` + +**패턴 B: 카드형 변환 (복잡한 데이터)** +```tsx +{/* 데스크탑: 테이블 */} + + {/* 테이블 내용 */} +
+ +{/* 모바일: 카드 리스트 */} +
+ {data.map((item) => ( + + +
+ 거래처 + {item.vendor} +
+ {/* 추가 필드 */} +
+
+ ))} +
+``` + +**패턴 C: 컬럼 숨김 (우선순위 기반)** +```tsx +등록일 +수정일 +필수 컬럼 + +{item.createdAt} +{item.updatedAt} +{item.essential} +``` + +--- + +### 2.3 카드 컴포넌트 + +#### 문제 +카드 내 금액, 라벨이 좁은 화면에서 잘림 + +#### 해결 패턴 + +**패턴 A: 텍스트 크기 반응형** +```tsx +// Before +

30,500,000,000원

+ +// After +

30.5억원

+``` + +**패턴 B: 금액 포맷 함수 개선** +```typescript +// utils/format.ts +export const formatAmountResponsive = (amount: number, compact = false): string => { + if (compact || amount >= 100000000) { + // 억 단위 + const billion = amount / 100000000; + return billion >= 1 ? `${billion.toFixed(1)}억원` : formatAmount(amount); + } + if (amount >= 10000) { + // 만 단위 + const man = amount / 10000; + return `${man.toFixed(0)}만원`; + } + return new Intl.NumberFormat('ko-KR').format(amount) + '원'; +}; +``` + +**패턴 C: 라벨 줄바꿈 허용** +```tsx +// Before +

현금성 자산 합계

+ +// After +

현금성 자산 합계

+``` + +**패턴 D: Truncate + Tooltip** +```tsx +

+ {longLabel} +

+``` + +--- + +### 2.4 모달/다이얼로그 + +#### 문제 +모달이 344px 화면에서 좌우 여백 없이 꽉 차거나 넘침 + +#### 해결 패턴 + +**패턴 A: 최대 너비 반응형** +```tsx +// Before + + +// After + +``` + +**패턴 B: 전체 화면 모달 (복잡한 내용)** +```tsx + +``` + +**패턴 C: 모달 내부 스크롤** +```tsx + + + {/* 헤더 */} + +
+ {/* 스크롤 가능한 내용 */} +
+ + {/* 푸터 */} + +
+``` + +--- + +### 2.5 버튼 그룹 + +#### 문제 +여러 버튼이 가로로 나열될 때 344px에서 넘침 + +#### 해결 패턴 + +**패턴 A: Flex Wrap** +```tsx +// Before +
+ + + +
+ +// After +
+ + + +
+``` + +**패턴 B: 세로 배치 (모바일)** +```tsx +
+ + +
+``` + +**패턴 C: 아이콘 전용 (극소 화면)** +```tsx + +``` + +--- + +### 2.6 긴 텍스트 처리 + +#### 문제 +긴 제목, 설명, 메시지가 좁은 화면에서 레이아웃 깨짐 + +#### 해결 패턴 + +**패턴 A: Truncate (한 줄)** +```tsx +

+ {title} +

+``` + +**패턴 B: Line Clamp (여러 줄)** +```tsx +

+ {description} +

+``` + +**패턴 C: Break Keep (한글 단어 단위)** +```tsx +

+ 가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의 +

+``` + +**패턴 D: 반응형 텍스트 크기** +```tsx +

+ {title} +

+``` + +--- + +### 2.7 헤더/네비게이션 + +#### 문제 +페이지 헤더의 타이틀과 액션 버튼이 충돌 + +#### 해결 패턴 + +**패턴 A: 세로 배치 (모바일)** +```tsx +
+
+

{title}

+

{description}

+
+
+ +
+
+``` + +**패턴 B: 아이콘 버튼 (극소 화면)** +```tsx + +``` + +--- + +### 2.8 패딩/마진 반응형 + +#### 문제 +데스크탑용 패딩이 모바일에서 공간 낭비 + +#### 해결 패턴 + +```tsx +// Before +
+ +// After +
+ +// 카드 내부 + +``` + +--- + +## 3. Tailwind 유틸리티 클래스 모음 + +### 3.1 자주 사용하는 반응형 패턴 + +```css +/* 그리드 */ +.grid-responsive-1-2-4: grid-cols-1 xs:grid-cols-2 md:grid-cols-4 +.grid-responsive-1-2-3: grid-cols-1 xs:grid-cols-2 md:grid-cols-3 +.grid-responsive-1-3: grid-cols-1 md:grid-cols-3 + +/* 텍스트 */ +.text-responsive-sm: text-xs xs:text-sm +.text-responsive-base: text-sm xs:text-base +.text-responsive-lg: text-base xs:text-lg md:text-xl +.text-responsive-xl: text-lg xs:text-xl md:text-2xl +.text-responsive-2xl: text-xl xs:text-2xl md:text-3xl + +/* 패딩 */ +.p-responsive: p-3 xs:p-4 md:p-6 +.px-responsive: px-3 xs:px-4 md:px-6 +.py-responsive: py-3 xs:py-4 md:py-6 + +/* 갭 */ +.gap-responsive: gap-2 xs:gap-3 md:gap-4 + +/* Flex 방향 */ +.flex-col-to-row: flex-col xs:flex-row +``` + +### 3.2 커스텀 유틸리티 (globals.css) + +```css +/* globals.css */ +@layer utilities { + .grid-responsive-cards { + @apply grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4; + } + + .text-amount { + @apply text-xl xs:text-2xl md:text-3xl font-bold; + } + + .card-padding { + @apply p-3 xs:p-4 md:p-6; + } + + .section-padding { + @apply p-4 xs:p-5 md:p-6; + } +} +``` + +--- + +## 4. 적용 체크리스트 + +### 4.1 페이지 단위 체크리스트 + +```markdown +## 페이지: [페이지명] +테스트 뷰포트: 344px (Galaxy Fold) + +### 레이아웃 +- [ ] 헤더 타이틀/액션 버튼 충돌 없음 +- [ ] 그리드 카드 오버플로우 없음 +- [ ] 사이드바 접힘 상태 정상 + +### 텍스트 +- [ ] 제목 텍스트 잘림/줄바꿈 정상 +- [ ] 금액 표시 가독성 확보 +- [ ] 라벨 텍스트 truncate 또는 줄바꿈 + +### 테이블 +- [ ] 가로 스크롤 정상 동작 +- [ ] 필수 컬럼 표시 확인 +- [ ] 체크박스/액션 버튼 접근 가능 + +### 카드 +- [ ] 카드 내용 오버플로우 없음 +- [ ] 터치 영역 충분 (최소 44px) +- [ ] 카드 간 간격 적절 + +### 모달 +- [ ] 화면 내 완전히 표시 +- [ ] 닫기 버튼 접근 가능 +- [ ] 내부 스크롤 정상 + +### 버튼 +- [ ] 버튼 그룹 wrap 정상 +- [ ] 터치 영역 충분 +- [ ] 아이콘/텍스트 가독성 +``` + +### 4.2 컴포넌트 단위 체크리스트 + +```markdown +## 컴포넌트: [컴포넌트명] + +### 필수 확인 +- [ ] min-width 고정값 없음 또는 반응형 처리 +- [ ] whitespace-nowrap 사용 시 truncate 동반 +- [ ] grid-cols-N 사용 시 모바일 breakpoint 추가 +- [ ] 패딩/마진 반응형 적용 + +### 권장 확인 +- [ ] 텍스트 크기 반응형 +- [ ] 버튼 크기 반응형 +- [ ] 아이콘 크기 반응형 +``` + +--- + +## 5. 적용 사례 + +### 5.1 CEO 대시보드 적용 예정 + +**현재 문제점**: +- `grid-cols-2 md:grid-cols-4` → 344px에서 카드당 ~160px +- 금액 "3,050,000,000원" 표시 → 잘림 +- "현금성 자산 합계" 라벨 → 잘림 + +**적용 계획**: +1. 그리드: `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` +2. 금액: `formatAmountResponsive()` 함수 사용 (억 단위) +3. 라벨: `break-keep` 또는 `truncate` +4. 카드 패딩: `p-3 xs:p-4 md:p-6` +5. 헤더 버튼: 아이콘 전용 옵션 + +**상세 계획**: `[PLAN] ceo-dashboard-refactoring.md` 참조 + +--- + +## 6. 테스트 방법 + +### 6.1 Chrome DevTools 설정 + +1. DevTools 열기 (F12) +2. Device Toolbar (Ctrl+Shift+M) +3. Edit → Add custom device: + - Name: `Galaxy Z Fold 5 (Folded)` + - Width: `344` + - Height: `882` + - Device pixel ratio: `3` + - User agent: Mobile + +### 6.2 권장 테스트 순서 + +1. **344px**: 최소 지원 너비 (Galaxy Fold) +2. **375px**: iPhone SE +3. **768px**: 태블릿 +4. **1280px**: 데스크탑 + +### 6.3 자동화 테스트 (Playwright) + +```typescript +// playwright.config.ts +const devices = [ + { name: 'Galaxy Fold', viewport: { width: 344, height: 882 } }, + { name: 'iPhone SE', viewport: { width: 375, height: 667 } }, + { name: 'iPad', viewport: { width: 768, height: 1024 } }, + { name: 'Desktop', viewport: { width: 1280, height: 800 } }, +]; +``` + +--- + +## 변경 이력 + +| 날짜 | 버전 | 변경 내용 | +|------|------|----------| +| 2026-01-10 | 1.0 | 초기 작성 | \ No newline at end of file diff --git a/src/app/[locale]/(protected)/board/[boardCode]/[postId]/page.tsx b/src/app/[locale]/(protected)/board/[boardCode]/[postId]/page.tsx index caa3b20c..54817992 100644 --- a/src/app/[locale]/(protected)/board/[boardCode]/[postId]/page.tsx +++ b/src/app/[locale]/(protected)/board/[boardCode]/[postId]/page.tsx @@ -7,7 +7,7 @@ import { useParams, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { Loader2 } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { BoardDetail } from '@/components/board/BoardDetail'; import { getPost } from '@/components/board/actions'; import type { Post, Comment } from '@/components/board/types'; @@ -60,11 +60,7 @@ export default function BoardDetailPage() { }, [boardCode, postId, router]); if (isLoading) { - return ( -
- -
- ); + return ; } if (!post) { diff --git a/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx index 18a6c9bc..ad093909 100644 --- a/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx @@ -3,6 +3,7 @@ import { useRouter, useParams } from 'next/navigation'; import { useState, useEffect, useCallback } from 'react'; import { Loader2 } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { BoardForm } from '@/components/board/BoardManagement/BoardForm'; import { getBoardById, updateBoard } from '@/components/board/BoardManagement/actions'; import { forceRefreshMenus } from '@/lib/utils/menuRefresh'; @@ -64,11 +65,7 @@ export default function BoardEditPage() { // 로딩 상태 if (isLoading) { - return ( -
- -
- ); + return ; } // 에러 상태 diff --git a/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx b/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx index f8475ebd..b93de1cc 100644 --- a/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx @@ -3,6 +3,7 @@ import { useRouter, useParams } from 'next/navigation'; import { useState, useEffect, useCallback } from 'react'; import { Loader2 } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { BoardDetail } from '@/components/board/BoardManagement/BoardDetail'; import { getBoardById, deleteBoard } from '@/components/board/BoardManagement/actions'; import { Button } from '@/components/ui/button'; @@ -74,11 +75,7 @@ export default function BoardDetailPage() { // 로딩 상태 if (isLoading) { - return ( -
- -
- ); + return ; } // 에러 상태 diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx index c8ebdab4..42211179 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/[postId]/edit/page.tsx @@ -6,7 +6,8 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; -import { ArrowLeft, Save, MessageSquare, Loader2 } from 'lucide-react'; +import { ArrowLeft, Save, MessageSquare } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -146,9 +147,7 @@ export default function DynamicBoardEditPage() { if (isLoading) { return ( -
- -
+
); } diff --git a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx index 17db9e70..551edcbb 100644 --- a/src/app/[locale]/(protected)/items/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/edit/page.tsx @@ -13,7 +13,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import DynamicItemForm from '@/components/items/DynamicItemForm'; import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemForm/types'; import type { ItemType } from '@/types/item'; -import { Loader2 } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { isMaterialType, transformMaterialDataForSave, @@ -391,12 +391,7 @@ export default function EditItemPage() { // 로딩 상태 if (isLoading) { - return ( -
- -

품목 정보 로딩 중...

-
- ); + return ; } // 에러 상태 diff --git a/src/app/[locale]/(protected)/items/[id]/page.tsx b/src/app/[locale]/(protected)/items/[id]/page.tsx index c23d03e2..dca4368f 100644 --- a/src/app/[locale]/(protected)/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/items/[id]/page.tsx @@ -11,7 +11,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { notFound } from 'next/navigation'; import ItemDetailClient from '@/components/items/ItemDetailClient'; import type { ItemMaster, ItemType, ProductCategory, PartType, PartUsage } from '@/types/item'; -import { Loader2 } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; // Materials 타입 (SM, RM, CS는 Material 테이블 사용) const MATERIAL_TYPES = ['SM', 'RM', 'CS']; @@ -255,12 +255,7 @@ export default function ItemDetailPage() { // 로딩 상태 if (isLoading) { - return ( -
- -

품목 정보 로딩 중...

-
- ); + return ; } // 에러 상태 diff --git a/src/app/[locale]/(protected)/production/dashboard/page.tsx b/src/app/[locale]/(protected)/production/dashboard/page.tsx index ffd86f86..ceb02e05 100644 --- a/src/app/[locale]/(protected)/production/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/production/dashboard/page.tsx @@ -6,10 +6,11 @@ import { Suspense } from 'react'; import ProductionDashboard from '@/components/production/ProductionDashboard'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; export default function ProductionDashboardPage() { return ( - 로딩 중...
}> + }> ); diff --git a/src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx b/src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx index 6d654e8f..4183f961 100644 --- a/src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import ItemForm from '@/components/items/ItemForm'; import type { ItemMaster } from '@/types/item'; import type { CreateItemFormData } from '@/lib/utils/validation'; @@ -189,11 +190,7 @@ export default function EditItemPage() { }; if (isLoading) { - return ( -
-
로딩 중...
-
- ); + return ; } if (!item) { diff --git a/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx b/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx index a8bd08db..ce3b7591 100644 --- a/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx +++ b/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx @@ -6,6 +6,7 @@ import { use, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import ItemDetailClient from '@/components/items/ItemDetailClient'; import type { ItemMaster } from '@/types/item'; @@ -159,11 +160,7 @@ export default function ItemDetailPage({ }, [id]); if (isLoading) { - return ( -
-
로딩 중...
-
- ); + return ; } if (!item) { diff --git a/src/app/[locale]/(protected)/production/worker-screen/page.tsx b/src/app/[locale]/(protected)/production/worker-screen/page.tsx index 26ec4787..d7fce3bc 100644 --- a/src/app/[locale]/(protected)/production/worker-screen/page.tsx +++ b/src/app/[locale]/(protected)/production/worker-screen/page.tsx @@ -6,10 +6,11 @@ import { Suspense } from 'react'; import WorkerScreen from '@/components/production/WorkerScreen'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; export default function WorkerScreenPage() { return ( - 로딩 중...
}> + }> ); diff --git a/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx b/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx new file mode 100644 index 00000000..04d78aca --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface AuditProgressBarProps { + day1Progress: { completed: number; total: number }; + day2Progress: { completed: number; total: number }; + activeDay: 1 | 2; +} + +export function AuditProgressBar({ + day1Progress, + day2Progress, + activeDay, +}: AuditProgressBarProps) { + const totalCompleted = day1Progress.completed + day2Progress.completed; + const totalItems = day1Progress.total + day2Progress.total; + const overallPercentage = totalItems > 0 ? Math.round((totalCompleted / totalItems) * 100) : 0; + + const day1Percentage = day1Progress.total > 0 + ? Math.round((day1Progress.completed / day1Progress.total) * 100) + : 0; + const day2Percentage = day2Progress.total > 0 + ? Math.round((day2Progress.completed / day2Progress.total) * 100) + : 0; + + return ( +
+
+

전체 심사 진행률

+ {overallPercentage}% +
+ + {/* 전체 진행률 바 */} +
+
+
+ + {/* 1일차/2일차 상세 진행률 */} +
+ {/* 1일차 */} +
+
+ 1일차: 기준/매뉴얼 + + {day1Progress.completed}/{day1Progress.total} + +
+
+
+
+
+ + {/* 2일차 */} +
+
+ 2일차: 로트추적 + + {day2Progress.completed}/{day2Progress.total} + +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx new file mode 100644 index 00000000..55dd8cb4 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx @@ -0,0 +1,206 @@ +'use client'; + +import React from 'react'; +import { Settings, X, Eye, EyeOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Switch } from '@/components/ui/switch'; + +export interface AuditDisplaySettings { + showProgressBar: boolean; + showDocumentViewer: boolean; + showDocumentSection: boolean; + showCompletedItems: boolean; + expandAllCategories: boolean; +} + +interface AuditSettingsPanelProps { + isOpen: boolean; + onClose: () => void; + settings: AuditDisplaySettings; + onSettingsChange: (settings: AuditDisplaySettings) => void; +} + +export function AuditSettingsPanel({ + isOpen, + onClose, + settings, + onSettingsChange, +}: AuditSettingsPanelProps) { + const handleToggle = (key: keyof AuditDisplaySettings) => { + onSettingsChange({ + ...settings, + [key]: !settings[key], + }); + }; + + if (!isOpen) return null; + + return ( +
+ {/* 배경 오버레이 */} +
+ + {/* 설정 패널 */} +
+ {/* 헤더 */} +
+
+ +

화면 설정

+
+ +
+ + {/* 설정 항목 */} +
+ {/* 레이아웃 섹션 */} +
+

레이아웃

+
+ handleToggle('showProgressBar')} + /> + handleToggle('showDocumentViewer')} + /> + handleToggle('showDocumentSection')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 점검표 섹션 */} +
+

점검표 옵션

+
+ handleToggle('showCompletedItems')} + /> + handleToggle('expandAllCategories')} + /> +
+
+ + {/* 구분선 */} +
+ + {/* 빠른 설정 */} +
+

빠른 설정

+
+ + +
+
+
+ + {/* 하단 안내 */} +
+

+ 설정은 자동으로 저장됩니다 +

+
+
+
+ ); +} + +interface SettingRowProps { + label: string; + description: string; + checked: boolean; + onChange: () => void; +} + +function SettingRow({ label, description, checked, onChange }: SettingRowProps) { + return ( +
+
+
+ {checked ? ( + + ) : ( + + )} + {label} +
+

{description}

+
+ +
+ ); +} + +// 설정 버튼 컴포넌트 (헤더에 배치용) +interface SettingsButtonProps { + onClick: () => void; +} + +export function SettingsButton({ onClick }: SettingsButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx new file mode 100644 index 00000000..8d788c8a --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -0,0 +1,270 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { ChevronDown, ChevronRight, Check, Search, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ChecklistCategory, ChecklistSubItem } from '../types'; + +interface Day1ChecklistPanelProps { + categories: ChecklistCategory[]; + selectedSubItemId: string | null; + onSubItemSelect: (categoryId: string, subItemId: string) => void; + onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void; +} + +export function Day1ChecklistPanel({ + categories, + selectedSubItemId, + onSubItemSelect, + onSubItemToggle, +}: Day1ChecklistPanelProps) { + const [expandedCategories, setExpandedCategories] = useState>( + new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침 + ); + const [searchTerm, setSearchTerm] = useState(''); + + // 검색 필터링된 카테고리 + const filteredCategories = useMemo(() => { + if (!searchTerm.trim()) return categories; + + const term = searchTerm.toLowerCase(); + return categories.map(category => { + // 카테고리 제목 매칭 + const categoryMatches = category.title.toLowerCase().includes(term); + + // 하위 항목 필터링 + const filteredSubItems = category.subItems.filter(item => + item.name.toLowerCase().includes(term) + ); + + // 카테고리가 매칭되면 모든 하위 항목 포함, 아니면 필터링된 항목만 + if (categoryMatches) { + return category; + } else if (filteredSubItems.length > 0) { + return { ...category, subItems: filteredSubItems }; + } + return null; + }).filter((cat): cat is ChecklistCategory => cat !== null); + }, [categories, searchTerm]); + + // 검색 시 모든 카테고리 펼치기 + React.useEffect(() => { + if (searchTerm.trim()) { + setExpandedCategories(new Set(filteredCategories.map(c => c.id))); + } + }, [searchTerm, filteredCategories]); + + const toggleCategory = (categoryId: string) => { + setExpandedCategories(prev => { + const newSet = new Set(prev); + if (newSet.has(categoryId)) { + newSet.delete(categoryId); + } else { + newSet.add(categoryId); + } + return newSet; + }); + }; + + const getCategoryProgress = (category: ChecklistCategory) => { + // 원본 카테고리에서 진행률 계산 + const originalCategory = categories.find(c => c.id === category.id); + if (!originalCategory) return { completed: 0, total: 0 }; + const completed = originalCategory.subItems.filter(item => item.isCompleted).length; + return { completed, total: originalCategory.subItems.length }; + }; + + const clearSearch = () => { + setSearchTerm(''); + }; + + // 검색 결과 하이라이트 + const highlightText = (text: string, term: string) => { + if (!term.trim()) return text; + const regex = new RegExp(`(${term})`, 'gi'); + const parts = text.split(regex); + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); + }; + + return ( +
+ {/* 헤더 + 검색 */} +
+

점검표 항목

+ {/* 검색 입력 */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searchTerm && ( + + )} +
+ {/* 검색 결과 카운트 */} + {searchTerm && ( +
+ {filteredCategories.length > 0 + ? `${filteredCategories.reduce((sum, cat) => sum + cat.subItems.length, 0)}개 항목 검색됨` + : '검색 결과가 없습니다' + } +
+ )} +
+ + {/* 카테고리 목록 */} +
+ {filteredCategories.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( + filteredCategories.map((category, categoryIndex) => { + const isExpanded = expandedCategories.has(category.id); + const progress = getCategoryProgress(category); + const allCompleted = progress.completed === progress.total; + // 원본 인덱스 찾기 + const originalIndex = categories.findIndex(c => c.id === category.id); + + return ( +
+ {/* 카테고리 헤더 */} + + + {/* 하위 항목 */} + {isExpanded && ( +
+ {category.subItems.map((subItem, subIndex) => ( + onSubItemSelect(category.id, subItem.id)} + onToggle={(isCompleted) => onSubItemToggle(category.id, subItem.id, isCompleted)} + searchTerm={searchTerm} + highlightText={highlightText} + /> + ))} +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +interface SubItemRowProps { + subItem: ChecklistSubItem; + index: number; + categoryId: string; + isSelected: boolean; + onSelect: () => void; + onToggle: (isCompleted: boolean) => void; + searchTerm: string; + highlightText: (text: string, term: string) => React.ReactNode; +} + +function SubItemRow({ + subItem, + index, + isSelected, + onSelect, + onToggle, + searchTerm, + highlightText, +}: SubItemRowProps) { + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggle(!subItem.isCompleted); + }; + + return ( +
+ {/* 체크박스 */} + + + {/* 항목 이름 */} + + {index + 1}. {highlightText(subItem.name, searchTerm)} + + + {/* 완료 표시 */} + {subItem.isCompleted && ( + 완료 + )} +
+ ); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx new file mode 100644 index 00000000..e35f9c15 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx @@ -0,0 +1,157 @@ +'use client'; + +import React from 'react'; +import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import type { Day1CheckItem, StandardDocument } from '../types'; + +interface Day1DocumentSectionProps { + checkItem: Day1CheckItem | null; + selectedDocumentId: string | null; + onDocumentSelect: (documentId: string) => void; + onConfirmComplete: () => void; + isCompleted: boolean; +} + +export function Day1DocumentSection({ + checkItem, + selectedDocumentId, + onDocumentSelect, + onConfirmComplete, + isCompleted, +}: Day1DocumentSectionProps) { + if (!checkItem) { + return ( +
+
+ +

점검표 항목을 선택하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

기준 문서화

+
+ + {/* 콘텐츠 */} +
+ {/* 항목 정보 */} +
+

{checkItem.title}

+

{checkItem.description}

+
+ + {/* 기준 문서 목록 */} +
+
관련 기준 문서
+
+ {checkItem.standardDocuments.map((doc) => ( + onDocumentSelect(doc.id)} + /> + ))} +
+
+ + {/* 확인 버튼 */} +
+ +
+
+
+ ); +} + +interface DocumentRowProps { + document: StandardDocument; + isSelected: boolean; + onSelect: () => void; +} + +function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) { + return ( +
+ {/* 아이콘 */} +
+ +
+ + {/* 문서 정보 */} +
+

{document.title}

+

+ {document.version !== '-' && {document.version}} + {document.date} +

+
+ + {/* 액션 버튼 */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx new file mode 100644 index 00000000..008d2719 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx @@ -0,0 +1,186 @@ +'use client'; + +import React from 'react'; +import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { StandardDocument } from '../types'; + +interface Day1DocumentViewerProps { + document: StandardDocument | null; +} + +export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) { + if (!document) { + return ( +
+
+ +

문서를 선택하면 미리보기가 표시됩니다

+
+
+ ); + } + + const isPdf = document.fileName?.endsWith('.pdf'); + const isExcel = document.fileName?.endsWith('.xlsx') || document.fileName?.endsWith('.xls'); + + return ( +
+ {/* 헤더 */} +
+
+
+ +
+
+

{document.title}

+

+ {document.version !== '-' && {document.version}} + {document.date} +

+
+
+ + {/* 툴바 */} +
+ + + +
+ + +
+
+ + {/* 문서 미리보기 영역 */} +
+
+ {/* Mock 문서 내용 */} + +
+
+ + {/* 푸터 */} +
+ + 파일명: {document.fileName || '-'} + + + 1 / 1 페이지 + +
+
+ ); +} + +// Mock 문서 미리보기 내용 +function DocumentPreviewContent({ document }: { document: StandardDocument }) { + return ( +
+ {/* 문서 헤더 */} +
+

{document.title}

+
+ 문서번호: QM-{document.id} + 개정: {document.version} + 시행일: {document.date} +
+
+ + {/* Mock 문서 내용 */} +
+
+

1. 목적

+

+ 본 문서는 {document.title}의 업무 절차 및 기준을 규정함으로써 + 품질관리 업무의 효율성과 일관성을 확보하는 것을 목적으로 한다. +

+
+ +
+

2. 적용범위

+

+ 본 기준서는 당사에서 생산하는 모든 제품의 품질관리 업무에 적용한다. +

+
+ +
+

3. 용어의 정의

+
+

3.1 검사: 품질특성을 측정, 시험하여 규정된 기준과 비교하는 활동

+

3.2 적합: 규정된 요구사항이 충족된 상태

+

3.3 부적합: 규정된 요구사항이 충족되지 않은 상태

+
+
+ +
+

4. 업무 절차

+
+

4.1 담당자는 본 기준서에 따라 업무를 수행한다.

+

4.2 검사 결과는 해당 기록 양식에 기록하고 보관한다.

+

4.3 부적합 발생 시 부적합품 관리 절차에 따라 처리한다.

+
+
+ + {/* 문서 서명란 */} +
+
+
+
작성
+
+ (서명) +
+
+
+
검토
+
+ (서명) +
+
+
+
승인
+
+ (서명) +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx new file mode 100644 index 00000000..212aa54b --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx @@ -0,0 +1,153 @@ +'use client'; + +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DayTabsProps { + activeDay: 1 | 2; + onDayChange: (day: 1 | 2) => void; + day1Progress: { completed: number; total: number }; + day2Progress: { completed: number; total: number }; +} + +export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }: DayTabsProps) { + // 전체 진행률 계산 + const totalCompleted = day1Progress.completed + day2Progress.completed; + const totalItems = day1Progress.total + day2Progress.total; + const overallPercentage = totalItems > 0 ? Math.round((totalCompleted / totalItems) * 100) : 0; + + const day1Percentage = day1Progress.total > 0 + ? Math.round((day1Progress.completed / day1Progress.total) * 100) + : 0; + const day2Percentage = day2Progress.total > 0 + ? Math.round((day2Progress.completed / day2Progress.total) * 100) + : 0; + + return ( +
+ {/* 탭 버튼 */} +
+ {/* 1일차 탭 */} + + + {/* 2일차 탭 */} + +
+ + {/* 진행률 - 3줄 표시 */} +
+ {/* 전체 심사 진행률 */} +
+ + 전체 심사 + 전체 + +
+
+
+ + {totalCompleted}/{totalItems} + +
+ + {/* 1일차 진행률 */} +
+ + 1일차: 기준/매뉴얼 + 1일차 + +
+
+
+ + {day1Progress.completed}/{day1Progress.total} + +
+ + {/* 2일차 진행률 */} +
+ + 2일차: 로트추적 + 2일차 + +
+
+
+ + {day2Progress.completed}/{day2Progress.total} + +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx index a8b3d031..604219fc 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx @@ -51,15 +51,15 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL }; return ( -
-

+
+

관련 서류{' '} {routeCode && ( ({routeCode}) )}

-
+
{!routeCode ? (
수주루트를 선택해주세요. @@ -74,7 +74,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
handleDocClick(doc)} - className={`p-4 flex justify-between items-center transition-colors ${ + className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${ hasItems ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60' } ${isExpanded ? 'bg-green-50' : 'bg-white'}`} > @@ -99,13 +99,13 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
{isExpanded && hasMultipleItems && ( -
-
+
+
{doc.items!.map((item) => (
handleItemClick(doc, item)} - className="flex items-center justify-between border border-gray-100 p-3 rounded cursor-pointer hover:bg-green-50 hover:border-green-200 transition-colors group" + className="flex items-center justify-between border border-gray-100 p-2 sm:p-3 rounded cursor-pointer hover:bg-green-50 hover:border-green-200 transition-colors group" >
{item.title}
diff --git a/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx b/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx index 2408ac22..1b890d2d 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Filters.tsx @@ -24,13 +24,13 @@ export const Filters = ({ const years = [2025, 2024, 2023, 2022, 2021]; return ( -
+
{/* 상단: 년도/분기 선택 */} -
+
{/* Year Selection */}
년도 -
+
setNewColumnType(value)}> + setNewFieldOptions(e.target.value)} + value={optionsString} + onChange={(e) => handleSetNewFieldOptions(e.target.value)} placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)" />
@@ -404,18 +440,18 @@ export function FieldDialog({ )} {/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */} - {(fieldInputMode === 'custom' || editingFieldId) && ( + {(isCustomMode || editingFieldId) && ( ConditionalFieldConfig[])) => void} tempConditionValue={tempConditionValue} setTempConditionValue={setTempConditionValue} newFieldKey={newFieldKey} - newFieldInputType={newFieldInputType} + newFieldInputType={newFieldInputType as InputType} selectedPage={selectedPage} selectedSectionForField={selectedSectionForField} editingFieldId={editingFieldId} @@ -438,7 +474,7 @@ export function FieldDialog({ }); setIsSubmitted(true); // 2025-11-28: field_key validation 추가 - const shouldValidate = fieldInputMode === 'custom' || editingFieldId; + const shouldValidate = isCustomMode || editingFieldId; console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate); if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) { console.log('[FieldDialog] ❌ 유효성 검사 실패로 return'); diff --git a/src/components/items/ItemMasterDataManagement/dialogs/FieldDrawer.tsx b/src/components/items/ItemMasterDataManagement/dialogs/FieldDrawer.tsx index 11913c7e..e2fdcc6f 100644 --- a/src/components/items/ItemMasterDataManagement/dialogs/FieldDrawer.tsx +++ b/src/components/items/ItemMasterDataManagement/dialogs/FieldDrawer.tsx @@ -27,45 +27,74 @@ const INPUT_TYPE_OPTIONS = [ { value: 'textarea', label: '텍스트영역' } ]; +// 유연한 조건부 필드 타입 +interface FlexibleConditionField { + fieldId?: string; + fieldKey?: string; + fieldName?: string; + operator?: string; + value?: string; + expectedValue?: string; + logicOperator?: 'AND' | 'OR'; +} + +// 유연한 조건부 섹션 타입 +interface FlexibleConditionSection { + sectionId?: string; + sectionTitle?: string; + operator?: string; + value?: string; + logicOperator?: 'AND' | 'OR'; +} + +// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원 +type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master'; + interface FieldDrawerProps { isOpen: boolean; onOpenChange: (open: boolean) => void; editingFieldId: number | null; setEditingFieldId: (id: number | null) => void; - fieldInputMode: 'custom' | 'master'; - setFieldInputMode: (mode: 'custom' | 'master') => void; + // 'new'/'existing' 또는 'custom'/'master' 모두 지원 + fieldInputMode: FieldInputModeType; + setFieldInputMode: (mode: FieldInputModeType) => void; showMasterFieldList: boolean; setShowMasterFieldList: (show: boolean) => void; - selectedMasterFieldId: string; - setSelectedMasterFieldId: (id: string) => void; + // string 또는 number | null 모두 지원 + selectedMasterFieldId: string | number | null; + setSelectedMasterFieldId: (id: string | number | null) => void; textboxColumns: OptionColumn[]; setTextboxColumns: React.Dispatch>; newFieldConditionEnabled: boolean; setNewFieldConditionEnabled: (enabled: boolean) => void; newFieldConditionTargetType: 'field' | 'section'; setNewFieldConditionTargetType: (type: 'field' | 'section') => void; - newFieldConditionFields: Array<{ fieldKey: string; expectedValue: string }>; - setNewFieldConditionFields: React.Dispatch>>; - newFieldConditionSections: string[]; - setNewFieldConditionSections: React.Dispatch>; + // 유연한 조건부 필드 타입 + newFieldConditionFields: FlexibleConditionField[] | Array<{ fieldKey: string; expectedValue: string }>; + setNewFieldConditionFields: React.Dispatch> | React.Dispatch>>; + // 유연한 조건부 섹션 타입 + newFieldConditionSections: string[] | FlexibleConditionSection[]; + setNewFieldConditionSections: React.Dispatch> | React.Dispatch>; tempConditionValue: string; setTempConditionValue: (value: string) => void; newFieldName: string; setNewFieldName: (name: string) => void; newFieldKey: string; setNewFieldKey: (key: string) => void; - newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; - setNewFieldInputType: (type: any) => void; + // string 타입으로 유연하게 처리 + newFieldInputType: string; + setNewFieldInputType: (type: string) => void; newFieldRequired: boolean; setNewFieldRequired: (required: boolean) => void; newFieldDescription: string; setNewFieldDescription: (description: string) => void; - newFieldOptions: string; - setNewFieldOptions: (options: string) => void; + // string | string[] 모두 지원 + newFieldOptions: string | string[]; + setNewFieldOptions: ((options: string) => void) | React.Dispatch>; selectedSectionForField: ItemSection | null; selectedPage: ItemPage | null; itemMasterFields: ItemMasterField[]; - handleAddField: () => Promise; + handleAddField: () => void | Promise; setIsColumnDialogOpen: (open: boolean) => void; setEditingColumnId: (id: string | null) => void; setColumnName: (name: string) => void; @@ -116,6 +145,30 @@ export function FieldDrawer({ setColumnName, setColumnKey }: FieldDrawerProps) { + // 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master' + const normalizedInputMode = + fieldInputMode === 'new' ? 'custom' : + fieldInputMode === 'existing' ? 'master' : + fieldInputMode; + + const isCustomMode = normalizedInputMode === 'custom'; + const isMasterMode = normalizedInputMode === 'master'; + + // 옵션을 문자열로 변환하여 처리 + const optionsString = Array.isArray(newFieldOptions) ? newFieldOptions.join(', ') : newFieldOptions; + + // setNewFieldOptions 래퍼 - union type 호환성 해결 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleSetNewFieldOptions = (options: string) => (setNewFieldOptions as any)(options); + + // setNewFieldConditionFields 래퍼 - union type 호환성 해결 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleSetNewFieldConditionFields = (updater: any) => (setNewFieldConditionFields as any)(updater); + + // setNewFieldConditionSections 래퍼 - union type 호환성 해결 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleSetNewFieldConditionSections = (updater: any) => (setNewFieldConditionSections as any)(updater); + const handleClose = () => { onOpenChange(false); setEditingFieldId(null); @@ -145,7 +198,7 @@ export function FieldDrawer({ {!editingFieldId && (