Files
sam-react-prod/src/components/material/ReceivingManagement/InventoryAdjustmentDialog.tsx
김보곤 d08184d728 fix: [receiving] 입고 등록 초기값 설정 및 UX 개선
- 작성자 필드에 세션 사용자 이름 기본값 설정
- 입고일 필드에 오늘 날짜 기본값 설정
- 등록 완료 후 목록 대신 생성된 입고 상세 페이지로 바로 이동
- 수입검사 저장 시 rendered_html 크기 제한 (500KB 초과 시 제외, 413 방지)
- Dialog 접근성 경고 수정 (DialogDescription 추가)
2026-03-18 17:25:31 +09:00

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