feat: 견적확정 밸리데이션, 수주등록 개소그룹, 작업지시 개선

- 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가
- 견적확정 후 수주등록 버튼 동적 전환
- 수주등록 품목 개소별(floor+code) 그룹핑 수정
- 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity)
- 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용)
- 작업지시 상세 개소별/품목별 합산 테이블 추가
- 작업자 화면 API 연동 및 목업 데이터 분리
- 입고관리 완료건 수정, 재고현황 개선
This commit is contained in:
2026-02-07 03:27:23 +09:00
parent b2085a84ca
commit a8591c438e
29 changed files with 3238 additions and 700 deletions

View File

@@ -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}
/>
</>
);
}

View File

@@ -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

View File

@@ -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; // 규격 (읽기전용)

View File

@@ -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>

View File

@@ -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),

View File

@@ -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',

View File

@@ -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)