2026-01-28 21:15:25 +09:00
|
|
|
'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;
|
|
|
|
|
actualQty: number;
|
|
|
|
|
newActualQty: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function StockAuditModal({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
stocks,
|
|
|
|
|
onComplete,
|
|
|
|
|
}: StockAuditModalProps) {
|
|
|
|
|
const [auditItems, setAuditItems] = useState<AuditItem[]>([]);
|
2026-03-11 10:27:10 +09:00
|
|
|
const [isLoading, _setIsLoading] = useState(false);
|
2026-01-28 21:15:25 +09:00
|
|
|
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,
|
|
|
|
|
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>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
<TableRow>
|
2026-01-29 22:56:01 +09:00
|
|
|
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
2026-01-28 21:15:25 +09:00
|
|
|
재고 데이터가 없습니다.
|
|
|
|
|
</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>
|
2026-01-29 22:56:01 +09:00
|
|
|
<TableHead className="text-center font-medium w-[30%]">품목명</TableHead>
|
|
|
|
|
<TableHead className="text-center font-medium w-[20%]">규격</TableHead>
|
|
|
|
|
<TableHead className="text-center font-medium w-[10%]">단위</TableHead>
|
2026-01-28 21:15:25 +09:00
|
|
|
<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">
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|