feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고 조정 팝업 (기획서 Page 78)
|
||||
*
|
||||
* - 품목 선택 (검색)
|
||||
* - 유형 필터 셀렉트 박스 (전체, 유형 목록)
|
||||
* - 테이블: 로트번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량, 증감 수량
|
||||
* - 증감 수량: 양수/음수 입력 가능
|
||||
* - 취소/저장 버튼
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import type { InventoryAdjustmentItem } from './types';
|
||||
|
||||
// 목데이터 - 품목 유형 목록
|
||||
const ITEM_TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'raw', label: '원자재' },
|
||||
{ value: 'sub', label: '부자재' },
|
||||
{ value: 'part', label: '부품' },
|
||||
{ value: 'product', label: '완제품' },
|
||||
];
|
||||
|
||||
// 목데이터 - 재고 품목 목록
|
||||
const MOCK_STOCK_ITEMS: InventoryAdjustmentItem[] = [
|
||||
{ id: '1', lotNo: 'LOT-2026-001', itemCode: 'STEEL-001', itemType: '원자재', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', stockQty: 100 },
|
||||
{ id: '2', lotNo: 'LOT-2026-002', itemCode: 'ELEC-002', itemType: '부품', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', stockQty: 500 },
|
||||
{ id: '3', lotNo: 'LOT-2026-003', itemCode: 'PLAS-003', itemType: '부자재', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', stockQty: 200 },
|
||||
{ id: '4', lotNo: 'LOT-2026-004', itemCode: 'STEEL-002', itemType: '원자재', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', stockQty: 50 },
|
||||
{ id: '5', lotNo: 'LOT-2026-005', itemCode: 'ELEC-005', itemType: '부품', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', stockQty: 80 },
|
||||
{ id: '6', lotNo: 'LOT-2026-006', itemCode: 'CHEM-001', itemType: '부자재', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA', stockQty: 300 },
|
||||
{ id: '7', lotNo: 'LOT-2026-007', itemCode: 'ELEC-007', itemType: '부품', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA', stockQty: 1000 },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave?: (items: InventoryAdjustmentItem[]) => void;
|
||||
}
|
||||
|
||||
export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [adjustments, setAdjustments] = useState<Record<string, number | undefined>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 필터링된 품목 목록
|
||||
const filteredItems = useMemo(() => {
|
||||
return MOCK_STOCK_ITEMS.filter((item) => {
|
||||
const matchesSearch = !search ||
|
||||
item.itemCode.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.itemName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.lotNo.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesType = typeFilter === 'all' || item.itemType === ITEM_TYPE_OPTIONS.find(o => o.value === typeFilter)?.label;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
}, [search, typeFilter]);
|
||||
|
||||
// 증감 수량 변경
|
||||
const handleAdjustmentChange = useCallback((itemId: string, value: string) => {
|
||||
const numValue = value === '' || value === '-' ? undefined : Number(value);
|
||||
setAdjustments((prev) => ({
|
||||
...prev,
|
||||
[itemId]: numValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
const itemsWithAdjustment = MOCK_STOCK_ITEMS
|
||||
.filter((item) => adjustments[item.id] !== undefined && adjustments[item.id] !== 0)
|
||||
.map((item) => ({ ...item, adjustmentQty: adjustments[item.id] }));
|
||||
|
||||
if (itemsWithAdjustment.length === 0) {
|
||||
toast.error('증감 수량을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
onSave?.(itemsWithAdjustment);
|
||||
toast.success('재고 조정이 저장되었습니다.');
|
||||
handleClose();
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 닫기 (상태 초기화)
|
||||
const handleClose = () => {
|
||||
setSearch('');
|
||||
setTypeFilter('all');
|
||||
setAdjustments({});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">재고 조정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{/* 품목 선택 - 검색 */}
|
||||
<div className="font-medium text-sm">품목 선택</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="품목코드, 품목명, 로트번호 검색"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 총 건수 + 유형 필터 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 <strong>{filteredItems.length}</strong>건
|
||||
</span>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[120px] h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[100px]">로트번호</TableHead>
|
||||
<TableHead className="text-center w-[90px]">품목코드</TableHead>
|
||||
<TableHead className="text-center w-[70px]">품목유형</TableHead>
|
||||
<TableHead className="min-w-[120px]">품목명</TableHead>
|
||||
<TableHead className="w-[90px]">규격</TableHead>
|
||||
<TableHead className="text-center w-[50px]">단위</TableHead>
|
||||
<TableHead className="text-center w-[70px]">재고량</TableHead>
|
||||
<TableHead className="text-center w-[90px]">증감 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center text-sm">{item.lotNo}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.itemType}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName}</TableCell>
|
||||
<TableCell className="text-sm">{item.specification}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.unit}</TableCell>
|
||||
<TableCell className="text-center text-sm">{item.stockQty}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adjustments[item.id] ?? ''}
|
||||
onChange={(e) => handleAdjustmentChange(item.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[80px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="min-w-[120px]"
|
||||
disabled={isSaving}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
className="min-w-[120px] bg-gray-900 text-white hover:bg-gray-800"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, FileText, Search, X } from 'lucide-react';
|
||||
import { Upload, FileText, Search, X, Plus } 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';
|
||||
@@ -40,10 +40,19 @@ import {
|
||||
createReceiving,
|
||||
updateReceiving,
|
||||
} from './actions';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RECEIVING_STATUS_OPTIONS,
|
||||
type ReceivingDetail as ReceivingDetailType,
|
||||
type ReceivingStatus,
|
||||
type InventoryAdjustmentRecord,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
@@ -56,6 +65,7 @@ interface Props {
|
||||
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
materialNo: '',
|
||||
lotNo: '',
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
@@ -71,6 +81,7 @@ const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
|
||||
inspectionDate: '',
|
||||
inspectionResult: '',
|
||||
certificateFile: undefined,
|
||||
inventoryAdjustments: [],
|
||||
};
|
||||
|
||||
// 로트번호 생성 (YYMMDD-NN)
|
||||
@@ -121,6 +132,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
|
||||
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
|
||||
|
||||
// 재고 조정 이력 상태
|
||||
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
|
||||
|
||||
// Dev 모드 폼 자동 채우기
|
||||
useDevFill(
|
||||
'receiving',
|
||||
@@ -159,9 +173,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
// 재고 조정 이력 설정
|
||||
if (result.data.inventoryAdjustments) {
|
||||
setAdjustments(result.data.inventoryAdjustments);
|
||||
}
|
||||
// 수정 모드일 때 폼 데이터 설정
|
||||
if (isEditMode) {
|
||||
setFormData({
|
||||
materialNo: result.data.materialNo || '',
|
||||
lotNo: result.data.lotNo || '',
|
||||
itemCode: result.data.itemCode,
|
||||
itemName: result.data.itemName,
|
||||
@@ -239,6 +258,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
setIsInspectionModalOpen(true);
|
||||
};
|
||||
|
||||
// 재고 조정 행 추가
|
||||
const handleAddAdjustment = () => {
|
||||
const newRecord: InventoryAdjustmentRecord = {
|
||||
id: `adj-${Date.now()}`,
|
||||
adjustmentDate: new Date().toISOString().split('T')[0],
|
||||
quantity: 0,
|
||||
inspector: getLoggedInUserName() || '홍길동',
|
||||
};
|
||||
setAdjustments((prev) => [...prev, newRecord]);
|
||||
};
|
||||
|
||||
// 재고 조정 행 삭제
|
||||
const handleRemoveAdjustment = (adjId: string) => {
|
||||
setAdjustments((prev) => prev.filter((a) => a.id !== adjId));
|
||||
};
|
||||
|
||||
// 재고 조정 수량 변경
|
||||
const handleAdjustmentQtyChange = (adjId: string, value: string) => {
|
||||
const numValue = value === '' || value === '-' ? 0 : Number(value);
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a))
|
||||
);
|
||||
};
|
||||
|
||||
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
|
||||
const handleCancel = () => {
|
||||
if (isNewMode) {
|
||||
@@ -277,6 +320,7 @@ 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.itemCode)}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
@@ -293,6 +337,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
detail.status === 'inspection_completed' ? '검사완료' :
|
||||
detail.status
|
||||
)}
|
||||
</div>
|
||||
{/* 비고 - 전체 너비 */}
|
||||
<div className="mt-4">
|
||||
{renderReadOnlyField('비고', detail.remark)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -317,15 +364,54 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
<span>{detail.certificateFileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
'등록된 파일이 없습니다.'
|
||||
'클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
|
||||
<TableCell className="text-center">{adj.quantity}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [detail, adjustments]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -338,6 +424,9 @@ 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.lotNo, true)}
|
||||
|
||||
@@ -512,17 +601,107 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAdjustment}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
<TableHead className="text-center w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="date"
|
||||
value={adj.adjustmentDate}
|
||||
onChange={(e) => {
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === adj.id ? { ...a, adjustmentDate: e.target.value } : a
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adj.quantity || ''}
|
||||
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[100px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveAdjustment(adj.id)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [formData]);
|
||||
}, [formData, adjustments]);
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleInspection}>
|
||||
수입검사하기
|
||||
</Button>
|
||||
</>
|
||||
{isViewMode && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={() => router.push(`/ko/material/receiving-management/${id}?mode=edit`)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
// 에러 상태 표시 (view/edit 모드에서만)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Plus,
|
||||
Eye,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
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 { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -50,6 +52,9 @@ export function ReceivingList() {
|
||||
const [stats, setStats] = useState<ReceivingStats | null>(null);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// ===== 재고 조정 팝업 상태 =====
|
||||
const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false);
|
||||
|
||||
// ===== 날짜 범위 상태 (최근 30일) =====
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -139,7 +144,7 @@ export function ReceivingList() {
|
||||
const tableFooter = useMemo(
|
||||
() => (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={15} className="py-3">
|
||||
<TableCell colSpan={18} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {totalItems}건
|
||||
</span>
|
||||
@@ -200,20 +205,23 @@ export function ReceivingList() {
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼 (기획서 순서)
|
||||
// 테이블 컬럼 (기획서 2026-02-03 순서)
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
|
||||
{ key: 'materialNo', label: '자재번호', className: 'w-[100px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
|
||||
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[80px] text-center' },
|
||||
{ key: 'inspectionDate', label: '검사일', className: 'w-[100px] text-center' },
|
||||
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[70px] text-center' },
|
||||
{ key: 'inspectionDate', label: '검사일', className: 'w-[90px] text-center' },
|
||||
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'receivingQty', label: '입고수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'receivingDate', label: '입고일', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdBy', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'manufacturer', label: '제조사', className: 'min-w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[80px] text-center' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[130px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[90px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[50px] text-center' },
|
||||
{ key: 'receivingQty', label: '수량', className: 'w-[60px] text-center' },
|
||||
{ key: 'receivingDate', label: '입고변경일', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdBy', label: '작성자', className: 'w-[70px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
],
|
||||
|
||||
@@ -250,17 +258,27 @@ export function ReceivingList() {
|
||||
// 통계 카드
|
||||
stats: statCards,
|
||||
|
||||
// 헤더 액션 (입고 등록 버튼)
|
||||
// 헤더 액션 (재고 조정 + 입고 등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
입고 등록
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentOpen(true)}
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-1" />
|
||||
재고 조정
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
입고 등록
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
@@ -286,12 +304,15 @@ export function ReceivingList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<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">{item.inspectionDate || '-'}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell>{item.manufacturer || '-'}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">{item.itemType || '-'}</TableCell>
|
||||
<TableCell className="max-w-[130px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -342,12 +363,14 @@ 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.itemType || '-'} />
|
||||
<InfoField label="발주처" value={item.supplier} />
|
||||
<InfoField label="제조사" value={item.manufacturer || '-'} />
|
||||
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
|
||||
<InfoField label="검사일" value={item.inspectionDate || '-'} />
|
||||
<InfoField label="입고수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고일" value={item.receivingDate || '-'} />
|
||||
<InfoField label="수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
|
||||
<InfoField label="입고변경일" value={item.receivingDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
@@ -376,9 +399,17 @@ export function ReceivingList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 팝업 */}
|
||||
<InventoryAdjustmentDialog
|
||||
open={isAdjustmentOpen}
|
||||
onOpenChange={setIsAdjustmentOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,14 @@ import type {
|
||||
const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
materialNo: 'MAT-001',
|
||||
lotNo: 'LOT-2026-001',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-25',
|
||||
supplier: '(주)대한철강',
|
||||
manufacturer: '포스코',
|
||||
itemCode: 'STEEL-001',
|
||||
itemType: '원자재',
|
||||
itemName: 'SUS304 스테인리스 판재',
|
||||
specification: '1000x2000x3T',
|
||||
unit: 'EA',
|
||||
@@ -46,11 +49,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
materialNo: 'MAT-002',
|
||||
lotNo: 'LOT-2026-002',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-26',
|
||||
supplier: '삼성전자부품',
|
||||
manufacturer: '삼성전자',
|
||||
itemCode: 'ELEC-002',
|
||||
itemType: '부품',
|
||||
itemName: 'MCU 컨트롤러 IC',
|
||||
specification: 'STM32F103C8T6',
|
||||
unit: 'EA',
|
||||
@@ -61,11 +67,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
materialNo: 'MAT-003',
|
||||
lotNo: 'LOT-2026-003',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '한국플라스틱',
|
||||
manufacturer: '한국플라스틱',
|
||||
itemCode: 'PLAS-003',
|
||||
itemType: '부자재',
|
||||
itemName: 'ABS 사출 케이스',
|
||||
specification: '150x100x50',
|
||||
unit: 'SET',
|
||||
@@ -76,11 +85,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
materialNo: 'MAT-004',
|
||||
lotNo: 'LOT-2026-004',
|
||||
inspectionStatus: '부적',
|
||||
inspectionDate: '2026-01-27',
|
||||
supplier: '(주)대한철강',
|
||||
manufacturer: '포스코',
|
||||
itemCode: 'STEEL-002',
|
||||
itemType: '원자재',
|
||||
itemName: '알루미늄 프로파일',
|
||||
specification: '40x40x2000L',
|
||||
unit: 'EA',
|
||||
@@ -91,11 +103,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
materialNo: 'MAT-005',
|
||||
lotNo: 'LOT-2026-005',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '글로벌전자',
|
||||
manufacturer: '글로벌전자',
|
||||
itemCode: 'ELEC-005',
|
||||
itemType: '부품',
|
||||
itemName: 'DC 모터 24V',
|
||||
specification: '24V 100RPM',
|
||||
unit: 'EA',
|
||||
@@ -106,11 +121,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
materialNo: 'MAT-006',
|
||||
lotNo: 'LOT-2026-006',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-24',
|
||||
supplier: '동양화학',
|
||||
manufacturer: '동양화학',
|
||||
itemCode: 'CHEM-001',
|
||||
itemType: '부자재',
|
||||
itemName: '에폭시 접착제',
|
||||
specification: '500ml',
|
||||
unit: 'EA',
|
||||
@@ -121,11 +139,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
materialNo: 'MAT-007',
|
||||
lotNo: 'LOT-2026-007',
|
||||
inspectionStatus: '적',
|
||||
inspectionDate: '2026-01-28',
|
||||
supplier: '삼성전자부품',
|
||||
manufacturer: '삼성전자',
|
||||
itemCode: 'ELEC-007',
|
||||
itemType: '부품',
|
||||
itemName: '커패시터 100uF',
|
||||
specification: '100uF 50V',
|
||||
unit: 'EA',
|
||||
@@ -136,11 +157,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
materialNo: 'MAT-008',
|
||||
lotNo: 'LOT-2026-008',
|
||||
inspectionStatus: '-',
|
||||
inspectionDate: undefined,
|
||||
supplier: '한국볼트',
|
||||
manufacturer: '한국볼트',
|
||||
itemCode: 'BOLT-001',
|
||||
itemType: '부품',
|
||||
itemName: 'SUS 볼트 M8x30',
|
||||
specification: 'M8x30 SUS304',
|
||||
unit: 'EA',
|
||||
@@ -158,11 +182,11 @@ const MOCK_RECEIVING_STATS: ReceivingStats = {
|
||||
inspectionCompletedCount: 5,
|
||||
};
|
||||
|
||||
// 기획서 2026-01-28 기준 상세 목데이터
|
||||
// 기획서 2026-02-03 기준 상세 목데이터
|
||||
const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
// 기본 정보
|
||||
materialNo: 'MAT-001',
|
||||
lotNo: 'LOT-2026-001',
|
||||
itemCode: 'STEEL-001',
|
||||
itemName: 'SUS304 스테인리스 판재',
|
||||
@@ -175,16 +199,21 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
createdBy: '김철수',
|
||||
status: 'completed',
|
||||
remark: '',
|
||||
// 수입검사 정보
|
||||
inspectionDate: '2026-01-25',
|
||||
inspectionResult: '합격',
|
||||
certificateFile: undefined,
|
||||
// 하위 호환
|
||||
inventoryAdjustments: [
|
||||
{ id: 'adj-1', adjustmentDate: '2026-01-05', quantity: 10, inspector: '홍길동' },
|
||||
{ id: 'adj-2', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' },
|
||||
{ id: 'adj-3', adjustmentDate: '2026-01-05', quantity: -15, inspector: '홍길동' },
|
||||
{ id: 'adj-4', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' },
|
||||
],
|
||||
orderNo: 'PO-2026-001',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
materialNo: 'MAT-002',
|
||||
lotNo: 'LOT-2026-002',
|
||||
itemCode: 'ELEC-002',
|
||||
itemName: 'MCU 컨트롤러 IC',
|
||||
@@ -199,11 +228,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
remark: '긴급 입고',
|
||||
inspectionDate: '2026-01-26',
|
||||
inspectionResult: '합격',
|
||||
inventoryAdjustments: [],
|
||||
orderNo: 'PO-2026-002',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
materialNo: 'MAT-003',
|
||||
lotNo: 'LOT-2026-003',
|
||||
itemCode: 'PLAS-003',
|
||||
itemName: 'ABS 사출 케이스',
|
||||
@@ -218,11 +249,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
remark: '',
|
||||
inspectionDate: undefined,
|
||||
inspectionResult: undefined,
|
||||
inventoryAdjustments: [],
|
||||
orderNo: 'PO-2026-003',
|
||||
orderUnit: 'SET',
|
||||
},
|
||||
'4': {
|
||||
id: '4',
|
||||
materialNo: 'MAT-004',
|
||||
lotNo: 'LOT-2026-004',
|
||||
itemCode: 'STEEL-002',
|
||||
itemName: '알루미늄 프로파일',
|
||||
@@ -237,11 +270,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
remark: '검사 진행 중',
|
||||
inspectionDate: '2026-01-27',
|
||||
inspectionResult: '불합격',
|
||||
inventoryAdjustments: [],
|
||||
orderNo: 'PO-2026-004',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
'5': {
|
||||
id: '5',
|
||||
materialNo: 'MAT-005',
|
||||
lotNo: 'LOT-2026-005',
|
||||
itemCode: 'ELEC-005',
|
||||
itemName: 'DC 모터 24V',
|
||||
@@ -256,6 +291,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
|
||||
remark: '',
|
||||
inspectionDate: undefined,
|
||||
inspectionResult: undefined,
|
||||
inventoryAdjustments: [],
|
||||
orderNo: 'PO-2026-005',
|
||||
orderUnit: 'EA',
|
||||
},
|
||||
|
||||
@@ -7,5 +7,6 @@ export { ReceivingDetail } from './ReceivingDetail';
|
||||
export { InspectionCreate } from './InspectionCreate';
|
||||
export { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
export { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
export { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
|
||||
export { SuccessDialog } from './SuccessDialog';
|
||||
export * from './types';
|
||||
@@ -41,16 +41,19 @@ export const RECEIVING_STATUS_OPTIONS = [
|
||||
// 입고 목록 아이템
|
||||
export interface ReceivingItem {
|
||||
id: string;
|
||||
materialNo?: string; // 자재번호
|
||||
lotNo?: string; // 로트번호
|
||||
inspectionStatus?: string; // 수입검사 (적/부적/-)
|
||||
inspectionDate?: string; // 검사일
|
||||
supplier: string; // 발주처
|
||||
manufacturer?: string; // 제조사
|
||||
itemCode: string; // 품목코드
|
||||
itemType?: string; // 품목유형
|
||||
itemName: string; // 품목명
|
||||
specification?: string; // 규격
|
||||
unit: string; // 단위
|
||||
receivingQty?: number; // 입고수량
|
||||
receivingDate?: string; // 입고일
|
||||
receivingQty?: number; // 수량
|
||||
receivingDate?: string; // 입고변경일
|
||||
createdBy?: string; // 작성자
|
||||
status: ReceivingStatus; // 상태
|
||||
// 기존 필드 (하위 호환)
|
||||
@@ -59,10 +62,11 @@ export interface ReceivingItem {
|
||||
orderUnit?: string; // 발주단위
|
||||
}
|
||||
|
||||
// 입고 상세 정보 (기획서 2026-01-28 기준)
|
||||
// 입고 상세 정보 (기획서 2026-02-03 기준)
|
||||
export interface ReceivingDetail {
|
||||
id: string;
|
||||
// 기본 정보
|
||||
materialNo?: string; // 자재번호 (읽기전용)
|
||||
lotNo?: string; // 로트번호 (읽기전용)
|
||||
itemCode: string; // 품목코드 (수정가능)
|
||||
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
|
||||
@@ -80,6 +84,8 @@ export interface ReceivingDetail {
|
||||
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
|
||||
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
|
||||
certificateFileName?: string; // 파일명
|
||||
// 재고 조정 이력
|
||||
inventoryAdjustments?: InventoryAdjustmentRecord[];
|
||||
// 기존 필드 (하위 호환)
|
||||
orderNo?: string; // 발주번호
|
||||
orderDate?: string; // 발주일자
|
||||
@@ -92,6 +98,27 @@ export interface ReceivingDetail {
|
||||
receivingManager?: string; // 입고담당
|
||||
}
|
||||
|
||||
// 재고 조정 이력 레코드 (입고 상세 내)
|
||||
export interface InventoryAdjustmentRecord {
|
||||
id: string;
|
||||
adjustmentDate: string; // 조정일시
|
||||
quantity: number; // 증감 수량 (양수/음수)
|
||||
inspector: string; // 검수자
|
||||
}
|
||||
|
||||
// 재고 조정 팝업용 품목 아이템
|
||||
export interface InventoryAdjustmentItem {
|
||||
id: string;
|
||||
lotNo: string; // 로트번호
|
||||
itemCode: string; // 품목코드
|
||||
itemType: string; // 품목유형
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
unit: string; // 단위
|
||||
stockQty: number; // 재고량
|
||||
adjustmentQty?: number; // 증감 수량
|
||||
}
|
||||
|
||||
// 검사 대상 아이템
|
||||
export interface InspectionTarget {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user