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;
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
* 재고현황 상세/수정 페이지
|
||||
*
|
||||
* 기획서 기준:
|
||||
* - 기본 정보: 재고번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
|
||||
* - 수정 가능: 실제 재고량, 안전재고, 상태 (사용/미사용)
|
||||
* - 기본 정보: 자재번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량 (읽기 전용)
|
||||
* - 수정 가능: 안전재고, 상태 (사용/미사용)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -25,7 +24,8 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
|
||||
import { stockStatusConfig } from './stockStatusConfig';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import { getStockById, updateStock } from './actions';
|
||||
import { USE_STATUS_LABELS } from './types';
|
||||
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
|
||||
import type { ItemType } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -38,11 +38,11 @@ interface StockDetailData {
|
||||
id: string;
|
||||
stockNumber: string;
|
||||
itemCode: string;
|
||||
itemType: ItemType;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
calculatedQty: number;
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}
|
||||
@@ -59,11 +59,9 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
|
||||
// 폼 데이터 (수정 모드용)
|
||||
const [formData, setFormData] = useState<{
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}>({
|
||||
actualQty: 0,
|
||||
safetyStock: 0,
|
||||
useStatus: 'active',
|
||||
});
|
||||
@@ -86,17 +84,16 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
id: data.id,
|
||||
stockNumber: data.id, // stockNumber가 없으면 id 사용
|
||||
itemCode: data.itemCode,
|
||||
itemType: (data.itemType || 'RM') as ItemType,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification || '-',
|
||||
unit: data.unit,
|
||||
calculatedQty: data.currentStock, // 계산 재고량
|
||||
actualQty: data.currentStock, // 실제 재고량 (별도 필드 없으면 currentStock 사용)
|
||||
calculatedQty: data.currentStock, // 재고량
|
||||
safetyStock: data.safetyStock,
|
||||
useStatus: data.status === null ? 'active' : 'active', // 기본값
|
||||
};
|
||||
setDetail(detailData);
|
||||
setFormData({
|
||||
actualQty: detailData.actualQty,
|
||||
safetyStock: detailData.safetyStock,
|
||||
useStatus: detailData.useStatus,
|
||||
});
|
||||
@@ -140,7 +137,6 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
actualQty: formData.actualQty,
|
||||
safetyStock: formData.safetyStock,
|
||||
useStatus: formData.useStatus,
|
||||
}
|
||||
@@ -189,19 +185,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 */}
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재고번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */}
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('실제 재고량', detail.actualQty)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
@@ -226,33 +222,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 재고번호, 품목코드, 품목명, 규격 (읽기 전용) */}
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재고번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */}
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('계산 재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 실제 재고량 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="actualQty" className="text-sm text-muted-foreground">
|
||||
실제 재고량
|
||||
</Label>
|
||||
<Input
|
||||
id="actualQty"
|
||||
type="number"
|
||||
value={formData.actualQty}
|
||||
onChange={(e) => handleInputChange('actualQty', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
@@ -322,4 +304,4 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
onSubmit={async () => { await handleSave(); return { success: true }; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import type { ExcelColumn } from '@/lib/utils/excel-download';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -33,11 +33,9 @@ import {
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getStocks, getStockStats } from './actions';
|
||||
import { USE_STATUS_LABELS } from './types';
|
||||
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
|
||||
import { ClipboardList } from 'lucide-react';
|
||||
import { StockAuditModal } from './StockAuditModal';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -65,10 +63,6 @@ export function StockStatusList() {
|
||||
useStatus: 'all',
|
||||
});
|
||||
|
||||
// ===== 재고 실사 모달 상태 =====
|
||||
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
|
||||
const [isAuditLoading, setIsAuditLoading] = useState(false);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
@@ -130,30 +124,15 @@ export function StockStatusList() {
|
||||
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// ===== 재고 실사 버튼 핸들러 =====
|
||||
const handleStockAudit = () => {
|
||||
setIsAuditLoading(true);
|
||||
// 약간의 딜레이 후 모달 오픈 (로딩 UI 표시를 위해)
|
||||
setTimeout(() => {
|
||||
setIsAuditModalOpen(true);
|
||||
setIsAuditLoading(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// ===== 재고 실사 완료 핸들러 =====
|
||||
const handleAuditComplete = () => {
|
||||
loadData(); // 데이터 새로고침
|
||||
};
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
const excelColumns: ExcelColumn<StockItem>[] = [
|
||||
{ header: '재고번호', key: 'stockNumber' },
|
||||
{ header: '자재번호', key: 'stockNumber' },
|
||||
{ header: '품목코드', key: 'itemCode' },
|
||||
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' },
|
||||
{ header: '품목명', key: 'itemName' },
|
||||
{ header: '규격', key: 'specification' },
|
||||
{ header: '단위', key: 'unit' },
|
||||
{ header: '계산 재고량', key: 'calculatedQty' },
|
||||
{ header: '실제 재고량', key: 'actualQty' },
|
||||
{ header: '재고량', key: 'calculatedQty' },
|
||||
{ header: '안전재고', key: 'safetyStock' },
|
||||
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
|
||||
];
|
||||
@@ -202,11 +181,17 @@ export function StockStatusList() {
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '재고부족',
|
||||
label: '재고 부족',
|
||||
value: `${stockStats?.lowCount || 0}`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '안전재고 미달',
|
||||
value: `${stockStats?.outCount || 0}`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 필터 설정 (전체/사용/미사용) =====
|
||||
@@ -226,13 +211,13 @@ export function StockStatusList() {
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockNumber', label: '재고번호', className: 'w-[100px]' },
|
||||
{ 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]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'calculatedQty', label: '계산 재고량', className: 'w-[100px] text-center' },
|
||||
{ key: 'actualQty', label: '실제 재고량', className: 'w-[100px] text-center' },
|
||||
{ key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
|
||||
];
|
||||
@@ -259,11 +244,11 @@ export function StockStatusList() {
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.stockNumber}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.calculatedQty}</TableCell>
|
||||
<TableCell className="text-center">{item.actualQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
|
||||
@@ -306,10 +291,10 @@ export function StockStatusList() {
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목유형" value={ITEM_TYPE_LABELS[item.itemType] || '-'} />
|
||||
<InfoField label="규격" value={item.specification || '-'} />
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="계산 재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="실제 재고량" value={`${item.actualQty}`} />
|
||||
<InfoField label="재고량" value={`${item.calculatedQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
</div>
|
||||
}
|
||||
@@ -401,24 +386,6 @@ export function StockStatusList() {
|
||||
// 통계
|
||||
computeStats: () => stats,
|
||||
|
||||
// 헤더 액션 버튼
|
||||
headerActions: () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
onClick={handleStockAudit}
|
||||
disabled={isAuditLoading}
|
||||
>
|
||||
{isAuditLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{isAuditLoading ? '로딩 중...' : '재고 실사'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 푸터
|
||||
tableFooter: (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
@@ -468,22 +435,12 @@ export function StockStatusList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
{/* 재고 실사 모달 */}
|
||||
<StockAuditModal
|
||||
open={isAuditModalOpen}
|
||||
onOpenChange={setIsAuditModalOpen}
|
||||
stocks={stocks}
|
||||
onComplete={handleAuditComplete}
|
||||
/>
|
||||
</>
|
||||
<UniversalListPage<StockItem>
|
||||
config={config}
|
||||
initialData={filteredStocks}
|
||||
initialTotalCount={filteredStocks.length}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -439,7 +439,6 @@ export async function getStockById(id: string): Promise<{
|
||||
export async function updateStock(
|
||||
id: string,
|
||||
data: {
|
||||
actualQty: number;
|
||||
safetyStock: number;
|
||||
useStatus: 'active' | 'inactive';
|
||||
}
|
||||
@@ -457,7 +456,6 @@ export async function updateStock(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actual_qty: data.actualQty,
|
||||
safety_stock: data.safetyStock,
|
||||
is_active: data.useStatus === 'active',
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user