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)
|
||||
|
||||
Reference in New Issue
Block a user