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:
유병철
2026-01-28 21:15:25 +09:00
parent 79b39a3ef6
commit 1f640622e0
23 changed files with 4683 additions and 1946 deletions

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