feat(WEB): QMS 품질관리 Day1 심사 기능 구현

- Day1 체크리스트 패널 및 문서 뷰어 컴포넌트 추가
- 심사 진행 상태바 및 설정 패널 구현
- Day 탭 네비게이션 컴포넌트 추가
- 목업 데이터 확장 및 타입 정의 보강

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-09 20:02:57 +09:00
parent 284c19f036
commit b8bd93532c
10 changed files with 1619 additions and 59 deletions

View File

@@ -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 (
<div className="bg-white rounded-lg border border-gray-200 px-4 py-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<span className="text-sm font-bold text-blue-600">{overallPercentage}%</span>
</div>
{/* 전체 진행률 바 */}
<div className="h-3 bg-gray-200 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-500"
style={{ width: `${overallPercentage}%` }}
/>
</div>
{/* 1일차/2일차 상세 진행률 */}
<div className="grid grid-cols-2 gap-4">
{/* 1일차 */}
<div className={cn(
'p-3 rounded-lg border transition-colors',
activeDay === 1 ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'
)}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-600">1일차: 기준/</span>
<span className={cn(
'text-xs font-bold',
day1Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day1Progress.completed}/{day1Progress.total}
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
day1Percentage === 100 ? 'bg-green-500' : 'bg-blue-500'
)}
style={{ width: `${day1Percentage}%` }}
/>
</div>
</div>
{/* 2일차 */}
<div className={cn(
'p-3 rounded-lg border transition-colors',
activeDay === 2 ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'
)}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-600">2일차: 로트추적</span>
<span className={cn(
'text-xs font-bold',
day2Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day2Progress.completed}/{day2Progress.total}
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
day2Percentage === 100 ? 'bg-green-500' : 'bg-blue-500'
)}
style={{ width: `${day2Percentage}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-start justify-end">
{/* 배경 오버레이 */}
<div
className="absolute inset-0 bg-black/30"
onClick={onClose}
/>
{/* 설정 패널 */}
<div className="relative w-80 h-full bg-white shadow-xl flex flex-col animate-slide-in-right">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" />
<h3 className="font-semibold text-gray-900"> </h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
{/* 설정 항목 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 레이아웃 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4>
<div className="space-y-3">
<SettingRow
label="진행률 표시"
description="상단 전체 심사 진행률 바를 표시합니다"
checked={settings.showProgressBar}
onChange={() => handleToggle('showProgressBar')}
/>
<SettingRow
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다"
checked={settings.showDocumentViewer}
onChange={() => handleToggle('showDocumentViewer')}
/>
<SettingRow
label="기준 문서화 섹션"
description="중앙 기준 문서 목록 패널을 표시합니다"
checked={settings.showDocumentSection}
onChange={() => handleToggle('showDocumentSection')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 점검표 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="space-y-3">
<SettingRow
label="완료된 항목 표시"
description="완료된 점검 항목을 목록에 표시합니다"
checked={settings.showCompletedItems}
onChange={() => handleToggle('showCompletedItems')}
/>
<SettingRow
label="모든 카테고리 펼치기"
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
checked={settings.expandAllCategories}
onChange={() => handleToggle('expandAllCategories')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 빠른 설정 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: true,
showDocumentViewer: true,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: true,
})}
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
>
</button>
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: false,
showDocumentViewer: false,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: false,
})}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
</div>
{/* 하단 안내 */}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500">
</p>
</div>
</div>
</div>
);
}
interface SettingRowProps {
label: string;
description: string;
checked: boolean;
onChange: () => void;
}
function SettingRow({ label, description, checked, onChange }: SettingRowProps) {
return (
<div className="flex items-start justify-between gap-3 p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
{checked ? (
<Eye className="h-4 w-4 text-blue-500" />
) : (
<EyeOff className="h-4 w-4 text-gray-400" />
)}
<span className="text-sm font-medium text-gray-800">{label}</span>
</div>
<p className="text-xs text-gray-500 mt-0.5 ml-6">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
className="data-[state=checked]:bg-blue-500"
/>
</div>
);
}
// 설정 버튼 컴포넌트 (헤더에 배치용)
interface SettingsButtonProps {
onClick: () => void;
}
export function SettingsButton({ onClick }: SettingsButtonProps) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-white bg-white/10 border border-white/20 rounded-lg hover:bg-white/20 transition-colors"
>
<Settings className="h-4 w-4" />
<span> </span>
</button>
);
}

View File

@@ -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<Set<string>>(
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) ? (
<mark key={index} className="bg-yellow-200 text-yellow-900 px-0.5 rounded">
{part}
</mark>
) : (
part
)
);
};
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 + 검색 */}
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 mb-2"> </h3>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="항목 검색..."
value={searchTerm}
onChange={(e) => 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 && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</div>
{/* 검색 결과 카운트 */}
{searchTerm && (
<div className="mt-2 text-xs text-gray-500">
{filteredCategories.length > 0
? `${filteredCategories.reduce((sum, cat) => sum + cat.subItems.length, 0)}개 항목 검색됨`
: '검색 결과가 없습니다'
}
</div>
)}
</div>
{/* 카테고리 목록 */}
<div className="flex-1 overflow-y-auto divide-y divide-gray-200">
{filteredCategories.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
</div>
) : (
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 (
<div key={category.id}>
{/* 카테고리 헤더 */}
<button
type="button"
onClick={() => toggleCategory(category.id)}
className={cn(
'w-full flex items-center justify-between px-4 py-3 text-left transition-colors',
'hover:bg-gray-50',
allCompleted && 'bg-green-50'
)}
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<span className="text-sm font-medium text-gray-800">
{originalIndex + 1}. {highlightText(category.title, searchTerm)}
</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
allCompleted
? 'bg-green-100 text-green-700'
: 'bg-gray-200 text-gray-600'
)}>
{progress.completed}/{progress.total}
</span>
</button>
{/* 하위 항목 */}
{isExpanded && (
<div className="bg-gray-50 border-t border-gray-100">
{category.subItems.map((subItem, subIndex) => (
<SubItemRow
key={subItem.id}
subItem={subItem}
index={subIndex}
categoryId={category.id}
isSelected={selectedSubItemId === subItem.id}
onSelect={() => onSubItemSelect(category.id, subItem.id)}
onToggle={(isCompleted) => onSubItemToggle(category.id, subItem.id, isCompleted)}
searchTerm={searchTerm}
highlightText={highlightText}
/>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
);
}
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 (
<div
className={cn(
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors',
isSelected
? 'bg-blue-100 border-l-4 border-blue-500'
: 'hover:bg-gray-100 border-l-4 border-transparent',
subItem.isCompleted && !isSelected && 'bg-green-50/50'
)}
onClick={onSelect}
>
{/* 체크박스 */}
<button
type="button"
onClick={handleCheckboxClick}
className={cn(
'flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors',
subItem.isCompleted
? 'bg-green-500 border-green-500 text-white'
: 'bg-white border-gray-300 hover:border-blue-400'
)}
>
{subItem.isCompleted && <Check className="h-3 w-3" />}
</button>
{/* 항목 이름 */}
<span className={cn(
'text-sm flex-1',
subItem.isCompleted ? 'text-gray-500' : 'text-gray-700'
)}>
{index + 1}. {highlightText(subItem.name, searchTerm)}
</span>
{/* 완료 표시 */}
{subItem.isCompleted && (
<span className="text-xs text-green-600 font-medium"></span>
)}
</div>
);
}

View File

@@ -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 (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
<div className="text-center text-gray-400 py-12">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900"> </h3>
</div>
{/* 콘텐츠 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 항목 정보 */}
<div className="bg-blue-50 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">{checkItem.title}</h4>
<p className="text-sm text-blue-700">{checkItem.description}</p>
</div>
{/* 기준 문서 목록 */}
<div>
<h5 className="text-sm font-medium text-gray-700 mb-2"> </h5>
<div className="space-y-2">
{checkItem.standardDocuments.map((doc) => (
<DocumentRow
key={doc.id}
document={doc}
isSelected={selectedDocumentId === doc.id}
onSelect={() => onDocumentSelect(doc.id)}
/>
))}
</div>
</div>
{/* 확인 버튼 */}
<div className="pt-4 border-t border-gray-200">
<Button
onClick={onConfirmComplete}
disabled={isCompleted}
className={cn(
'w-full',
isCompleted
? 'bg-green-500 hover:bg-green-500 cursor-default'
: 'bg-blue-600 hover:bg-blue-700'
)}
>
{isCompleted ? (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
</>
) : (
<>
{checkItem.buttonLabel}
</>
)}
</Button>
</div>
</div>
</div>
);
}
interface DocumentRowProps {
document: StandardDocument;
isSelected: boolean;
onSelect: () => void;
}
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
return (
<div
className={cn(
'flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors',
isSelected
? 'bg-blue-50 border-blue-300'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
)}
onClick={onSelect}
>
{/* 아이콘 */}
<div className={cn(
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
document.fileName?.endsWith('.pdf')
? 'bg-red-100 text-red-600'
: 'bg-green-100 text-green-600'
)}>
<FileText className="h-5 w-5" />
</div>
{/* 문서 정보 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p>
<p className="text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
<span>{document.date}</span>
</p>
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1">
<button
type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="미리보기"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="다운로드"
onClick={(e) => {
e.stopPropagation();
// TODO: 다운로드 기능
}}
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -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 (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
<div className="text-center text-gray-400 py-12">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"> </p>
</div>
</div>
);
}
const isPdf = document.fileName?.endsWith('.pdf');
const isExcel = document.fileName?.endsWith('.xlsx') || document.fileName?.endsWith('.xls');
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn(
'w-8 h-8 rounded flex items-center justify-center',
isPdf ? 'bg-red-100 text-red-600' :
isExcel ? 'bg-green-100 text-green-600' :
'bg-gray-200 text-gray-600'
)}>
<FileText className="h-4 w-4" />
</div>
<div>
<h3 className="font-medium text-gray-900 text-sm">{document.title}</h3>
<p className="text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
{document.date}
</p>
</div>
</div>
{/* 툴바 */}
<div className="flex items-center gap-1">
<button
type="button"
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="축소"
>
<ZoomOut className="h-4 w-4" />
</button>
<button
type="button"
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="확대"
>
<ZoomIn className="h-4 w-4" />
</button>
<button
type="button"
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="전체화면"
>
<Maximize2 className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-1" />
<button
type="button"
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="인쇄"
>
<Printer className="h-4 w-4" />
</button>
<button
type="button"
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="다운로드"
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
{/* 문서 미리보기 영역 */}
<div className="flex-1 bg-gray-200 p-4 overflow-auto">
<div className="bg-white rounded shadow-lg max-w-3xl mx-auto min-h-[600px]">
{/* Mock 문서 내용 */}
<DocumentPreviewContent document={document} />
</div>
</div>
{/* 푸터 */}
<div className="bg-gray-100 px-4 py-2 border-t border-gray-200 flex items-center justify-between">
<span className="text-xs text-gray-500">
: {document.fileName || '-'}
</span>
<span className="text-xs text-gray-500">
1 / 1
</span>
</div>
</div>
);
}
// Mock 문서 미리보기 내용
function DocumentPreviewContent({ document }: { document: StandardDocument }) {
return (
<div className="p-8">
{/* 문서 헤더 */}
<div className="text-center border-b-2 border-gray-800 pb-4 mb-6">
<h1 className="text-xl font-bold text-gray-900">{document.title}</h1>
<div className="flex justify-center gap-6 mt-2 text-sm text-gray-600">
<span>문서번호: QM-{document.id}</span>
<span>: {document.version}</span>
<span>: {document.date}</span>
</div>
</div>
{/* Mock 문서 내용 */}
<div className="space-y-4 text-sm text-gray-700">
<section>
<h2 className="font-bold text-gray-900 mb-2">1. </h2>
<p className="pl-4">
{document.title}
.
</p>
</section>
<section>
<h2 className="font-bold text-gray-900 mb-2">2. </h2>
<p className="pl-4">
.
</p>
</section>
<section>
<h2 className="font-bold text-gray-900 mb-2">3. </h2>
<div className="pl-4 space-y-1">
<p>3.1 검사: 품질특성을 , </p>
<p>3.2 적합: 규정된 </p>
<p>3.3 부적합: 규정된 </p>
</div>
</section>
<section>
<h2 className="font-bold text-gray-900 mb-2">4. </h2>
<div className="pl-4 space-y-2">
<p>4.1 .</p>
<p>4.2 .</p>
<p>4.3 .</p>
</div>
</section>
{/* 문서 서명란 */}
<div className="mt-12 pt-6 border-t border-gray-300">
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div className="border border-gray-300 p-3">
<div className="text-gray-500 mb-2"></div>
<div className="h-12 flex items-center justify-center text-gray-400">
()
</div>
</div>
<div className="border border-gray-300 p-3">
<div className="text-gray-500 mb-2"></div>
<div className="h-12 flex items-center justify-center text-gray-400">
()
</div>
</div>
<div className="border border-gray-300 p-3">
<div className="text-gray-500 mb-2"></div>
<div className="h-12 flex items-center justify-center text-gray-400">
()
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="mb-4 space-y-3">
{/* 탭 버튼 */}
<div className="flex gap-3">
{/* 1일차 탭 */}
<button
type="button"
onClick={() => onDayChange(1)}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 transition-all',
activeDay === 1
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
)}
>
<Calendar className="h-4 w-4" />
<span className="font-medium">1일차: 기준/ </span>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full ml-2',
activeDay === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
)}>
{day1Progress.completed}/{day1Progress.total}
</span>
</button>
{/* 2일차 탭 */}
<button
type="button"
onClick={() => onDayChange(2)}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 transition-all',
activeDay === 2
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
)}
>
<Calendar className="h-4 w-4" />
<span className="font-medium">2일차: 로트추적 </span>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full ml-2',
activeDay === 2 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
)}>
{day2Progress.completed}/{day2Progress.total}
</span>
</button>
</div>
{/* 전체 진행률 - 한줄 표시 */}
<div className="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-4">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"> </span>
{/* 전체 프로그레스 바 */}
<div className="flex-1 flex items-center gap-3">
<div className="flex-1 h-2.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-500"
style={{ width: `${overallPercentage}%` }}
/>
</div>
<span className="text-sm font-bold text-blue-600 whitespace-nowrap">{overallPercentage}%</span>
</div>
{/* 구분선 */}
<div className="w-px h-5 bg-gray-300" />
{/* 1일차 진행률 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">1</span>
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
day1Percentage === 100 ? 'bg-green-500' : 'bg-blue-500'
)}
style={{ width: `${day1Percentage}%` }}
/>
</div>
<span className={cn(
'text-xs font-medium whitespace-nowrap',
day1Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day1Percentage}%
</span>
</div>
{/* 2일차 진행률 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">2</span>
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
day2Percentage === 100 ? 'bg-green-500' : 'bg-blue-500'
)}
style={{ width: `${day2Percentage}%` }}
/>
</div>
<span className={cn(
'text-xs font-medium whitespace-nowrap',
day2Percentage === 100 ? 'text-green-600' : 'text-gray-600'
)}>
{day2Percentage}%
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,21 @@
import React from 'react';
export const Header = () => {
interface HeaderProps {
rightContent?: React.ReactNode;
}
export const Header = ({ rightContent }: HeaderProps) => {
return (
<div className="w-full bg-[#1e3a8a] text-white p-6 rounded-lg mb-4 shadow-md flex flex-col justify-center h-24">
<h1 className="text-2xl font-bold mb-1"> </h1>
<p className="text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
<div className="w-full bg-[#1e3a8a] text-white p-6 rounded-lg mb-4 shadow-md flex items-center justify-between h-24">
<div className="flex flex-col justify-center">
<h1 className="text-2xl font-bold mb-1"> </h1>
<p className="text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
</div>
{rightContent && (
<div className="flex items-center">
{rightContent}
</div>
)}
</div>
);
};

View File

@@ -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<string, StandardDocument[]> = {
'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'] || [],
},
];

View File

@@ -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<AuditDisplaySettings>(DEFAULT_SETTINGS);
// 1일차 상태
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// 2일차(로트추적) 필터 상태
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedQuarter, setSelectedQuarter] = useState<string>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 선택 상태
// 2일차 선택 상태
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
@@ -25,19 +60,98 @@ export default function QualityInspectionPage() {
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(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 (
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
<Header />
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
{/* 헤더 (설정 버튼 포함) */}
<Header
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
/>
<div className="flex-1 grid grid-cols-12 gap-6 lg:min-h-0">
{/* Left Panel: Report List */}
<div className="col-span-12 lg:col-span-3 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<ReportList
reports={filteredReports}
selectedId={selectedReport?.id || null}
onSelect={handleReportSelect}
/>
{/* 1일차/2일차 탭 + 진행률 */}
{displaySettings.showProgressBar ? (
<DayTabs
activeDay={activeDay}
onDayChange={setActiveDay}
day1Progress={day1Progress}
day2Progress={day2Progress}
/>
) : (
// 진행률 숨김 시 탭만 표시
<div className="flex gap-3 mb-4">
<button
type="button"
onClick={() => setActiveDay(1)}
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-all ${
activeDay === 1
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300'
}`}
>
1일차: 기준/
</button>
<button
type="button"
onClick={() => setActiveDay(2)}
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-all ${
activeDay === 2
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300'
}`}
>
2일차: 로트추적
</button>
</div>
)}
{/* Middle Panel: Route List */}
<div className="col-span-12 lg:col-span-4 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<RouteList
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect}
reportCode={selectedReport?.code || null}
/>
</div>
{activeDay === 1 ? (
// ===== 1일차: 기준/매뉴얼 심사 =====
<div className="flex-1 grid grid-cols-12 gap-4 lg:min-h-0">
{/* 좌측: 점검표 항목 */}
<div className={`col-span-12 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
? 'lg:col-span-3'
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
? 'lg:col-span-4'
: 'lg:col-span-12'
}`}>
<Day1ChecklistPanel
categories={filteredDay1Categories}
selectedSubItemId={selectedSubItemId}
onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle}
/>
</div>
{/* Right Panel: Documents */}
<div className="col-span-12 lg:col-span-5 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<DocumentList
documents={currentDocuments}
routeCode={selectedRoute?.code || null}
onViewDocument={handleViewDocument}
/>
{/* 중앙: 기준 문서화 */}
{displaySettings.showDocumentSection && (
<div className={`col-span-12 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
}`}>
<Day1DocumentSection
checkItem={selectedCheckItem}
selectedDocumentId={selectedStandardDocId}
onDocumentSelect={setSelectedStandardDocId}
onConfirmComplete={handleConfirmComplete}
isCompleted={isSelectedItemCompleted}
/>
</div>
)}
{/* 우측: 문서 뷰어 */}
{displaySettings.showDocumentViewer && (
<div className={`col-span-12 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}>
<Day1DocumentViewer document={selectedStandardDoc} />
</div>
)}
</div>
</div>
) : (
// ===== 2일차: 로트추적 심사 =====
<>
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
/>
<div className="flex-1 grid grid-cols-12 gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<ReportList
reports={filteredReports}
selectedId={selectedReport?.id || null}
onSelect={handleReportSelect}
/>
</div>
<div className="col-span-12 lg:col-span-4 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<RouteList
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect}
reportCode={selectedReport?.code || null}
/>
</div>
<div className="col-span-12 lg:col-span-5 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<DocumentList
documents={currentDocuments}
routeCode={selectedRoute?.code || null}
onViewDocument={handleViewDocument}
/>
</div>
</div>
</>
)}
{/* 설정 패널 */}
<AuditSettingsPanel
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={displaySettings}
onSettingsChange={setDisplaySettings}
/>
<InspectionModal
isOpen={modalOpen}
@@ -148,4 +347,4 @@ export default function QualityInspectionPage() {
/>
</div>
);
}
}

View File

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