feat(WEB): QMS 품질관리 Day1 심사 기능 구현
- Day1 체크리스트 패널 및 문서 뷰어 컴포넌트 추가 - 심사 진행 상태바 및 설정 패널 구현 - Day 탭 네비게이션 컴포넌트 추가 - 목업 데이터 확장 및 타입 정의 보강 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx
Normal file
134
src/app/[locale]/(protected)/quality/qms/components/DayTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const Header = () => {
|
interface HeaderProps {
|
||||||
|
rightContent?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = ({ rightContent }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-[#1e3a8a] text-white p-6 rounded-lg mb-4 shadow-md flex flex-col justify-center h-24">
|
<div className="w-full bg-[#1e3a8a] text-white p-6 rounded-lg mb-4 shadow-md flex items-center justify-between h-24">
|
||||||
<h1 className="text-2xl font-bold mb-1">품질인정심사 시스템</h1>
|
<div className="flex flex-col justify-center">
|
||||||
<p className="text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { WorkOrder } from '@/components/production/ProductionDashboard/types';
|
||||||
import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types';
|
import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types';
|
||||||
|
|
||||||
@@ -337,3 +337,254 @@ export const DEFAULT_DOCUMENTS: Document[] = [
|
|||||||
{ id: 'def-7', type: 'product', title: '제품검사 성적서', count: 0, items: [] },
|
{ id: 'def-7', type: 'product', title: '제품검사 성적서', count: 0, items: [] },
|
||||||
{ id: 'def-8', type: 'quality', 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'] || [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,22 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Filters } from './components/Filters';
|
import { Filters } from './components/Filters';
|
||||||
import { ReportList } from './components/ReportList';
|
import { ReportList } from './components/ReportList';
|
||||||
import { RouteList } from './components/RouteList';
|
import { RouteList } from './components/RouteList';
|
||||||
import { DocumentList } from './components/DocumentList';
|
import { DocumentList } from './components/DocumentList';
|
||||||
import { InspectionModal } from './components/InspectionModal';
|
import { InspectionModal } from './components/InspectionModal';
|
||||||
import { InspectionReport, RouteItem, Document, DocumentItem } from './types';
|
import { DayTabs } from './components/DayTabs';
|
||||||
import { MOCK_REPORTS, MOCK_ROUTES, MOCK_DOCUMENTS, DEFAULT_DOCUMENTS } from './mockData';
|
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() {
|
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 [selectedYear, setSelectedYear] = useState(2025);
|
||||||
const [selectedQuarter, setSelectedQuarter] = useState<string>('전체');
|
const [selectedQuarter, setSelectedQuarter] = useState<string>('전체');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
// 선택 상태
|
// 2일차 선택 상태
|
||||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | 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 [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | 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(() => {
|
const filteredReports = useMemo(() => {
|
||||||
return MOCK_REPORTS.filter((report) => {
|
return MOCK_REPORTS.filter((report) => {
|
||||||
// 년도 필터
|
|
||||||
if (report.year !== selectedYear) return false;
|
if (report.year !== selectedYear) return false;
|
||||||
|
|
||||||
// 분기 필터
|
|
||||||
if (selectedQuarter !== '전체') {
|
if (selectedQuarter !== '전체') {
|
||||||
const quarterNum = parseInt(selectedQuarter.replace('분기', ''));
|
const quarterNum = parseInt(selectedQuarter.replace('분기', ''));
|
||||||
if (report.quarterNum !== quarterNum) return false;
|
if (report.quarterNum !== quarterNum) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색어 필터
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
const term = searchTerm.toLowerCase();
|
const term = searchTerm.toLowerCase();
|
||||||
const matchesCode = report.code.toLowerCase().includes(term);
|
const matchesCode = report.code.toLowerCase().includes(term);
|
||||||
@@ -45,42 +159,35 @@ export default function QualityInspectionPage() {
|
|||||||
const matchesItem = report.item.toLowerCase().includes(term);
|
const matchesItem = report.item.toLowerCase().includes(term);
|
||||||
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [selectedYear, selectedQuarter, searchTerm]);
|
}, [selectedYear, selectedQuarter, searchTerm]);
|
||||||
|
|
||||||
// 선택된 리포트의 루트 목록
|
|
||||||
const currentRoutes = useMemo(() => {
|
const currentRoutes = useMemo(() => {
|
||||||
if (!selectedReport) return [];
|
if (!selectedReport) return [];
|
||||||
return MOCK_ROUTES[selectedReport.id] || [];
|
return MOCK_ROUTES[selectedReport.id] || [];
|
||||||
}, [selectedReport]);
|
}, [selectedReport]);
|
||||||
|
|
||||||
// 선택된 루트의 문서 목록
|
|
||||||
const currentDocuments = useMemo(() => {
|
const currentDocuments = useMemo(() => {
|
||||||
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
||||||
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
||||||
}, [selectedRoute]);
|
}, [selectedRoute]);
|
||||||
|
|
||||||
// 리포트 선택 핸들러
|
|
||||||
const handleReportSelect = (report: InspectionReport) => {
|
const handleReportSelect = (report: InspectionReport) => {
|
||||||
setSelectedReport(report);
|
setSelectedReport(report);
|
||||||
setSelectedRoute(null); // 루트 선택 초기화
|
setSelectedRoute(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 루트 선택 핸들러
|
|
||||||
const handleRouteSelect = (route: RouteItem) => {
|
const handleRouteSelect = (route: RouteItem) => {
|
||||||
setSelectedRoute(route);
|
setSelectedRoute(route);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 문서 보기 핸들러
|
|
||||||
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
|
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
|
||||||
setSelectedDoc(doc);
|
setSelectedDoc(doc);
|
||||||
setSelectedDocItem(item || null);
|
setSelectedDocItem(item || null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 핸들러
|
|
||||||
const handleYearChange = (year: number) => {
|
const handleYearChange = (year: number) => {
|
||||||
setSelectedYear(year);
|
setSelectedYear(year);
|
||||||
setSelectedReport(null);
|
setSelectedReport(null);
|
||||||
@@ -99,46 +206,138 @@ export default function QualityInspectionPage() {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 />
|
{/* 헤더 (설정 버튼 포함) */}
|
||||||
|
<Header
|
||||||
<Filters
|
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||||
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">
|
{/* 1일차/2일차 탭 + 진행률 */}
|
||||||
{/* Left Panel: Report List */}
|
{displaySettings.showProgressBar ? (
|
||||||
<div className="col-span-12 lg:col-span-3 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
<DayTabs
|
||||||
<ReportList
|
activeDay={activeDay}
|
||||||
reports={filteredReports}
|
onDayChange={setActiveDay}
|
||||||
selectedId={selectedReport?.id || null}
|
day1Progress={day1Progress}
|
||||||
onSelect={handleReportSelect}
|
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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Middle Panel: Route List */}
|
{activeDay === 1 ? (
|
||||||
<div className="col-span-12 lg:col-span-4 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
// ===== 1일차: 기준/매뉴얼 심사 =====
|
||||||
<RouteList
|
<div className="flex-1 grid grid-cols-12 gap-4 lg:min-h-0">
|
||||||
routes={currentRoutes}
|
{/* 좌측: 점검표 항목 */}
|
||||||
selectedId={selectedRoute?.id || null}
|
<div className={`col-span-12 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||||
onSelect={handleRouteSelect}
|
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||||
reportCode={selectedReport?.code || null}
|
? 'lg:col-span-3'
|
||||||
/>
|
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||||
</div>
|
? '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">
|
{displaySettings.showDocumentSection && (
|
||||||
<DocumentList
|
<div className={`col-span-12 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||||
documents={currentDocuments}
|
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||||
routeCode={selectedRoute?.code || null}
|
}`}>
|
||||||
onViewDocument={handleViewDocument}
|
<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>
|
||||||
</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
|
<InspectionModal
|
||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
|
|||||||
@@ -43,3 +43,52 @@ export interface DocumentItem {
|
|||||||
// 중간검사 성적서 서브타입 (report 타입일 때만 사용)
|
// 중간검사 성적서 서브타입 (report 타입일 때만 사용)
|
||||||
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user