From b8bd93532cb98865cd856ac96e5ac73c30c340d1 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Fri, 9 Jan 2026 20:02:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(WEB):=20QMS=20=ED=92=88=EC=A7=88?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20Day1=20=EC=8B=AC=EC=82=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Day1 체크리스트 패널 및 문서 뷰어 컴포넌트 추가 - 심사 진행 상태바 및 설정 패널 구현 - Day 탭 네비게이션 컴포넌트 추가 - 목업 데이터 확장 및 타입 정의 보강 Co-Authored-By: Claude Opus 4.5 --- .../qms/components/AuditProgressBar.tsx | 97 ++++++ .../qms/components/AuditSettingsPanel.tsx | 206 ++++++++++++ .../qms/components/Day1ChecklistPanel.tsx | 270 +++++++++++++++ .../qms/components/Day1DocumentSection.tsx | 157 +++++++++ .../qms/components/Day1DocumentViewer.tsx | 186 +++++++++++ .../quality/qms/components/DayTabs.tsx | 134 ++++++++ .../quality/qms/components/Header.tsx | 19 +- .../(protected)/quality/qms/mockData.ts | 253 ++++++++++++++- .../[locale]/(protected)/quality/qms/page.tsx | 307 +++++++++++++++--- .../[locale]/(protected)/quality/qms/types.ts | 49 +++ 10 files changed, 1619 insertions(+), 59 deletions(-) create mode 100644 src/app/[locale]/(protected)/quality/qms/components/AuditProgressBar.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/components/AuditSettingsPanel.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx create mode 100644 src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx 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..4d554c82 --- /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-9 pr-8 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..d35d922b --- /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..1a25de5a --- /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..d7945c1d --- /dev/null +++ b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx @@ -0,0 +1,134 @@ +'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일차 탭 */} + +
+ + {/* 전체 진행률 - 한줄 표시 */} +
+ 전체 심사 진행률 + + {/* 전체 프로그레스 바 */} +
+
+
+
+ {overallPercentage}% +
+ + {/* 구분선 */} +
+ + {/* 1일차 진행률 */} +
+ 1일차 +
+
+
+ + {day1Percentage}% + +
+ + {/* 2일차 진행률 */} +
+ 2일차 +
+
+
+ + {day2Percentage}% + +
+
+
+ ); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Header.tsx b/src/app/[locale]/(protected)/quality/qms/components/Header.tsx index cd2dd5f5..59c441b0 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Header.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Header.tsx @@ -1,10 +1,21 @@ import React from 'react'; -export const Header = () => { +interface HeaderProps { + rightContent?: React.ReactNode; +} + +export const Header = ({ rightContent }: HeaderProps) => { return ( -
-

품질인정심사 시스템

-

SAM - Smart Automation Management

+
+
+

품질인정심사 시스템

+

SAM - Smart Automation Management

+
+ {rightContent && ( +
+ {rightContent} +
+ )}
); }; diff --git a/src/app/[locale]/(protected)/quality/qms/mockData.ts b/src/app/[locale]/(protected)/quality/qms/mockData.ts index 14664a8d..c0a2b985 100644 --- a/src/app/[locale]/(protected)/quality/qms/mockData.ts +++ b/src/app/[locale]/(protected)/quality/qms/mockData.ts @@ -1,4 +1,4 @@ -import { InspectionReport, RouteItem, Document } from './types'; +import { InspectionReport, RouteItem, Document, ChecklistCategory, StandardDocument, Day1CheckItem } from './types'; import type { WorkOrder } from '@/components/production/ProductionDashboard/types'; import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types'; @@ -336,4 +336,255 @@ export const DEFAULT_DOCUMENTS: Document[] = [ { id: 'def-6', type: 'shipping', title: '출고증', count: 0, items: [] }, { id: 'def-7', type: 'product', title: '제품검사 성적서', count: 0, items: [] }, { id: 'def-8', type: 'quality', title: '품질관리서', count: 0, items: [] }, +]; + +// ===== 1일차: 기준/매뉴얼 심사 Mock 데이터 ===== + +// 1일차 점검표 카테고리 +export const MOCK_DAY1_CATEGORIES: ChecklistCategory[] = [ + { + id: 'cat-1', + title: '원재료 품질관리 기준', + subItems: [ + { id: 'cat-1-1', name: '수입검사 기준 확인', isCompleted: true }, + { id: 'cat-1-2', name: '불합격품 처리 기준 확인', isCompleted: true }, + { id: 'cat-1-3', name: '자재 보관 기준 확인', isCompleted: false }, + ], + }, + { + id: 'cat-2', + title: '제조공정 관리 기준', + subItems: [ + { id: 'cat-2-1', name: '작업표준서 확인', isCompleted: false }, + { id: 'cat-2-2', name: '공정검사 기준 확인', isCompleted: false }, + { id: 'cat-2-3', name: '부적합품 처리 기준 확인', isCompleted: false }, + ], + }, + { + id: 'cat-3', + title: '제품 품질관리 기준', + subItems: [ + { id: 'cat-3-1', name: '제품검사 기준 확인', isCompleted: false }, + { id: 'cat-3-2', name: '출하검사 기준 확인', isCompleted: false }, + { id: 'cat-3-3', name: '클레임 처리 기준 확인', isCompleted: false }, + ], + }, + { + id: 'cat-4', + title: '제조설비 관리', + subItems: [ + { id: 'cat-4-1', name: '설비관리 기준 확인', isCompleted: false }, + { id: 'cat-4-2', name: '설비점검 이력 확인', isCompleted: false }, + ], + }, + { + id: 'cat-5', + title: '검사설비 관리', + subItems: [ + { id: 'cat-5-1', name: '검사설비 관리 기준 확인', isCompleted: false }, + { id: 'cat-5-2', name: '교정 이력 확인', isCompleted: false }, + ], + }, + { + id: 'cat-6', + title: '문서 및 인증 관리', + subItems: [ + { id: 'cat-6-1', name: '문서관리 기준 확인', isCompleted: false }, + { id: 'cat-6-2', name: 'KS/인증 관리 현황 확인', isCompleted: false }, + ], + }, +]; + +// 1일차 기준 문서 목록 +export const MOCK_DAY1_STANDARD_DOCUMENTS: Record = { + 'cat-1-1': [ + { id: 'std-1-1-1', title: '수입검사기준서', version: 'REV12', date: '2024-10-20', fileName: '수입검사기준서_REV12.pdf' }, + { id: 'std-1-1-2', title: '검사지침서', version: 'REV11', date: '2024-08-15', fileName: '검사지침서_REV11.pdf' }, + ], + 'cat-1-2': [ + { id: 'std-1-2-1', title: '불합격품 처리절차서', version: 'REV08', date: '2024-06-10', fileName: '불합격품처리절차서_REV08.pdf' }, + ], + 'cat-1-3': [ + { id: 'std-1-3-1', title: '자재보관 관리기준서', version: 'REV05', date: '2024-03-20', fileName: '자재보관관리기준서_REV05.pdf' }, + { id: 'std-1-3-2', title: '창고관리 지침서', version: 'REV03', date: '2024-02-15', fileName: '창고관리지침서_REV03.pdf' }, + ], + 'cat-2-1': [ + { id: 'std-2-1-1', title: '스크린 작업표준서', version: 'REV15', date: '2024-09-01', fileName: '스크린작업표준서_REV15.pdf' }, + { id: 'std-2-1-2', title: '슬랫 작업표준서', version: 'REV10', date: '2024-07-20', fileName: '슬랫작업표준서_REV10.pdf' }, + { id: 'std-2-1-3', title: '절곡 작업표준서', version: 'REV12', date: '2024-08-10', fileName: '절곡작업표준서_REV12.pdf' }, + ], + 'cat-2-2': [ + { id: 'std-2-2-1', title: '공정검사 기준서', version: 'REV09', date: '2024-05-15', fileName: '공정검사기준서_REV09.pdf' }, + ], + 'cat-2-3': [ + { id: 'std-2-3-1', title: '부적합품 관리절차서', version: 'REV06', date: '2024-04-01', fileName: '부적합품관리절차서_REV06.pdf' }, + ], + 'cat-3-1': [ + { id: 'std-3-1-1', title: '제품검사 기준서', version: 'REV14', date: '2024-09-15', fileName: '제품검사기준서_REV14.pdf' }, + ], + 'cat-3-2': [ + { id: 'std-3-2-1', title: '출하검사 기준서', version: 'REV11', date: '2024-08-01', fileName: '출하검사기준서_REV11.pdf' }, + ], + 'cat-3-3': [ + { id: 'std-3-3-1', title: '클레임 처리절차서', version: 'REV07', date: '2024-05-20', fileName: '클레임처리절차서_REV07.pdf' }, + ], + 'cat-4-1': [ + { id: 'std-4-1-1', title: '설비관리 기준서', version: 'REV08', date: '2024-06-01', fileName: '설비관리기준서_REV08.pdf' }, + ], + 'cat-4-2': [ + { id: 'std-4-2-1', title: '설비점검 대장', version: 'REV04', date: '2024-10-01', fileName: '설비점검대장_REV04.xlsx' }, + ], + 'cat-5-1': [ + { id: 'std-5-1-1', title: '검사설비 관리기준서', version: 'REV06', date: '2024-04-15', fileName: '검사설비관리기준서_REV06.pdf' }, + ], + 'cat-5-2': [ + { id: 'std-5-2-1', title: '교정 관리대장', version: 'REV03', date: '2024-09-01', fileName: '교정관리대장_REV03.xlsx' }, + { id: 'std-5-2-2', title: '교정성적서', version: '-', date: '2024-09-10', fileName: '교정성적서_2024.pdf' }, + ], + 'cat-6-1': [ + { id: 'std-6-1-1', title: '문서관리 절차서', version: 'REV10', date: '2024-07-01', fileName: '문서관리절차서_REV10.pdf' }, + ], + 'cat-6-2': [ + { id: 'std-6-2-1', title: 'KS인증서', version: '-', date: '2024-01-15', fileName: 'KS인증서_2024.pdf' }, + { id: 'std-6-2-2', title: 'ISO 9001 인증서', version: '-', date: '2024-02-20', fileName: 'ISO9001인증서_2024.pdf' }, + ], +}; + +// 1일차 점검 항목 상세 정보 (버튼 클릭 시 표시) +export const MOCK_DAY1_CHECK_ITEMS: Day1CheckItem[] = [ + { + id: 'check-1-1', + categoryId: 'cat-1', + subItemId: 'cat-1-1', + title: '수입검사 기준 확인', + description: '원재료 수입검사 기준서 및 검사지침서의 내용을 확인하고, 실제 운영 현황과의 일치 여부를 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-1-1'] || [], + }, + { + id: 'check-1-2', + categoryId: 'cat-1', + subItemId: 'cat-1-2', + title: '불합격품 처리 기준 확인', + description: '불합격품 처리 절차서의 내용을 확인하고, 처리 프로세스가 적절하게 수립되어 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-1-2'] || [], + }, + { + id: 'check-1-3', + categoryId: 'cat-1', + subItemId: 'cat-1-3', + title: '자재 보관 기준 확인', + description: '자재 보관 관리기준서 및 창고관리 지침서를 확인하고, 보관 환경 및 관리 방법이 적절한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-1-3'] || [], + }, + { + id: 'check-2-1', + categoryId: 'cat-2', + subItemId: 'cat-2-1', + title: '작업표준서 확인', + description: '각 공정별 작업표준서의 내용을 확인하고, 작업 방법 및 품질 기준이 명확하게 정의되어 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-2-1'] || [], + }, + { + id: 'check-2-2', + categoryId: 'cat-2', + subItemId: 'cat-2-2', + title: '공정검사 기준 확인', + description: '공정검사 기준서를 확인하고, 검사 항목 및 판정 기준이 적절하게 수립되어 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-2-2'] || [], + }, + { + id: 'check-2-3', + categoryId: 'cat-2', + subItemId: 'cat-2-3', + title: '부적합품 처리 기준 확인', + description: '부적합품 관리절차서를 확인하고, 부적합품 발생 시 처리 절차가 명확한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-2-3'] || [], + }, + { + id: 'check-3-1', + categoryId: 'cat-3', + subItemId: 'cat-3-1', + title: '제품검사 기준 확인', + description: '제품검사 기준서를 확인하고, 검사 항목 및 합격 기준이 적절하게 수립되어 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-3-1'] || [], + }, + { + id: 'check-3-2', + categoryId: 'cat-3', + subItemId: 'cat-3-2', + title: '출하검사 기준 확인', + description: '출하검사 기준서를 확인하고, 출하 전 최종 검사 항목 및 기준이 명확한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-3-2'] || [], + }, + { + id: 'check-3-3', + categoryId: 'cat-3', + subItemId: 'cat-3-3', + title: '클레임 처리 기준 확인', + description: '클레임 처리절차서를 확인하고, 고객 불만 처리 프로세스가 적절하게 수립되어 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-3-3'] || [], + }, + { + id: 'check-4-1', + categoryId: 'cat-4', + subItemId: 'cat-4-1', + title: '설비관리 기준 확인', + description: '설비관리 기준서를 확인하고, 설비 유지보수 및 관리 방법이 적절한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-4-1'] || [], + }, + { + id: 'check-4-2', + categoryId: 'cat-4', + subItemId: 'cat-4-2', + title: '설비점검 이력 확인', + description: '설비점검 대장을 확인하고, 정기 점검이 계획대로 수행되고 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-4-2'] || [], + }, + { + id: 'check-5-1', + categoryId: 'cat-5', + subItemId: 'cat-5-1', + title: '검사설비 관리 기준 확인', + description: '검사설비 관리기준서를 확인하고, 검사 장비의 관리 방법이 적절한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-5-1'] || [], + }, + { + id: 'check-5-2', + categoryId: 'cat-5', + subItemId: 'cat-5-2', + title: '교정 이력 확인', + description: '교정 관리대장 및 교정성적서를 확인하고, 검사설비 교정이 적기에 수행되고 있는지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-5-2'] || [], + }, + { + id: 'check-6-1', + categoryId: 'cat-6', + subItemId: 'cat-6-1', + title: '문서관리 기준 확인', + description: '문서관리 절차서를 확인하고, 문서의 작성, 검토, 승인, 배포 프로세스가 적절한지 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-6-1'] || [], + }, + { + id: 'check-6-2', + categoryId: 'cat-6', + subItemId: 'cat-6-2', + title: 'KS/인증 관리 현황 확인', + description: 'KS인증서 및 ISO 인증서를 확인하고, 인증 유효기간 및 관리 현황을 점검합니다.', + buttonLabel: '기준/매뉴얼 확인', + standardDocuments: MOCK_DAY1_STANDARD_DOCUMENTS['cat-6-2'] || [], + }, ]; \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index c6581dd5..ad1bda51 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -1,22 +1,57 @@ "use client"; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Header } from './components/Header'; import { Filters } from './components/Filters'; import { ReportList } from './components/ReportList'; import { RouteList } from './components/RouteList'; import { DocumentList } from './components/DocumentList'; import { InspectionModal } from './components/InspectionModal'; -import { InspectionReport, RouteItem, Document, DocumentItem } from './types'; -import { MOCK_REPORTS, MOCK_ROUTES, MOCK_DOCUMENTS, DEFAULT_DOCUMENTS } from './mockData'; +import { DayTabs } from './components/DayTabs'; +import { Day1ChecklistPanel } from './components/Day1ChecklistPanel'; +import { Day1DocumentSection } from './components/Day1DocumentSection'; +import { Day1DocumentViewer } from './components/Day1DocumentViewer'; +import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel'; +import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types'; +import { + MOCK_REPORTS, + MOCK_ROUTES, + MOCK_DOCUMENTS, + DEFAULT_DOCUMENTS, + MOCK_DAY1_CATEGORIES, + MOCK_DAY1_CHECK_ITEMS, + MOCK_DAY1_STANDARD_DOCUMENTS, +} from './mockData'; + +// 기본 설정값 +const DEFAULT_SETTINGS: AuditDisplaySettings = { + showProgressBar: true, + showDocumentViewer: true, + showDocumentSection: true, + showCompletedItems: true, + expandAllCategories: true, +}; export default function QualityInspectionPage() { - // 필터 상태 + // 탭 상태 + const [activeDay, setActiveDay] = useState<1 | 2>(1); + + // 설정 상태 + const [settingsOpen, setSettingsOpen] = useState(false); + const [displaySettings, setDisplaySettings] = useState(DEFAULT_SETTINGS); + + // 1일차 상태 + const [day1Categories, setDay1Categories] = useState(MOCK_DAY1_CATEGORIES); + const [selectedSubItemId, setSelectedSubItemId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [selectedStandardDocId, setSelectedStandardDocId] = useState(null); + + // 2일차(로트추적) 필터 상태 const [selectedYear, setSelectedYear] = useState(2025); const [selectedQuarter, setSelectedQuarter] = useState('전체'); const [searchTerm, setSearchTerm] = useState(''); - // 선택 상태 + // 2일차 선택 상태 const [selectedReport, setSelectedReport] = useState(null); const [selectedRoute, setSelectedRoute] = useState(null); @@ -25,19 +60,98 @@ export default function QualityInspectionPage() { const [selectedDoc, setSelectedDoc] = useState(null); const [selectedDocItem, setSelectedDocItem] = useState(null); - // 필터링된 리포트 목록 + // ===== 1일차 진행률 계산 ===== + const day1Progress = useMemo(() => { + const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0); + const completed = day1Categories.reduce( + (sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length, + 0 + ); + return { completed, total }; + }, [day1Categories]); + + // ===== 2일차 진행률 계산 (로트 추적 완료 기준) ===== + const day2Progress = useMemo(() => { + let completed = 0; + let total = 0; + Object.values(MOCK_ROUTES).forEach(routes => { + routes.forEach(route => { + total++; + const allPassed = route.subItems.length > 0 && + route.subItems.every(item => item.status === '합격'); + if (allPassed) completed++; + }); + }); + return { completed, total }; + }, []); + + // ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) ===== + const filteredDay1Categories = useMemo(() => { + if (displaySettings.showCompletedItems) return day1Categories; + + return day1Categories.map(category => ({ + ...category, + subItems: category.subItems.filter(item => !item.isCompleted), + })).filter(category => category.subItems.length > 0); + }, [day1Categories, displaySettings.showCompletedItems]); + + // ===== 1일차 핸들러 ===== + const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => { + setSelectedCategoryId(categoryId); + setSelectedSubItemId(subItemId); + setSelectedStandardDocId(null); + }, []); + + const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => { + setDay1Categories(prev => prev.map(cat => { + if (cat.id !== categoryId) return cat; + return { + ...cat, + subItems: cat.subItems.map(item => { + if (item.id !== subItemId) return item; + return { ...item, isCompleted }; + }), + }; + })); + }, []); + + const handleConfirmComplete = useCallback(() => { + if (selectedCategoryId && selectedSubItemId) { + handleSubItemToggle(selectedCategoryId, selectedSubItemId, true); + } + }, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]); + + // 선택된 1일차 점검 항목 + const selectedCheckItem = useMemo(() => { + if (!selectedSubItemId) return null; + return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null; + }, [selectedSubItemId]); + + // 선택된 표준 문서 + const selectedStandardDoc = useMemo(() => { + if (!selectedStandardDocId || !selectedSubItemId) return null; + const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || []; + return docs.find(doc => doc.id === selectedStandardDocId) || null; + }, [selectedStandardDocId, selectedSubItemId]); + + // 선택된 항목의 완료 여부 + const isSelectedItemCompleted = useMemo(() => { + if (!selectedSubItemId) return false; + for (const cat of day1Categories) { + const item = cat.subItems.find(item => item.id === selectedSubItemId); + if (item) return item.isCompleted; + } + return false; + }, [day1Categories, selectedSubItemId]); + + // ===== 2일차(로트추적) 로직 ===== const filteredReports = useMemo(() => { return MOCK_REPORTS.filter((report) => { - // 년도 필터 if (report.year !== selectedYear) return false; - - // 분기 필터 if (selectedQuarter !== '전체') { const quarterNum = parseInt(selectedQuarter.replace('분기', '')); if (report.quarterNum !== quarterNum) return false; } - - // 검색어 필터 if (searchTerm) { const term = searchTerm.toLowerCase(); const matchesCode = report.code.toLowerCase().includes(term); @@ -45,42 +159,35 @@ export default function QualityInspectionPage() { const matchesItem = report.item.toLowerCase().includes(term); if (!matchesCode && !matchesSite && !matchesItem) return false; } - return true; }); }, [selectedYear, selectedQuarter, searchTerm]); - // 선택된 리포트의 루트 목록 const currentRoutes = useMemo(() => { if (!selectedReport) return []; return MOCK_ROUTES[selectedReport.id] || []; }, [selectedReport]); - // 선택된 루트의 문서 목록 const currentDocuments = useMemo(() => { if (!selectedRoute) return DEFAULT_DOCUMENTS; return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS; }, [selectedRoute]); - // 리포트 선택 핸들러 const handleReportSelect = (report: InspectionReport) => { setSelectedReport(report); - setSelectedRoute(null); // 루트 선택 초기화 + setSelectedRoute(null); }; - // 루트 선택 핸들러 const handleRouteSelect = (route: RouteItem) => { setSelectedRoute(route); }; - // 문서 보기 핸들러 const handleViewDocument = (doc: Document, item?: DocumentItem) => { setSelectedDoc(doc); setSelectedDocItem(item || null); setModalOpen(true); }; - // 필터 핸들러 const handleYearChange = (year: number) => { setSelectedYear(year); setSelectedReport(null); @@ -99,46 +206,138 @@ export default function QualityInspectionPage() { return (
-
- - setSettingsOpen(true)} />} /> -
- {/* Left Panel: Report List */} -
- + {/* 1일차/2일차 탭 + 진행률 */} + {displaySettings.showProgressBar ? ( + + ) : ( + // 진행률 숨김 시 탭만 표시 +
+ +
+ )} - {/* Middle Panel: Route List */} -
- -
+ {activeDay === 1 ? ( + // ===== 1일차: 기준/매뉴얼 심사 ===== +
+ {/* 좌측: 점검표 항목 */} +
+ +
- {/* Right Panel: Documents */} -
- + {/* 중앙: 기준 문서화 */} + {displaySettings.showDocumentSection && ( +
+ +
+ )} + + {/* 우측: 문서 뷰어 */} + {displaySettings.showDocumentViewer && ( +
+ +
+ )}
-
+ ) : ( + // ===== 2일차: 로트추적 심사 ===== + <> + + +
+
+ +
+ +
+ +
+ +
+ +
+
+ + )} + + {/* 설정 패널 */} + setSettingsOpen(false)} + settings={displaySettings} + onSettingsChange={setDisplaySettings} + />
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/quality/qms/types.ts b/src/app/[locale]/(protected)/quality/qms/types.ts index f0b07a48..a8fabc3f 100644 --- a/src/app/[locale]/(protected)/quality/qms/types.ts +++ b/src/app/[locale]/(protected)/quality/qms/types.ts @@ -43,3 +43,52 @@ export interface DocumentItem { // 중간검사 성적서 서브타입 (report 타입일 때만 사용) subType?: 'screen' | 'bending' | 'slat' | 'jointbar'; } + +// ===== 1일차: 기준/매뉴얼 심사 타입 ===== + +// 점검표 하위 항목 +export interface ChecklistSubItem { + id: string; + name: string; + isCompleted: boolean; +} + +// 점검표 카테고리 +export interface ChecklistCategory { + id: string; + title: string; + subItems: ChecklistSubItem[]; +} + +// 기준 문서 +export interface StandardDocument { + id: string; + title: string; + version: string; + date: string; + fileName?: string; + fileUrl?: string; +} + +// 1일차 점검 항목 상세 정보 +export interface Day1CheckItem { + id: string; + categoryId: string; + subItemId: string; + title: string; + description: string; + buttonLabel: string; + standardDocuments: StandardDocument[]; +} + +// 1일차 진행 상태 +export interface Day1Progress { + completed: number; + total: number; +} + +// 2일차 진행 상태 (기존 로트추적) +export interface Day2Progress { + completed: number; + total: number; +} From 8bc4b90fe93f0a9991cb1b6614a54157cab41b79 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Sat, 10 Jan 2026 11:07:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore(WEB):=20QMS=20=ED=92=88=EC=A7=88?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20Day=20=ED=83=AD=20=EB=B0=8F=20=EB=A3=A8?= =?UTF-8?q?=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DayTabs 컴포넌트 리팩토링 - RouteList 기능 확장 및 UI 개선 - 목업 데이터 구조 조정 - 페이지 레이아웃 개선 Co-Authored-By: Claude Opus 4.5 --- .../quality/qms/components/DayTabs.tsx | 46 +++++++------- .../quality/qms/components/RouteList.tsx | 61 ++++++++++++++----- .../(protected)/quality/qms/mockData.ts | 26 ++++---- .../[locale]/(protected)/quality/qms/page.tsx | 44 ++++++++++--- .../[locale]/(protected)/quality/qms/types.ts | 2 +- 5 files changed, 119 insertions(+), 60 deletions(-) diff --git a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx index d7945c1d..312ff3d3 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx @@ -71,28 +71,32 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
- {/* 전체 진행률 - 한줄 표시 */} -
- 전체 심사 진행률 - - {/* 전체 프로그레스 바 */} -
+ {/* 진행률 - 3줄 표시 */} +
+ {/* 전체 심사 진행률 */} +
+ 전체 심사
- {overallPercentage}% + + {totalCompleted}/{totalItems} +
- {/* 구분선 */} -
- {/* 1일차 진행률 */} -
- 1일차 -
+
+ 1일차: 기준/매뉴얼 +
- {day1Percentage}% + {day1Progress.completed}/{day1Progress.total}
{/* 2일차 진행률 */} -
- 2일차 -
+
+ 2일차: 로트추적 +
- {day2Percentage}% + {day2Progress.completed}/{day2Progress.total}
diff --git a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx index 32b373cc..e436d620 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx @@ -1,17 +1,19 @@ "use client"; import React, { useState } from 'react'; -import { ChevronDown, ChevronUp, MapPin } from 'lucide-react'; +import { ChevronDown, ChevronUp, MapPin, Check } from 'lucide-react'; import { RouteItem } from '../types'; +import { cn } from '@/lib/utils'; interface RouteListProps { routes: RouteItem[]; selectedId: string | null; onSelect: (route: RouteItem) => void; + onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void; reportCode: string | null; } -export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteListProps) => { +export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => { const [expandedId, setExpandedId] = useState(null); const handleClick = (route: RouteItem) => { @@ -19,6 +21,11 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis setExpandedId(expandedId === route.id ? null : route.id); }; + const handleToggle = (e: React.MouseEvent, routeId: string, itemId: string, currentState: boolean) => { + e.stopPropagation(); + onToggleItem(routeId, itemId, !currentState); + }; + return (

@@ -37,6 +44,8 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis routes.map((route) => { const isSelected = selectedId === route.id; const isExpanded = expandedId === route.id; + const completedCount = route.subItems.filter(item => item.isCompleted).length; + const totalCount = route.subItems.length; return (
@@ -52,6 +61,16 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis

{route.code}

+ {totalCount > 0 && ( + + {completedCount}/{totalCount} + + )}

수주일: {route.date}

현장: {route.site}

@@ -75,23 +94,35 @@ export const RouteList = ({ routes, selectedId, onSelect, reportCode }: RouteLis {route.subItems.map((item) => (
-
-
{item.name}
+
+
+ {item.name} +
{item.location}
- handleToggle(e, route.id, item.id, item.isCompleted)} + className={cn( + "flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded border transition-all", + item.isCompleted + ? "text-green-600 border-green-300 bg-green-100 hover:bg-green-200" + : "text-gray-500 border-gray-300 bg-white hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300" + )} > - {item.status} - + + {item.isCompleted ? '완료' : '확인'} +
))}
diff --git a/src/app/[locale]/(protected)/quality/qms/mockData.ts b/src/app/[locale]/(protected)/quality/qms/mockData.ts index c0a2b985..cd98a852 100644 --- a/src/app/[locale]/(protected)/quality/qms/mockData.ts +++ b/src/app/[locale]/(protected)/quality/qms/mockData.ts @@ -146,7 +146,7 @@ export const MOCK_REPORTS: InspectionReport[] = [ ]; // 수주루트 목록 (reportId로 연결) -export const MOCK_ROUTES: Record = { +export const MOCK_ROUTES_INITIAL: Record = { '1': [ { id: '1-1', @@ -155,13 +155,13 @@ export const MOCK_ROUTES: Record = { site: '강남 아파트 A동', locationCount: 7, subItems: [ - { id: '1-1-1', name: 'KD-SS-240924-19-01', location: '101동 501호', status: '합격' }, - { id: '1-1-2', name: 'KD-SS-240924-19-02', location: '101동 502호', status: '합격' }, - { id: '1-1-3', name: 'KD-SS-240924-19-03', location: '101동 503호', status: '합격' }, - { id: '1-1-4', name: 'KD-SS-240924-19-04', location: '101동 601호', status: '합격' }, - { id: '1-1-5', name: 'KD-SS-240924-19-05', location: '101동 602호', status: '합격' }, - { id: '1-1-6', name: 'KD-SS-240924-19-06', location: '101동 603호', status: '합격' }, - { id: '1-1-7', name: 'KD-SS-240924-19-07', location: '102동 501호', status: '합격' }, + { id: '1-1-1', name: 'KD-SS-240924-19-01', location: '101동 501호', isCompleted: true }, + { id: '1-1-2', name: 'KD-SS-240924-19-02', location: '101동 502호', isCompleted: true }, + { id: '1-1-3', name: 'KD-SS-240924-19-03', location: '101동 503호', isCompleted: true }, + { id: '1-1-4', name: 'KD-SS-240924-19-04', location: '101동 601호', isCompleted: true }, + { id: '1-1-5', name: 'KD-SS-240924-19-05', location: '101동 602호', isCompleted: true }, + { id: '1-1-6', name: 'KD-SS-240924-19-06', location: '101동 603호', isCompleted: false }, + { id: '1-1-7', name: 'KD-SS-240924-19-07', location: '102동 501호', isCompleted: false }, ], }, { @@ -171,8 +171,8 @@ export const MOCK_ROUTES: Record = { site: '강남 아파트 B동', locationCount: 7, subItems: [ - { id: '1-2-1', name: 'KD-SS-241024-15-01', location: '103동 501호', status: '합격' }, - { id: '1-2-2', name: 'KD-SS-241024-15-02', location: '103동 502호', status: '대기' }, + { id: '1-2-1', name: 'KD-SS-241024-15-01', location: '103동 501호', isCompleted: true }, + { id: '1-2-2', name: 'KD-SS-241024-15-02', location: '103동 502호', isCompleted: false }, ], }, ], @@ -184,8 +184,8 @@ export const MOCK_ROUTES: Record = { site: '서초 오피스텔 본관', locationCount: 8, subItems: [ - { id: '2-1-1', name: 'SC-AP-241101-01-01', location: '1층 로비', status: '합격' }, - { id: '2-1-2', name: 'SC-AP-241101-01-02', location: '2층 사무실', status: '합격' }, + { id: '2-1-1', name: 'SC-AP-241101-01-01', location: '1층 로비', isCompleted: true }, + { id: '2-1-2', name: 'SC-AP-241101-01-02', location: '2층 사무실', isCompleted: false }, ], }, ], @@ -197,7 +197,7 @@ export const MOCK_ROUTES: Record = { site: '송파 주상복합 A타워', locationCount: 10, subItems: [ - { id: '3-1-1', name: 'SP-CW-240801-01-01', location: '1층 외벽', status: '합격' }, + { id: '3-1-1', name: 'SP-CW-240801-01-01', location: '1층 외벽', isCompleted: false }, ], }, { diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index ad1bda51..3f6ba24a 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -15,7 +15,7 @@ import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from '. import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types'; import { MOCK_REPORTS, - MOCK_ROUTES, + MOCK_ROUTES_INITIAL, MOCK_DOCUMENTS, DEFAULT_DOCUMENTS, MOCK_DAY1_CATEGORIES, @@ -55,6 +55,9 @@ export default function QualityInspectionPage() { const [selectedReport, setSelectedReport] = useState(null); const [selectedRoute, setSelectedRoute] = useState(null); + // 2일차 루트 데이터 상태 (완료 토글용) + const [routesData, setRoutesData] = useState>(MOCK_ROUTES_INITIAL); + // 모달 상태 const [modalOpen, setModalOpen] = useState(false); const [selectedDoc, setSelectedDoc] = useState(null); @@ -70,20 +73,20 @@ export default function QualityInspectionPage() { return { completed, total }; }, [day1Categories]); - // ===== 2일차 진행률 계산 (로트 추적 완료 기준) ===== + // ===== 2일차 진행률 계산 (개소별 완료 기준) ===== const day2Progress = useMemo(() => { let completed = 0; let total = 0; - Object.values(MOCK_ROUTES).forEach(routes => { + Object.values(routesData).forEach(routes => { routes.forEach(route => { - total++; - const allPassed = route.subItems.length > 0 && - route.subItems.every(item => item.status === '합격'); - if (allPassed) completed++; + route.subItems.forEach(item => { + total++; + if (item.isCompleted) completed++; + }); }); }); return { completed, total }; - }, []); + }, [routesData]); // ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) ===== const filteredDay1Categories = useMemo(() => { @@ -165,8 +168,8 @@ export default function QualityInspectionPage() { const currentRoutes = useMemo(() => { if (!selectedReport) return []; - return MOCK_ROUTES[selectedReport.id] || []; - }, [selectedReport]); + return routesData[selectedReport.id] || []; + }, [selectedReport, routesData]); const currentDocuments = useMemo(() => { if (!selectedRoute) return DEFAULT_DOCUMENTS; @@ -204,6 +207,26 @@ export default function QualityInspectionPage() { setSearchTerm(term); }; + // ===== 2일차 개소별 완료 토글 ===== + const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => { + setRoutesData(prev => { + const newData = { ...prev }; + for (const reportId of Object.keys(newData)) { + newData[reportId] = newData[reportId].map(route => { + if (route.id !== routeId) return route; + return { + ...route, + subItems: route.subItems.map(item => { + if (item.id !== itemId) return item; + return { ...item, isCompleted }; + }), + }; + }); + } + return newData; + }); + }, []); + return (
{/* 헤더 (설정 버튼 포함) */} @@ -316,6 +339,7 @@ export default function QualityInspectionPage() { routes={currentRoutes} selectedId={selectedRoute?.id || null} onSelect={handleRouteSelect} + onToggleItem={handleToggleItem} reportCode={selectedReport?.code || null} />
diff --git a/src/app/[locale]/(protected)/quality/qms/types.ts b/src/app/[locale]/(protected)/quality/qms/types.ts index a8fabc3f..d3da2363 100644 --- a/src/app/[locale]/(protected)/quality/qms/types.ts +++ b/src/app/[locale]/(protected)/quality/qms/types.ts @@ -23,7 +23,7 @@ export interface UnitInspection { id: string; name: string; // e.g., KD-SS-240924-19-01 location: string; // e.g., 101동 501호 - status: '합격' | '불합격' | '대기'; + isCompleted: boolean; // 확인 완료 여부 } export interface Document { From e56b7d53a404d443e70eca11d4642d43f4f8b1b2 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Sun, 11 Jan 2026 17:19:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(WEB):=20=ED=86=A0=ED=81=B0=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EB=AC=B4=ED=95=9C=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A6=AC?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 52개 이상의 컴포넌트에 isNextRedirectError 처리 추가 - Server Action의 redirect() 에러가 catch 블록에서 삼켜지는 문제 해결 - access_token + refresh_token 모두 만료 시 정상적으로 로그인 페이지로 리다이렉트 수정된 영역: - accounting: 10개 컴포넌트 - production: 12개 컴포넌트 - hr: 5개 컴포넌트 - settings: 8개 컴포넌트 - approval: 5개 컴포넌트 - items: 20개+ 컴포넌트 - board: 5개 컴포넌트 - quality: 4개 컴포넌트 - material, outbound, quotes 등 기타 컴포넌트 Co-Authored-By: Claude --- .../[PLAN] ceo-dashboard-refactoring.md | 331 ++++ .../[GUIDE] mobile-responsive-patterns.md | 538 +++++++ .../board/[boardCode]/[postId]/page.tsx | 8 +- .../board/board-management/[id]/edit/page.tsx | 7 +- .../board/board-management/[id]/page.tsx | 7 +- .../boards/[boardCode]/[postId]/edit/page.tsx | 7 +- .../(protected)/items/[id]/edit/page.tsx | 9 +- .../[locale]/(protected)/items/[id]/page.tsx | 9 +- .../(protected)/production/dashboard/page.tsx | 3 +- .../screen-production/[id]/edit/page.tsx | 7 +- .../screen-production/[id]/page.tsx | 7 +- .../production/worker-screen/page.tsx | 3 +- .../qms/components/Day1ChecklistPanel.tsx | 14 +- .../qms/components/Day1DocumentSection.tsx | 24 +- .../qms/components/Day1DocumentViewer.tsx | 40 +- .../quality/qms/components/DayTabs.tsx | 61 +- .../quality/qms/components/DocumentList.tsx | 14 +- .../quality/qms/components/Filters.tsx | 8 +- .../quality/qms/components/Header.tsx | 6 +- .../quality/qms/components/ReportList.tsx | 22 +- .../quality/qms/components/RouteList.tsx | 12 +- .../[locale]/(protected)/quality/qms/page.tsx | 18 +- .../BadDebtCollection/BadDebtDetail.tsx | 5 + .../BankTransactionInquiry/index.tsx | 2 + .../accounting/BillManagement/BillDetail.tsx | 5 +- .../CardTransactionInquiry/index.tsx | 2 + .../accounting/DailyReport/index.tsx | 16 +- .../accounting/ReceivablesStatus/index.tsx | 2 + .../SalesManagement/SalesDetail.tsx | 11 +- .../VendorLedger/VendorLedgerDetail.tsx | 13 +- .../accounting/VendorLedger/index.tsx | 2 + .../VendorManagement/VendorDetail.tsx | 10 + src/components/approval/ApprovalBox/index.tsx | 5 + .../DocumentCreate/ExpenseEstimateForm.tsx | 7 +- .../approval/DocumentCreate/index.tsx | 15 +- src/components/approval/DraftBox/index.tsx | 7 + .../approval/ReferenceBox/index.tsx | 5 + src/components/auth/LoginPage.tsx | 2 + src/components/auth/SignupPage.tsx | 2 + src/components/board/BoardDetail/index.tsx | 2 + src/components/board/BoardForm/index.tsx | 3 + src/components/board/BoardList/index.tsx | 11 +- .../board/BoardManagement/index.tsx | 7 +- .../board/RichTextEditor/MenuBar.tsx | 2 + .../business/CEODashboard/CEODashboard.tsx | 1364 +---------------- .../business/CEODashboard/components.tsx | 8 +- .../business/CEODashboard/mockData.ts | 350 +++++ .../modalConfigs/cardManagementConfigs.ts | 269 ++++ .../modalConfigs/entertainmentConfigs.ts | 231 +++ .../CEODashboard/modalConfigs/index.ts | 5 + .../modalConfigs/monthlyExpenseConfigs.ts | 317 ++++ .../CEODashboard/modalConfigs/vatConfigs.ts | 91 ++ .../modalConfigs/welfareConfigs.ts | 129 ++ .../CEODashboard/modals/DetailModal.tsx | 90 +- .../sections/CardManagementSection.tsx | 2 +- .../sections/DailyReportSection.tsx | 2 +- .../sections/EntertainmentSection.tsx | 2 +- .../sections/MonthlyExpenseSection.tsx | 2 +- .../sections/ReceivableSection.tsx | 2 +- .../sections/TodayIssueSection.tsx | 8 +- .../CEODashboard/sections/WelfareSection.tsx | 2 +- .../category-management/index.tsx | 12 +- .../contract/ContractDetailForm.tsx | 4 + src/components/common/DataTable/DataTable.tsx | 8 +- .../hr/AttendanceManagement/index.tsx | 11 +- .../hr/DepartmentManagement/index.tsx | 4 + .../hr/EmployeeManagement/index.tsx | 4 + src/components/hr/SalaryManagement/index.tsx | 6 + .../hr/VacationManagement/index.tsx | 8 + .../items/DynamicItemForm/index.tsx | 5 + .../sections/DynamicBOMSection.tsx | 2 + src/components/items/ItemDetailClient.tsx | 2 + src/components/items/ItemListClient.tsx | 2 + .../items/ItemMasterDataManagement.tsx | 33 +- .../components/ItemMasterDialogs.tsx | 124 +- .../dialogs/ColumnManageDialog.tsx | 24 +- .../dialogs/FieldDialog.tsx | 86 +- .../dialogs/FieldDrawer.tsx | 107 +- .../dialogs/ImportFieldDialog.tsx | 8 +- .../dialogs/ImportSectionDialog.tsx | 8 +- .../dialogs/LoadTemplateDialog.tsx | 5 +- .../dialogs/MasterFieldDialog.tsx | 30 +- .../dialogs/OptionDialog.tsx | 18 +- .../dialogs/SectionDialog.tsx | 36 +- .../dialogs/SectionTemplateDialog.tsx | 16 +- .../dialogs/TemplateFieldDialog.tsx | 68 +- .../hooks/useAttributeManagement.ts | 30 +- .../hooks/useDeleteManagement.ts | 3 + .../hooks/useFieldManagement.ts | 30 +- .../hooks/useImportManagement.ts | 4 + .../hooks/useMasterFieldManagement.ts | 25 +- .../hooks/useReorderManagement.ts | 3 + .../hooks/useSectionManagement.ts | 15 +- .../hooks/useTemplateManagement.ts | 33 +- .../tabs/HierarchyTab/index.tsx | 2 + .../items/ItemMasterDataManagement/types.ts | 8 +- .../ReceivingManagement/InspectionCreate.tsx | 9 +- .../ReceivingManagement/ReceivingDetail.tsx | 10 +- .../ReceivingManagement/ReceivingList.tsx | 10 +- .../StockStatus/StockStatusDetail.tsx | 9 +- .../material/StockStatus/StockStatusList.tsx | 10 +- .../ShipmentManagement/ShipmentCreate.tsx | 8 +- .../ShipmentManagement/ShipmentDetail.tsx | 8 +- .../ShipmentManagement/ShipmentEdit.tsx | 8 +- .../ShipmentManagement/ShipmentList.tsx | 10 +- src/components/pricing/PricingFormClient.tsx | 3 + .../production/ProductionDashboard/index.tsx | 11 +- .../WorkOrders/AssigneeSelectModal.tsx | 8 +- .../WorkOrders/SalesOrderSelectModal.tsx | 9 +- .../production/WorkOrders/WorkOrderCreate.tsx | 2 + .../production/WorkOrders/WorkOrderDetail.tsx | 9 +- .../production/WorkOrders/WorkOrderList.tsx | 2 + .../production/WorkResults/WorkResultList.tsx | 10 +- .../WorkerScreen/MaterialInputModal.tsx | 8 +- .../WorkerScreen/ProcessDetailSection.tsx | 8 +- .../production/WorkerScreen/index.tsx | 12 +- .../InspectionManagement/InspectionCreate.tsx | 2 + .../InspectionManagement/InspectionDetail.tsx | 8 +- .../InspectionManagement/InspectionList.tsx | 11 +- src/components/quotes/QuoteRegistration.tsx | 6 + .../reports/ComprehensiveAnalysis/index.tsx | 9 +- .../settings/LeavePolicyManagement/index.tsx | 6 +- .../PaymentHistoryClient.tsx | 2 + .../PermissionDetailClient.tsx | 16 +- .../settings/PermissionManagement/index.tsx | 8 +- .../settings/PopupManagement/PopupForm.tsx | 1 + .../settings/PopupManagement/PopupList.tsx | 41 +- .../settings/RankManagement/index.tsx | 12 +- .../settings/TitleManagement/index.tsx | 12 +- src/layouts/AuthenticatedLayout.tsx | 50 +- tsconfig.tsbuildinfo | 2 +- 131 files changed, 3320 insertions(+), 1979 deletions(-) create mode 100644 claudedocs/[PLAN] ceo-dashboard-refactoring.md create mode 100644 claudedocs/guides/[GUIDE] mobile-responsive-patterns.md create mode 100644 src/components/business/CEODashboard/mockData.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/cardManagementConfigs.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/entertainmentConfigs.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/index.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/monthlyExpenseConfigs.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/vatConfigs.ts create mode 100644 src/components/business/CEODashboard/modalConfigs/welfareConfigs.ts 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/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx index 4d554c82..8d788c8a 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -97,17 +97,17 @@ export function Day1ChecklistPanel({ return (
{/* 헤더 + 검색 */} -
-

점검표 항목

+
+

점검표 항목

{/* 검색 입력 */}
- + setSearchTerm(e.target.value)} - className="w-full pl-9 pr-8 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + 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 && ( -
+
{/* 문서 미리보기 영역 */} -
-
+
+
{/* Mock 문서 내용 */}
{/* 푸터 */} -
- +
+ 파일명: {document.fileName || '-'} - + 1 / 1 페이지
diff --git a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx index 312ff3d3..212aa54b 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx @@ -25,24 +25,27 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }: : 0; return ( -
+
{/* 탭 버튼 */} -
+
{/* 1일차 탭 */}
{/* 진행률 - 3줄 표시 */} -
+
{/* 전체 심사 진행률 */} -
- 전체 심사 -
+
+ + 전체 심사 + 전체 + +
{totalCompleted}/{totalItems} @@ -94,9 +103,12 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
{/* 1일차 진행률 */} -
- 1일차: 기준/매뉴얼 -
+
+ + 1일차: 기준/매뉴얼 + 1일차 + +
{day1Progress.completed}/{day1Progress.total} @@ -114,9 +126,12 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
{/* 2일차 진행률 */} -
- 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 && (