feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선
- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가 - 견적확정 후 수주등록 버튼 동적 전환 - 수주등록 품목 개소별(floor+code) 그룹핑 수정 - 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity) - 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용) - 작업지시 상세 개소별/품목별 합산 테이블 추가 - 작업자 화면 API 연동 및 목업 데이터 분리 - 입고관리 완료건 수정, 재고현황 개선
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
|
||||
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
|
||||
import { ImportInspectionInputModal, type ImportInspectionData } from './ImportInspectionInputModal';
|
||||
import { ImportInspectionInputModal } from './ImportInspectionInputModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
getReceivingById,
|
||||
createReceiving,
|
||||
updateReceiving,
|
||||
checkInspectionTemplate,
|
||||
} from './actions';
|
||||
import {
|
||||
Table,
|
||||
@@ -68,6 +69,7 @@ interface Props {
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
materialNo: '',
|
||||
supplierMaterialNo: '',
|
||||
lotNo: '',
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
@@ -96,19 +98,23 @@ function generateLotNo(): string {
|
||||
return `${yy}${mm}${dd}-${seq}`;
|
||||
}
|
||||
|
||||
// localStorage에서 로그인 사용자명 가져오기
|
||||
function getLoggedInUserName(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
// localStorage에서 로그인 사용자 정보 가져오기
|
||||
function getLoggedInUser(): { name: string; department: string } {
|
||||
if (typeof window === 'undefined') return { name: '', department: '' };
|
||||
try {
|
||||
const userData = localStorage.getItem('user');
|
||||
if (userData) {
|
||||
const parsed = JSON.parse(userData);
|
||||
return parsed.name || '';
|
||||
return { name: parsed.name || '', department: parsed.department || '' };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return '';
|
||||
return { name: '', department: '' };
|
||||
}
|
||||
|
||||
function getLoggedInUserName(): string {
|
||||
return getLoggedInUser().name;
|
||||
}
|
||||
|
||||
export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
@@ -136,6 +142,17 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
|
||||
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
|
||||
|
||||
// 수입검사 성적서 템플릿 존재 여부
|
||||
const [hasInspectionTemplate, setHasInspectionTemplate] = useState(false);
|
||||
|
||||
// 수입검사 첨부파일 (document_attachments)
|
||||
const [inspectionAttachments, setInspectionAttachments] = useState<Array<{
|
||||
id: number;
|
||||
file_id: number;
|
||||
attachment_type: string;
|
||||
file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string };
|
||||
}>>([]);
|
||||
|
||||
// 재고 조정 이력 상태
|
||||
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
|
||||
|
||||
@@ -185,6 +202,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
if (isEditMode) {
|
||||
setFormData({
|
||||
materialNo: result.data.materialNo || '',
|
||||
supplierMaterialNo: result.data.supplierMaterialNo || '',
|
||||
lotNo: result.data.lotNo || '',
|
||||
itemCode: result.data.itemCode,
|
||||
itemName: result.data.itemName,
|
||||
@@ -202,6 +220,17 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
certificateFile: result.data.certificateFile,
|
||||
});
|
||||
}
|
||||
|
||||
// 수입검사 성적서 템플릿 존재 여부 + 첨부파일 확인
|
||||
if (result.data.itemId) {
|
||||
const templateCheck = await checkInspectionTemplate(result.data.itemId);
|
||||
setHasInspectionTemplate(templateCheck.hasTemplate);
|
||||
if (templateCheck.attachments && templateCheck.attachments.length > 0) {
|
||||
setInspectionAttachments(templateCheck.attachments);
|
||||
}
|
||||
} else {
|
||||
setHasInspectionTemplate(false);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '입고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
@@ -227,41 +256,37 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
}));
|
||||
};
|
||||
|
||||
// 저장 핸들러 - IntegratedDetailTemplate의 onSubmit에서 호출
|
||||
// 반환값으로 성공/실패를 전달하여 템플릿이 toast/navigation 처리
|
||||
// 저장 핸들러 - 결과 반환
|
||||
const handleSave = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 클라이언트 사이드 필수 필드 검증
|
||||
const errors: string[] = [];
|
||||
if (!formData.itemCode) errors.push('품목코드');
|
||||
if (!formData.supplier) errors.push('발주처');
|
||||
if (!formData.receivingQty) errors.push('입고수량');
|
||||
if (!formData.receivingDate) errors.push('입고일');
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: `필수 항목을 입력해주세요: ${errors.join(', ')}` };
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isNewMode) {
|
||||
const result = await createReceiving(formData);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '등록에 실패했습니다.' };
|
||||
if (result.success) {
|
||||
toast.success('입고가 등록되었습니다.');
|
||||
router.push('/ko/material/receiving-management');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
} else if (isEditMode) {
|
||||
const result = await updateReceiving(id, formData);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '수정에 실패했습니다.' };
|
||||
if (result.success) {
|
||||
toast.success('입고 정보가 수정되었습니다.');
|
||||
router.push(`/ko/material/receiving-management/${id}?mode=view`);
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: '알 수 없는 모드입니다.' };
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ReceivingDetail] handleSave error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
|
||||
return { success: false, error: errorMessage };
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -277,11 +302,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
setIsInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
// 수입검사 완료 핸들러
|
||||
const handleImportInspectionComplete = (data: ImportInspectionData) => {
|
||||
console.log('수입검사 완료:', data);
|
||||
toast.success('수입검사가 완료되었습니다.');
|
||||
// TODO: API 호출하여 검사 결과 저장
|
||||
// 수입검사 저장 완료 핸들러 → 데이터 새로고침
|
||||
const handleImportInspectionSave = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 재고 조정 행 추가
|
||||
@@ -346,8 +369,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.materialNo)}
|
||||
{renderReadOnlyField('로트번호', detail.lotNo)}
|
||||
{renderReadOnlyField('입고번호', detail.materialNo)}
|
||||
{renderReadOnlyField('자재번호', detail.supplierMaterialNo)}
|
||||
{renderReadOnlyField('원자재로트', detail.lotNo)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
@@ -382,17 +406,52 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
{renderReadOnlyField('검사결과', detail.inspectionResult)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-sm text-muted-foreground">업체 제공 성적서 자료</Label>
|
||||
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
|
||||
{detail.certificateFileName ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
<span>{detail.certificateFileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
'클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
|
||||
)}
|
||||
</div>
|
||||
<Label className="text-sm text-muted-foreground">검사 첨부파일</Label>
|
||||
{inspectionAttachments.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{inspectionAttachments.map((att) => {
|
||||
const fileName = att.file?.display_name || att.file?.original_name || `file-${att.file_id}`;
|
||||
const fileSize = att.file?.file_size;
|
||||
const isImage = att.file?.mime_type?.startsWith('image/');
|
||||
const downloadUrl = `/api/proxy/files/${att.file_id}/download`;
|
||||
return (
|
||||
<div key={att.id} className="flex items-center gap-3 p-2 rounded-md border bg-muted/30">
|
||||
{isImage && att.file?.file_path ? (
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={fileName}
|
||||
className="w-10 h-10 rounded object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<FileText className="w-5 h-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{fileName}</p>
|
||||
{fileSize && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fileSize < 1024 * 1024
|
||||
? `${(fileSize / 1024).toFixed(1)} KB`
|
||||
: `${(fileSize / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||
>
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 px-4 py-8 border-2 border-dashed rounded-md bg-gray-50 text-center text-sm text-muted-foreground">
|
||||
첨부된 파일이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -437,7 +496,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail, adjustments]);
|
||||
}, [detail, adjustments, inspectionAttachments]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -450,11 +509,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 자재번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('자재번호', formData.materialNo, true)}
|
||||
{/* 입고번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('입고번호', formData.materialNo, true)}
|
||||
|
||||
{/* 로트번호 - 읽기전용 */}
|
||||
{renderReadOnlyField('로트번호', formData.lotNo, true)}
|
||||
{/* 자재번호 (거래처) - 수정 가능 */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">자재번호</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
value={formData.supplierMaterialNo || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, supplierMaterialNo: e.target.value }))}
|
||||
placeholder="거래처 자재번호"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 원자재로트 - 수정 가능 */}
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">원자재로트</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
value={formData.lotNo || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, lotNo: e.target.value }))}
|
||||
placeholder="원자재로트를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 - 검색 모달 선택 */}
|
||||
<div>
|
||||
@@ -710,17 +788,18 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사하기</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
|
||||
<FileText className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사성적서 보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
// 수입검사하기 버튼은 수입검사 성적서 템플릿이 있는 품목만 표시
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail && hasInspectionTemplate ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사하기</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
|
||||
<FileText className="w-4 h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">수입검사성적서 보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// 에러 상태 표시 (view/edit 모드에서만)
|
||||
@@ -804,7 +883,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
id: 'import-inspection',
|
||||
type: 'import',
|
||||
title: '수입검사 성적서',
|
||||
count: 0,
|
||||
count: 0,
|
||||
}}
|
||||
documentItem={{
|
||||
id: id,
|
||||
@@ -813,20 +892,31 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
code: detail?.lotNo || '',
|
||||
}}
|
||||
// 수입검사 템플릿 로드용 props
|
||||
itemId={detail?.itemId}
|
||||
itemName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
supplier={detail?.supplier}
|
||||
inspector={getLoggedInUserName()}
|
||||
inspectorDept={getLoggedInUser().department}
|
||||
lotSize={detail?.receivingQty}
|
||||
materialNo={detail?.materialNo}
|
||||
readOnly={true}
|
||||
/>
|
||||
|
||||
{/* 수입검사 입력 모달 */}
|
||||
<ImportInspectionInputModal
|
||||
open={isImportInspectionModalOpen}
|
||||
onOpenChange={setIsImportInspectionModalOpen}
|
||||
productName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
onComplete={handleImportInspectionComplete}
|
||||
/>
|
||||
{/* 수입검사 입력 모달 */}
|
||||
<ImportInspectionInputModal
|
||||
open={isImportInspectionModalOpen}
|
||||
onOpenChange={setIsImportInspectionModalOpen}
|
||||
itemId={detail?.itemId}
|
||||
itemName={detail?.itemName}
|
||||
specification={detail?.specification}
|
||||
supplier={detail?.supplier}
|
||||
inspector={getLoggedInUserName()}
|
||||
lotSize={detail?.receivingQty}
|
||||
materialNo={detail?.materialNo}
|
||||
receivingId={id}
|
||||
onSave={handleImportInspectionSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,41 @@ import {
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
|
||||
import { getReceivings, getReceivingStats } from './actions';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import {
|
||||
RECEIVING_STATUS_LABELS,
|
||||
RECEIVING_STATUS_STYLES,
|
||||
INSPECTION_STATUS_LABELS,
|
||||
INSPECTION_STATUS_STYLES,
|
||||
type InspectionDisplayStatus,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { ReceivingItem, ReceivingStats } from './types';
|
||||
|
||||
/**
|
||||
* 수입검사 표시 상태 결정
|
||||
* - 수입검사 템플릿이 연결되지 않으면 → none (수입검사 대상 아님)
|
||||
* - 검사결과가 '합격'이면 → passed
|
||||
* - 검사결과가 '불합격'이면 → failed
|
||||
* - 그 외 (템플릿 연결되어 있고 검사결과 없음) → waiting (대기)
|
||||
*/
|
||||
function getInspectionDisplayStatus(item: ReceivingItem): InspectionDisplayStatus {
|
||||
// 수입검사 템플릿이 연결되지 않은 경우 수입검사 대상 아님
|
||||
if (!item.hasInspectionTemplate) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// 검사결과에 따른 상태
|
||||
if (item.inspectionResult === '합격') {
|
||||
return 'passed';
|
||||
}
|
||||
if (item.inspectionResult === '불합격') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// 템플릿이 연결되어 있지만 검사결과가 없으면 대기 상태
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
@@ -208,8 +239,8 @@ export function ReceivingList() {
|
||||
// 테이블 컬럼 (기획서 2026-02-03 순서)
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
|
||||
{ key: 'materialNo', label: '자재번호', className: 'w-[100px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
|
||||
{ key: 'materialNo', label: '입고번호', className: 'w-[130px]' },
|
||||
{ key: 'lotNo', label: '원자재로트', className: 'w-[120px]' },
|
||||
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[70px] text-center' },
|
||||
{ key: 'inspectionDate', label: '검사일', className: 'w-[90px] text-center' },
|
||||
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
|
||||
@@ -306,7 +337,17 @@ export function ReceivingList() {
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.materialNo || '-'}</TableCell>
|
||||
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{(() => {
|
||||
const status = getInspectionDisplayStatus(item);
|
||||
if (status === 'none') return '-';
|
||||
return (
|
||||
<Badge className={`text-xs ${INSPECTION_STATUS_STYLES[status]}`}>
|
||||
{INSPECTION_STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell>{item.manufacturer || '-'}</TableCell>
|
||||
@@ -363,12 +404,22 @@ export function ReceivingList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="자재번호" value={item.materialNo || '-'} />
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목코드" value={item.itemCode || '-'} />
|
||||
<InfoField label="품목유형" value={item.itemType || '-'} />
|
||||
<InfoField label="발주처" value={item.supplier} />
|
||||
<InfoField label="제조사" value={item.manufacturer || '-'} />
|
||||
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
|
||||
<InfoField
|
||||
label="수입검사"
|
||||
value={(() => {
|
||||
const status = getInspectionDisplayStatus(item);
|
||||
if (status === 'none') return '-';
|
||||
return (
|
||||
<Badge className={`text-xs ${INSPECTION_STATUS_STYLES[status]}`}>
|
||||
{INSPECTION_STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
/>
|
||||
<InfoField label="수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고변경일" value={item.receivingDate || '-'} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,12 +38,35 @@ export const RECEIVING_STATUS_OPTIONS = [
|
||||
{ value: 'inspection_completed', label: '검사완료' },
|
||||
] as const;
|
||||
|
||||
// 수입검사 상태 (리스트 표시용)
|
||||
export type InspectionDisplayStatus = 'waiting' | 'passed' | 'failed' | 'none';
|
||||
|
||||
// 수입검사 상태 라벨
|
||||
export const INSPECTION_STATUS_LABELS: Record<InspectionDisplayStatus, string> = {
|
||||
waiting: '대기',
|
||||
passed: '합격',
|
||||
failed: '불합격',
|
||||
none: '-',
|
||||
};
|
||||
|
||||
// 수입검사 상태 스타일
|
||||
export const INSPECTION_STATUS_STYLES: Record<InspectionDisplayStatus, string> = {
|
||||
waiting: 'bg-yellow-100 text-yellow-800',
|
||||
passed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
// 입고 목록 아이템
|
||||
export interface ReceivingItem {
|
||||
id: string;
|
||||
materialNo?: string; // 자재번호
|
||||
lotNo?: string; // 로트번호
|
||||
materialNo?: string; // 입고번호 (receiving_number)
|
||||
supplierMaterialNo?: string; // 거래처 자재번호
|
||||
lotNo?: string; // 원자재로트
|
||||
itemId?: number; // 품목 ID
|
||||
hasInspectionTemplate?: boolean; // 수입검사 템플릿 연결 여부
|
||||
inspectionStatus?: string; // 수입검사 (적/부적/-)
|
||||
inspectionResult?: string; // 검사결과 (합격/불합격)
|
||||
inspectionDate?: string; // 검사일
|
||||
supplier: string; // 발주처
|
||||
manufacturer?: string; // 제조사
|
||||
@@ -66,8 +89,10 @@ export interface ReceivingItem {
|
||||
export interface ReceivingDetail {
|
||||
id: string;
|
||||
// 기본 정보
|
||||
materialNo?: string; // 자재번호 (읽기전용)
|
||||
lotNo?: string; // 로트번호 (읽기전용)
|
||||
materialNo?: string; // 입고번호 (receiving_number, 읽기전용)
|
||||
supplierMaterialNo?: string; // 거래처 자재번호 (수정가능)
|
||||
lotNo?: string; // 원자재로트 (수정가능)
|
||||
itemId?: number; // 품목 ID (수입검사 템플릿 조회용)
|
||||
itemCode: string; // 품목코드 (수정가능)
|
||||
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
|
||||
specification?: string; // 규격 (읽기전용)
|
||||
|
||||
@@ -106,8 +106,7 @@ export function StockStatusList() {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
stock.itemCode.toLowerCase().includes(searchLower) ||
|
||||
stock.itemName.toLowerCase().includes(searchLower) ||
|
||||
stock.stockNumber.toLowerCase().includes(searchLower);
|
||||
stock.itemName.toLowerCase().includes(searchLower);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
@@ -127,7 +126,6 @@ export function StockStatusList() {
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
const excelColumns: ExcelColumn<StockItem>[] = [
|
||||
{ header: '자재번호', key: 'stockNumber' },
|
||||
{ header: '품목코드', key: 'itemCode' },
|
||||
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' },
|
||||
{ header: '품목명', key: 'itemName' },
|
||||
@@ -148,14 +146,25 @@ export function StockStatusList() {
|
||||
const hasStock = !!stock;
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
stockNumber: hasStock ? (String(stock?.stock_number ?? stock?.id ?? item.id)) : String(item.id ?? ''),
|
||||
itemCode: (item.code ?? '') as string,
|
||||
itemName: (item.name ?? '') as string,
|
||||
itemType: (item.item_type ?? 'RM') as ItemType,
|
||||
specification: (item.specification ?? item.attributes ?? '') as string,
|
||||
specification: (() => {
|
||||
if (item.attributes && typeof item.attributes === 'object') {
|
||||
const attrs = item.attributes as Record<string, unknown>;
|
||||
if (attrs.spec && String(attrs.spec).trim()) return String(attrs.spec).trim();
|
||||
const parts: string[] = [];
|
||||
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
|
||||
if (attrs.width) parts.push(`${attrs.width}`);
|
||||
if (attrs.length) parts.push(`${attrs.length}`);
|
||||
if (parts.length > 0) return parts.join('×');
|
||||
}
|
||||
if (stock?.specification && String(stock.specification).trim()) return String(stock.specification).trim();
|
||||
return '';
|
||||
})(),
|
||||
unit: (item.unit ?? 'EA') as string,
|
||||
calculatedQty: hasStock ? (parseFloat(String(stock?.calculated_qty ?? stock?.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String(stock?.actual_qty ?? stock?.stock_qty)) || 0) : 0,
|
||||
calculatedQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock?.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock?.safety_stock)) || 0) : 0,
|
||||
wipQty: hasStock ? (parseFloat(String(stock?.wip_qty)) || 0) : 0,
|
||||
@@ -223,7 +232,6 @@ export function StockStatusList() {
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockNumber', label: '자재번호', className: 'w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
@@ -255,8 +263,7 @@ export function StockStatusList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.stockNumber}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
@@ -290,7 +297,6 @@ export function StockStatusList() {
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.stockNumber}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
@@ -367,8 +373,7 @@ export function StockStatusList() {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return (
|
||||
stock.itemCode.toLowerCase().includes(searchLower) ||
|
||||
stock.itemName.toLowerCase().includes(searchLower) ||
|
||||
stock.stockNumber.toLowerCase().includes(searchLower)
|
||||
stock.itemName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -411,7 +416,7 @@ export function StockStatusList() {
|
||||
// 테이블 푸터
|
||||
tableFooter: (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={12} className="py-3">
|
||||
<TableCell colSpan={11} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {filteredStocks.length}건
|
||||
</span>
|
||||
|
||||
@@ -114,27 +114,34 @@ function transformApiToListItem(data: ItemApiData): StockItem {
|
||||
const stock = data.stock;
|
||||
const hasStock = !!stock;
|
||||
|
||||
// description 또는 attributes에서 규격 정보 추출
|
||||
// 규격: attributes.spec → thickness/width/length 조합 → stock.specification
|
||||
let specification = '';
|
||||
if (data.description) {
|
||||
specification = data.description;
|
||||
} else if (data.attributes && typeof data.attributes === 'object') {
|
||||
if (data.attributes && typeof data.attributes === 'object') {
|
||||
const attrs = data.attributes as Record<string, unknown>;
|
||||
if (attrs.specification) {
|
||||
specification = String(attrs.specification);
|
||||
if (attrs.spec && String(attrs.spec).trim()) {
|
||||
specification = String(attrs.spec).trim();
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
|
||||
if (attrs.width) parts.push(`${attrs.width}`);
|
||||
if (attrs.length) parts.push(`${attrs.length}`);
|
||||
if (parts.length > 0) specification = parts.join('×');
|
||||
}
|
||||
}
|
||||
if (!specification && hasStock) {
|
||||
const stockSpec = (stock as unknown as Record<string, unknown>).specification;
|
||||
if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
stockNumber: hasStock ? String((stock as unknown as Record<string, unknown>).stock_number ?? stock.id ?? data.id) : String(data.id),
|
||||
itemCode: data.code,
|
||||
itemName: data.name,
|
||||
itemType: data.item_type,
|
||||
specification,
|
||||
unit: data.unit || 'EA',
|
||||
calculatedQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).calculated_qty ?? stock.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).actual_qty ?? stock.stock_qty)) || 0) : 0,
|
||||
calculatedQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
actualQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
stockQty: hasStock ? (parseFloat(String(stock.stock_qty)) || 0) : 0,
|
||||
safetyStock: hasStock ? (parseFloat(String(stock.safety_stock)) || 0) : 0,
|
||||
wipQty: hasStock ? (parseFloat(String((stock as unknown as Record<string, unknown>).wip_qty)) || 0) : 0,
|
||||
@@ -169,17 +176,24 @@ function transformApiToDetail(data: ItemApiData): StockDetail {
|
||||
const stock = data.stock;
|
||||
const hasStock = !!stock;
|
||||
|
||||
// description 또는 attributes에서 규격 정보 추출
|
||||
// 규격: attributes.spec → thickness/width/length 조합 → stock.specification
|
||||
let specification = '-';
|
||||
if (data.description) {
|
||||
specification = data.description;
|
||||
} else if (data.attributes && typeof data.attributes === 'object') {
|
||||
// attributes에서 규격 관련 정보 추출 시도
|
||||
if (data.attributes && typeof data.attributes === 'object') {
|
||||
const attrs = data.attributes as Record<string, unknown>;
|
||||
if (attrs.specification) {
|
||||
specification = String(attrs.specification);
|
||||
if (attrs.spec && String(attrs.spec).trim()) {
|
||||
specification = String(attrs.spec).trim();
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (attrs.thickness) parts.push(`${attrs.thickness}T`);
|
||||
if (attrs.width) parts.push(`${attrs.width}`);
|
||||
if (attrs.length) parts.push(`${attrs.length}`);
|
||||
if (parts.length > 0) specification = parts.join('×');
|
||||
}
|
||||
}
|
||||
if (specification === '-' && hasStock) {
|
||||
const stockSpec = (stock as unknown as Record<string, unknown>).specification;
|
||||
if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
|
||||
@@ -41,7 +41,6 @@ function generateLocation(type: string, seed: number): string {
|
||||
const rawMaterialItems: StockItem[] = [
|
||||
{
|
||||
id: 'rm-1',
|
||||
stockNumber: 'STK-RM-001',
|
||||
itemCode: 'SCR-FABRIC-WHT-03T',
|
||||
itemName: '스크린원단-백색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
@@ -61,7 +60,6 @@ const rawMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'rm-2',
|
||||
stockNumber: 'STK-RM-002',
|
||||
itemCode: 'SCR-FABRIC-GRY-03T',
|
||||
itemName: '스크린원단-회색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
@@ -81,7 +79,6 @@ const rawMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'rm-3',
|
||||
stockNumber: 'STK-RM-003',
|
||||
itemCode: 'SCR-FABRIC-BLK-03T',
|
||||
itemName: '스크린원단-흑색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
@@ -101,7 +98,6 @@ const rawMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'rm-4',
|
||||
stockNumber: 'STK-RM-004',
|
||||
itemCode: 'SCR-FABRIC-BEI-03T',
|
||||
itemName: '스크린원단-베이지-0.3T',
|
||||
itemType: 'raw_material',
|
||||
@@ -133,7 +129,6 @@ const bentPartItems: StockItem[] = Array.from({ length: 41 }, (_, i) => {
|
||||
|
||||
return {
|
||||
id: `bp-${i + 1}`,
|
||||
stockNumber: `STK-BP-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `BENT-${type.toUpperCase().slice(0, 3)}-${variant}-${String(i + 1).padStart(2, '0')}`,
|
||||
itemName: `${type}-${variant}형-${i + 1}`,
|
||||
itemType: 'bent_part' as const,
|
||||
@@ -167,7 +162,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-sqp-${i + 1}`,
|
||||
stockNumber: `STK-PP-SQP-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `SQP-${size.replace('×', '')}-${length.slice(0, 2)}`,
|
||||
itemName: `각파이프 ${size} L:${length}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -198,7 +192,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-ang-${i + 1}`,
|
||||
stockNumber: `STK-PP-ANG-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `ANG-${size.replace('×', '')}-${length.slice(0, 2)}`,
|
||||
itemName: `앵글 ${size} L:${length}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -231,7 +224,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-motor-${i + 1}`,
|
||||
stockNumber: `STK-PP-MOT-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `MOTOR-${voltage}${weight}${type === '무선' ? '-W' : ''}`,
|
||||
itemName: `전동개폐기-${voltage}${weight}${type}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -262,7 +254,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-bolt-${i + 1}`,
|
||||
stockNumber: `STK-PP-BLT-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `BOLT-${size}-${length}`,
|
||||
itemName: `볼트 ${size}×${length}mm`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -291,7 +282,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-bearing-${i + 1}`,
|
||||
stockNumber: `STK-PP-BRG-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `BEARING-${type}`,
|
||||
itemName: `베어링 ${type}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -322,7 +312,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
|
||||
return {
|
||||
id: `pp-spring-${i + 1}`,
|
||||
stockNumber: `STK-PP-SPR-${String(i + 1).padStart(3, '0')}`,
|
||||
itemCode: `SPRING-${type.toUpperCase().slice(0, 2)}-${size}`,
|
||||
itemName: `스프링-${type}-${size}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
@@ -347,7 +336,6 @@ const purchasedPartItems: StockItem[] = [
|
||||
const subMaterialItems: StockItem[] = [
|
||||
{
|
||||
id: 'sm-1',
|
||||
stockNumber: 'STK-SM-001',
|
||||
itemCode: 'SEW-WHT',
|
||||
itemName: '미싱실-백색',
|
||||
itemType: 'sub_material',
|
||||
@@ -367,7 +355,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-2',
|
||||
stockNumber: 'STK-SM-002',
|
||||
itemCode: 'ALU-BAR',
|
||||
itemName: '하단바-알루미늄',
|
||||
itemType: 'sub_material',
|
||||
@@ -387,7 +374,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-3',
|
||||
stockNumber: 'STK-SM-003',
|
||||
itemCode: 'END-CAP-STD',
|
||||
itemName: '앤드락-표준',
|
||||
itemType: 'sub_material',
|
||||
@@ -407,7 +393,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-4',
|
||||
stockNumber: 'STK-SM-004',
|
||||
itemCode: 'SILICON-TRANS',
|
||||
itemName: '실리콘-투명',
|
||||
itemType: 'sub_material',
|
||||
@@ -427,7 +412,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-5',
|
||||
stockNumber: 'STK-SM-005',
|
||||
itemCode: 'TAPE-DBL-25',
|
||||
itemName: '양면테이프-25mm',
|
||||
itemType: 'sub_material',
|
||||
@@ -447,7 +431,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-6',
|
||||
stockNumber: 'STK-SM-006',
|
||||
itemCode: 'RIVET-STL-4',
|
||||
itemName: '리벳-스틸-4mm',
|
||||
itemType: 'sub_material',
|
||||
@@ -467,7 +450,6 @@ const subMaterialItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'sm-7',
|
||||
stockNumber: 'STK-SM-007',
|
||||
itemCode: 'WASHER-M8',
|
||||
itemName: '와셔-M8',
|
||||
itemType: 'sub_material',
|
||||
@@ -491,7 +473,6 @@ const subMaterialItems: StockItem[] = [
|
||||
const consumableItems: StockItem[] = [
|
||||
{
|
||||
id: 'cs-1',
|
||||
stockNumber: 'STK-CS-001',
|
||||
itemCode: 'PKG-BOX-L',
|
||||
itemName: '포장박스-대형',
|
||||
itemType: 'consumable',
|
||||
@@ -511,7 +492,6 @@ const consumableItems: StockItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'cs-2',
|
||||
stockNumber: 'STK-CS-002',
|
||||
itemCode: 'PKG-BOX-M',
|
||||
itemName: '포장박스-중형',
|
||||
itemType: 'consumable',
|
||||
|
||||
@@ -54,7 +54,6 @@ export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
|
||||
// 재고 목록 아이템 (Item 기준 + Stock 정보)
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
stockNumber: string; // 재고번호 (Stock.stock_number)
|
||||
itemCode: string; // Item.code
|
||||
itemName: string; // Item.name
|
||||
itemType: ItemType; // Item.item_type (RM, SM, CS)
|
||||
|
||||
@@ -234,93 +234,58 @@ export function OrderRegistration({
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 제품코드에서 그룹핑 키 추출: FG-KWE01-벽면형-SUS → KWE01-SUS
|
||||
const extractGroupKey = useCallback((productName: string): string => {
|
||||
const parts = productName.split('-');
|
||||
if (parts.length >= 4) {
|
||||
// FG-{model}-{installationType}-{finishType}
|
||||
return `${parts[1]}-${parts[3]}`;
|
||||
}
|
||||
return productName;
|
||||
}, []);
|
||||
|
||||
// 아이템을 제품 모델+타입별로 그룹핑 (제품 단위 집약)
|
||||
// 아이템을 개소별(floor+code)로 그룹핑
|
||||
const itemGroups = useMemo(() => {
|
||||
const calcItems = form.selectedQuotation?.calculationInputs?.items;
|
||||
if (!calcItems || calcItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// floor+code → productCode 매핑
|
||||
const locationProductMap = new Map<string, string>();
|
||||
// floor+code → calculationInput 매핑 (개소 메타정보)
|
||||
const locationMetaMap = new Map<string, {
|
||||
productCode: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
floor: string;
|
||||
code: string;
|
||||
}>();
|
||||
calcItems.forEach(ci => {
|
||||
if (ci.floor && ci.code && ci.productCode) {
|
||||
locationProductMap.set(`${ci.floor}|${ci.code}`, ci.productCode);
|
||||
if (ci.floor && ci.code) {
|
||||
const locKey = `${ci.floor}|${ci.code}`;
|
||||
locationMetaMap.set(locKey, {
|
||||
productCode: ci.productCode || '',
|
||||
productName: ci.productName || '',
|
||||
quantity: ci.quantity ?? 1,
|
||||
floor: ci.floor,
|
||||
code: ci.code,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 그룹별 데이터 집계
|
||||
// 개소별 그룹
|
||||
const groups = new Map<string, {
|
||||
items: OrderItem[];
|
||||
productCode: string;
|
||||
locations: Set<string>; // 개소 목록
|
||||
quantity: number; // 개소별 수량 합계 (calculation_inputs 기준)
|
||||
meta: { productCode: string; productName: string; quantity: number; floor: string; code: string };
|
||||
}>();
|
||||
const ungrouped: OrderItem[] = [];
|
||||
|
||||
form.items.forEach(item => {
|
||||
const locKey = `${item.type}|${item.symbol}`;
|
||||
const productCode = locationProductMap.get(locKey);
|
||||
if (productCode) {
|
||||
const groupKey = extractGroupKey(productCode);
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, { items: [], productCode, locations: new Set(), quantity: 0 });
|
||||
const meta = locationMetaMap.get(locKey);
|
||||
if (meta) {
|
||||
if (!groups.has(locKey)) {
|
||||
groups.set(locKey, { items: [], meta });
|
||||
}
|
||||
const g = groups.get(groupKey)!;
|
||||
g.items.push(item);
|
||||
g.locations.add(locKey);
|
||||
groups.get(locKey)!.items.push(item);
|
||||
} else {
|
||||
ungrouped.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// calculation_inputs에서 개소별 수량 합산
|
||||
calcItems.forEach(ci => {
|
||||
if (ci.productCode) {
|
||||
const groupKey = extractGroupKey(ci.productCode);
|
||||
const g = groups.get(groupKey);
|
||||
if (g) {
|
||||
g.quantity += ci.quantity ?? 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (groups.size <= 1 && ungrouped.length === 0) {
|
||||
if (groups.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 그룹 내 동일 품목(item_code) 합산
|
||||
const aggregateItems = (items: OrderItem[]) => {
|
||||
const map = new Map<string, OrderItem & { _sourceIds: string[] }>();
|
||||
items.forEach(item => {
|
||||
const code = item.itemCode || item.itemName;
|
||||
if (map.has(code)) {
|
||||
const existing = map.get(code)!;
|
||||
existing.quantity += item.quantity;
|
||||
existing.amount = (existing.amount ?? 0) + (item.amount ?? 0);
|
||||
existing._sourceIds.push(item.id);
|
||||
} else {
|
||||
map.set(code, {
|
||||
...item,
|
||||
quantity: item.quantity,
|
||||
amount: item.amount ?? 0,
|
||||
_sourceIds: [item.id],
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const result: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -329,20 +294,19 @@ export function OrderRegistration({
|
||||
quantity: number;
|
||||
amount: number;
|
||||
items: OrderItem[];
|
||||
aggregatedItems: (OrderItem & { _sourceIds: string[] })[];
|
||||
}> = [];
|
||||
|
||||
let orderNum = 1;
|
||||
groups.forEach((value, key) => {
|
||||
const amount = value.items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
||||
result.push({
|
||||
key,
|
||||
label: `수주 ${orderNum}: ${key}`,
|
||||
productCode: key,
|
||||
locationCount: value.locations.size,
|
||||
quantity: value.quantity,
|
||||
label: `${orderNum}. ${value.meta.floor} / ${value.meta.code}`,
|
||||
productCode: value.meta.productName || value.meta.productCode,
|
||||
locationCount: 1,
|
||||
quantity: value.meta.quantity,
|
||||
amount,
|
||||
items: value.items,
|
||||
aggregatedItems: aggregateItems(value.items),
|
||||
});
|
||||
orderNum++;
|
||||
});
|
||||
@@ -357,12 +321,11 @@ export function OrderRegistration({
|
||||
quantity: ungrouped.length,
|
||||
amount,
|
||||
items: ungrouped,
|
||||
aggregatedItems: aggregateItems(ungrouped),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [form.items, form.selectedQuotation?.calculationInputs, extractGroupKey]);
|
||||
}, [form.items, form.selectedQuotation?.calculationInputs]);
|
||||
|
||||
// 견적 선택 핸들러
|
||||
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||||
@@ -903,17 +866,18 @@ export function OrderRegistration({
|
||||
{itemGroups ? (
|
||||
// 그룹핑 표시
|
||||
<div className="space-y-4">
|
||||
{itemGroups.map((group) => {
|
||||
return (
|
||||
{itemGroups.map((group) => (
|
||||
<div key={group.key} className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||
<div className="bg-blue-50 px-4 py-2 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={getPresetStyle('info')}>
|
||||
{group.label}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({group.locationCount}개소 / {group.quantity}대)
|
||||
</span>
|
||||
{group.productCode && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{group.productCode} ({group.quantity}대)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
소계: {formatAmount(group.amount)}
|
||||
@@ -934,8 +898,8 @@ export function OrderRegistration({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.aggregatedItems.map((item, index) => (
|
||||
<TableRow key={`agg-${item.itemCode || item.id}`}>
|
||||
{group.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
@@ -960,8 +924,7 @@ export function OrderRegistration({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 기본 플랫 리스트
|
||||
|
||||
@@ -33,6 +33,7 @@ interface WorkOrderApiItem {
|
||||
client_id?: number;
|
||||
client_name?: string;
|
||||
client?: { id: number; name: string };
|
||||
root_nodes_count?: number;
|
||||
};
|
||||
assignee?: { id: number; name: string };
|
||||
items?: { id: number; item_name: string; quantity: number }[];
|
||||
@@ -53,7 +54,7 @@ function mapApiStatus(status: WorkOrderApiItem['status']): 'waiting' | 'inProgre
|
||||
|
||||
// ===== API → WorkOrder 변환 =====
|
||||
function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
const productName = api.items?.[0]?.item_name || '-';
|
||||
|
||||
// 납기일 계산 (지연 여부)
|
||||
@@ -81,6 +82,7 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
projectName: api.project_name || '-',
|
||||
assignees: api.assignee ? [api.assignee.name] : [],
|
||||
quantity: totalQuantity,
|
||||
shutterCount: api.sales_order?.root_nodes_count || 0,
|
||||
dueDate,
|
||||
priority: isUrgent ? 1 : 5,
|
||||
status: mapApiStatus(api.status),
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface WorkOrder {
|
||||
projectName: string; // 강남 타워 신축현장
|
||||
assignees: string[]; // 담당자 배열
|
||||
quantity: number; // EA 수량
|
||||
shutterCount: number; // 개소수 (root_nodes_count)
|
||||
dueDate: string; // 납기
|
||||
priority: number; // 순위 (1~5)
|
||||
status: WorkOrderStatus;
|
||||
@@ -28,7 +29,27 @@ export interface WorkOrder {
|
||||
isDelayed: boolean;
|
||||
delayDays?: number; // 지연 일수
|
||||
instruction?: string; // 지시사항
|
||||
salesOrderNo?: string; // 수주번호
|
||||
createdAt: string;
|
||||
// 개소별 아이템 그룹 (작업자 화면용)
|
||||
nodeGroups?: WorkOrderNodeGroup[];
|
||||
}
|
||||
|
||||
// 개소별 아이템 그룹
|
||||
export interface WorkOrderNodeGroup {
|
||||
nodeId: number | null;
|
||||
nodeName: string;
|
||||
items: WorkOrderNodeItem[];
|
||||
totalQuantity: number;
|
||||
}
|
||||
|
||||
// 개소 내 개별 아이템
|
||||
export interface WorkOrderNodeItem {
|
||||
id: number;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
specification?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// 작업자 현황
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -38,6 +38,18 @@ import {
|
||||
type ProcessStep,
|
||||
} from './types';
|
||||
|
||||
// 수량 포맷팅 (EA, 개 등 개수 단위는 정수, m, kg 등은 소수점 유지)
|
||||
function formatQuantity(quantity: number | string, unit: string): string {
|
||||
const num = typeof quantity === 'string' ? Number(quantity) : quantity;
|
||||
if (isNaN(num)) return String(quantity);
|
||||
const countableUnits = ['EA', 'ea', '개', '대', '세트', 'SET', 'set', 'PCS', 'pcs'];
|
||||
if (countableUnits.includes(unit) || unit === '-') {
|
||||
return String(Math.floor(num));
|
||||
}
|
||||
// 소수점이 있으면 표시, 없으면 정수로
|
||||
return Number.isInteger(num) ? String(num) : num.toFixed(2);
|
||||
}
|
||||
|
||||
// 공정 진행 단계 (wrapper 없이 pills만 렌더링)
|
||||
function ProcessStepPills({
|
||||
processType,
|
||||
@@ -105,7 +117,7 @@ function BendingDetailsSection({ order }: { order: WorkOrder }) {
|
||||
<span className="font-medium">{detail.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{detail.material}</span>
|
||||
</div>
|
||||
<span className="text-sm">수량: {detail.quantity}</span>
|
||||
<span className="text-sm">수량: {Math.floor(detail.quantity)}</span>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
@@ -384,7 +396,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground mb-1">구분</p>
|
||||
<p className="font-medium">-</p>
|
||||
<p className="font-medium">{order.processName !== '-' ? order.processName : '-'}</p>
|
||||
</div>
|
||||
|
||||
{/* 2행: 로트번호 | 수주처 | 현장명 | 수주 담당자 */}
|
||||
@@ -402,21 +414,21 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">수주 담당자</p>
|
||||
<p className="font-medium">-</p>
|
||||
<p className="font-medium">{order.salesOrderWriter || '-'}</p>
|
||||
</div>
|
||||
|
||||
{/* 3행: 담당자 연락처 | 출고예정일 | 틀수 | 우선순위 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">담당자 연락처</p>
|
||||
<p className="font-medium">-</p>
|
||||
<p className="font-medium">{order.clientContact || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">출고예정일</p>
|
||||
<p className="font-medium">{order.shipmentDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">틀수</p>
|
||||
<p className="font-medium">{order.shutterCount ?? '-'}</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">틀수 (개소)</p>
|
||||
<p className="font-medium">{order.shutterCount != null ? `${Math.floor(order.shutterCount)}개소` : '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">우선순위</p>
|
||||
@@ -461,7 +473,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<>
|
||||
{order.items[0].productName}
|
||||
{order.items[0].specification !== '-' ? ` ${order.items[0].specification}` : ''}
|
||||
{` ${order.items[0].quantity}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
|
||||
{` ${formatQuantity(order.items[0].quantity, order.items[0].unit)}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -478,28 +490,101 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작업 품목 테이블 (로트번호 | 품목명 | 수량 | 단위) */}
|
||||
{/* 작업 품목 - 개소별 그룹 */}
|
||||
{order.items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>로트번호</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-24 text-right">수량</TableHead>
|
||||
<TableHead className="w-20">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{order.lotNo}</TableCell>
|
||||
<TableCell className="font-medium">{item.productName}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="space-y-6">
|
||||
{/* 개소별 품목 그룹 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-2">개소별 품목</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-36">개소</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-24 text-right">수량</TableHead>
|
||||
<TableHead className="w-20">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// 개소별로 그룹화
|
||||
const nodeGroups = new Map<string, { nodeName: string; items: typeof order.items }>();
|
||||
for (const item of order.items) {
|
||||
const key = item.orderNodeId != null ? String(item.orderNodeId) : 'none';
|
||||
if (!nodeGroups.has(key)) {
|
||||
nodeGroups.set(key, { nodeName: item.orderNodeName, items: [] });
|
||||
}
|
||||
nodeGroups.get(key)!.items.push(item);
|
||||
}
|
||||
|
||||
const rows: React.ReactNode[] = [];
|
||||
for (const [key, group] of nodeGroups) {
|
||||
group.items.forEach((item, idx) => {
|
||||
rows.push(
|
||||
<TableRow key={`node-${key}-${item.id}`} className={idx === 0 ? 'border-t-2' : ''}>
|
||||
{idx === 0 && (
|
||||
<TableCell rowSpan={group.items.length} className="align-top font-medium bg-muted/30">
|
||||
{group.nodeName}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{item.productName}</TableCell>
|
||||
<TableCell className="text-right">{formatQuantity(item.quantity, item.unit)}</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 품목별 합산 그룹 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-2">품목별 합산</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-12 text-center">No</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-24 text-right">합산수량</TableHead>
|
||||
<TableHead className="w-20">단위</TableHead>
|
||||
<TableHead className="w-20 text-right">개소수</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// 품목명+단위 기준으로 중복 합산
|
||||
const itemMap = new Map<string, { productName: string; totalQty: number; unit: string; nodeCount: number }>();
|
||||
for (const item of order.items) {
|
||||
const key = `${item.productName}||${item.unit}`;
|
||||
if (!itemMap.has(key)) {
|
||||
itemMap.set(key, { productName: item.productName, totalQty: 0, unit: item.unit, nodeCount: 0 });
|
||||
}
|
||||
const entry = itemMap.get(key)!;
|
||||
entry.totalQty += Number(item.quantity);
|
||||
entry.nodeCount += 1;
|
||||
}
|
||||
|
||||
let no = 0;
|
||||
return Array.from(itemMap.values()).map((entry) => {
|
||||
no++;
|
||||
return (
|
||||
<TableRow key={`sum-${entry.productName}-${entry.unit}`}>
|
||||
<TableCell className="text-center">{no}</TableCell>
|
||||
<TableCell className="font-medium">{entry.productName}</TableCell>
|
||||
<TableCell className="text-right">{formatQuantity(entry.totalQty, entry.unit)}</TableCell>
|
||||
<TableCell>{entry.unit}</TableCell>
|
||||
<TableCell className="text-right">{entry.nodeCount}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
등록된 품목이 없습니다.
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 목록 - 공정 기반 탭 구조
|
||||
* 작업지시 목록 - 공정 기반 동적 탭 구조
|
||||
*
|
||||
* 기획서 기반 전면 개편:
|
||||
* - 탭: 공정 기반 3개 (스크린/슬랫/절곡) — 통계 카드 위에 배치
|
||||
* - 탭: 전체 + 공정관리에서 가져온 동적 공정 탭 + 기타(미지정)
|
||||
* - 필터: 상태 + 우선순위
|
||||
* - 통계 카드 6개: 전체 작업 / 작업 대기 / 작업중 / 작업 완료 / 긴급 / 지연
|
||||
* - 컬럼: 작업번호/수주일/출고예정일/로트번호/수주처/현장명/틀수/상태/우선순위/부서/비고
|
||||
* - API: getProcessOptions로 공정 ID 매핑 후 processId로 필터링
|
||||
* - API: getProcessOptions로 공정 목록 동적 로드 → processId로 필터링
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
@@ -57,11 +56,11 @@ export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 활성 탭 및 재공품 모달 =====
|
||||
const [activeTab, setActiveTab] = useState('screen');
|
||||
const [activeTab, setActiveTab] = useState(TAB_ALL);
|
||||
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
|
||||
|
||||
// ===== 공정 ID 매핑 (getProcessOptions) =====
|
||||
const [processMap, setProcessMap] = useState<Record<string, number>>({});
|
||||
// ===== 공정 목록 (동적 탭 생성용) =====
|
||||
const [processList, setProcessList] = useState<ProcessOption[]>([]);
|
||||
const [processMapLoaded, setProcessMapLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,17 +68,7 @@ export function WorkOrderList() {
|
||||
try {
|
||||
const result = await getProcessOptions();
|
||||
if (result.success && result.data) {
|
||||
const map: Record<string, number> = {};
|
||||
result.data.forEach((process: ProcessOption) => {
|
||||
// process_name 또는 process_code로 탭 매핑
|
||||
const tabKeyByName = PROCESS_NAME_TO_TAB[process.processName];
|
||||
const tabKeyByCode = PROCESS_CODE_TO_TAB[process.processCode];
|
||||
const tabKey = tabKeyByName || tabKeyByCode;
|
||||
if (tabKey) {
|
||||
map[tabKey] = process.id;
|
||||
}
|
||||
});
|
||||
setProcessMap(map);
|
||||
setProcessList(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -99,15 +88,29 @@ export function WorkOrderList() {
|
||||
waiting: 0,
|
||||
inProgress: 0,
|
||||
completed: 0,
|
||||
byProcess: {},
|
||||
});
|
||||
|
||||
// 통계 데이터 로드 (초기 1회)
|
||||
// 통계 데이터 로드 (초기 1회) + 탭 카운트 세팅
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await getWorkOrderStats();
|
||||
if (result.success && result.data) {
|
||||
setStatsData(result.data);
|
||||
|
||||
// 공정별 카운트 → 탭 카운트에 반영
|
||||
const bp = result.data.byProcess;
|
||||
const counts: Record<string, number> = {
|
||||
[TAB_ALL]: result.data.total,
|
||||
[TAB_OTHER]: bp['none'] || 0,
|
||||
};
|
||||
for (const [processId, count] of Object.entries(bp)) {
|
||||
if (processId !== 'none') {
|
||||
counts[processId] = count;
|
||||
}
|
||||
}
|
||||
setTabCounts(counts);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -130,20 +133,20 @@ export function WorkOrderList() {
|
||||
router.push('/ko/production/work-orders?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 탭 옵션 (공정 기반 3개) — 카운트는 API 응답으로 동적 업데이트 =====
|
||||
const [tabCounts, setTabCounts] = useState<Record<string, number>>({
|
||||
screen: 0,
|
||||
slat: 0,
|
||||
bending: 0,
|
||||
});
|
||||
// ===== 탭 옵션 (전체 + 동적 공정 + 기타) =====
|
||||
const [tabCounts, setTabCounts] = useState<Record<string, number>>({});
|
||||
|
||||
const tabs: TabOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'screen', label: '스크린 공정', count: tabCounts.screen },
|
||||
{ value: 'slat', label: '슬랫 공정', count: tabCounts.slat },
|
||||
{ value: 'bending', label: '절곡 공정', count: tabCounts.bending },
|
||||
{ value: TAB_ALL, label: '전체', count: tabCounts[TAB_ALL] },
|
||||
...processList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.processName} 공정`,
|
||||
count: tabCounts[String(p.id)],
|
||||
})),
|
||||
{ value: TAB_OTHER, label: '기타', count: tabCounts[TAB_OTHER] },
|
||||
],
|
||||
[tabCounts]
|
||||
[processList, tabCounts]
|
||||
);
|
||||
|
||||
// ===== 통계 카드 6개 (기획서 기반) =====
|
||||
@@ -205,20 +208,16 @@ export function WorkOrderList() {
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
// 탭 → processId 매핑
|
||||
const tabValue = params?.tab || 'screen';
|
||||
const tabValue = params?.tab || TAB_ALL;
|
||||
setActiveTab(tabValue);
|
||||
const processId = processMap[tabValue];
|
||||
|
||||
// 해당 공정이 DB에 없으면 빈 목록 반환
|
||||
if (!processId) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
// 탭별 processId 결정
|
||||
// 'all' → 필터 없음 (전체), 'other' → 'none' (미지정), 그 외 → 공정 ID
|
||||
const processId = tabValue === TAB_ALL
|
||||
? ('all' as const)
|
||||
: tabValue === TAB_OTHER
|
||||
? ('none' as const)
|
||||
: Number(tabValue);
|
||||
|
||||
// 필터 값 추출
|
||||
const statusFilter = params?.filters?.status as string | undefined;
|
||||
@@ -238,16 +237,21 @@ export function WorkOrderList() {
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 현재 탭의 카운트 업데이트
|
||||
setTabCounts((prev) => ({
|
||||
...prev,
|
||||
[tabValue]: result.pagination.total,
|
||||
}));
|
||||
|
||||
// 통계도 다시 로드
|
||||
// 통계 + 공정별 카운트 다시 로드
|
||||
const statsResult = await getWorkOrderStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
const bp = statsResult.data.byProcess;
|
||||
const counts: Record<string, number> = {
|
||||
[TAB_ALL]: statsResult.data.total,
|
||||
[TAB_OTHER]: bp['none'] || 0,
|
||||
};
|
||||
for (const [processId, count] of Object.entries(bp)) {
|
||||
if (processId !== 'none') {
|
||||
counts[processId] = count;
|
||||
}
|
||||
}
|
||||
setTabCounts(counts);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -312,11 +316,8 @@ export function WorkOrderList() {
|
||||
tabsPosition: 'above-stats',
|
||||
|
||||
// 테이블 헤더 액션 (절곡 공정 탭일 때만 재공품 생산 버튼)
|
||||
// 절곡 공정 ID 찾기
|
||||
tableHeaderActions: (() => {
|
||||
const bendingProcess = processList.find(p =>
|
||||
p.processName === '절곡' || p.processCode.toLowerCase() === 'bending'
|
||||
);
|
||||
const bendingProcess = processList.find(p => p.processName === '절곡');
|
||||
return bendingProcess && activeTab === String(bendingProcess.id);
|
||||
})() ? (
|
||||
<Button
|
||||
@@ -370,7 +371,7 @@ export function WorkOrderList() {
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
|
||||
<TableCell className="text-center">{item.shutterCount ?? '-'}</TableCell>
|
||||
<TableCell>{'-'}</TableCell>
|
||||
<TableCell>{item.processName !== '-' ? item.processName : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[item.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[item.status]}
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function getWorkOrders(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: WorkOrderStatus | 'all';
|
||||
processId?: number | 'all'; // 공정 ID (FK → processes.id)
|
||||
processId?: number | 'all' | 'none'; // 공정 ID (FK → processes.id), 'none' = 미지정
|
||||
processType?: 'screen' | 'slat' | 'bending'; // 공정 타입 필터
|
||||
priority?: string; // 우선순위 필터 (urgent/priority/normal)
|
||||
search?: string;
|
||||
@@ -74,6 +74,7 @@ export async function getWorkOrders(params?: {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
if (params?.processId && params.processId !== 'all') {
|
||||
// 'none': 공정 미지정 필터 (process_id IS NULL)
|
||||
searchParams.set('process_id', String(params.processId));
|
||||
}
|
||||
if (params?.processType) {
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface WorkOrderItem {
|
||||
specification: string; // 규격
|
||||
quantity: number;
|
||||
unit: string; // 단위
|
||||
orderNodeId: number | null; // 개소 ID
|
||||
orderNodeName: string; // 개소명
|
||||
}
|
||||
|
||||
// 전개도 상세 (절곡용)
|
||||
@@ -186,6 +188,8 @@ export interface WorkOrder {
|
||||
|
||||
// 수주 관련
|
||||
salesOrderDate: string; // 수주일
|
||||
salesOrderWriter?: string; // 수주 담당자
|
||||
clientContact?: string; // 담당자 연락처
|
||||
shutterCount: number | null; // 틀수
|
||||
department: string; // 부서명
|
||||
|
||||
@@ -232,6 +236,8 @@ export interface WorkOrderStats {
|
||||
waiting: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
/** 공정별 카운트 (key: process_id 또는 'none', value: count) */
|
||||
byProcess: Record<string, number>;
|
||||
}
|
||||
|
||||
// ===== API 타입 정의 =====
|
||||
@@ -249,6 +255,15 @@ export interface WorkOrderItemApi {
|
||||
status: 'waiting' | 'in_progress' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
source_order_item?: {
|
||||
id: number;
|
||||
order_node_id: number | null;
|
||||
node?: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// API 응답 - 벤딩 상세
|
||||
@@ -315,17 +330,24 @@ export interface WorkOrderApi {
|
||||
deleted_at: string | null;
|
||||
// Relations
|
||||
priority: number | null;
|
||||
shutter_count: number | null;
|
||||
sales_order?: {
|
||||
id: number;
|
||||
order_no: string;
|
||||
order_date?: string;
|
||||
site_name?: string;
|
||||
client_contact?: string;
|
||||
received_at?: string;
|
||||
created_at?: string;
|
||||
quantity?: number;
|
||||
root_nodes_count?: number;
|
||||
client?: { id: number; name: string };
|
||||
writer?: { id: number; name: string };
|
||||
};
|
||||
process?: {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
department?: string;
|
||||
work_steps?: string[] | { key: string; label: string; order: number }[];
|
||||
};
|
||||
assignee?: { id: number; name: string };
|
||||
@@ -354,6 +376,7 @@ export interface WorkOrderStatsApi {
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
shipped: number;
|
||||
by_process?: Record<string, number>;
|
||||
}
|
||||
|
||||
// ===== 변환 함수 =====
|
||||
@@ -407,7 +430,7 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
||||
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
|
||||
status: api.status,
|
||||
client: api.sales_order?.client?.name || '-',
|
||||
projectName: api.project_name || '-',
|
||||
projectName: api.sales_order?.site_name || api.project_name || '-',
|
||||
dueDate: api.scheduled_date || '-',
|
||||
assignee: assigneeName,
|
||||
assignees: assignees.length > 0 ? assignees : undefined,
|
||||
@@ -418,9 +441,11 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
||||
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
|
||||
priority: priorityValue,
|
||||
priorityLabel: getPriorityLabel(priorityValue),
|
||||
salesOrderDate: api.sales_order?.order_date || api.created_at.split('T')[0],
|
||||
shutterCount: api.shutter_count ?? null,
|
||||
department: api.team?.name || '-',
|
||||
salesOrderDate: api.sales_order?.received_at?.split('T')[0] || api.sales_order?.created_at?.split('T')[0] || api.created_at.split('T')[0],
|
||||
salesOrderWriter: api.sales_order?.writer?.name || '-',
|
||||
clientContact: api.sales_order?.client_contact || '-',
|
||||
shutterCount: api.sales_order?.root_nodes_count || null,
|
||||
department: api.process?.department || api.team?.name || '-',
|
||||
currentStep: getStatusStep(api.status),
|
||||
items: (api.items || []).map((item, idx) => ({
|
||||
id: String(item.id),
|
||||
@@ -431,6 +456,8 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
||||
specification: item.specification || '-',
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || '-',
|
||||
orderNodeId: item.source_order_item?.order_node_id ?? null,
|
||||
orderNodeName: item.source_order_item?.node?.name || '-',
|
||||
})),
|
||||
bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined,
|
||||
issues: (api.issues || []).map(issue => ({
|
||||
@@ -521,5 +548,6 @@ export function transformStatsApiToFrontend(api: WorkOrderStatsApi): WorkOrderSt
|
||||
waiting: api.waiting,
|
||||
inProgress: api.in_progress,
|
||||
completed: api.completed + api.shipped,
|
||||
byProcess: api.by_process || {},
|
||||
};
|
||||
}
|
||||
@@ -232,8 +232,8 @@ export function MaterialInputModal({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow key={material.id}>
|
||||
{materials.map((material, index) => (
|
||||
<TableRow key={`mat-${material.id}-${index}`}>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.materialCode}
|
||||
</TableCell>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function WorkItemCard({
|
||||
{item.itemNo}
|
||||
</span>
|
||||
<span className="text-base font-bold text-gray-900">
|
||||
{item.itemCode} ({item.itemName})
|
||||
{item.itemCode} - {item.itemName}
|
||||
</span>
|
||||
</div>
|
||||
{!item.isWip && (
|
||||
|
||||
131
src/components/production/WorkerScreen/WorkOrderListPanel.tsx
Normal file
131
src/components/production/WorkerScreen/WorkOrderListPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시서 리스트 패널 (좌측)
|
||||
*
|
||||
* 마스터-디테일 레이아웃의 좌측 패널.
|
||||
* 공정별 필터링된 작업지시서 목록을 표시하고 선택 기능 제공.
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertTriangle, Package } from 'lucide-react';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
|
||||
interface WorkOrderListPanelProps {
|
||||
workOrders: WorkOrder[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function WorkOrderListPanel({
|
||||
workOrders,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: WorkOrderListPanelProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-1.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-[84px] rounded-lg bg-gray-100 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-6">
|
||||
<Package className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-sm">배정된 작업이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-2 space-y-1.5">
|
||||
{workOrders.map((order, index) => (
|
||||
<button
|
||||
key={order.id}
|
||||
onClick={() => onSelect(order.id)}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg border transition-all duration-150',
|
||||
selectedId === order.id
|
||||
? 'bg-primary/5 border-primary/30 shadow-sm'
|
||||
: 'bg-white border-gray-100 hover:bg-gray-50 hover:border-gray-200'
|
||||
)}
|
||||
>
|
||||
{/* 헤더: 번호 + 작업지시번호 + 상태 */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-emerald-500 text-white text-xs font-bold shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">
|
||||
{order.orderNo}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
{order.isUrgent && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목명 */}
|
||||
<p className="text-xs text-gray-600 truncate ml-8">{order.productName}</p>
|
||||
|
||||
{/* 현장명 + 수량 */}
|
||||
<div className="flex items-center justify-between mt-1.5 ml-8">
|
||||
<span className="text-xs text-gray-400 truncate">
|
||||
{order.projectName || order.client}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500 shrink-0">
|
||||
{order.quantity}EA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 납기일 */}
|
||||
{order.dueDate && (
|
||||
<div className="mt-1 ml-8">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px]',
|
||||
order.isDelayed
|
||||
? 'text-red-500 font-medium'
|
||||
: 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
납기: {new Date(order.dueDate).toLocaleDateString('ko-KR')}
|
||||
{order.isDelayed &&
|
||||
order.delayDays &&
|
||||
` (${order.delayDays}일 지연)`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
waiting: { label: '대기', className: 'bg-gray-100 text-gray-600' },
|
||||
inProgress: { label: '작업중', className: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: '완료', className: 'bg-green-100 text-green-700' },
|
||||
};
|
||||
const { label, className } = config[status] || config.waiting;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 작업자 화면 서버 액션
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* API 연동 (2025-12-26 초기, 2026-02-06 options + step-progress 확장)
|
||||
*
|
||||
* WorkOrders API를 호출하고 WorkerScreen에 맞는 형식으로 변환
|
||||
*/
|
||||
@@ -11,13 +11,22 @@
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { WorkOrder, WorkOrderStatus } from '../ProductionDashboard/types';
|
||||
import type { WorkItemData, WorkStepData, ProcessTab } from './types';
|
||||
|
||||
// ===== API 타입 =====
|
||||
interface WorkOrderApiItem {
|
||||
id: number;
|
||||
work_order_no: string;
|
||||
project_name: string | null;
|
||||
process_type: 'screen' | 'slat' | 'bending';
|
||||
process_id: number | null;
|
||||
process?: {
|
||||
id: number;
|
||||
process_name: string;
|
||||
process_code: string;
|
||||
department?: string | null;
|
||||
};
|
||||
/** @deprecated process_id + process relation 사용 */
|
||||
process_type?: 'screen' | 'slat' | 'bending';
|
||||
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
|
||||
scheduled_date: string | null;
|
||||
memo: string | null;
|
||||
@@ -26,9 +35,21 @@ interface WorkOrderApiItem {
|
||||
id: number;
|
||||
order_no: string;
|
||||
client?: { id: number; name: string };
|
||||
root_nodes_count?: number;
|
||||
};
|
||||
assignee?: { id: number; name: string };
|
||||
items?: { id: number; item_name: string; quantity: number }[];
|
||||
items?: {
|
||||
id: number;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
specification?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
source_order_item?: {
|
||||
id: number;
|
||||
order_node_id: number | null;
|
||||
node?: { id: number; name: string; code: string } | null;
|
||||
} | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ===== 상태 변환 =====
|
||||
@@ -46,7 +67,7 @@ function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus {
|
||||
|
||||
// ===== API → WorkOrder 변환 =====
|
||||
function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
const totalQuantity = (api.items || []).reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
const productName = api.items?.[0]?.item_name || '-';
|
||||
|
||||
// 납기일 계산 (지연 여부)
|
||||
@@ -59,13 +80,62 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: undefined;
|
||||
|
||||
// process_type → processCode/processName 매핑
|
||||
const processTypeMap: Record<string, { code: string; name: string }> = {
|
||||
screen: { code: 'screen', name: '스크린' },
|
||||
slat: { code: 'slat', name: '슬랫' },
|
||||
bending: { code: 'bending', name: '절곡' },
|
||||
// process relation → processCode/processName 매핑
|
||||
// 신규: process relation 사용, 폴백: deprecated process_type 필드
|
||||
const rawProcessName = api.process?.process_name || '';
|
||||
const rawProcessCode = api.process?.process_code || '';
|
||||
|
||||
// 한글 공정명 → 탭 키 매핑 (필터링에 사용)
|
||||
const nameToTab: Record<string, { code: string; name: string }> = {
|
||||
'스크린': { code: 'screen', name: '스크린' },
|
||||
'슬랫': { code: 'slat', name: '슬랫' },
|
||||
'절곡': { code: 'bending', name: '절곡' },
|
||||
};
|
||||
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
|
||||
|
||||
// 1차: process relation의 process_name으로 매핑
|
||||
let processInfo = Object.entries(nameToTab).find(
|
||||
([key]) => rawProcessName.includes(key)
|
||||
)?.[1];
|
||||
|
||||
// 2차: deprecated process_type 폴백
|
||||
if (!processInfo && api.process_type) {
|
||||
const legacyMap: Record<string, { code: string; name: string }> = {
|
||||
screen: { code: 'screen', name: '스크린' },
|
||||
slat: { code: 'slat', name: '슬랫' },
|
||||
bending: { code: 'bending', name: '절곡' },
|
||||
};
|
||||
processInfo = legacyMap[api.process_type];
|
||||
}
|
||||
|
||||
// 3차: 그래도 없으면 원시값 사용
|
||||
if (!processInfo) {
|
||||
processInfo = { code: rawProcessCode || 'unknown', name: rawProcessName || '알수없음' };
|
||||
}
|
||||
|
||||
// 아이템을 개소(node)별로 그룹핑
|
||||
const nodeMap = new Map<string, { nodeId: number | null; nodeName: string; items: typeof api.items }>();
|
||||
for (const item of (api.items || [])) {
|
||||
const nodeId = item.source_order_item?.order_node_id ?? null;
|
||||
const nodeName = item.source_order_item?.node?.name || '미지정';
|
||||
const key = nodeId != null ? String(nodeId) : 'unassigned';
|
||||
if (!nodeMap.has(key)) {
|
||||
nodeMap.set(key, { nodeId, nodeName, items: [] });
|
||||
}
|
||||
nodeMap.get(key)!.items!.push(item);
|
||||
}
|
||||
|
||||
const nodeGroups = Array.from(nodeMap.values()).map((g) => ({
|
||||
nodeId: g.nodeId,
|
||||
nodeName: g.nodeName,
|
||||
items: (g.items || []).map((it) => ({
|
||||
id: it.id,
|
||||
itemName: it.item_name,
|
||||
quantity: Number(it.quantity),
|
||||
specification: it.specification,
|
||||
options: it.options,
|
||||
})),
|
||||
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: String(api.id),
|
||||
@@ -77,6 +147,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
projectName: api.project_name || '-',
|
||||
assignees: api.assignee ? [api.assignee.name] : [],
|
||||
quantity: totalQuantity,
|
||||
shutterCount: api.sales_order?.root_nodes_count || 0,
|
||||
dueDate,
|
||||
priority: 5, // 기본 우선순위
|
||||
status: mapApiStatus(api.status),
|
||||
@@ -84,7 +155,9 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
isDelayed,
|
||||
delayDays,
|
||||
instruction: api.memo || undefined,
|
||||
salesOrderNo: api.sales_order?.order_no || undefined,
|
||||
createdAt: api.created_at,
|
||||
nodeGroups,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,8 +168,8 @@ export async function getMyWorkOrders(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
|
||||
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&worker_screen=1`;
|
||||
|
||||
console.log('[WorkerScreenActions] GET my work orders:', url);
|
||||
|
||||
@@ -530,4 +603,216 @@ export async function requestInspection(
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 공정 단계 진행 현황 조회 =====
|
||||
export interface StepProgressItem {
|
||||
id: number;
|
||||
process_step_id: number;
|
||||
step_code: string;
|
||||
step_name: string;
|
||||
sort_order: number;
|
||||
needs_inspection: boolean;
|
||||
connection_type: string | null;
|
||||
completion_type: string | null;
|
||||
status: string;
|
||||
is_completed: boolean;
|
||||
completed_at: string | null;
|
||||
completed_by: number | null;
|
||||
}
|
||||
|
||||
export async function getStepProgress(
|
||||
workOrderId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: StepProgressItem[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, data: [], error: error?.message || '네트워크 오류' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, data: [], error: result.message || '단계 진행 조회 실패' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data || [] };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] getStepProgress error:', error);
|
||||
return { success: false, data: [], error: '서버 오류' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 공정 단계 완료 토글 =====
|
||||
export async function toggleStepProgress(
|
||||
workOrderId: string,
|
||||
progressId: number
|
||||
): Promise<{ success: boolean; data?: StepProgressItem; error?: string }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/step-progress/${progressId}/toggle`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'PATCH' });
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || '네트워크 오류' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '단계 토글 실패' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] toggleStepProgress error:', error);
|
||||
return { success: false, error: '서버 오류' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 작업지시 상세 조회 (items + options 포함) =====
|
||||
export async function getWorkOrderDetail(
|
||||
workOrderId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: WorkItemData[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, data: [], error: error?.message || '네트워크 오류' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, data: [], error: result.message || '상세 조회 실패' };
|
||||
}
|
||||
|
||||
const wo = result.data;
|
||||
const processName = (wo.process?.process_name || '').toLowerCase();
|
||||
const processType: ProcessTab =
|
||||
processName.includes('스크린') ? 'screen' :
|
||||
processName.includes('슬랫') ? 'slat' :
|
||||
processName.includes('절곡') ? 'bending' : 'screen';
|
||||
|
||||
// items → WorkItemData 변환 (options 파싱)
|
||||
const items: WorkItemData[] = (wo.items || []).map((item: {
|
||||
id: number;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
quantity: string;
|
||||
unit: string | null;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
options: Record<string, unknown> | null;
|
||||
}, index: number) => {
|
||||
const opts = item.options || {};
|
||||
|
||||
// steps: stepProgress에서 가져오거나 process.steps에서 생성
|
||||
const stepProgressList = wo.step_progress || [];
|
||||
const processSteps = wo.process?.steps || [];
|
||||
|
||||
let steps: WorkStepData[];
|
||||
if (stepProgressList.length > 0) {
|
||||
steps = stepProgressList
|
||||
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
|
||||
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
|
||||
id: String(sp.id),
|
||||
name: sp.process_step?.step_name || '',
|
||||
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
|
||||
isCompleted: sp.status === 'completed',
|
||||
}));
|
||||
} else {
|
||||
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
|
||||
id: `${item.id}-step-${si}`,
|
||||
name: ps.step_name,
|
||||
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
|
||||
isCompleted: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const workItem: WorkItemData = {
|
||||
id: String(item.id),
|
||||
itemNo: index + 1,
|
||||
itemCode: wo.work_order_no || '-',
|
||||
itemName: item.item_name || '-',
|
||||
floor: (opts.floor as string) || '-',
|
||||
code: (opts.code as string) || '-',
|
||||
width: (opts.width as number) || 0,
|
||||
height: (opts.height as number) || 0,
|
||||
quantity: Number(item.quantity) || 0,
|
||||
processType,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
};
|
||||
|
||||
// 공정별 상세 정보 파싱 (options에서)
|
||||
if (opts.cutting_info) {
|
||||
const ci = opts.cutting_info as { width: number; sheets: number };
|
||||
workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets };
|
||||
}
|
||||
if (opts.slat_info) {
|
||||
const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number };
|
||||
workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar };
|
||||
}
|
||||
if (opts.bending_info) {
|
||||
const bi = opts.bending_info as {
|
||||
common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] };
|
||||
detail_parts: { part_name: string; material: string; barcy_info: string }[];
|
||||
};
|
||||
workItem.bendingInfo = {
|
||||
common: {
|
||||
kind: bi.common.kind,
|
||||
type: bi.common.type,
|
||||
lengthQuantities: bi.common.length_quantities || [],
|
||||
},
|
||||
detailParts: (bi.detail_parts || []).map(dp => ({
|
||||
partName: dp.part_name,
|
||||
material: dp.material,
|
||||
barcyInfo: dp.barcy_info,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (opts.is_wip) {
|
||||
workItem.isWip = true;
|
||||
const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined;
|
||||
if (wi) {
|
||||
workItem.wipInfo = {
|
||||
specification: wi.specification,
|
||||
lengthQuantity: wi.length_quantity,
|
||||
drawingUrl: wi.drawing_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (opts.is_joint_bar) {
|
||||
workItem.isJointBar = true;
|
||||
const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined;
|
||||
if (jb) {
|
||||
workItem.slatJointBarInfo = jb;
|
||||
}
|
||||
}
|
||||
|
||||
return workItem;
|
||||
});
|
||||
|
||||
return { success: true, data: items };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkerScreenActions] getWorkOrderDetail error:', error);
|
||||
return { success: false, data: [], error: '서버 오류' };
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,7 @@ export default function WorkerScreen() {
|
||||
const { sidebarCollapsed } = useMenuStore();
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
|
||||
const [activeTab, setActiveTab] = useState<string>('');
|
||||
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
|
||||
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function WorkerScreen() {
|
||||
const [productionDate, setProductionDate] = useState('');
|
||||
|
||||
// 좌측 사이드바
|
||||
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('order-1');
|
||||
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
|
||||
@@ -406,24 +406,36 @@ export default function WorkerScreen() {
|
||||
fetchProcessList();
|
||||
}, []);
|
||||
|
||||
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
|
||||
const processTabs = useMemo(() => {
|
||||
return processListCache.filter((p) => p.status === '사용중');
|
||||
}, [processListCache]);
|
||||
|
||||
// 공정 목록 로드 후 첫 번째 공정을 기본 선택
|
||||
useEffect(() => {
|
||||
if (processTabs.length > 0 && !activeTab) {
|
||||
setActiveTab(processTabs[0].id);
|
||||
}
|
||||
}, [processTabs, activeTab]);
|
||||
|
||||
// 선택된 공정의 ProcessTab 키 (mock 데이터 및 기존 로직 호환용)
|
||||
const activeProcessTabKey: ProcessTab = useMemo(() => {
|
||||
const process = processListCache.find((p) => p.id === activeTab);
|
||||
if (!process) return 'screen';
|
||||
const name = process.processName.toLowerCase();
|
||||
if (name.includes('스크린')) return 'screen';
|
||||
if (name.includes('슬랫')) return 'slat';
|
||||
if (name.includes('절곡')) return 'bending';
|
||||
return 'screen';
|
||||
}, [activeTab, processListCache]);
|
||||
|
||||
// activeTab 변경 시 해당 공정의 중간검사 설정 조회
|
||||
useEffect(() => {
|
||||
if (processListCache.length === 0) return;
|
||||
if (processListCache.length === 0 || !activeTab) return;
|
||||
|
||||
// activeTab에 해당하는 공정 찾기
|
||||
const tabToProcessName: Record<ProcessTab, string[]> = {
|
||||
screen: ['스크린', 'screen'],
|
||||
slat: ['슬랫', 'slat'],
|
||||
bending: ['절곡', 'bending'],
|
||||
};
|
||||
|
||||
const matchNames = tabToProcessName[activeTab] || [];
|
||||
const matchedProcess = processListCache.find((p) =>
|
||||
matchNames.some((name) => p.processName.toLowerCase().includes(name.toLowerCase()))
|
||||
);
|
||||
const matchedProcess = processListCache.find((p) => p.id === activeTab);
|
||||
|
||||
if (matchedProcess?.steps) {
|
||||
// 검사 단계에서 inspectionSetting 찾기
|
||||
const inspectionStep = matchedProcess.steps.find(
|
||||
(step) => step.needsInspection && step.inspectionSetting
|
||||
);
|
||||
@@ -435,22 +447,49 @@ export default function WorkerScreen() {
|
||||
|
||||
// ===== 탭별 필터링된 작업 =====
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
// process_type 기반 필터링
|
||||
const selectedProcess = processListCache.find((p) => p.id === activeTab);
|
||||
if (!selectedProcess) return workOrders;
|
||||
|
||||
const selectedName = selectedProcess.processName.toLowerCase();
|
||||
return workOrders.filter((order) => {
|
||||
// WorkOrder의 processCode/processName으로 매칭
|
||||
const processName = (order.processName || '').toLowerCase();
|
||||
switch (activeTab) {
|
||||
case 'screen':
|
||||
return processName.includes('스크린') || processName === 'screen';
|
||||
case 'slat':
|
||||
return processName.includes('슬랫') || processName === 'slat';
|
||||
case 'bending':
|
||||
return processName.includes('절곡') || processName === 'bending';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
const orderProcessName = (order.processName || '').toLowerCase();
|
||||
return orderProcessName.includes(selectedName) || selectedName.includes(orderProcessName);
|
||||
});
|
||||
}, [workOrders, activeTab]);
|
||||
}, [workOrders, activeTab, processListCache]);
|
||||
|
||||
// ===== API WorkOrders → SidebarOrder 변환 =====
|
||||
const apiSidebarOrders: SidebarOrder[] = useMemo(() => {
|
||||
return filteredWorkOrders.map((wo) => ({
|
||||
id: wo.id,
|
||||
siteName: wo.projectName || wo.client || '-',
|
||||
date: wo.dueDate || (wo.createdAt ? wo.createdAt.slice(0, 10) : '-'),
|
||||
quantity: wo.quantity,
|
||||
shutterCount: wo.shutterCount || 0,
|
||||
priority: (wo.isUrgent ? 'urgent' : (wo.priority <= 3 ? 'priority' : 'normal')) as SidebarOrder['priority'],
|
||||
}));
|
||||
}, [filteredWorkOrders]);
|
||||
|
||||
// ===== 탭 변경/데이터 로드 시 최상위 우선순위 작업 자동 선택 =====
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
|
||||
// 우선순위 순서: urgent → priority → normal
|
||||
for (const group of PRIORITY_GROUPS) {
|
||||
const first = allOrders.find((o) => o.priority === group.key);
|
||||
if (first) {
|
||||
setSelectedSidebarOrderId(first.id);
|
||||
// subType에 따라 서브모드도 설정
|
||||
if (activeProcessTabKey === 'slat') {
|
||||
setSlatSubMode(first.subType === 'jointbar' ? 'jointbar' : 'normal');
|
||||
}
|
||||
if (activeProcessTabKey === 'bending') {
|
||||
setBendingSubMode(first.subType === 'wip' ? 'wip' : 'normal');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [isLoading, apiSidebarOrders, activeProcessTabKey]);
|
||||
|
||||
// ===== 통계 계산 (탭별) =====
|
||||
const stats: WorkerStats = useMemo(() => {
|
||||
@@ -462,46 +501,84 @@ export default function WorkerScreen() {
|
||||
};
|
||||
}, [filteredWorkOrders]);
|
||||
|
||||
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
|
||||
// ===== 선택된 작업지시의 개소별 WorkItemData 변환 + 목업 =====
|
||||
const workItems: WorkItemData[] = useMemo(() => {
|
||||
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
|
||||
const stepsKey = (activeTab === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeTab;
|
||||
const stepsTemplate = PROCESS_STEPS[stepsKey];
|
||||
const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
|
||||
const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey;
|
||||
const stepsTemplate = PROCESS_STEPS[stepsKey];
|
||||
|
||||
const apiItems: WorkItemData[] = [];
|
||||
|
||||
if (selectedOrder && selectedOrder.nodeGroups && selectedOrder.nodeGroups.length > 0) {
|
||||
// 개소별로 WorkItemData 생성
|
||||
selectedOrder.nodeGroups.forEach((group, index) => {
|
||||
const nodeKey = group.nodeId != null ? String(group.nodeId) : `unassigned-${index}`;
|
||||
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
|
||||
const stepKey = `${selectedOrder.id}-${nodeKey}-${st.name}`;
|
||||
return {
|
||||
id: `${selectedOrder.id}-${nodeKey}-step-${si}`,
|
||||
name: st.name,
|
||||
isMaterialInput: st.isMaterialInput,
|
||||
isInspection: st.isInspection,
|
||||
isCompleted: stepCompletionMap[stepKey] || false,
|
||||
};
|
||||
});
|
||||
|
||||
// 개소 내 아이템 이름 요약
|
||||
const itemNames = group.items.map((it) => it.itemName).filter(Boolean);
|
||||
const itemSummary = itemNames.length > 0 ? itemNames.join(', ') : '-';
|
||||
|
||||
apiItems.push({
|
||||
id: `${selectedOrder.id}-node-${nodeKey}`,
|
||||
itemNo: index + 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: `${group.nodeName} : ${itemSummary}`,
|
||||
floor: '-',
|
||||
code: '-',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: group.totalQuantity,
|
||||
processType: activeProcessTabKey,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
});
|
||||
});
|
||||
} else if (selectedOrder) {
|
||||
// nodeGroups가 없는 경우 폴백 (단일 항목)
|
||||
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
|
||||
const stepKey = `${order.id}-${st.name}`;
|
||||
const stepKey = `${selectedOrder.id}-${st.name}`;
|
||||
return {
|
||||
id: `${order.id}-step-${si}`,
|
||||
id: `${selectedOrder.id}-step-${si}`,
|
||||
name: st.name,
|
||||
isMaterialInput: st.isMaterialInput,
|
||||
isInspection: st.isInspection,
|
||||
isCompleted: stepCompletionMap[stepKey] || false,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
itemNo: index + 1,
|
||||
itemCode: order.orderNo || '-',
|
||||
itemName: order.productName || '-',
|
||||
apiItems.push({
|
||||
id: selectedOrder.id,
|
||||
itemNo: 1,
|
||||
itemCode: selectedOrder.orderNo || '-',
|
||||
itemName: selectedOrder.productName || '-',
|
||||
floor: '-',
|
||||
code: '-',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: order.quantity || 0,
|
||||
processType: activeTab,
|
||||
quantity: selectedOrder.quantity || 0,
|
||||
processType: activeProcessTabKey,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
|
||||
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
|
||||
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
|
||||
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
|
||||
const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip')
|
||||
? MOCK_ITEMS_BENDING_WIP
|
||||
: (activeTab === 'slat' && slatSubMode === 'jointbar')
|
||||
: (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar')
|
||||
? MOCK_ITEMS_SLAT_JOINTBAR
|
||||
: MOCK_ITEMS[activeTab];
|
||||
: MOCK_ITEMS[activeProcessTabKey];
|
||||
const mockItems = baseMockItems.map((item, i) => ({
|
||||
...item,
|
||||
itemNo: apiItems.length + i + 1,
|
||||
@@ -515,22 +592,51 @@ export default function WorkerScreen() {
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode]);
|
||||
|
||||
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
|
||||
// ===== 수주 정보 (사이드바 선택 항목 기반) =====
|
||||
const orderInfo = useMemo(() => {
|
||||
// 1. 선택된 API 작업지시에서 찾기
|
||||
const apiOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
|
||||
if (apiOrder) {
|
||||
return {
|
||||
orderDate: apiOrder.createdAt ? new Date(apiOrder.createdAt).toLocaleDateString('ko-KR') : '-',
|
||||
salesOrderNo: apiOrder.salesOrderNo || '-',
|
||||
siteName: apiOrder.projectName || '-',
|
||||
client: apiOrder.client || '-',
|
||||
salesManager: apiOrder.assignees?.[0] || '-',
|
||||
managerPhone: '-',
|
||||
shippingDate: apiOrder.dueDate ? new Date(apiOrder.dueDate).toLocaleDateString('ko-KR') : '-',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 목업 사이드바에서 찾기
|
||||
const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId);
|
||||
if (mockOrder) {
|
||||
return {
|
||||
orderDate: mockOrder.date,
|
||||
salesOrderNo: 'SO-2024-0001',
|
||||
siteName: mockOrder.siteName,
|
||||
client: '-',
|
||||
salesManager: '-',
|
||||
managerPhone: '-',
|
||||
shippingDate: '-',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 폴백: 첫 번째 작업
|
||||
const first = filteredWorkOrders[0];
|
||||
if (!first) return null;
|
||||
return {
|
||||
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
|
||||
lotNo: '-',
|
||||
salesOrderNo: first.salesOrderNo || '-',
|
||||
siteName: first.projectName || '-',
|
||||
client: first.client || '-',
|
||||
salesManager: first.assignees?.[0] || '-',
|
||||
managerPhone: '-',
|
||||
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
|
||||
};
|
||||
}, [filteredWorkOrders]);
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
|
||||
@@ -722,14 +828,14 @@ export default function WorkerScreen() {
|
||||
|
||||
// 현재 공정에 맞는 중간검사 타입 결정
|
||||
const getInspectionProcessType = useCallback((): InspectionProcessType => {
|
||||
if (activeTab === 'bending' && bendingSubMode === 'wip') {
|
||||
if (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') {
|
||||
return 'bending_wip';
|
||||
}
|
||||
if (activeTab === 'slat' && slatSubMode === 'jointbar') {
|
||||
if (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar') {
|
||||
return 'slat_jointbar';
|
||||
}
|
||||
return activeTab as InspectionProcessType;
|
||||
}, [activeTab, bendingSubMode, slatSubMode]);
|
||||
return activeProcessTabKey as InspectionProcessType;
|
||||
}, [activeProcessTabKey, bendingSubMode, slatSubMode]);
|
||||
|
||||
// 하단 버튼 핸들러
|
||||
const handleWorkLog = useCallback(() => {
|
||||
@@ -766,13 +872,13 @@ export default function WorkerScreen() {
|
||||
|
||||
// ===== 재공품 감지 =====
|
||||
const hasWipItems = useMemo(() => {
|
||||
return activeTab === 'bending' && workItems.some(item => item.isWip);
|
||||
}, [activeTab, workItems]);
|
||||
return activeProcessTabKey === 'bending' && workItems.some(item => item.isWip);
|
||||
}, [activeProcessTabKey, workItems]);
|
||||
|
||||
// ===== 조인트바 감지 =====
|
||||
const hasJointBarItems = useMemo(() => {
|
||||
return activeTab === 'slat' && slatSubMode === 'jointbar';
|
||||
}, [activeTab, slatSubMode]);
|
||||
return activeProcessTabKey === 'slat' && slatSubMode === 'jointbar';
|
||||
}, [activeProcessTabKey, slatSubMode]);
|
||||
|
||||
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
|
||||
const handleWipInspection = useCallback(() => {
|
||||
@@ -802,21 +908,32 @@ export default function WorkerScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정별 탭 */}
|
||||
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as ProcessTab)}
|
||||
onValueChange={(v) => setActiveTab(v)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab} className="flex-1">
|
||||
{PROCESS_TAB_LABELS[tab]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{processTabs.length > 0 ? (
|
||||
processTabs.map((proc) => (
|
||||
<TabsTrigger key={proc.id} value={proc.id} className="flex-1">
|
||||
{proc.processName}
|
||||
</TabsTrigger>
|
||||
))
|
||||
) : (
|
||||
(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab} className="flex-1">
|
||||
{PROCESS_TAB_LABELS[tab]}
|
||||
</TabsTrigger>
|
||||
))
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsContent key={tab} value={tab} className="mt-4">
|
||||
{(processTabs.length > 0
|
||||
? processTabs.map((p) => p.id)
|
||||
: ['screen', 'slat', 'bending']
|
||||
).map((tabValue) => (
|
||||
<TabsContent key={tabValue} value={tabValue} className="mt-4">
|
||||
{/* 모바일: 사이드바 토글 */}
|
||||
<div className="md:hidden mb-4">
|
||||
<button
|
||||
@@ -826,15 +943,16 @@ export default function WorkerScreen() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">작업 목록</span>
|
||||
<span className="text-sm font-medium">수주 목록</span>
|
||||
</div>
|
||||
{isSidebarOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{isSidebarOpen && (
|
||||
<div className="mt-2 p-3 border rounded-lg bg-white max-h-[300px] overflow-y-auto">
|
||||
<SidebarContent
|
||||
tab={tab}
|
||||
tab={activeProcessTabKey}
|
||||
selectedOrderId={selectedSidebarOrderId}
|
||||
apiOrders={apiSidebarOrders}
|
||||
onSelectOrder={(id, subType) => {
|
||||
setSelectedSidebarOrderId(id);
|
||||
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
|
||||
@@ -853,8 +971,9 @@ export default function WorkerScreen() {
|
||||
<Card className="max-h-[calc(100vh-7.5rem)] overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<SidebarContent
|
||||
tab={tab}
|
||||
tab={activeProcessTabKey}
|
||||
selectedOrderId={selectedSidebarOrderId}
|
||||
apiOrders={apiSidebarOrders}
|
||||
onSelectOrder={(id, subType) => {
|
||||
setSelectedSidebarOrderId(id);
|
||||
if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal');
|
||||
@@ -901,7 +1020,7 @@ export default function WorkerScreen() {
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">수주 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
<InfoField label="수주일" value={orderInfo?.orderDate} />
|
||||
<InfoField label="로트번호" value={orderInfo?.lotNo} />
|
||||
<InfoField label="수주로트" value={orderInfo?.salesOrderNo} />
|
||||
<InfoField label="현장명" value={orderInfo?.siteName} />
|
||||
<InfoField label="수주처" value={orderInfo?.client} />
|
||||
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
|
||||
@@ -977,16 +1096,37 @@ export default function WorkerScreen() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workItems.map((item) => (
|
||||
<WorkItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length;
|
||||
return apiCount > 0 ? (
|
||||
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block">
|
||||
실제 데이터 ({apiCount}건)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{workItems.map((item, index) => {
|
||||
const isFirstMock = item.id.startsWith('mock-') &&
|
||||
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isFirstMock && (
|
||||
<div className="mb-3 pt-1 space-y-2">
|
||||
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
|
||||
목업 데이터
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<WorkItemCard
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1049,14 +1189,14 @@ export default function WorkerScreen() {
|
||||
open={isWorkLogModalOpen}
|
||||
onOpenChange={setIsWorkLogModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={activeTab}
|
||||
processType={activeProcessTabKey}
|
||||
/>
|
||||
|
||||
<InspectionReportModal
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={setIsInspectionModalOpen}
|
||||
workOrderId={selectedOrder?.id || null}
|
||||
processType={hasWipItems ? 'bending_wip' : activeTab}
|
||||
processType={hasWipItems ? 'bending_wip' : activeProcessTabKey}
|
||||
readOnly={true}
|
||||
isJointBar={hasJointBarItems}
|
||||
inspectionData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
|
||||
@@ -1094,6 +1234,7 @@ export default function WorkerScreen() {
|
||||
interface SidebarContentProps {
|
||||
tab: ProcessTab;
|
||||
selectedOrderId: string;
|
||||
apiOrders: SidebarOrder[];
|
||||
onSelectOrder: (id: string, subType?: SidebarOrder['subType']) => void;
|
||||
}
|
||||
|
||||
@@ -1101,14 +1242,12 @@ function SidebarContent({
|
||||
tab,
|
||||
selectedOrderId,
|
||||
onSelectOrder,
|
||||
apiOrders,
|
||||
}: SidebarContentProps) {
|
||||
const orders = MOCK_SIDEBAR_ORDERS[tab];
|
||||
const mockOrders = MOCK_SIDEBAR_ORDERS[tab];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">작업 목록</h3>
|
||||
|
||||
{/* 우선순위별 작업지시 카드 + 태그 */}
|
||||
const renderOrders = (orders: SidebarOrder[]) => (
|
||||
<>
|
||||
{PRIORITY_GROUPS.map((group) => {
|
||||
const groupOrders = orders.filter((o) => o.priority === group.key);
|
||||
if (groupOrders.length === 0) return null;
|
||||
@@ -1140,7 +1279,7 @@ function SidebarContent({
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1 text-gray-500">
|
||||
<span>{order.date}</span>
|
||||
<span>{order.quantity}/{order.shutterCount}소</span>
|
||||
<span>{order.shutterCount}개소</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -1149,6 +1288,37 @@ function SidebarContent({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">수주 목록</h3>
|
||||
|
||||
{/* API 실제 데이터 */}
|
||||
{apiOrders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
|
||||
실제 데이터 ({apiOrders.length}건)
|
||||
</span>
|
||||
{renderOrders(apiOrders)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
{apiOrders.length > 0 && mockOrders.length > 0 && (
|
||||
<div className="border-t border-dashed border-gray-300 my-1" />
|
||||
)}
|
||||
|
||||
{/* 목업 데이터 */}
|
||||
{mockOrders.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">
|
||||
목업 데이터
|
||||
</span>
|
||||
{renderOrders(mockOrders)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
194
src/components/production/WorkerScreen/mockData.ts
Normal file
194
src/components/production/WorkerScreen/mockData.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 작업자 화면 목업 데이터
|
||||
*
|
||||
* 실제 API 연동 전환 시 참조용으로 보존
|
||||
* USE_MOCK = true 일 때 API 데이터 뒤에 병합됨
|
||||
*/
|
||||
|
||||
import type { ProcessTab, WorkItemData } from './types';
|
||||
|
||||
// ===== 목업 ON/OFF 플래그 =====
|
||||
// true: API 데이터 + 목업 데이터 병합 (개발 중)
|
||||
// false: API 데이터만 사용 (운영)
|
||||
export const USE_MOCK = false;
|
||||
|
||||
// ===== 공정별 목업 데이터 =====
|
||||
export const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
screen: [
|
||||
{
|
||||
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'screen',
|
||||
cuttingInfo: { width: 1210, sheets: 8 },
|
||||
steps: [
|
||||
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
|
||||
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
|
||||
width: 6400, height: 5200, quantity: 4, processType: 'screen',
|
||||
cuttingInfo: { width: 1600, sheets: 4 },
|
||||
steps: [
|
||||
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
|
||||
width: 12000, height: 4500, quantity: 1, processType: 'screen',
|
||||
cuttingInfo: { width: 2400, sheets: 5 },
|
||||
steps: [
|
||||
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
slat: [
|
||||
{
|
||||
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
|
||||
width: 8260, height: 8350, quantity: 2, processType: 'slat',
|
||||
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
|
||||
steps: [
|
||||
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
|
||||
width: 10500, height: 6200, quantity: 3, processType: 'slat',
|
||||
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
|
||||
steps: [
|
||||
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
bending: [
|
||||
{
|
||||
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
|
||||
width: 0, height: 0, quantity: 6, processType: 'bending',
|
||||
bendingInfo: {
|
||||
common: {
|
||||
kind: '혼합형 120X70', type: '혼합형',
|
||||
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
|
||||
},
|
||||
detailParts: [
|
||||
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
|
||||
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ===== 절곡 재공품 전용 목업 데이터 =====
|
||||
export 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 슬랫 조인트바 전용 목업 데이터 =====
|
||||
export 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 하드코딩된 공정별 단계 폴백 =====
|
||||
export const PROCESS_STEPS_FALLBACK: Record<string, { name: string; isMaterialInput: boolean }[]> = {
|
||||
screen: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '미싱', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
slat: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '포밍/절단', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '절곡', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending_wip: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
],
|
||||
};
|
||||
@@ -70,13 +70,10 @@ export function ItemSearchModal({
|
||||
}
|
||||
}, [itemType]);
|
||||
|
||||
// 검색어 유효성 검사: 영문 1자 이상 또는 한글 1자 이상
|
||||
// 검색어 유효성 검사: 영문, 한글, 숫자 1자 이상
|
||||
const isValidSearchQuery = useCallback((query: string) => {
|
||||
if (!query) return false;
|
||||
// 영문 1자 이상 또는 한글 1자 이상
|
||||
const hasEnglish = /[a-zA-Z]/.test(query);
|
||||
const hasKorean = /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(query);
|
||||
return hasEnglish || hasKorean;
|
||||
if (!query || !query.trim()) return false;
|
||||
return /[a-zA-Z가-힣ㄱ-ㅎㅏ-ㅣ0-9]/.test(query);
|
||||
}, []);
|
||||
|
||||
// 모달 열릴 때 초기화 (자동 로드 안함)
|
||||
@@ -167,7 +164,7 @@ export function ItemSearchModal({
|
||||
{!searchQuery
|
||||
? "품목코드 또는 품목명을 입력하세요"
|
||||
: !isValidSearchQuery(searchQuery)
|
||||
? "영문 또는 한글 1자 이상 입력하세요"
|
||||
? "영문, 한글 또는 숫자 1자 이상 입력하세요"
|
||||
: "검색 결과가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -188,8 +188,8 @@ export function QuoteFooterBar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 견적완료 - edit 모드에서만 표시 (final 상태가 아닐 때만) */}
|
||||
{!isViewMode && status !== "final" && (
|
||||
{/* 견적확정 - final 상태가 아닐 때 표시 (view/edit 모두) */}
|
||||
{status !== "final" && (
|
||||
<Button
|
||||
onClick={onFinalize}
|
||||
disabled={isSaving || totalAmount === 0}
|
||||
@@ -201,7 +201,7 @@ export function QuoteFooterBar({
|
||||
) : (
|
||||
<Check className="h-4 w-4 md:mr-2" />
|
||||
)}
|
||||
<span className="hidden md:inline">견적완료</span>
|
||||
<span className="hidden md:inline">{isViewMode ? "견적확정" : "견적완료"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ import { quoteRegistrationCreateConfig, quoteRegistrationEditConfig } from "./qu
|
||||
import { FormSection } from "@/components/organisms/FormSection";
|
||||
import { FormFieldGrid } from "@/components/organisms/FormFieldGrid";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods, type BomCalculationResult } from "./actions";
|
||||
import { getFinishedGoods, calculateBomBulk, getSiteNames, type FinishedGoods } from "./actions";
|
||||
import type { BomCalculationResult } from "./types";
|
||||
import { getClients } from "../accounting/VendorManagement/actions";
|
||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
import type { Vendor } from "../accounting/VendorManagement";
|
||||
|
||||
@@ -693,6 +693,19 @@ export function QuoteRegistrationV2({
|
||||
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
|
||||
if (!onSave) return;
|
||||
|
||||
// 확정 시 필수 필드 밸리데이션
|
||||
if (saveType === "final") {
|
||||
const missing: string[] = [];
|
||||
if (!formData.clientName?.trim()) missing.push("업체명");
|
||||
if (!formData.siteName?.trim()) missing.push("현장명");
|
||||
if (!formData.managerName?.trim()) missing.push("담당자");
|
||||
if (!formData.managerContact?.trim()) missing.push("연락처");
|
||||
if (missing.length > 0) {
|
||||
toast.error(`견적확정을 위해 다음 항목을 입력해주세요: ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const dataToSave: QuoteFormDataV2 = {
|
||||
@@ -700,7 +713,10 @@ export function QuoteRegistrationV2({
|
||||
status: saveType === "temporary" ? "temporary" : "final",
|
||||
};
|
||||
await onSave(dataToSave, saveType);
|
||||
toast.success(saveType === "temporary" ? "저장되었습니다." : "견적이 확정되었습니다.");
|
||||
// 확정 성공 시 상태 즉시 반영 → 견적확정 버튼 → 수주등록 버튼으로 전환
|
||||
if (saveType === "final") {
|
||||
setFormData(prev => ({ ...prev, status: "final" }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
const message = error instanceof Error ? error.message : "저장 중 오류가 발생했습니다.";
|
||||
|
||||
@@ -902,8 +902,8 @@ export interface BomCalculateItem {
|
||||
inspectionFee?: number;
|
||||
}
|
||||
|
||||
// BomCalculationResult는 types.ts에서 import하고 re-export
|
||||
export type { BomCalculationResult } from './types';
|
||||
// BomCalculationResult는 types.ts에서 직접 import하세요
|
||||
// import type { BomCalculationResult } from './types';
|
||||
|
||||
// API 서버 응답 구조 (QuoteCalculationService::calculateBomBulk)
|
||||
export interface BomBulkResponse {
|
||||
|
||||
@@ -71,7 +71,7 @@ function formatFileSize(bytes: number): string {
|
||||
|
||||
// 파일 확장자에 따른 아이콘 색상
|
||||
function getFileColor(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
const ext = (fileName || '').split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'text-red-500';
|
||||
|
||||
@@ -136,7 +136,7 @@ export function useItemList(): UseItemListResult {
|
||||
itemType: (item.type_code ?? item.item_type ?? '') as ItemType,
|
||||
partType: item.part_type as PartType | undefined,
|
||||
unit: (item.unit ?? '') as string,
|
||||
specification: (item.specification ?? '') as string,
|
||||
specification: ((item.details as Record<string, unknown>)?.specification ?? item.specification ?? '') as string,
|
||||
// is_active가 null/undefined면 deleted_at 기준으로 판단 (삭제 안됐으면 활성)
|
||||
// deleted_at이 없거나 null이면 활성, 값이 있으면 비활성
|
||||
isActive: item.is_active != null ? Boolean(item.is_active) : !item.deleted_at,
|
||||
|
||||
Reference in New Issue
Block a user