feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가
- 입고관리: 상세/목록 UI 개선, actions 로직 강화 - 재고현황: 상세/목록 개선, StockAuditModal 신규 추가 - 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화 - 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가 - 견적: QuoteTransactionModal 기능 개선 - 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선 - UniversalListPage: 템플릿 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal file
243
src/components/material/StockStatus/StockAuditModal.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고 실사 모달
|
||||
*
|
||||
* 기능:
|
||||
* - 재고 목록 표시 (품목코드, 품목명, 규격, 단위, 실제 재고량)
|
||||
* - 실제 재고량 입력/수정
|
||||
* - 저장 시 일괄 업데이트
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { updateStockAudit } from './actions';
|
||||
import type { StockItem } from './types';
|
||||
|
||||
interface StockAuditModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stocks: StockItem[];
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface AuditItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
calculatedQty: number;
|
||||
actualQty: number;
|
||||
newActualQty: number;
|
||||
}
|
||||
|
||||
export function StockAuditModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
stocks,
|
||||
onComplete,
|
||||
}: StockAuditModalProps) {
|
||||
const [auditItems, setAuditItems] = useState<AuditItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 모달이 열릴 때 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (open && stocks.length > 0) {
|
||||
setAuditItems(
|
||||
stocks.map((stock) => ({
|
||||
id: stock.id,
|
||||
itemCode: stock.itemCode,
|
||||
itemName: stock.itemName,
|
||||
specification: stock.specification || '',
|
||||
unit: stock.unit,
|
||||
calculatedQty: stock.calculatedQty,
|
||||
actualQty: stock.actualQty,
|
||||
newActualQty: stock.actualQty,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [open, stocks]);
|
||||
|
||||
// 실제 재고량 변경 핸들러
|
||||
const handleQtyChange = useCallback((id: string, value: string) => {
|
||||
const numValue = value === '' ? 0 : parseFloat(value);
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
setAuditItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, newActualQty: numValue } : item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSubmit = async () => {
|
||||
// 변경된 항목만 필터링
|
||||
const changedItems = auditItems.filter(
|
||||
(item) => item.actualQty !== item.newActualQty
|
||||
);
|
||||
|
||||
if (changedItems.length === 0) {
|
||||
toast.info('변경된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updates = changedItems.map((item) => ({
|
||||
id: item.id,
|
||||
actualQty: item.newActualQty,
|
||||
}));
|
||||
|
||||
const result = await updateStockAudit(updates);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${changedItems.length}개 항목의 재고가 업데이트되었습니다.`);
|
||||
onOpenChange(false);
|
||||
onComplete?.();
|
||||
} else {
|
||||
toast.error(result.error || '재고 실사 저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[StockAuditModal] handleSubmit error:', error);
|
||||
toast.error('재고 실사 저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1200px] w-full p-0 gap-0 max-h-[80vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="p-6 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="text-xl font-semibold">재고 실사</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 space-y-6 flex-1 overflow-hidden flex flex-col">
|
||||
{/* 테이블 */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="table" rows={6} />
|
||||
) : auditItems.length === 0 ? (
|
||||
<div className="border rounded-lg flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center">품목코드</TableHead>
|
||||
<TableHead className="text-center">품목명</TableHead>
|
||||
<TableHead className="text-center">규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-center">계산 재고량</TableHead>
|
||||
<TableHead className="text-center">실제 재고량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
|
||||
재고 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-auto flex-1">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 bg-gray-50 z-10">
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center font-medium w-[15%]">품목코드</TableHead>
|
||||
<TableHead className="text-center font-medium w-[25%]">품목명</TableHead>
|
||||
<TableHead className="text-center font-medium w-[15%]">규격</TableHead>
|
||||
<TableHead className="text-center font-medium w-[8%]">단위</TableHead>
|
||||
<TableHead className="text-center font-medium w-[12%]">계산 재고량</TableHead>
|
||||
<TableHead className="text-center font-medium w-[15%]">실제 재고량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell className="text-center max-w-[200px] truncate" title={item.itemName}>
|
||||
{item.itemName}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.specification || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{item.calculatedQty}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.newActualQty}
|
||||
onChange={(e) => handleQtyChange(item.id, e.target.value)}
|
||||
className="w-24 text-center mx-auto"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'저장'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user