feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
295
src/components/production/WorkOrders/WipProductionModal.tsx
Normal file
295
src/components/production/WorkOrders/WipProductionModal.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재공품 생산 모달
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 품목 선택 (검색) → 재공품 목록 테이블 추가
|
||||
* - 테이블: 품목코드, 품목명, 규격, 단위, 재고량, 안전재고, 수량(입력)
|
||||
* - 행 삭제 (X 버튼)
|
||||
* - 우선순위: 긴급/우선/일반 토글 (디폴트: 일반)
|
||||
* - 부서 Select (디폴트: 생산부서)
|
||||
* - 비고 Textarea
|
||||
* - 하단: 취소 / 생산지시 확정
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 재공품 아이템 타입
|
||||
interface WipItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
stockQuantity: number;
|
||||
safetyStock: number;
|
||||
quantity: number; // 사용자 입력
|
||||
}
|
||||
|
||||
// 우선순위
|
||||
type Priority = '긴급' | '우선' | '일반';
|
||||
|
||||
// Mock 재공품 데이터 (품목관리 > 재공품/사용 상태 '사용' 품목)
|
||||
const MOCK_WIP_ITEMS: Omit<WipItem, 'quantity'>[] = [
|
||||
{ id: 'wip-1', itemCode: 'WIP-GR-001', itemName: '가이드레일(벽면형)', specification: '120X70 EGI 1.6T', unit: 'EA', stockQuantity: 150, safetyStock: 50 },
|
||||
{ id: 'wip-2', itemCode: 'WIP-CS-001', itemName: '케이스(500X380)', specification: '500X380 EGI 1.6T', unit: 'EA', stockQuantity: 80, safetyStock: 30 },
|
||||
{ id: 'wip-3', itemCode: 'WIP-BF-001', itemName: '하단마감재(60X40)', specification: '60X40 EGI 1.6T', unit: 'EA', stockQuantity: 200, safetyStock: 100 },
|
||||
{ id: 'wip-4', itemCode: 'WIP-LB-001', itemName: '하단L-BAR(17X60)', specification: '17X60 EGI 1.6T', unit: 'EA', stockQuantity: 120, safetyStock: 40 },
|
||||
];
|
||||
|
||||
interface WipProductionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function WipProductionModal({ open, onOpenChange }: WipProductionModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<WipItem[]>([]);
|
||||
const [priority, setPriority] = useState<Priority>('일반');
|
||||
const [department, setDepartment] = useState('생산부서');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
// 검색 결과 필터링
|
||||
const searchResults = searchTerm.trim()
|
||||
? MOCK_WIP_ITEMS.filter(
|
||||
(item) =>
|
||||
!selectedItems.some((s) => s.id === item.id) &&
|
||||
(item.itemCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.itemName.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
: [];
|
||||
|
||||
// 품목 추가
|
||||
const handleAddItem = useCallback((item: Omit<WipItem, 'quantity'>) => {
|
||||
setSelectedItems((prev) => [...prev, { ...item, quantity: 0 }]);
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
|
||||
// 품목 삭제
|
||||
const handleRemoveItem = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
// 수량 변경
|
||||
const handleQuantityChange = useCallback((id: string, value: string) => {
|
||||
const qty = parseInt(value) || 0;
|
||||
setSelectedItems((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: qty } : item))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 생산지시 확정
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error('품목을 추가해주세요.');
|
||||
return;
|
||||
}
|
||||
const invalidItems = selectedItems.filter((item) => item.quantity <= 0);
|
||||
if (invalidItems.length > 0) {
|
||||
toast.error('수량을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 연동
|
||||
toast.success(`재공품 생산지시가 확정되었습니다. (${selectedItems.length}건)`);
|
||||
handleReset();
|
||||
onOpenChange(false);
|
||||
}, [selectedItems, onOpenChange]);
|
||||
|
||||
// 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
setSearchTerm('');
|
||||
setSelectedItems([]);
|
||||
setPriority('일반');
|
||||
setDepartment('생산부서');
|
||||
setNote('');
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
handleReset();
|
||||
onOpenChange(false);
|
||||
}, [handleReset, onOpenChange]);
|
||||
|
||||
const priorityOptions: Priority[] = ['긴급', '우선', '일반'];
|
||||
const priorityColors: Record<Priority, string> = {
|
||||
'긴급': 'bg-red-500 text-white hover:bg-red-600',
|
||||
'우선': 'bg-orange-500 text-white hover:bg-orange-600',
|
||||
'일반': 'bg-gray-500 text-white hover:bg-gray-600',
|
||||
};
|
||||
const priorityInactiveColors: Record<Priority, string> = {
|
||||
'긴급': 'bg-white text-red-500 border-red-300 hover:bg-red-50',
|
||||
'우선': 'bg-white text-orange-500 border-orange-300 hover:bg-orange-50',
|
||||
'일반': 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50',
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재공품 생산</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* 품목 검색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">품목 선택</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 결과 드롭다운 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="border rounded-md bg-white shadow-md max-h-40 overflow-y-auto">
|
||||
{searchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-gray-50 text-sm"
|
||||
onClick={() => handleAddItem(item)}
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs">{item.itemCode}</span>
|
||||
<span className="font-medium">{item.itemName}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.specification}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 재공품 목록 테이블 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-xs">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">단위</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">재고량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs">안전재고</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-24">수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-xs w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.itemCode}</td>
|
||||
<td className="px-3 py-2">{item.itemName}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{item.specification}</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="px-3 py-2 text-center">{item.stockQuantity}</td>
|
||||
<td className="px-3 py-2 text-center">{item.safetyStock}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={item.quantity || ''}
|
||||
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
|
||||
className="h-8 text-center text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">우선순위</Label>
|
||||
<div className="flex gap-2">
|
||||
{priorityOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${
|
||||
priority === opt ? priorityColors[opt] : priorityInactiveColors[opt]
|
||||
}`}
|
||||
onClick={() => setPriority(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">부서</Label>
|
||||
<Select value={department} onValueChange={setDepartment}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="생산부서">생산부서</SelectItem>
|
||||
<SelectItem value="품질관리부">품질관리부</SelectItem>
|
||||
<SelectItem value="자재부">자재부</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">비고</Label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
생산지시 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, Loader, CheckCircle2, AlertTriangle, TimerOff } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
type WorkOrderStatus,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { WipProductionModal } from './WipProductionModal';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -93,6 +95,10 @@ const filterConfig: FilterFieldConfig[] = [
|
||||
export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 활성 탭 및 재공품 모달 =====
|
||||
const [activeTab, setActiveTab] = useState('screen');
|
||||
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
|
||||
|
||||
// ===== 공정 ID 매핑 (getProcessOptions) =====
|
||||
const [processMap, setProcessMap] = useState<Record<string, number>>({});
|
||||
const [processMapLoaded, setProcessMapLoaded] = useState(false);
|
||||
@@ -240,6 +246,7 @@ export function WorkOrderList() {
|
||||
try {
|
||||
// 탭 → processId 매핑
|
||||
const tabValue = params?.tab || 'screen';
|
||||
setActiveTab(tabValue);
|
||||
const processId = processMap[tabValue];
|
||||
|
||||
// 해당 공정이 DB에 없으면 빈 목록 반환
|
||||
@@ -342,6 +349,17 @@ export function WorkOrderList() {
|
||||
defaultTab: 'screen',
|
||||
tabsPosition: 'above-stats',
|
||||
|
||||
// 테이블 헤더 액션 (절곡 탭일 때만 재공품 생산 버튼)
|
||||
tableHeaderActions: activeTab === 'bending' ? (
|
||||
<Button
|
||||
onClick={() => setIsWipModalOpen(true)}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
size="sm"
|
||||
>
|
||||
재공품 생산
|
||||
</Button>
|
||||
) : undefined,
|
||||
|
||||
// 통계 카드 (6개)
|
||||
stats,
|
||||
|
||||
@@ -446,7 +464,7 @@ export function WorkOrderList() {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[tabs, stats, processMap, handleRowClick]
|
||||
[tabs, stats, processMap, handleRowClick, activeTab]
|
||||
);
|
||||
|
||||
// processMap 로딩 완료 전에는 UniversalListPage를 마운트하지 않음
|
||||
@@ -459,5 +477,13 @@ export function WorkOrderList() {
|
||||
);
|
||||
}
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} />
|
||||
<WipProductionModal
|
||||
open={isWipModalOpen}
|
||||
onOpenChange={setIsWipModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 절곡 재공품 통합 문서 (작업일지 & 중간검사성적서)
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 제목: "절곡품 재고생산 작업일지 중간검사성적서"
|
||||
* - 결재란: 작성/승인/승인/승인
|
||||
* - 기본 정보: 제품명, 규격, 길이, 판고 LOT NO / 생산 LOT NO, 수량, 검사일자, 검사자
|
||||
* - ■ 중간검사 기준서 KDPS-20: 도해 IMG + 검사항목(겉모양/절곡상태, 치수/길이/폭/간격)
|
||||
* - ■ 중간검사 DATA: No, 제품명, 절곡상태(양호/불량), 길이(mm), 너비(mm)+포인트, 간격(mm), 판정
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface BendingWipInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
productName: string; // 제품명
|
||||
processStatus: CheckStatus; // 절곡상태
|
||||
lengthDesign: string; // 길이 도면치수
|
||||
lengthMeasured: string; // 길이 측정값
|
||||
widthDesign: string; // 너비 도면치수
|
||||
widthMeasured: string; // 너비 측정값
|
||||
spacingPoint: string; // 너비 포인트
|
||||
spacingDesign: string; // 간격 도면치수
|
||||
spacingMeasured: string; // 간격 측정값
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const BendingWipInspectionContent = forwardRef<InspectionContentRef, BendingWipInspectionContentProps>(function BendingWipInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
// 아이템 기반 초기 행 생성
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() => {
|
||||
const items = order.items || [];
|
||||
const count = Math.max(items.length, DEFAULT_ROW_COUNT);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
productName: items[i]?.productName || '',
|
||||
processStatus: null,
|
||||
lengthDesign: '4000',
|
||||
lengthMeasured: '',
|
||||
widthDesign: 'N/A',
|
||||
widthMeasured: 'N/A',
|
||||
spacingPoint: '',
|
||||
spacingDesign: '380',
|
||||
spacingMeasured: '',
|
||||
}));
|
||||
});
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, processStatus: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleNumericInput = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
const filtered = value.replace(/[^\d.]/g, '');
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: filtered } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
// 행별 판정 자동 계산
|
||||
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
|
||||
if (row.processStatus === '불량') return '부';
|
||||
if (row.processStatus === '양호') return '적';
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const overallResult = useMemo(() => {
|
||||
const judgments = rows.map(getRowJudgment);
|
||||
if (judgments.some(j => j === '부')) return '불합격';
|
||||
if (judgments.every(j => j === '적')) return '합격';
|
||||
return null;
|
||||
}, [rows, getRowJudgment]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => ({
|
||||
rows: rows.map(row => ({
|
||||
id: row.id,
|
||||
productName: row.productName,
|
||||
processStatus: row.processStatus,
|
||||
lengthMeasured: row.lengthMeasured,
|
||||
widthMeasured: row.widthMeasured,
|
||||
spacingPoint: row.spacingPoint,
|
||||
spacingMeasured: row.spacingMeasured,
|
||||
})),
|
||||
inadequateContent,
|
||||
overallResult,
|
||||
}),
|
||||
}), [rows, inadequateContent, overallResult]);
|
||||
|
||||
// PDF 호환 체크박스 렌더
|
||||
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||
}`}
|
||||
onClick={() => !readOnly && onClick()}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
);
|
||||
|
||||
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* ===== 헤더 영역 ===== */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">절곡품 재고생산 작업일지 중간검사성적서</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24">제품명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.productName || '가이드레일'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28">판고 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">규격</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.specification || 'EGI 1.6T'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">생산 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.workOrderNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-3 py-2">3,000 mm</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">수량</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.items?.reduce((sum, item) => sum + item.quantity, 0) || 0} EA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사일자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{today}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 기준서 KDPS-20 ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 기준서 KDPS-20</div>
|
||||
<table className="w-full table-fixed border-collapse text-xs mb-6">
|
||||
<colgroup>
|
||||
<col style={{width: '200px'}} />
|
||||
<col style={{width: '52px'}} />
|
||||
<col style={{width: '58px'}} />
|
||||
<col />
|
||||
<col style={{width: '68px'}} />
|
||||
<col style={{width: '78px'}} />
|
||||
<col style={{width: '120px'}} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 - 넓게 */}
|
||||
<td className="border border-gray-400 p-3 text-center align-middle" rowSpan={4}>
|
||||
<div className="text-xs font-medium text-gray-500 mb-2 text-left">도해</div>
|
||||
<div className="h-32 border border-gray-300 rounded flex items-center justify-center text-gray-300 text-sm">IMG</div>
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사기준</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사방법</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사주기</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">관련규정</th>
|
||||
</tr>
|
||||
{/* 겉모양 > 절곡상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">겉모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">절곡상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">사용상 재료로 결함이 없을것</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">육안검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>n = 1, c = 0</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1항</td>
|
||||
</tr>
|
||||
{/* 치수 > 길이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={2}>치수<br/>(mm)</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-2 py-1">도면치수 ± 4</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}>체크검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 7항<br/>표9</td>
|
||||
</tr>
|
||||
{/* 치수 > 간격 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">간격</td>
|
||||
<td className="border border-gray-400 px-2 py-1">도면치수 ± 2</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 7항<br/>표9 / 자체규정</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 DATA ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 DATA</div>
|
||||
<table className="w-full border-collapse text-xs mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
|
||||
<th className="border border-gray-400 p-1 w-20" rowSpan={2}>제품명</th>
|
||||
<th className="border border-gray-400 p-1 w-16" rowSpan={2}>절곡상태<br/>겉모양</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>길이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>너비 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={3}>간격 (mm)</th>
|
||||
<th className="border border-gray-400 p-1 w-14" rowSpan={2}>판정<br/>(적/부)</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-12">포인트</th>
|
||||
<th className="border border-gray-400 p-1 w-14">도면치수</th>
|
||||
<th className="border border-gray-400 p-1 w-14">측정값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const judgment = getRowJudgment(row);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
|
||||
{/* 제품명 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.productName}
|
||||
onChange={(e) => handleInputChange(row.id, 'productName', e.target.value)}
|
||||
disabled={readOnly}
|
||||
className={inputClass}
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
{/* 절곡상태 - 단일 셀, 세로 체크박스 (절곡 버전 동일) */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(row.processStatus === '양호', () => handleStatusChange(row.id, row.processStatus === '양호' ? null : '양호'))}
|
||||
양호
|
||||
</label>
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(row.processStatus === '불량', () => handleStatusChange(row.id, row.processStatus === '불량' ? null : '불량'))}
|
||||
불량
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
{/* 길이 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
|
||||
{/* 길이 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.lengthMeasured} onChange={(e) => handleNumericInput(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 너비 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
|
||||
{/* 너비 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 간격 - 포인트 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.spacingPoint} onChange={(e) => handleInputChange(row.id, 'spacingPoint', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 간격 - 도면치수 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.spacingDesign}</td>
|
||||
{/* 간격 - 측정값 */}
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.spacingMeasured} onChange={(e) => handleNumericInput(row.id, 'spacingMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 판정 - 자동 계산 */}
|
||||
<td className={`border border-gray-400 p-1 text-center font-bold ${
|
||||
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
{judgment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 부적합 내용 + 종합판정 ===== */}
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||
</td>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{overallResult || '합격'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -19,12 +19,15 @@ import type { WorkOrder, ProcessType } from '../types';
|
||||
import { ScreenInspectionContent } from './ScreenInspectionContent';
|
||||
import { SlatInspectionContent } from './SlatInspectionContent';
|
||||
import { BendingInspectionContent } from './BendingInspectionContent';
|
||||
import { BendingWipInspectionContent } from './BendingWipInspectionContent';
|
||||
import { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
|
||||
import type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
|
||||
const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
bending_wip: '절곡 재공품',
|
||||
};
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
@@ -33,6 +36,7 @@ interface InspectionReportModalProps {
|
||||
workOrderId: string | null;
|
||||
processType?: ProcessType;
|
||||
readOnly?: boolean;
|
||||
isJointBar?: boolean;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
@@ -41,6 +45,7 @@ export function InspectionReportModal({
|
||||
workOrderId,
|
||||
processType = 'screen',
|
||||
readOnly = true,
|
||||
isJointBar = false,
|
||||
}: InspectionReportModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -138,6 +143,11 @@ export function InspectionReportModal({
|
||||
|
||||
const processLabel = PROCESS_LABELS[processType] || '스크린';
|
||||
const subtitle = order ? `${processLabel} 생산부서` : undefined;
|
||||
const modalTitle = processType === 'bending_wip'
|
||||
? '절곡품 재고생산 작업일지 중간검사성적서'
|
||||
: (isJointBar || (order?.items?.some(item => item.productName?.includes('조인트바'))))
|
||||
? '중간검사성적서 (조인트바)'
|
||||
: '중간검사 성적서';
|
||||
|
||||
const renderContent = () => {
|
||||
if (!order) return null;
|
||||
@@ -146,9 +156,15 @@ export function InspectionReportModal({
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'slat':
|
||||
// 조인트바 여부 체크: isJointBar prop 또는 items에서 자동 감지
|
||||
if (isJointBar || order.items?.some(item => item.productName?.includes('조인트바'))) {
|
||||
return <SlatJointBarInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
}
|
||||
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
case 'bending_wip':
|
||||
return <BendingWipInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
default:
|
||||
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
|
||||
}
|
||||
@@ -167,7 +183,7 @@ export function InspectionReportModal({
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="중간검사 성적서"
|
||||
title={modalTitle}
|
||||
subtitle={subtitle}
|
||||
preset="inspection"
|
||||
open={open}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 슬랫 조인트바 중간검사성적서
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 제목: "중간검사성적서 (조인트바)"
|
||||
* - 결재란: 작성/승인/승인/승인
|
||||
* - 기본 정보: 제품명/슬랫, 규격/슬랫, 수주처, 현장명 / 제품 LOT NO, 부서, 검사일자, 검사자
|
||||
* - ■ 중간검사 기준서 KOPS-20: 도해 IMG + 치수 기준 (43.1 ± 0.5 등)
|
||||
* - ■ 중간검사 DATA: No, 가공상태, 조립상태, ①높이(기준치/측정값),
|
||||
* ②높이(기준치/측정값), ③길이(기준치/측정값), ④간격(기준치/측정값), 판정
|
||||
* - 부적합 내용 / 종합판정 (자동)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { WorkOrder } from '../types';
|
||||
|
||||
export interface InspectionContentRef {
|
||||
getInspectionData: () => unknown;
|
||||
}
|
||||
|
||||
interface SlatJointBarInspectionContentProps {
|
||||
data: WorkOrder;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
type CheckStatus = '양호' | '불량' | null;
|
||||
|
||||
interface InspectionRow {
|
||||
id: number;
|
||||
processStatus: CheckStatus; // 가공상태
|
||||
assemblyStatus: CheckStatus; // 조립상태
|
||||
height1Standard: string; // ①높이 기준치
|
||||
height1Measured: string; // ①높이 측정값
|
||||
height2Standard: string; // ②높이 기준치
|
||||
height2Measured: string; // ②높이 측정값
|
||||
lengthDesign: string; // 길이 도면치수
|
||||
lengthMeasured: string; // 길이 측정값
|
||||
intervalStandard: string; // 간격 기준치
|
||||
intervalMeasured: string; // 간격 측정값
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_COUNT = 6;
|
||||
|
||||
export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, SlatJointBarInspectionContentProps>(function SlatJointBarInspectionContent({ data: order, readOnly = false }, ref) {
|
||||
const fullDate = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = order.workOrderNo || 'ABC123';
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
const [rows, setRows] = useState<InspectionRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
|
||||
id: i + 1,
|
||||
processStatus: null,
|
||||
assemblyStatus: null,
|
||||
height1Standard: '43.1 ± 0.5',
|
||||
height1Measured: '',
|
||||
height2Standard: '14.5 ± 1',
|
||||
height2Measured: '',
|
||||
lengthDesign: '',
|
||||
lengthMeasured: '',
|
||||
intervalStandard: '150 ± 4',
|
||||
intervalMeasured: '',
|
||||
}))
|
||||
);
|
||||
|
||||
const [inadequateContent, setInadequateContent] = useState('');
|
||||
|
||||
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => {
|
||||
if (readOnly) return;
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: value } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
|
||||
if (readOnly) return;
|
||||
const filtered = value.replace(/[^\d.]/g, '');
|
||||
setRows(prev => prev.map(row =>
|
||||
row.id === rowId ? { ...row, [field]: filtered } : row
|
||||
));
|
||||
}, [readOnly]);
|
||||
|
||||
// 행별 판정 자동 계산
|
||||
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
|
||||
const { processStatus, assemblyStatus } = row;
|
||||
if (processStatus === '불량' || assemblyStatus === '불량') return '부';
|
||||
if (processStatus === '양호' && assemblyStatus === '양호') return '적';
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const overallResult = useMemo(() => {
|
||||
const judgments = rows.map(getRowJudgment);
|
||||
if (judgments.some(j => j === '부')) return '불합격';
|
||||
if (judgments.every(j => j === '적')) return '합격';
|
||||
return null;
|
||||
}, [rows, getRowJudgment]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => ({
|
||||
rows: rows.map(row => ({
|
||||
id: row.id,
|
||||
processStatus: row.processStatus,
|
||||
assemblyStatus: row.assemblyStatus,
|
||||
height1Measured: row.height1Measured,
|
||||
height2Measured: row.height2Measured,
|
||||
lengthMeasured: row.lengthMeasured,
|
||||
intervalMeasured: row.intervalMeasured,
|
||||
})),
|
||||
inadequateContent,
|
||||
overallResult,
|
||||
}),
|
||||
}), [rows, inadequateContent, overallResult]);
|
||||
|
||||
// PDF 호환 체크박스 렌더
|
||||
const renderCheckbox = (checked: boolean, onClick: () => void) => (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
|
||||
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
|
||||
}`}
|
||||
onClick={() => !readOnly && onClick()}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
|
||||
<td className="border border-gray-400 p-1">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
|
||||
양호
|
||||
</label>
|
||||
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
|
||||
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
|
||||
불량
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* ===== 헤더 영역 ===== */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">중간검사성적서 (조인트바)</h1>
|
||||
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
|
||||
문서번호: {documentNo} | 작성일자: {fullDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24">제품명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">슬랫</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28">제품 LOT NO</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">규격</td>
|
||||
<td className="border border-gray-400 px-3 py-2">슬랫</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">부서</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.department || '생산부'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">수주처</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사일자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{today}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">현장명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
|
||||
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium">검사자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 기준서 KOPS-20 ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 기준서 KOPS-20</div>
|
||||
<table className="w-full border-collapse text-xs mb-6">
|
||||
<tbody>
|
||||
<tr>
|
||||
{/* 도해 영역 */}
|
||||
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={8}>
|
||||
<div className="h-40 flex items-center justify-center">도해 이미지 영역</div>
|
||||
</td>
|
||||
{/* 헤더 행 */}
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사기준</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사방법</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사주기</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">관련규정</th>
|
||||
</tr>
|
||||
{/* 결모양 > 가공상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={3}>결모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">가공상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">사용상 해로운 결함이 없을것</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>육안검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={7}>n = 1, c = 0</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1항</td>
|
||||
</tr>
|
||||
{/* 결모양 > 조립상태 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}>조립상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">엔드락이 용접에 의해<br/>견고하게 조립되어야 함</td>
|
||||
<td className="border border-gray-400 px-2 py-1">KS F 4510 9항</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1">용접부위에 락카도색이<br/>되어야 함</td>
|
||||
<td className="border border-gray-400 px-2 py-1">자체규정</td>
|
||||
</tr>
|
||||
{/* 치수 > ① 높이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50 text-center" rowSpan={4}>치수<br/>(mm)</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">① 높이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">43.1 ± 0.5</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={4}>체크검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1" rowSpan={3}>KS F 4510 7항<br/>표9</td>
|
||||
</tr>
|
||||
{/* 치수 > ② 높이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">② 높이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">14.5 ± 1</td>
|
||||
</tr>
|
||||
{/* 치수 > 길이 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">길이</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">도면치수 ± 4</td>
|
||||
</tr>
|
||||
{/* 치수 > 간격 */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">간격</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">150 ± 4</td>
|
||||
<td className="border border-gray-400 px-2 py-1">자체규정</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 중간검사 DATA ===== */}
|
||||
<div className="mb-1 font-bold text-sm">■ 중간검사 DATA</div>
|
||||
<table className="w-full border-collapse text-xs mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>겉모양</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>① 높이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>② 높이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>③ 길이 (mm)</th>
|
||||
<th className="border border-gray-400 p-1" colSpan={2}>④ 간격</th>
|
||||
<th className="border border-gray-400 p-1 w-14" rowSpan={2}>판정<br/>(적/부)</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 p-1 w-16">가공상태</th>
|
||||
<th className="border border-gray-400 p-1 w-16">조립상태</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
<th className="border border-gray-400 p-1 w-16">기준치</th>
|
||||
<th className="border border-gray-400 p-1 w-16">측정값</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const judgment = getRowJudgment(row);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
|
||||
{/* 가공상태 */}
|
||||
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
|
||||
{/* 조립상태 */}
|
||||
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
|
||||
{/* ① 높이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.height1Standard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.height1Measured} onChange={(e) => handleInputChange(row.id, 'height1Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* ② 높이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.height2Standard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.height2Measured} onChange={(e) => handleInputChange(row.id, 'height2Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 길이 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign || '-'}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* ④ 간격 */}
|
||||
<td className="border border-gray-400 p-1 text-center">{row.intervalStandard}</td>
|
||||
<td className="border border-gray-400 p-1">
|
||||
<input type="text" value={row.intervalMeasured} onChange={(e) => handleInputChange(row.id, 'intervalMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
|
||||
</td>
|
||||
{/* 판정 - 자동 계산 */}
|
||||
<td className={`border border-gray-400 p-1 text-center font-bold ${
|
||||
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
{judgment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* ===== 부적합 내용 + 종합판정 ===== */}
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center">부적합 내용</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
|
||||
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
|
||||
</td>
|
||||
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24">종합판정</td>
|
||||
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
|
||||
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
|
||||
}`}>
|
||||
{overallResult || '합격'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export { BendingWorkLogContent } from './BendingWorkLogContent';
|
||||
export { ScreenInspectionContent } from './ScreenInspectionContent';
|
||||
export { SlatInspectionContent } from './SlatInspectionContent';
|
||||
export { BendingInspectionContent } from './BendingInspectionContent';
|
||||
export { BendingWipInspectionContent } from './BendingWipInspectionContent';
|
||||
export { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
|
||||
export type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
|
||||
// 모달
|
||||
|
||||
@@ -11,12 +11,13 @@ export interface ProcessInfo {
|
||||
|
||||
// @deprecated process_type은 process_id FK로 변경됨
|
||||
// 하위 호환성을 위해 유지
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending' | 'bending_wip';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
bending_wip: '절곡 재공품',
|
||||
};
|
||||
|
||||
// 작업 상태
|
||||
|
||||
@@ -70,19 +70,23 @@ export function WorkItemCard({
|
||||
{item.itemCode} ({item.itemName})
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
{!item.isWip && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{item.floor} / {item.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제작 사이즈 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="text-gray-500">제작 사이즈</span>
|
||||
<span className="font-medium">
|
||||
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
|
||||
</span>
|
||||
<span className="font-medium">{item.quantity}개</span>
|
||||
</div>
|
||||
{/* 제작 사이즈 (재공품은 숨김) */}
|
||||
{!item.isWip && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="text-gray-500">제작 사이즈</span>
|
||||
<span className="font-medium">
|
||||
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
|
||||
</span>
|
||||
<span className="font-medium">{item.quantity}개</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공정별 추가 정보 */}
|
||||
{item.processType === 'screen' && item.cuttingInfo && (
|
||||
@@ -100,10 +104,14 @@ export function WorkItemCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.processType === 'bending' && item.bendingInfo && (
|
||||
{item.processType === 'bending' && !item.isWip && item.bendingInfo && (
|
||||
<BendingExtraInfo info={item.bendingInfo} />
|
||||
)}
|
||||
|
||||
{item.isWip && item.wipInfo && (
|
||||
<WipExtraInfo info={item.wipInfo} />
|
||||
)}
|
||||
|
||||
{/* 진척률 프로그래스 바 */}
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
@@ -240,7 +248,7 @@ function SlatExtraInfo({
|
||||
}
|
||||
|
||||
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
|
||||
import type { BendingInfo } from './types';
|
||||
import type { BendingInfo, WipInfo } from './types';
|
||||
|
||||
function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
return (
|
||||
@@ -311,3 +319,41 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
|
||||
function WipExtraInfo({ info }: { info: WipInfo }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{/* 도면 이미지 (큰 영역) */}
|
||||
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{info.drawingUrl ? (
|
||||
<img
|
||||
src={info.drawingUrl}
|
||||
alt="도면"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 공통사항 */}
|
||||
<div className="flex-1 space-y-0">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">공통사항</p>
|
||||
<div className="border rounded-lg divide-y">
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">규격</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r">길이별 수량</span>
|
||||
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,8 +166,71 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// 절곡 재공품 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
|
||||
steps: [
|
||||
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 4, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
|
||||
steps: [
|
||||
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
|
||||
width: 0, height: 0, quantity: 10, processType: 'bending',
|
||||
isWip: true,
|
||||
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
|
||||
steps: [
|
||||
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 슬랫 조인트바 전용 목업 데이터 (토글로 전환)
|
||||
const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
|
||||
{
|
||||
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 8, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
|
||||
steps: [
|
||||
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
|
||||
width: 0, height: 0, quantity: 12, processType: 'slat',
|
||||
isJointBar: true,
|
||||
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
|
||||
steps: [
|
||||
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 하드코딩된 공정별 단계 폴백
|
||||
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
|
||||
const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean }[]> = {
|
||||
screen: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
@@ -185,6 +248,10 @@ const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean
|
||||
{ name: '절곡', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending_wip: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
],
|
||||
};
|
||||
|
||||
export default function WorkerScreen() {
|
||||
@@ -193,6 +260,8 @@ export default function WorkerScreen() {
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
||||
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
|
||||
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
|
||||
|
||||
// 작업 정보
|
||||
const [productionManagerId, setProductionManagerId] = useState('');
|
||||
@@ -305,13 +374,27 @@ export default function WorkerScreen() {
|
||||
});
|
||||
|
||||
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
|
||||
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
|
||||
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
|
||||
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
|
||||
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
|
||||
? MOCK_ITEMS_BENDING_WIP
|
||||
: (activeTab === 'slat' && slatSubMode === 'jointbar')
|
||||
? MOCK_ITEMS_SLAT_JOINTBAR
|
||||
: MOCK_ITEMS[activeTab];
|
||||
const mockItems = baseMockItems.map((item, i) => ({
|
||||
...item,
|
||||
itemNo: apiItems.length + i + 1,
|
||||
steps: item.steps.map((step) => {
|
||||
const stepKey = `${item.id}-${step.name}`;
|
||||
return {
|
||||
...step,
|
||||
isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
|
||||
|
||||
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
|
||||
const orderInfo = useMemo(() => {
|
||||
@@ -509,6 +592,27 @@ export default function WorkerScreen() {
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
// ===== 재공품 감지 =====
|
||||
const hasWipItems = useMemo(() => {
|
||||
return activeTab === 'bending' && workItems.some(item => item.isWip);
|
||||
}, [activeTab, workItems]);
|
||||
|
||||
// ===== 조인트바 감지 =====
|
||||
const hasJointBarItems = useMemo(() => {
|
||||
return activeTab === 'slat' && slatSubMode === 'jointbar';
|
||||
}, [activeTab, slatSubMode]);
|
||||
|
||||
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
|
||||
const handleWipInspection = useCallback(() => {
|
||||
const target = getTargetOrder();
|
||||
if (target) {
|
||||
setSelectedOrder(target);
|
||||
setIsInspectionModalOpen(true);
|
||||
} else {
|
||||
toast.error('표시할 작업이 없습니다.');
|
||||
}
|
||||
}, [getTargetOrder]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 pb-20">
|
||||
@@ -521,7 +625,9 @@ export default function WorkerScreen() {
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{hasJointBarItems ? '슬랫 조인트바 공정' : hasWipItems ? '절곡 재공품 공정' : '작업자 화면'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">작업을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +677,68 @@ export default function WorkerScreen() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 슬랫 탭: 슬랫/조인트바 전환 토글 */}
|
||||
{tab === 'slat' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
슬랫
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('jointbar')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'jointbar'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
조인트바
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 절곡 탭: 절곡/재공품 전환 토글 */}
|
||||
{tab === 'bending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수주 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -661,19 +829,32 @@ export default function WorkerScreen() {
|
||||
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleWorkLog}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
작업일지 보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
</Button>
|
||||
{hasWipItems ? (
|
||||
// 재공품: 통합 버튼 1개
|
||||
<Button
|
||||
onClick={handleWipInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
작업일지 및 중간검사하기
|
||||
</Button>
|
||||
) : (
|
||||
// 일반/조인트바: 버튼 2개
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleWorkLog}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
작업일지 보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
중간검사하기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -706,8 +887,9 @@ export default function WorkerScreen() {
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
processType={hasWipItems ? 'bending_wip' : activeTab}
|
||||
readOnly={false}
|
||||
isJointBar={hasJointBarItems}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
|
||||
@@ -41,16 +41,29 @@ export interface WorkItemData {
|
||||
quantity: number; // 수량
|
||||
processType: ProcessTab; // 공정 타입
|
||||
steps: WorkStepData[]; // 공정 단계들
|
||||
isWip?: boolean; // 재공품 여부
|
||||
isJointBar?: boolean; // 조인트바 여부
|
||||
// 스크린 전용
|
||||
cuttingInfo?: CuttingInfo;
|
||||
// 슬랫 전용
|
||||
slatInfo?: SlatInfo;
|
||||
// 슬랫 조인트바 전용
|
||||
slatJointBarInfo?: SlatJointBarInfo;
|
||||
// 절곡 전용
|
||||
bendingInfo?: BendingInfo;
|
||||
// 재공품 전용
|
||||
wipInfo?: WipInfo;
|
||||
// 자재 투입 목록
|
||||
materialInputs?: MaterialListItem[];
|
||||
}
|
||||
|
||||
// ===== 재공품 전용 정보 =====
|
||||
export interface WipInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
specification: string; // 규격 (EGI 1.55T (W576))
|
||||
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
|
||||
}
|
||||
|
||||
// ===== 절단 정보 (스크린 전용) =====
|
||||
export interface CuttingInfo {
|
||||
width: number; // 절단 폭 (mm)
|
||||
@@ -64,6 +77,13 @@ export interface SlatInfo {
|
||||
jointBar: number; // 조인트바 개수
|
||||
}
|
||||
|
||||
// ===== 슬랫 조인트바 전용 정보 =====
|
||||
export interface SlatJointBarInfo {
|
||||
specification: string; // 규격 (예: EGI 1.6T)
|
||||
length: number; // 길이 (mm)
|
||||
quantity: number; // 수량
|
||||
}
|
||||
|
||||
// ===== 절곡 전용 정보 =====
|
||||
export interface BendingInfo {
|
||||
drawingUrl?: string; // 도면 이미지 URL
|
||||
|
||||
Reference in New Issue
Block a user