-
-
handleNumberChange('thickness', e.target.value)}
- className=""
- />
+ ) : (
+
+ {/* 기본 정보 (읽기전용 인풋 스타일) */}
+
+
+ {/* N탭 측정 항목 (measurementCount > 1) — 그룹 카드 */}
+ {multiItems.length > 0 && maxN > 0 && (
+
+ {/* N 탭 (밑줄 스타일) */}
+
+ {Array.from({ length: maxN }, (_, i) => (
+
+ ))}
+
+
+ {/* 현재 탭 항목 (2단 그리드) */}
+
+ {multiItems.map((item) => {
+ if (currentTab >= item.measurementCount) return null;
+ return renderItemInput(item, currentTab);
+ })}
+
+
+ )}
+
+ {/* 단일 측정 항목 (measurementCount <= 1, 2단 그리드) */}
+ {singleItems.length > 0 && (
+
+ {singleItems.map((item) => renderItemInput(item, 0))}
+
+ )}
+
+ {/* 사진 첨부 */}
-
- handleNumberChange('width', e.target.value)}
- className=""
+ setNewPhotos((prev) => [...prev, ...files])}
+ title="클릭하거나 사진을 드래그하세요"
+ description="이미지 파일 (최대 10MB)"
+ />
+ {(newPhotos.length > 0 || existingPhotos.length > 0) && (
+ ({ file: f }))}
+ existingFiles={existingPhotos}
+ onRemove={(index) =>
+ setNewPhotos((prev) => prev.filter((_, i) => i !== index))
+ }
+ onRemoveExisting={(id) =>
+ setExistingPhotos((prev) => prev.filter((p) => p.id !== id))
+ }
+ compact
+ />
+ )}
+
+
+ {/* 내용 (비고) */}
+
+ 내용
+
+ )}
- {/* 길이 */}
-
-
- handleNumberChange('length', e.target.value)}
- className=""
- />
-
-
- {/* 판정: 적합/부적합 */}
-
-
- setFormData((prev) => ({ ...prev, judgment: v }))}
- />
-
-
- {/* 인장강도 / 연신율 */}
-
-
-
- handleNumberChange('tensileStrength', e.target.value)}
- className=""
- />
-
-
-
-
handleNumberChange('elongation', e.target.value)}
- className=""
+ {/* 하단 고정: 판정 + 버튼 */}
+
+ {template && (
+
+ 판정
+ setOverallResult(v)}
/>
+ )}
+
+
+
-
- {/* 아연의 최소 부착량 */}
-
-
- handleNumberChange('zincCoating', e.target.value)}
- className=""
- />
-
-
- {/* 내용 */}
-
-
-
-
-
- {/* 버튼 영역 */}
-
-
-
);
-}
\ No newline at end of file
+}
diff --git a/src/components/material/ReceivingManagement/ReceivingDetail.tsx b/src/components/material/ReceivingManagement/ReceivingDetail.tsx
index e57782a9..0af167f1 100644
--- a/src/components/material/ReceivingManagement/ReceivingDetail.tsx
+++ b/src/components/material/ReceivingManagement/ReceivingDetail.tsx
@@ -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
= {
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>([]);
+
// 재고 조정 이력 상태
const [adjustments, setAdjustments] = useState([]);
@@ -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) {
- {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)}
-
-
- {detail.certificateFileName ? (
-
-
- {detail.certificateFileName}
-
- ) : (
- '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
- )}
-
+
+ {inspectionAttachments.length > 0 ? (
+
+ {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 (
+
+ {isImage && att.file?.file_path ? (
+

+ ) : (
+
+ )}
+
+
{fileName}
+ {fileSize && (
+
+ {fileSize < 1024 * 1024
+ ? `${(fileSize / 1024).toFixed(1)} KB`
+ : `${(fileSize / (1024 * 1024)).toFixed(1)} MB`}
+
+ )}
+
+
+ 다운로드
+
+
+ );
+ })}
+
+ ) : (
+
+ 첨부된 파일이 없습니다.
+
+ )}
@@ -437,7 +496,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
);
- }, [detail, adjustments]);
+ }, [detail, adjustments, inspectionAttachments]);
// ===== 등록/수정 폼 콘텐츠 =====
const renderFormContent = useCallback(() => {
@@ -450,11 +509,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
- {/* 자재번호 - 읽기전용 */}
- {renderReadOnlyField('자재번호', formData.materialNo, true)}
+ {/* 입고번호 - 읽기전용 */}
+ {renderReadOnlyField('입고번호', formData.materialNo, true)}
- {/* 로트번호 - 읽기전용 */}
- {renderReadOnlyField('로트번호', formData.lotNo, true)}
+ {/* 자재번호 (거래처) - 수정 가능 */}
+
+
+ setFormData((prev) => ({ ...prev, supplierMaterialNo: e.target.value }))}
+ placeholder="거래처 자재번호"
+ />
+
+
+ {/* 원자재로트 - 수정 가능 */}
+
+
+ setFormData((prev) => ({ ...prev, lotNo: e.target.value }))}
+ placeholder="원자재로트를 입력하세요"
+ />
+
{/* 품목코드 - 검색 모달 선택 */}
@@ -710,17 +788,18 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
- const customHeaderActions = (isViewMode || isEditMode) && detail ? (
-
-
-
-
+ // 수입검사하기 버튼은 수입검사 성적서 템플릿이 있는 품목만 표시
+ const customHeaderActions = (isViewMode || isEditMode) && detail && hasInspectionTemplate ? (
+
+
+
+
) : 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}
/>
- {/* 수입검사 입력 모달 */}
-
+ {/* 수입검사 입력 모달 */}
+
>
);
}
diff --git a/src/components/material/ReceivingManagement/ReceivingList.tsx b/src/components/material/ReceivingManagement/ReceivingList.tsx
index d082220c..e43c391f 100644
--- a/src/components/material/ReceivingManagement/ReceivingList.tsx
+++ b/src/components/material/ReceivingManagement/ReceivingList.tsx
@@ -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() {
{globalIndex}
{item.materialNo || '-'}
{item.lotNo || '-'}
-
{item.inspectionStatus || '-'}
+
+ {(() => {
+ const status = getInspectionDisplayStatus(item);
+ if (status === 'none') return '-';
+ return (
+
+ {INSPECTION_STATUS_LABELS[status]}
+
+ );
+ })()}
+
{item.inspectionDate || '-'}
{item.supplier}
{item.manufacturer || '-'}
@@ -363,12 +404,22 @@ export function ReceivingList() {
}
infoGrid={
-
-
+
-
+ {
+ const status = getInspectionDisplayStatus(item);
+ if (status === 'none') return '-';
+ return (
+
+ {INSPECTION_STATUS_LABELS[status]}
+
+ );
+ })()}
+ />
diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts
index e142cfec..4ac08f16 100644
--- a/src/components/material/ReceivingManagement/actions.ts
+++ b/src/components/material/ReceivingManagement/actions.ts
@@ -14,7 +14,7 @@
'use server';
// ===== 목데이터 모드 플래그 =====
-const USE_MOCK_DATA = true;
+const USE_MOCK_DATA = false;
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -33,7 +33,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '1',
materialNo: 'MAT-001',
lotNo: 'LOT-2026-001',
+ itemId: 101,
inspectionStatus: '적',
+ inspectionResult: '합격',
inspectionDate: '2026-01-25',
supplier: '(주)대한철강',
manufacturer: '포스코',
@@ -51,7 +53,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '2',
materialNo: 'MAT-002',
lotNo: 'LOT-2026-002',
+ itemId: 102,
inspectionStatus: '적',
+ inspectionResult: '합격',
inspectionDate: '2026-01-26',
supplier: '삼성전자부품',
manufacturer: '삼성전자',
@@ -69,7 +73,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '3',
materialNo: 'MAT-003',
lotNo: 'LOT-2026-003',
+ itemId: 103,
inspectionStatus: '-',
+ inspectionResult: undefined,
inspectionDate: undefined,
supplier: '한국플라스틱',
manufacturer: '한국플라스틱',
@@ -87,7 +93,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '4',
materialNo: 'MAT-004',
lotNo: 'LOT-2026-004',
+ itemId: 104,
inspectionStatus: '부적',
+ inspectionResult: '불합격',
inspectionDate: '2026-01-27',
supplier: '(주)대한철강',
manufacturer: '포스코',
@@ -105,7 +113,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '5',
materialNo: 'MAT-005',
lotNo: 'LOT-2026-005',
+ itemId: 105,
inspectionStatus: '-',
+ inspectionResult: undefined,
inspectionDate: undefined,
supplier: '글로벌전자',
manufacturer: '글로벌전자',
@@ -123,8 +133,10 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '6',
materialNo: 'MAT-006',
lotNo: 'LOT-2026-006',
- inspectionStatus: '적',
- inspectionDate: '2026-01-24',
+ itemId: undefined, // 품목 미연결 → 수입검사 대상 아님
+ inspectionStatus: '-',
+ inspectionResult: undefined,
+ inspectionDate: undefined,
supplier: '동양화학',
manufacturer: '동양화학',
itemCode: 'CHEM-001',
@@ -141,7 +153,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '7',
materialNo: 'MAT-007',
lotNo: 'LOT-2026-007',
+ itemId: 107,
inspectionStatus: '적',
+ inspectionResult: '합격',
inspectionDate: '2026-01-28',
supplier: '삼성전자부품',
manufacturer: '삼성전자',
@@ -159,7 +173,9 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
id: '8',
materialNo: 'MAT-008',
lotNo: 'LOT-2026-008',
+ itemId: undefined, // 품목 미연결 → 수입검사 대상 아님
inspectionStatus: '-',
+ inspectionResult: undefined,
inspectionDate: undefined,
supplier: '한국볼트',
manufacturer: '한국볼트',
@@ -319,9 +335,25 @@ interface ReceivingApiData {
receiving_manager?: string;
status: ReceivingStatus;
remark?: string;
+ options?: Record
;
creator?: { id: number; name: string };
created_at?: string;
updated_at?: string;
+ // 품목 관계 (item relation이 로드된 경우)
+ item?: {
+ id: number;
+ item_type?: string;
+ code?: string;
+ name?: string;
+ };
+ // options에서 추출된 접근자 (API appends)
+ manufacturer?: string;
+ material_no?: string;
+ inspection_status?: string;
+ inspection_date?: string;
+ inspection_result?: string;
+ // 수입검사 템플릿 연결 여부 (서버에서 계산)
+ has_inspection_template?: boolean;
}
interface ReceivingApiPaginatedResponse {
@@ -339,23 +371,61 @@ interface ReceivingApiStatsResponse {
today_receiving_count: number;
}
+// ===== 품목유형 코드 → 라벨 변환 =====
+const ITEM_TYPE_LABELS: Record = {
+ FG: '완제품',
+ PT: '부품',
+ SM: '부자재',
+ RM: '원자재',
+ CS: '소모품',
+};
+
// ===== API → Frontend 변환 (목록용) =====
function transformApiToListItem(data: ReceivingApiData): ReceivingItem {
return {
id: String(data.id),
- orderNo: data.order_no || data.receiving_number,
- itemCode: data.item_code,
- itemName: data.item_name,
- specification: data.specification,
+ // 입고번호: receiving_number 매핑
+ materialNo: data.receiving_number,
+ // 거래처 자재번호: options.material_no
+ supplierMaterialNo: data.material_no,
+ // 원자재로트
+ lotNo: data.lot_no,
+ // 품목 ID
+ itemId: data.item_id,
+ // 수입검사 템플릿 연결 여부
+ hasInspectionTemplate: data.has_inspection_template ?? false,
+ // 수입검사: options.inspection_status (적/부적/-)
+ inspectionStatus: data.inspection_status,
+ // 검사결과: options.inspection_result (합격/불합격)
+ inspectionResult: data.inspection_result,
+ // 검사일: options.inspection_date
+ inspectionDate: data.inspection_date,
+ // 발주처
supplier: data.supplier,
+ // 제조사: options.manufacturer
+ manufacturer: data.manufacturer,
+ // 품목코드
+ itemCode: data.item_code,
+ // 품목유형: item relation에서 가져옴
+ itemType: data.item?.item_type ? ITEM_TYPE_LABELS[data.item.item_type] || data.item.item_type : undefined,
+ // 품목명
+ itemName: data.item_name,
+ // 규격
+ specification: data.specification,
+ // 단위
+ unit: data.order_unit || 'EA',
+ // 수량 (입고수량)
+ receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined,
+ // 입고변경일: updated_at 매핑
+ receivingDate: data.updated_at ? data.updated_at.split('T')[0] : data.receiving_date,
+ // 작성자
+ createdBy: data.creator?.name,
+ // 상태
+ status: data.status,
+ // 기존 필드 (하위 호환)
+ orderNo: data.order_no || data.receiving_number,
orderQty: parseFloat(String(data.order_qty)) || 0,
orderUnit: data.order_unit || 'EA',
- receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined,
- receivingDate: data.receiving_date,
- lotNo: data.lot_no,
- unit: data.order_unit || 'EA',
- createdBy: data.creator?.name,
- status: data.status,
};
}
@@ -363,9 +433,14 @@ function transformApiToListItem(data: ReceivingApiData): ReceivingItem {
function transformApiToDetail(data: ReceivingApiData): ReceivingDetail {
return {
id: String(data.id),
+ materialNo: data.receiving_number,
+ supplierMaterialNo: data.material_no,
+ lotNo: data.lot_no,
orderNo: data.order_no || data.receiving_number,
orderDate: data.order_date,
supplier: data.supplier,
+ manufacturer: data.manufacturer,
+ itemId: data.item_id,
itemCode: data.item_code,
itemName: data.item_name,
specification: data.specification,
@@ -373,6 +448,7 @@ function transformApiToDetail(data: ReceivingApiData): ReceivingDetail {
orderUnit: data.order_unit || 'EA',
dueDate: data.due_date,
status: data.status,
+ remark: data.remark,
receivingDate: data.receiving_date,
receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined,
receivingLot: data.lot_no,
@@ -380,6 +456,9 @@ function transformApiToDetail(data: ReceivingApiData): ReceivingDetail {
receivingLocation: data.receiving_location,
receivingManager: data.receiving_manager,
unit: data.order_unit || 'EA',
+ createdBy: data.creator?.name,
+ inspectionDate: data.inspection_date,
+ inspectionResult: data.inspection_result,
};
}
@@ -417,6 +496,7 @@ function transformFrontendToApi(
if (data.receivingQty !== undefined) result.receiving_qty = data.receivingQty;
if (data.receivingDate !== undefined) result.receiving_date = data.receivingDate;
if (data.lotNo !== undefined) result.lot_no = data.lotNo;
+ if (data.supplierMaterialNo !== undefined) result.material_no = data.supplierMaterialNo;
return result;
}
@@ -947,6 +1027,14 @@ export interface InspectionTemplateResponse {
reviewer?: string;
approver?: string;
};
+ // 결재선 원본 데이터 (동적 UI 표시용)
+ approvalLines?: Array<{
+ id: number;
+ name: string;
+ dept: string;
+ role: string;
+ sortOrder: number;
+ }>;
};
inspectionItems: Array<{
id: string;
@@ -957,6 +1045,7 @@ export interface InspectionTemplateResponse {
standard: {
description?: string;
value?: string | number;
+ tolerance?: string; // 허용치 문자열 (MNG와 동일)
options?: Array<{
id: string;
label: string;
@@ -966,47 +1055,281 @@ export interface InspectionTemplateResponse {
};
inspectionMethod: string;
inspectionCycle: string;
- measurementType: 'okng' | 'numeric' | 'both';
+ measurementType: 'okng' | 'numeric' | 'both' | 'single_value' | 'substitute';
measurementCount: number;
+ // 1차 그룹 (category) rowspan - NO, 카테고리명 컬럼용
+ categoryRowSpan?: number;
+ isFirstInCategory?: boolean;
+ // 2차 그룹 (item) rowspan - 검사항목(세부), 검사방식, 검사주기, 측정치, 판정 컬럼용
+ itemRowSpan?: number;
+ isFirstInItem?: boolean;
+ // 기존 호환용 (deprecated)
rowSpan?: number;
isSubRow?: boolean;
}>;
notes?: string[];
}
-// ===== 수입검사 템플릿 조회 (품목명/규격 기반) =====
+// ===== 수입검사 템플릿 Resolve API 응답 타입 =====
+export interface DocumentResolveResponse {
+ is_new: boolean;
+ template: {
+ id: number;
+ name: string;
+ category: string;
+ title: string;
+ company_name?: string;
+ company_address?: string;
+ company_contact?: string;
+ footer_remark_label?: string;
+ footer_judgement_label?: string;
+ footer_judgement_options?: string[];
+ approval_lines: Array<{
+ id: number;
+ name?: string;
+ dept?: string;
+ role: string;
+ user_id?: number;
+ sort_order: number;
+ }>;
+ basic_fields: Array<{
+ id: number;
+ field_key: string;
+ label: string;
+ input_type: string;
+ options?: unknown;
+ default_value?: string;
+ is_required: boolean;
+ sort_order: number;
+ }>;
+ section_fields: Array<{
+ id: number;
+ field_key: string;
+ label: string;
+ field_type: string;
+ options?: unknown;
+ width?: string;
+ is_required: boolean;
+ sort_order: number;
+ }>;
+ sections: Array<{
+ id: number;
+ name: string;
+ sort_order: number;
+ items: Array<{
+ id: number;
+ /** 새로운 API 형식: field_values에 모든 필드값 포함 */
+ field_values?: Record;
+ /** 레거시 필드 (하위 호환) */
+ category?: string;
+ item?: string;
+ standard?: string;
+ /** 범위 조건 객체: { min, min_op, max, max_op } */
+ standard_criteria?: {
+ min?: number | null;
+ min_op?: 'gt' | 'gte' | null;
+ max?: number | null;
+ max_op?: 'lt' | 'lte' | null;
+ } | null;
+ /** 공차 객체: { type, value, plus, minus, min, max, op } */
+ tolerance?: {
+ type?: 'symmetric' | 'asymmetric' | 'range' | 'percentage' | 'limit';
+ value?: string | number;
+ plus?: string | number;
+ minus?: string | number;
+ min?: string | number;
+ max?: string | number;
+ op?: 'lte' | 'lt' | 'gte' | 'gt'; // limit 타입용
+ } | string | null;
+ method?: string;
+ method_name?: string; // 검사방식 한글 이름 (API에서 common_codes join)
+ measurement_type?: string;
+ frequency?: string;
+ frequency_n?: number;
+ frequency_c?: number;
+ regulation?: string;
+ sort_order: number;
+ }>;
+ }>;
+ columns: Array<{
+ id: number;
+ label: string;
+ input_type: string;
+ options?: unknown;
+ width?: string;
+ is_required: boolean;
+ sort_order: number;
+ }>;
+ };
+ document: {
+ id: number;
+ document_no: string;
+ title: string;
+ status: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED';
+ linkable_type?: string;
+ linkable_id?: number;
+ submitted_at?: string;
+ completed_at?: string;
+ created_at?: string;
+ data: Array<{
+ section_id?: number | null;
+ column_id?: number | null;
+ row_index: number;
+ field_key: string;
+ field_value?: string | null;
+ }>;
+ attachments: Array<{
+ id: number;
+ file_id: number;
+ attachment_type: string;
+ description?: string;
+ file?: {
+ id: number;
+ original_name: string;
+ display_name?: string;
+ file_path: string;
+ file_size: number;
+ mime_type?: string;
+ };
+ }>;
+ approvals: Array<{
+ id: number;
+ user_id: number;
+ user_name?: string;
+ step: number;
+ role: string;
+ status: string;
+ comment?: string;
+ acted_at?: string;
+ }>;
+ } | null;
+ item: {
+ id: number;
+ code: string;
+ name: string;
+ /** 품목 속성 (thickness, width, length 등) */
+ attributes?: {
+ thickness?: number;
+ width?: number;
+ length?: number;
+ [key: string]: unknown;
+ } | null;
+ };
+}
+
+// ===== 수입검사 템플릿 존재 여부 확인 =====
+export async function checkInspectionTemplate(itemId?: number): Promise<{
+ success: boolean;
+ hasTemplate: boolean;
+ attachments?: Array<{
+ id: number;
+ file_id: number;
+ attachment_type: string;
+ description?: string;
+ file?: {
+ id: number;
+ display_name?: string;
+ original_name?: string;
+ file_path: string;
+ file_size: number;
+ mime_type?: string;
+ };
+ }>;
+ error?: string;
+}> {
+ if (!itemId) {
+ return { success: true, hasTemplate: false };
+ }
+
+ // 목데이터 모드
+ if (USE_MOCK_DATA) {
+ return { success: true, hasTemplate: true };
+ }
+
+ try {
+ const searchParams = new URLSearchParams();
+ searchParams.append('category', 'incoming_inspection');
+ searchParams.append('item_id', String(itemId));
+
+ const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/documents/resolve?${searchParams.toString()}`;
+
+ const { response, error } = await serverFetch(url, { method: 'GET' });
+
+ if (error) {
+ // 404는 템플릿 없음으로 처리
+ if (error.code === 'NOT_FOUND' || error.message?.includes('404')) {
+ return { success: true, hasTemplate: false };
+ }
+ return { success: false, hasTemplate: false, error: error.message || '템플릿 조회 실패' };
+ }
+
+ if (!response) {
+ return { success: false, hasTemplate: false, error: '템플릿 조회 실패' };
+ }
+
+ // 404 응답도 템플릿 없음으로 처리
+ if (response.status === 404) {
+ return { success: true, hasTemplate: false };
+ }
+
+ const result = await response.json();
+ // template.id가 있으면 템플릿 존재
+ const hasTemplate = !!(result?.data?.template?.id);
+ const attachments = result?.data?.document?.attachments || [];
+ return { success: true, hasTemplate, attachments };
+ } catch (error) {
+ if (isNextRedirectError(error)) throw error;
+ console.error('checkInspectionTemplate error:', error);
+ return { success: false, hasTemplate: false, error: '템플릿 조회 중 오류' };
+ }
+}
+
+// ===== 수입검사 템플릿 조회 (item_id 기반 - /v1/documents/resolve API 사용) =====
export async function getInspectionTemplate(params: {
- itemName: string;
- specification: string;
+ itemId?: number;
+ itemName?: string;
+ specification?: string;
lotNo?: string;
supplier?: string;
+ inspector?: string; // 검사자 (현재 로그인 사용자)
+ lotSize?: number; // 로트크기 (입고수량)
+ materialNo?: string; // 자재번호
}): Promise<{
success: boolean;
data?: InspectionTemplateResponse;
+ resolveData?: DocumentResolveResponse;
error?: string;
__authError?: boolean;
}> {
// ===== 목데이터 모드 - EGI 강판 템플릿 반환 =====
if (USE_MOCK_DATA) {
// 품목명/규격에 따라 다른 템플릿 반환 (추후 24종 확장)
+ const inspectorName = params.inspector || '노원호';
const mockTemplate: InspectionTemplateResponse = {
templateId: 'EGI-001',
templateName: '전기 아연도금 강판',
headerInfo: {
productName: params.itemName || '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"',
specification: params.specification || '1.55 * 1218 × 480',
- materialNo: 'PE02RB',
- lotSize: 200,
+ materialNo: params.materialNo || 'PE02RB',
+ lotSize: params.lotSize || 200,
supplier: params.supplier || '지오TNS (KG스틸)',
lotNo: params.lotNo || '250715-02',
inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
- inspector: '노원호',
+ inspector: inspectorName,
reportDate: new Date().toISOString().split('T')[0],
approvers: {
- writer: '노원호',
+ writer: inspectorName,
reviewer: '',
approver: '',
},
+ // 결재선 데이터
+ approvalLines: [
+ { id: 1, role: '작성', sortOrder: 1 },
+ { id: 2, role: '검토', sortOrder: 2 },
+ { id: 3, role: '승인', sortOrder: 3 },
+ { id: 4, role: '승인', sortOrder: 4 },
+ ],
},
inspectionItems: [
{
@@ -1117,16 +1440,19 @@ export async function getInspectionTemplate(params: {
return { success: true, data: mockTemplate };
}
- // ===== 실제 API 호출 =====
+ // ===== 실제 API 호출 (/v1/documents/resolve) =====
+ // itemId가 있으면 실제 API로 템플릿 조회
+ if (!params.itemId) {
+ return { success: false, error: '품목 ID가 필요합니다.' };
+ }
+
try {
const searchParams = new URLSearchParams();
- searchParams.set('item_name', params.itemName);
- searchParams.set('specification', params.specification);
- if (params.lotNo) searchParams.set('lot_no', params.lotNo);
- if (params.supplier) searchParams.set('supplier', params.supplier);
+ searchParams.set('category', 'incoming_inspection');
+ searchParams.set('item_id', String(params.itemId));
const { response, error } = await serverFetch(
- `${process.env.NEXT_PUBLIC_API_URL}/api/v1/inspection-templates?${searchParams.toString()}`,
+ `${process.env.NEXT_PUBLIC_API_URL}/api/v1/documents/resolve?${searchParams.toString()}`,
{ method: 'GET', cache: 'no-store' }
);
@@ -1144,10 +1470,732 @@ export async function getInspectionTemplate(params: {
return { success: false, error: result.message || '검사 템플릿 조회에 실패했습니다.' };
}
- return { success: true, data: result.data };
+ const resolveData: DocumentResolveResponse = result.data;
+
+ // API 응답을 기존 InspectionTemplateResponse 형식으로 변환
+ const template = transformResolveToTemplate(resolveData, params);
+
+ return {
+ success: true,
+ data: template,
+ resolveData,
+ };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ReceivingActions] getInspectionTemplate error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
+}
+
+// ===== Tolerance 타입 정의 =====
+interface ToleranceObject {
+ type?: 'symmetric' | 'asymmetric' | 'range' | 'percentage' | 'limit';
+ value?: string | number;
+ plus?: string | number;
+ minus?: string | number;
+ min?: string | number;
+ max?: string | number;
+ min_op?: string;
+ max_op?: string;
+ op?: 'lte' | 'lt' | 'gte' | 'gt'; // limit 타입용
+}
+
+// ===== StandardCriteria 타입 정의 =====
+interface StandardCriteriaObject {
+ min?: number | null;
+ min_op?: 'gt' | 'gte' | null;
+ max?: number | null;
+ max_op?: 'lt' | 'lte' | null;
+}
+
+// ===== tolerance 객체를 문자열로 변환하는 헬퍼 함수 =====
+function formatTolerance(tolerance: unknown): string {
+ if (!tolerance) return '';
+ if (typeof tolerance === 'string') return tolerance;
+ if (typeof tolerance !== 'object') return String(tolerance);
+
+ const t = tolerance as ToleranceObject;
+
+ // 새로운 API 형식: type 기반 처리
+ switch (t.type) {
+ case 'symmetric':
+ return t.value ? `± ${t.value}` : '';
+ case 'asymmetric':
+ return `+ ${t.plus || 0}\n- ${t.minus || 0}`;
+ case 'range':
+ return `${t.min || ''} ~ ${t.max || ''}`;
+ case 'percentage':
+ return t.value ? `± ${t.value}%` : '';
+ }
+
+ // 레거시 형식: min/max 직접 처리 (type 없는 경우)
+ if (t.min !== undefined || t.max !== undefined) {
+ return formatStandardCriteria(t as StandardCriteriaObject);
+ }
+
+ return '';
+}
+
+// ===== standard_criteria 객체를 문자열로 변환 =====
+function formatStandardCriteria(criteria: unknown): string {
+ if (!criteria) return '';
+ if (typeof criteria === 'string') return criteria;
+ if (typeof criteria !== 'object') return String(criteria);
+
+ const c = criteria as StandardCriteriaObject;
+ const parts: string[] = [];
+
+ // min 조건
+ if (c.min !== undefined && c.min !== null) {
+ const op = c.min_op === 'gte' ? '≥' : c.min_op === 'gt' ? '>' : '≥';
+ parts.push(`${op} ${c.min}`);
+ }
+
+ // max 조건
+ if (c.max !== undefined && c.max !== null) {
+ const op = c.max_op === 'lte' ? '≤' : c.max_op === 'lt' ? '<' : '≤';
+ parts.push(`${op} ${c.max}`);
+ }
+
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return parts[0];
+
+ // 두 조건 모두 있으면 "min 이상 ~ max 미만" 형태
+ return parts.join(', ');
+}
+
+// ===== 범위 라벨 생성 (예: "0.8 이상 ~ 1.0 미만") =====
+function formatCriteriaLabel(criteria: unknown): string {
+ if (!criteria) return '';
+ if (typeof criteria === 'string') return criteria;
+ if (typeof criteria !== 'object') return '';
+
+ const c = criteria as StandardCriteriaObject;
+ const parts: string[] = [];
+
+ if (c.min !== undefined && c.min !== null) {
+ const op = c.min_op === 'gte' ? '이상' : c.min_op === 'gt' ? '초과' : '이상';
+ parts.push(`${c.min} ${op}`);
+ }
+
+ if (c.max !== undefined && c.max !== null) {
+ const op = c.max_op === 'lte' ? '이하' : c.max_op === 'lt' ? '미만' : '미만';
+ parts.push(`${c.max} ${op}`);
+ }
+
+ return parts.join(' ~ ');
+}
+
+// ===== MNG와 동일한 허용치 포맷팅 =====
+function formatToleranceForDisplay(tolerance: unknown): string {
+ if (!tolerance) return '-';
+ // 문자열인 경우 (레거시)
+ if (typeof tolerance === 'string') return tolerance || '-';
+ if (typeof tolerance !== 'object') return String(tolerance);
+
+ const t = tolerance as ToleranceObject;
+ // type이 없으면 레거시
+ if (!t.type) return '-';
+
+ switch (t.type) {
+ case 'symmetric':
+ return t.value != null ? `±${t.value}` : '-';
+ case 'asymmetric':
+ return (t.plus != null || t.minus != null)
+ ? `+${t.plus ?? 0} / -${t.minus ?? 0}` : '-';
+ case 'range':
+ return (t.min != null || t.max != null)
+ ? `${t.min ?? ''} ~ ${t.max ?? ''}` : '-';
+ case 'limit': {
+ const opSymbol: Record = { lte: '≤', lt: '<', gte: '≥', gt: '>' };
+ return t.value != null ? `${opSymbol[t.op as string] || '≤'}${t.value}` : '-';
+ }
+ default:
+ return '-';
+ }
+}
+
+// ===== MNG와 동일한 검사기준 포맷팅 =====
+function formatStandardForDisplay(item: {
+ standard?: string;
+ standard_criteria?: unknown;
+ tolerance?: unknown;
+}): string {
+ const c = item.standard_criteria as StandardCriteriaObject | undefined;
+ if (c && (c.min != null || c.max != null)) {
+ const opLabel: Record = { gte: '이상', gt: '초과', lte: '이하', lt: '미만' };
+ const parts: string[] = [];
+ if (c.min != null) parts.push(`${c.min} ${opLabel[c.min_op || 'gte']}`);
+ if (c.max != null) parts.push(`${c.max} ${opLabel[c.max_op || 'lte']}`);
+ return parts.join(' ~ ');
+ }
+ let std = item.standard || '-';
+ const tolStr = formatToleranceForDisplay(item.tolerance);
+ if (tolStr !== '-') std += ` (${tolStr})`;
+ return std;
+}
+
+// ===== MNG와 동일한 검사주기 포맷팅 =====
+function formatFrequencyForDisplay(item: {
+ frequency?: string;
+ frequency_n?: number;
+ frequency_c?: number;
+}): string {
+ const parts: string[] = [];
+ if (item.frequency_n != null && item.frequency_n !== 0) {
+ let nc = `n=${item.frequency_n}`;
+ // c=0도 표시 (null이 아니면 표시)
+ if (item.frequency_c != null) nc += `, c=${item.frequency_c}`;
+ parts.push(nc);
+ }
+ if (item.frequency) parts.push(item.frequency);
+ return parts.length > 0 ? parts.join(' / ') : '-';
+}
+
+// ===== DocumentResolve 응답을 InspectionTemplateResponse로 변환 =====
+function transformResolveToTemplate(
+ resolveData: DocumentResolveResponse,
+ params: { itemName?: string; specification?: string; lotNo?: string; supplier?: string; inspector?: string; lotSize?: number; materialNo?: string }
+): InspectionTemplateResponse {
+ const { template, document, item } = resolveData;
+
+ // 기존 문서 데이터를 맵으로 변환 (빠른 조회용)
+ const savedDataMap = new Map();
+ if (document?.data) {
+ document.data.forEach(d => {
+ const key = `${d.section_id || 0}_${d.row_index}_${d.field_key}`;
+ savedDataMap.set(key, d.field_value || '');
+ });
+ }
+
+ // 품목 속성 추출 (자동 하이라이트용)
+ const itemAttrs = item.attributes as { thickness?: number; width?: number; length?: number } | undefined;
+
+ // 모든 섹션의 items를 하나로 합침
+ let allSectionItems: Array<{
+ sectionItem: typeof template.sections[0]['items'][0];
+ category: string;
+ itemName: string;
+ }> = [];
+
+ for (const section of template.sections) {
+ for (const sectionItem of section.items) {
+ const fieldValues = sectionItem.field_values || {};
+ const category = safeString(sectionItem.category) || safeString(fieldValues.category);
+ const itemName = safeString(sectionItem.item) || safeString(fieldValues.item);
+ allSectionItems.push({ sectionItem, category, itemName });
+ }
+ }
+
+ // 품목에 해당 치수 속성이 없으면 검사항목에서 제거 (코일 등 너비/길이 없는 품목)
+ if (itemAttrs) {
+ const dimensionAttrMap: Record = {
+ '두께': 'thickness',
+ '너비': 'width',
+ '길이': 'length',
+ };
+
+ allSectionItems = allSectionItems.filter(({ itemName }) => {
+ const attrKey = dimensionAttrMap[itemName];
+ if (!attrKey) return true;
+ return itemAttrs[attrKey] != null;
+ });
+ }
+
+ // 그룹핑 로직:
+ // - category가 있으면: category로 1차 그룹, item으로 2차 그룹
+ // - category가 없으면: 같은 item 이름끼리 1차 그룹 (2차 그룹 없음)
+
+ interface GroupInfo {
+ categoryRowSpan: number;
+ isFirstInCategory: boolean;
+ itemRowSpan: number;
+ isFirstInItem: boolean;
+ hasCategory: boolean;
+ }
+
+ const groupInfoMap = new Map();
+
+ for (let i = 0; i < allSectionItems.length; i++) {
+ const { category, itemName } = allSectionItems[i];
+ const hasCategory = !!category;
+
+ let categoryRowSpan = 1;
+ let isFirstInCategory = true;
+ let itemRowSpan = 1;
+ let isFirstInItem = true;
+
+ if (hasCategory) {
+ // category가 있는 경우: category로 1차 그룹, item으로 2차 그룹
+
+ // 1차 그룹 (category) 계산
+ for (let j = 0; j < i; j++) {
+ if (allSectionItems[j].category === category) {
+ isFirstInCategory = false;
+ break;
+ }
+ }
+
+ if (isFirstInCategory) {
+ for (let j = i + 1; j < allSectionItems.length; j++) {
+ if (allSectionItems[j].category === category) {
+ categoryRowSpan++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ // 2차 그룹 (item) 계산 - category 내에서 같은 item 그룹핑
+ for (let j = 0; j < i; j++) {
+ if (allSectionItems[j].category === category && allSectionItems[j].itemName === itemName) {
+ isFirstInItem = false;
+ break;
+ }
+ }
+
+ if (isFirstInItem) {
+ for (let j = i + 1; j < allSectionItems.length; j++) {
+ if (allSectionItems[j].category === category && allSectionItems[j].itemName === itemName) {
+ itemRowSpan++;
+ } else if (allSectionItems[j].category !== category) {
+ break;
+ }
+ }
+ }
+ } else {
+ // category가 없는 경우: 같은 item 이름끼리 그룹핑
+
+ // 같은 item 이름이 이전에 있었는지 확인
+ for (let j = 0; j < i; j++) {
+ if (!allSectionItems[j].category && allSectionItems[j].itemName === itemName) {
+ isFirstInCategory = false;
+ isFirstInItem = false;
+ break;
+ }
+ }
+
+ // 같은 item 이름이 몇 개 연속되는지 확인 (처음인 경우만)
+ if (isFirstInCategory) {
+ for (let j = i + 1; j < allSectionItems.length; j++) {
+ if (!allSectionItems[j].category && allSectionItems[j].itemName === itemName) {
+ categoryRowSpan++;
+ itemRowSpan++;
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ groupInfoMap.set(i, {
+ categoryRowSpan: isFirstInCategory ? categoryRowSpan : 0,
+ isFirstInCategory,
+ itemRowSpan: isFirstInItem ? itemRowSpan : 0,
+ isFirstInItem,
+ hasCategory,
+ });
+ }
+
+ // 섹션의 items를 검사항목으로 변환
+ const inspectionItems: InspectionTemplateResponse['inspectionItems'] = [];
+ let noCounter = 0;
+ let prevCategoryForNo = '';
+
+ for (let i = 0; i < allSectionItems.length; i++) {
+ const { sectionItem, category, itemName } = allSectionItems[i];
+ const groupInfo = groupInfoMap.get(i)!;
+
+ // NO 증가 (category가 바뀔 때만, 또는 category가 없을 때)
+ if (category !== prevCategoryForNo || !category) {
+ noCounter++;
+ prevCategoryForNo = category;
+ }
+
+ const fieldValues = sectionItem.field_values || {};
+ const standardText = safeString(sectionItem.standard) || safeString(fieldValues.standard);
+ const method = safeString(sectionItem.method) || safeString(fieldValues.method);
+ const measurementType = safeString(sectionItem.measurement_type) || safeString(fieldValues.measurement_type);
+
+ // MNG와 동일한 검사기준 포맷팅
+ const formattedStandard = formatStandardForDisplay({
+ standard: standardText,
+ standard_criteria: sectionItem.standard_criteria,
+ tolerance: sectionItem.tolerance,
+ });
+
+ // MNG와 동일한 허용치 포맷팅
+ const formattedTolerance = formatToleranceForDisplay(sectionItem.tolerance);
+
+ // MNG와 동일한 검사주기 포맷팅
+ const formattedFrequency = formatFrequencyForDisplay({
+ frequency: sectionItem.frequency,
+ frequency_n: sectionItem.frequency_n,
+ frequency_c: sectionItem.frequency_c,
+ });
+
+ // 참조 속성 (연신율 등은 두께를 기준으로 검사기준 결정)
+ const referenceAttribute = safeString(fieldValues.reference_attribute);
+
+ // 자동 하이라이트 (품목 속성과 standard_criteria 매칭)
+ const isHighlighted = shouldHighlightRow(
+ itemName,
+ sectionItem.standard_criteria,
+ itemAttrs,
+ referenceAttribute || undefined
+ );
+
+ // standard_criteria가 있으면 옵션으로 변환 (하이라이트용)
+ let toleranceOptions: Array<{ id: string; label: string; tolerance: string; isSelected: boolean }> | undefined;
+ const criteriaLabel = formatCriteriaLabel(sectionItem.standard_criteria);
+ if (sectionItem.standard_criteria && criteriaLabel) {
+ toleranceOptions = [{
+ id: `criteria-${sectionItem.id}`,
+ label: criteriaLabel,
+ tolerance: formattedTolerance !== '-' ? formattedTolerance : '',
+ isSelected: isHighlighted,
+ }];
+ }
+
+ // 측정 횟수: frequency_n 값 사용 (기본값 1)
+ const measurementCount = sectionItem.frequency_n && sectionItem.frequency_n > 0 ? sectionItem.frequency_n : 1;
+
+ inspectionItems.push({
+ id: String(sectionItem.id),
+ no: noCounter,
+ name: category || itemName, // 카테고리가 있으면 카테고리, 없으면 항목명
+ subName: category ? itemName : undefined, // 카테고리가 있을 때만 항목명이 subName
+ standard: {
+ description: formattedStandard, // MNG와 동일한 포맷 (검사기준 전체)
+ value: undefined,
+ tolerance: formattedTolerance !== '-' ? formattedTolerance : undefined, // 허용치
+ options: toleranceOptions,
+ },
+ inspectionMethod: formatInspectionMethod(method, sectionItem),
+ inspectionCycle: formattedFrequency, // MNG와 동일한 포맷
+ measurementType: mapMeasurementType(measurementType),
+ measurementCount,
+ // 3단계 그룹핑 정보
+ categoryRowSpan: groupInfo.categoryRowSpan,
+ isFirstInCategory: groupInfo.isFirstInCategory,
+ itemRowSpan: groupInfo.itemRowSpan,
+ isFirstInItem: groupInfo.isFirstInItem,
+ // 기존 호환용
+ rowSpan: groupInfo.categoryRowSpan,
+ isSubRow: !groupInfo.isFirstInCategory,
+ });
+ }
+
+ // 품목 속성에서 규격 추출 (두께*너비*길이 형식)
+ let specificationFromAttrs = '';
+ if (itemAttrs) {
+ const parts: string[] = [];
+ if (itemAttrs.thickness) parts.push(String(itemAttrs.thickness));
+ if (itemAttrs.width) parts.push(String(itemAttrs.width));
+ if (itemAttrs.length) parts.push(String(itemAttrs.length));
+ if (parts.length > 0) {
+ specificationFromAttrs = parts.join(' × ');
+ }
+ }
+
+ // 결재선 정보 (role → 표시명)
+ const sortedApprovalLines = [...template.approval_lines].sort((a, b) => a.sort_order - b.sort_order);
+ const writerLine = sortedApprovalLines.find(l => l.role === '작성');
+ const reviewerLine = sortedApprovalLines.find(l => l.role === '검토');
+ const approverLines = sortedApprovalLines.filter(l => l.role === '승인');
+
+ // notes에 비고 추가
+ const notes: string[] = [];
+ if (template.footer_remark_label) {
+ notes.push(template.footer_remark_label);
+ }
+
+ return {
+ templateId: String(template.id),
+ templateName: template.name,
+ headerInfo: {
+ productName: item.name || params.itemName || '',
+ specification: params.specification || specificationFromAttrs || '',
+ materialNo: params.materialNo || item.code || '',
+ lotSize: params.lotSize || 0,
+ supplier: params.supplier || '',
+ lotNo: params.lotNo || '',
+ inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
+ inspector: params.inspector || '',
+ reportDate: new Date().toISOString().split('T')[0],
+ approvers: {
+ // 작성자는 현재 검사자 (로그인 사용자)로 설정
+ writer: params.inspector || writerLine?.role || '',
+ reviewer: reviewerLine?.role || '',
+ // 승인자들 (여러 명 가능)
+ approver: approverLines.map(l => l.role).join(', ') || '',
+ },
+ // 결재선 원본 데이터 (UI에서 동적으로 표시하기 위해)
+ approvalLines: sortedApprovalLines.map(l => ({
+ id: l.id,
+ name: l.name || l.role || '',
+ dept: l.dept || '',
+ role: l.role || l.name || '',
+ sortOrder: l.sort_order,
+ })),
+ },
+ inspectionItems,
+ notes,
+ };
+}
+
+// ===== 안전한 문자열 변환 =====
+function safeString(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number') return String(value);
+ if (typeof value === 'object') {
+ // 객체인 경우 JSON.stringify 하지 않고 빈 문자열 반환
+ return '';
+ }
+ return String(value);
+}
+
+// ===== 자동 하이라이트 판단 =====
+function shouldHighlightRow(
+ itemName: string,
+ criteria: unknown,
+ itemAttrs?: { thickness?: number; width?: number; length?: number },
+ referenceAttribute?: string // field_values.reference_attribute
+): boolean {
+ if (!criteria || !itemAttrs) return false;
+ if (typeof criteria !== 'object') return false;
+
+ const c = criteria as StandardCriteriaObject;
+ const name = itemName.toLowerCase();
+
+ let targetValue: number | undefined;
+
+ // 1. referenceAttribute가 명시되어 있으면 해당 속성 사용 (연신율 등)
+ if (referenceAttribute) {
+ const attrMap: Record = {
+ 'thickness': 'thickness',
+ 'width': 'width',
+ 'length': 'length',
+ };
+ const attrKey = attrMap[referenceAttribute];
+ if (attrKey && itemAttrs[attrKey] !== undefined) {
+ targetValue = itemAttrs[attrKey];
+ }
+ }
+ // 2. referenceAttribute가 없으면 항목명에서 추론 (기존 로직)
+ else {
+ if (name.includes('두께') && itemAttrs.thickness !== undefined) {
+ targetValue = itemAttrs.thickness;
+ } else if (name.includes('너비') && itemAttrs.width !== undefined) {
+ targetValue = itemAttrs.width;
+ } else if (name.includes('길이') && itemAttrs.length !== undefined) {
+ targetValue = itemAttrs.length;
+ }
+ }
+
+ if (targetValue === undefined) return false;
+
+ // 범위 체크
+ let match = true;
+ if (c.min !== undefined && c.min !== null) {
+ match = match && (c.min_op === 'gte' ? targetValue >= c.min : targetValue > c.min);
+ }
+ if (c.max !== undefined && c.max !== null) {
+ match = match && (c.max_op === 'lte' ? targetValue <= c.max : targetValue < c.max);
+ }
+
+ return match;
+}
+
+// ===== 검사방식 포맷 =====
+function formatInspectionMethod(
+ method: string,
+ sectionItem: { method_name?: string; frequency_n?: number; frequency_c?: number }
+): string {
+ // API에서 반환한 method_name이 있으면 우선 사용 (common_codes join 결과)
+ if (sectionItem.method_name) {
+ return sectionItem.method_name;
+ }
+
+ // method 코드가 있으면 fallback 매핑 사용
+ if (method) {
+ const methodMap: Record = {
+ 'visual': '육안검사',
+ 'check': '체크검사',
+ 'measure': '계측검사',
+ 'mill_sheet': '공급업체 밀시트',
+ 'millsheet': '밀시트',
+ 'certified_agency': '공인시험기관',
+ 'substitute_cert': '공급업체 성적서 대체',
+ 'other': '기타',
+ };
+ return methodMap[method] || method;
+ }
+
+ // frequency_n, frequency_c가 있으면 "n = X, c = Y" 형식
+ if (sectionItem.frequency_n !== undefined && sectionItem.frequency_n > 0) {
+ const n = sectionItem.frequency_n;
+ const c = sectionItem.frequency_c ?? 0;
+ return `n = ${n}\nc = ${c}`;
+ }
+
+ return '';
+}
+
+// ===== 측정유형 매핑 - MNG와 동일 =====
+function mapMeasurementType(type: string): 'okng' | 'numeric' | 'both' | 'single_value' | 'substitute' {
+ switch (type) {
+ case 'numeric':
+ return 'numeric';
+ case 'checkbox':
+ case 'okng':
+ return 'okng';
+ case 'both':
+ return 'both';
+ case 'single_value':
+ return 'single_value';
+ case 'substitute':
+ return 'substitute';
+ default:
+ return 'okng';
+ }
+}
+
+// ===== 수입검사 파일 업로드 (사진 첨부용) =====
+export interface UploadedInspectionFile {
+ id: number;
+ name: string;
+ url: string;
+ size?: number;
+}
+
+export async function uploadInspectionFiles(files: File[]): Promise<{
+ success: boolean;
+ data?: UploadedInspectionFile[];
+ error?: string;
+}> {
+ if (files.length === 0) {
+ return { success: true, data: [] };
+ }
+
+ try {
+ const { cookies } = await import('next/headers');
+ const cookieStore = await cookies();
+ const token = cookieStore.get('access_token')?.value;
+
+ const uploadedFiles: UploadedInspectionFile[] = [];
+
+ for (const file of files) {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
+ {
+ method: 'POST',
+ headers: {
+ 'Authorization': token ? `Bearer ${token}` : '',
+ 'X-API-KEY': process.env.API_KEY || '',
+ },
+ body: formData,
+ }
+ );
+
+ if (!response.ok) {
+ console.error('[ReceivingActions] File upload error:', response.status);
+ return { success: false, error: `파일 업로드 실패: ${file.name}` };
+ }
+
+ const result = await response.json();
+ if (result.success && result.data) {
+ uploadedFiles.push({
+ id: result.data.id,
+ name: result.data.display_name || file.name,
+ url: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${result.data.id}/download`,
+ size: result.data.file_size,
+ });
+ }
+ }
+
+ return { success: true, data: uploadedFiles };
+ } catch (error) {
+ if (isNextRedirectError(error)) throw error;
+ console.error('[ReceivingActions] uploadInspectionFiles error:', error);
+ return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' };
+ }
+}
+
+// ===== 수입검사 데이터 저장 (documents/upsert + receivings 상태 업데이트) =====
+export async function saveInspectionData(params: {
+ templateId: number;
+ itemId: number;
+ title?: string;
+ data: Array<{ section_id?: number | null; row_index: number; field_key: string; field_value: string | null }>;
+ attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>;
+ receivingId: string;
+ inspectionResult?: 'pass' | 'fail' | null;
+}): Promise<{
+ success: boolean;
+ error?: string;
+ __authError?: boolean;
+}> {
+ try {
+ // Step 1: POST /v1/documents/upsert - 검사 데이터 저장
+ const upsertBody = {
+ template_id: params.templateId,
+ item_id: params.itemId,
+ title: params.title || '수입검사 성적서',
+ data: params.data,
+ attachments: params.attachments || [],
+ };
+
+ const { response: docResponse, error: docError } = await serverFetch(
+ `${process.env.NEXT_PUBLIC_API_URL}/api/v1/documents/upsert`,
+ { method: 'POST', body: JSON.stringify(upsertBody) }
+ );
+
+ if (docError) {
+ return { success: false, error: docError.message, __authError: docError.code === 'UNAUTHORIZED' };
+ }
+
+ if (!docResponse) {
+ return { success: false, error: '검사 데이터 저장에 실패했습니다.' };
+ }
+
+ const docResult = await docResponse.json();
+ if (!docResponse.ok || !docResult.success) {
+ return { success: false, error: docResult.message || '검사 데이터 저장에 실패했습니다.' };
+ }
+
+ // Step 2: PUT /v1/receivings/{id} - 검사 완료 후 입고대기로 상태 변경 + 검사 정보 저장
+ const today = new Date().toISOString().split('T')[0];
+ const inspectionStatus = params.inspectionResult === 'pass' ? '적' : params.inspectionResult === 'fail' ? '부적' : '-';
+ const inspectionResultLabel = params.inspectionResult === 'pass' ? '합격' : params.inspectionResult === 'fail' ? '불합격' : null;
+
+ const { response: recResponse, error: recError } = await serverFetch(
+ `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivings/${params.receivingId}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify({
+ status: 'receiving_pending',
+ inspection_status: inspectionStatus,
+ inspection_date: today,
+ inspection_result: inspectionResultLabel,
+ }),
+ }
+ );
+
+ if (recError) {
+ console.error('[ReceivingActions] 입고 상태 업데이트 실패 (검사 데이터는 저장됨):', recError.message);
+ } else if (recResponse && !recResponse.ok) {
+ console.error('[ReceivingActions] 입고 상태 업데이트 실패 (검사 데이터는 저장됨)');
+ }
+
+ return { success: true };
+ } catch (error) {
+ if (isNextRedirectError(error)) throw error;
+ console.error('[ReceivingActions] saveInspectionData error:', error);
+ return { success: false, error: '서버 오류가 발생했습니다.' };
+ }
}
\ No newline at end of file
diff --git a/src/components/material/ReceivingManagement/types.ts b/src/components/material/ReceivingManagement/types.ts
index 3f2ec604..f3a6a938 100644
--- a/src/components/material/ReceivingManagement/types.ts
+++ b/src/components/material/ReceivingManagement/types.ts
@@ -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 = {
+ waiting: '대기',
+ passed: '합격',
+ failed: '불합격',
+ none: '-',
+};
+
+// 수입검사 상태 스타일
+export const INSPECTION_STATUS_STYLES: Record = {
+ 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; // 규격 (읽기전용)
diff --git a/src/components/material/StockStatus/StockStatusList.tsx b/src/components/material/StockStatus/StockStatusList.tsx
index 4a0fefbf..073b0fdc 100644
--- a/src/components/material/StockStatus/StockStatusList.tsx
+++ b/src/components/material/StockStatus/StockStatusList.tsx
@@ -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[] = [
- { 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;
+ 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() {
/>
{globalIndex}
- {item.stockNumber}
- {item.itemCode}
+ {item.itemCode}
{ITEM_TYPE_LABELS[item.itemType] || '-'}
{item.itemName}
{item.specification || '-'}
@@ -290,7 +297,6 @@ export function StockStatusList() {
headerBadges={
<>
#{globalIndex}
- {item.stockNumber}
>
}
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: (
-
+
총 {filteredStocks.length}건
diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts
index 8b77b309..e6b77744 100644
--- a/src/components/material/StockStatus/actions.ts
+++ b/src/components/material/StockStatus/actions.ts
@@ -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;
- 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).specification;
+ if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
+ }
return {
id: String(data.id),
- stockNumber: hasStock ? String((stock as unknown as Record).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).calculated_qty ?? stock.stock_qty)) || 0) : 0,
- actualQty: hasStock ? (parseFloat(String((stock as unknown as Record).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).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;
- 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).specification;
+ if (stockSpec && String(stockSpec).trim()) specification = String(stockSpec).trim();
+ }
return {
id: String(data.id),
diff --git a/src/components/material/StockStatus/mockData.ts b/src/components/material/StockStatus/mockData.ts
index 3d8cc2cc..052f0aac 100644
--- a/src/components/material/StockStatus/mockData.ts
+++ b/src/components/material/StockStatus/mockData.ts
@@ -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',
diff --git a/src/components/material/StockStatus/types.ts b/src/components/material/StockStatus/types.ts
index 6e70791b..930340de 100644
--- a/src/components/material/StockStatus/types.ts
+++ b/src/components/material/StockStatus/types.ts
@@ -54,7 +54,6 @@ export const LOT_STATUS_LABELS: Record = {
// 재고 목록 아이템 (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)
diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx
index 01a7481c..4e772e8f 100644
--- a/src/components/orders/OrderRegistration.tsx
+++ b/src/components/orders/OrderRegistration.tsx
@@ -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();
+ // floor+code → calculationInput 매핑 (개소 메타정보)
+ const locationMetaMap = new Map();
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; // 개소 목록
- 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();
- 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 ? (
// 그룹핑 표시
- {itemGroups.map((group) => {
- return (
+ {itemGroups.map((group) => (
{group.label}
-
- ({group.locationCount}개소 / {group.quantity}대)
-
+ {group.productCode && (
+
+ {group.productCode} ({group.quantity}대)
+
+ )}
소계: {formatAmount(group.amount)}
@@ -934,8 +898,8 @@ export function OrderRegistration({
- {group.aggregatedItems.map((item, index) => (
-
+ {group.items.map((item, index) => (
+
{index + 1}
@@ -960,8 +924,7 @@ export function OrderRegistration({
- );
- })}
+ ))}
) : (
// 기본 플랫 리스트
diff --git a/src/components/production/ProductionDashboard/actions.ts b/src/components/production/ProductionDashboard/actions.ts
index 87caa7af..a927b83f 100644
--- a/src/components/production/ProductionDashboard/actions.ts
+++ b/src/components/production/ProductionDashboard/actions.ts
@@ -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),
diff --git a/src/components/production/ProductionDashboard/types.ts b/src/components/production/ProductionDashboard/types.ts
index a10f9be1..a7d5a31d 100644
--- a/src/components/production/ProductionDashboard/types.ts
+++ b/src/components/production/ProductionDashboard/types.ts
@@ -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
| null;
}
// 작업자 현황
diff --git a/src/components/production/WorkOrders/WorkOrderDetail.tsx b/src/components/production/WorkOrders/WorkOrderDetail.tsx
index 79ba1d56..906154ea 100644
--- a/src/components/production/WorkOrders/WorkOrderDetail.tsx
+++ b/src/components/production/WorkOrders/WorkOrderDetail.tsx
@@ -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 }) {
{detail.name}
{detail.material}
- 수량: {detail.quantity}
+ 수량: {Math.floor(detail.quantity)}
{/* 상세 정보 */}
@@ -384,7 +396,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
구분
-
-
+
{order.processName !== '-' ? order.processName : '-'}
{/* 2행: 로트번호 | 수주처 | 현장명 | 수주 담당자 */}
@@ -402,21 +414,21 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
수주 담당자
-
-
+
{order.salesOrderWriter || '-'}
{/* 3행: 담당자 연락처 | 출고예정일 | 틀수 | 우선순위 */}
담당자 연락처
-
-
+
{order.clientContact || '-'}
출고예정일
{order.shipmentDate || '-'}
-
틀수
-
{order.shutterCount ?? '-'}
+
틀수 (개소)
+
{order.shutterCount != null ? `${Math.floor(order.shutterCount)}개소` : '-'}
우선순위
@@ -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) {
/>
- {/* 작업 품목 테이블 (로트번호 | 품목명 | 수량 | 단위) */}
+ {/* 작업 품목 - 개소별 그룹 */}
{order.items.length > 0 ? (
-
-
-
- 로트번호
- 품목명
- 수량
- 단위
-
-
-
- {order.items.map((item) => (
-
- {order.lotNo}
- {item.productName}
- {item.quantity}
- {item.unit}
-
- ))}
-
-
+
+ {/* 개소별 품목 그룹 */}
+
+
개소별 품목
+
+
+
+ 개소
+ 품목명
+ 수량
+ 단위
+
+
+
+ {(() => {
+ // 개소별로 그룹화
+ const nodeGroups = new Map();
+ 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(
+
+ {idx === 0 && (
+
+ {group.nodeName}
+
+ )}
+ {item.productName}
+ {formatQuantity(item.quantity, item.unit)}
+ {item.unit}
+
+ );
+ });
+ }
+ return rows;
+ })()}
+
+
+
+
+ {/* 품목별 합산 그룹 */}
+
+
품목별 합산
+
+
+
+ No
+ 품목명
+ 합산수량
+ 단위
+ 개소수
+
+
+
+ {(() => {
+ // 품목명+단위 기준으로 중복 합산
+ const itemMap = new Map();
+ 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 (
+
+ {no}
+ {entry.productName}
+ {formatQuantity(entry.totalQty, entry.unit)}
+ {entry.unit}
+ {entry.nodeCount}
+
+ );
+ });
+ })()}
+
+
+
+
) : (
등록된 품목이 없습니다.
diff --git a/src/components/production/WorkOrders/WorkOrderList.tsx b/src/components/production/WorkOrders/WorkOrderList.tsx
index c4f401c7..a3fb0477 100644
--- a/src/components/production/WorkOrders/WorkOrderList.tsx
+++ b/src/components/production/WorkOrders/WorkOrderList.tsx
@@ -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>({});
+ // ===== 공정 목록 (동적 탭 생성용) =====
+ const [processList, setProcessList] = useState([]);
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 = {};
- 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 = {
+ [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>({
- screen: 0,
- slat: 0,
- bending: 0,
- });
+ // ===== 탭 옵션 (전체 + 동적 공정 + 기타) =====
+ const [tabCounts, setTabCounts] = useState>({});
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 = {
+ [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);
})() ? (
{!item.isWip && (
diff --git a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx
new file mode 100644
index 00000000..8ce2b26b
--- /dev/null
+++ b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx
@@ -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 (
+
+ );
+}
+
+function StatusBadge({ status }: { status: string }) {
+ const config: Record