- 작성자 필드에 세션 사용자 이름 기본값 설정 - 입고일 필드에 오늘 날짜 기본값 설정 - 등록 완료 후 목록 대신 생성된 입고 상세 페이지로 바로 이동 - 수입검사 저장 시 rendered_html 크기 제한 (500KB 초과 시 제외, 413 방지) - Dialog 접근성 경고 수정 (DialogDescription 추가)
239 lines
9.4 KiB
TypeScript
239 lines
9.4 KiB
TypeScript
'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,
|
|
DialogDescription,
|
|
VisuallyHidden,
|
|
} 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>
|
|
<VisuallyHidden><DialogDescription>재고 수량 조정</DialogDescription></VisuallyHidden>
|
|
</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="min-w-[120px] w-auto 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>
|
|
);
|
|
}
|