feat: [material] 재고현황 상세 개선 + 입고관리 정리 + BOM 트리뷰어 추가
- 재고현황 상세 페이지 대폭 개선 - 입고관리 상세/목록 코드 정리 - BomTreeViewer 컴포넌트 신규 - 품목 상세 수정
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
UNIT_OPTIONS,
|
||||
} from './constants';
|
||||
import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions';
|
||||
import { BomTreeViewer } from '@/components/items/BomTreeViewer';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
itemId?: string;
|
||||
@@ -460,6 +461,9 @@ export default function ItemDetailClient({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 트리 */}
|
||||
{itemId && <BomTreeViewer itemId={itemId} itemType={formData.itemType} />}
|
||||
</div>
|
||||
), [
|
||||
formData,
|
||||
@@ -469,6 +473,7 @@ export default function ItemDetailClient({
|
||||
handleAddOrderItem,
|
||||
handleRemoveOrderItem,
|
||||
handleOrderItemChange,
|
||||
itemId,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
304
src/components/items/BomTreeViewer.tsx
Normal file
304
src/components/items/BomTreeViewer.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* BOM Tree 시각화 컴포넌트
|
||||
*
|
||||
* API: GET /api/proxy/items/{id}/bom/tree
|
||||
* 재귀적 트리 렌더링 + 유형별 뱃지 색상 + 펼침/접힘
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronDown, ChevronRight, ChevronsUpDown, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
// BOM 트리 노드 타입
|
||||
interface BomTreeNode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
item_type: string;
|
||||
specification?: string;
|
||||
unit?: string;
|
||||
quantity: number;
|
||||
depth: number;
|
||||
children: BomTreeNode[];
|
||||
}
|
||||
|
||||
// 유형별 뱃지 스타일
|
||||
const ITEM_TYPE_COLORS: Record<string, string> = {
|
||||
FG: 'bg-blue-100 text-blue-800',
|
||||
PT: 'bg-green-100 text-green-800',
|
||||
RM: 'bg-orange-100 text-orange-800',
|
||||
SM: 'bg-purple-100 text-purple-800',
|
||||
CS: 'bg-gray-100 text-gray-800',
|
||||
BN: 'bg-pink-100 text-pink-800',
|
||||
SF: 'bg-cyan-100 text-cyan-800',
|
||||
};
|
||||
|
||||
const ITEM_TYPE_LABELS: Record<string, string> = {
|
||||
FG: '완제품',
|
||||
PT: '부품',
|
||||
RM: '원자재',
|
||||
SM: '부자재',
|
||||
CS: '소모품',
|
||||
BN: '절곡품',
|
||||
SF: '반제품',
|
||||
};
|
||||
|
||||
// 모든 노드의 ID를 재귀적으로 수집
|
||||
function collectNodeIds(nodes: BomTreeNode[]): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
function walk(node: BomTreeNode) {
|
||||
ids.add(node.id);
|
||||
node.children.forEach(walk);
|
||||
}
|
||||
nodes.forEach(walk);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// 개별 트리 노드 컴포넌트
|
||||
function BomTreeNodeItem({
|
||||
node,
|
||||
level = 0,
|
||||
expandedNodes,
|
||||
onToggle,
|
||||
}: {
|
||||
node: BomTreeNode;
|
||||
level?: number;
|
||||
expandedNodes: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isOpen = expandedNodes.has(node.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 px-2 hover:bg-gray-50 rounded cursor-default"
|
||||
style={{ paddingLeft: level * 24 + 8 }}
|
||||
>
|
||||
{/* 펼침/접힘 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggle(node.id)}
|
||||
className="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-800 shrink-0"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* 유형 뱃지 */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 font-medium shrink-0 ${ITEM_TYPE_COLORS[node.item_type] || 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{node.item_type}
|
||||
</Badge>
|
||||
|
||||
{/* 코드 */}
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-600 shrink-0">
|
||||
{node.code}
|
||||
</code>
|
||||
|
||||
{/* 품목명 */}
|
||||
<span className="text-sm truncate">{node.name}</span>
|
||||
|
||||
{/* 규격 */}
|
||||
{node.specification && (
|
||||
<span className="text-xs text-muted-foreground truncate hidden md:inline">
|
||||
({node.specification})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 수량 */}
|
||||
<span className="text-sm text-blue-600 font-medium ml-auto shrink-0">
|
||||
×{node.quantity}
|
||||
</span>
|
||||
|
||||
{/* 단위 */}
|
||||
{node.unit && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{node.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 */}
|
||||
{isOpen && hasChildren && node.children.map((child) => (
|
||||
<BomTreeNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BomTreeViewerProps {
|
||||
itemId: string;
|
||||
itemType: string;
|
||||
}
|
||||
|
||||
export function BomTreeViewer({ itemId, itemType }: BomTreeViewerProps) {
|
||||
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
|
||||
// 트리 데이터 로드
|
||||
const loadTree = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/proxy/items/${itemId}/bom/tree`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const data = Array.isArray(result.data) ? result.data : [result.data];
|
||||
setTreeData(data);
|
||||
// 기본: 2단계까지 펼침
|
||||
const allIds = collectNodeIds(data);
|
||||
setExpandedNodes(allIds);
|
||||
} else {
|
||||
setTreeData([]);
|
||||
}
|
||||
} catch {
|
||||
setError('BOM 트리를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
// FG/PT (또는 한글: 제품/부품)만 BOM 트리 로드
|
||||
const isBomTarget = ['FG', 'PT', '제품', '부품'].includes(itemType);
|
||||
if (isBomTarget) {
|
||||
loadTree();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadTree, itemType]);
|
||||
|
||||
// BOM 대상이 아니면 렌더링하지 않음
|
||||
if (!['FG', 'PT', '제품', '부품'].includes(itemType)) return null;
|
||||
|
||||
// 로딩
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
BOM 트리를 불러오는 중...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
{error}
|
||||
<Button variant="link" size="sm" onClick={loadTree} className="ml-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (treeData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 트리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 BOM 정보가 없습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleNode = (id: number) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => setExpandedNodes(collectNodeIds(treeData));
|
||||
const collapseAll = () => setExpandedNodes(new Set());
|
||||
|
||||
// 총 노드 수 계산
|
||||
const totalCount = collectNodeIds(treeData).size;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 트리
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
총 {totalCount}개 품목
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={expandAll} className="h-7 text-xs">
|
||||
<ChevronsUpDown className="w-3 h-3 mr-1" />
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={collapseAll} className="h-7 text-xs">
|
||||
전체 접기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 범례 */}
|
||||
<div className="flex flex-wrap gap-2 mb-3 pb-3 border-b">
|
||||
{Object.entries(ITEM_TYPE_LABELS).map(([type, label]) => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 ${ITEM_TYPE_COLORS[type] || ''}`}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="border rounded-md">
|
||||
{treeData.map((node) => (
|
||||
<BomTreeNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggle={toggleNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { BomTreeViewer } from './BomTreeViewer';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
@@ -554,60 +555,9 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* BOM 정보 - 절곡 부품은 제외 */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
부품 구성 (BOM)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
총 {item.bom.length}개 품목
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>번호</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.bom.map((line, index) => (
|
||||
<TableRow key={line.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{line.childItemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{line.childItemName}
|
||||
{line.isBending && (
|
||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700">
|
||||
절곡품
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{line.quantity}</TableCell>
|
||||
<TableCell>{line.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* BOM 트리 - FG/PT만 표시 (절곡 부품 제외) */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && (
|
||||
<BomTreeViewer itemId={item.id} itemType={item.itemType} />
|
||||
)}
|
||||
|
||||
{/* 하단 액션 버튼 (sticky) */}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[700px] max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-[95vw] w-[95vw] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold">재고 조정</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -162,7 +162,7 @@ export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props)
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-md">
|
||||
<div className="flex-1 overflow-auto border rounded-md [&::-webkit-scrollbar]:h-[10px] [&::-webkit-scrollbar-thumb]:bg-gray-400 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
RECEIVING_STATUS_OPTIONS,
|
||||
type ReceivingDetail as ReceivingDetailType,
|
||||
type ReceivingStatus,
|
||||
type InventoryAdjustmentRecord,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
@@ -148,9 +147,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string };
|
||||
}>>([]);
|
||||
|
||||
// 재고 조정 이력 상태
|
||||
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
|
||||
|
||||
// Dev 모드 폼 자동 채우기
|
||||
useDevFill(
|
||||
'receiving',
|
||||
@@ -188,10 +184,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
// 재고 조정 이력 설정
|
||||
if (result.data.inventoryAdjustments) {
|
||||
setAdjustments(result.data.inventoryAdjustments);
|
||||
}
|
||||
// 기존 성적서 파일 정보 설정
|
||||
if (result.data.certificateFileId) {
|
||||
setExistingCertFile({
|
||||
@@ -326,30 +318,6 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 재고 조정 행 추가
|
||||
const handleAddAdjustment = () => {
|
||||
const newRecord: InventoryAdjustmentRecord = {
|
||||
id: `adj-${Date.now()}`,
|
||||
adjustmentDate: getTodayString(),
|
||||
quantity: 0,
|
||||
inspector: getLoggedInUserName() || '홍길동',
|
||||
};
|
||||
setAdjustments((prev) => [...prev, newRecord]);
|
||||
};
|
||||
|
||||
// 재고 조정 행 삭제
|
||||
const handleRemoveAdjustment = (adjId: string) => {
|
||||
setAdjustments((prev) => prev.filter((a) => a.id !== adjId));
|
||||
};
|
||||
|
||||
// 재고 조정 수량 변경
|
||||
const handleAdjustmentQtyChange = (adjId: string, value: string) => {
|
||||
const numValue = value === '' || value === '-' ? 0 : Number(value);
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a))
|
||||
);
|
||||
};
|
||||
|
||||
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
|
||||
const handleCancel = () => {
|
||||
if (isNewMode) {
|
||||
@@ -496,47 +464,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
|
||||
<TableCell className="text-center">{adj.quantity}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail, adjustments, inspectionAttachments, existingCertFile]);
|
||||
}, [detail, inspectionAttachments, existingCertFile]);
|
||||
|
||||
// ===== 등록/수정 폼 콘텐츠 =====
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -779,88 +709,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 조정 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddAdjustment}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[120px]">검수자</TableHead>
|
||||
<TableHead className="text-center w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DatePicker
|
||||
value={adj.adjustmentDate}
|
||||
onChange={(date) => {
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === adj.id ? { ...a, adjustmentDate: date } : a
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
value={adj.quantity || ''}
|
||||
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
|
||||
className="h-8 text-sm text-center w-[100px] mx-auto"
|
||||
placeholder="0"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveAdjustment(adj.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [formData, adjustments, uploadedFile, existingCertFile]);
|
||||
}, [formData, uploadedFile, existingCertFile]);
|
||||
|
||||
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
|
||||
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
|
||||
import { getReceivings, getReceivingStats } from './actions';
|
||||
import {
|
||||
RECEIVING_STATUS_LABELS,
|
||||
@@ -84,9 +83,6 @@ export function ReceivingList() {
|
||||
const [stats, setStats] = useState<ReceivingStats | null>(null);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// ===== 재고 조정 팝업 상태 =====
|
||||
const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false);
|
||||
|
||||
// ===== 날짜 범위 상태 (최근 30일) =====
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -290,17 +286,9 @@ export function ReceivingList() {
|
||||
// 통계 카드
|
||||
stats: statCards,
|
||||
|
||||
// 헤더 액션 (재고 조정 + 입고 등록 버튼)
|
||||
// 헤더 액션 (입고 등록 버튼)
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentOpen(true)}
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-1" />
|
||||
재고 조정
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -451,17 +439,9 @@ export function ReceivingList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 팝업 */}
|
||||
<InventoryAdjustmentDialog
|
||||
open={isAdjustmentOpen}
|
||||
onOpenChange={setIsAdjustmentOpen}
|
||||
/>
|
||||
</>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
onFilterChange={(newFilters) => setFilterValues(newFilters)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ export async function searchItems(query?: string): Promise<{
|
||||
|
||||
interface ItemApiData { data: Array<Record<string, string>> }
|
||||
const result = await executeServerAction<ItemApiData, ItemOption[]>({
|
||||
url: buildApiUrl('/api/v1/items', { search: query, per_page: 50 }),
|
||||
url: buildApiUrl('/api/v1/items', { search: query, per_page: 200, item_type: 'RM,SM,CS' }),
|
||||
transform: (d) => (d.data || []).map((item) => ({
|
||||
value: item.item_code,
|
||||
label: item.item_code,
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
* 기획서 기준:
|
||||
* - 기본 정보: 자재번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량 (읽기 전용)
|
||||
* - 수정 가능: 안전재고, 상태 (사용/미사용)
|
||||
* - 재고 조정: 이력 테이블 + 추가 기능
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,10 +24,31 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { stockStatusConfig } from './stockStatusConfig';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import { getStockById, updateStock } from './actions';
|
||||
import {
|
||||
getStockById,
|
||||
updateStock,
|
||||
getStockAdjustments,
|
||||
createStockAdjustment,
|
||||
} from './actions';
|
||||
import type { StockAdjustmentRecord } from './actions';
|
||||
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
|
||||
import type { ItemType } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -71,6 +95,12 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
// 저장 중 상태
|
||||
const [, setIsSaving] = useState(false);
|
||||
|
||||
// 재고 조정 상태
|
||||
const [adjustments, setAdjustments] = useState<StockAdjustmentRecord[]>([]);
|
||||
const [isAdjustmentDialogOpen, setIsAdjustmentDialogOpen] = useState(false);
|
||||
const [adjustmentForm, setAdjustmentForm] = useState({ quantity: '', remark: '' });
|
||||
const [isAdjustmentSaving, setIsAdjustmentSaving] = useState(false);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -112,10 +142,24 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 재고 조정 이력 로드
|
||||
const loadAdjustments = useCallback(async () => {
|
||||
try {
|
||||
const result = await getStockAdjustments(id);
|
||||
if (result.success && result.data) {
|
||||
setAdjustments(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[StockStatusDetail] loadAdjustments error:', err);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadAdjustments();
|
||||
}, [loadData, loadAdjustments]);
|
||||
|
||||
// 폼 값 변경 핸들러
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number) => {
|
||||
@@ -160,6 +204,39 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 재고 조정 등록
|
||||
const handleAdjustmentSave = async () => {
|
||||
const qty = Number(adjustmentForm.quantity);
|
||||
if (!qty || qty === 0) {
|
||||
toast.error('증감 수량을 입력해주세요. (0 제외)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdjustmentSaving(true);
|
||||
try {
|
||||
const result = await createStockAdjustment(id, {
|
||||
quantity: qty,
|
||||
remark: adjustmentForm.remark || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('재고 조정이 등록되었습니다.');
|
||||
setIsAdjustmentDialogOpen(false);
|
||||
setAdjustmentForm({ quantity: '', remark: '' });
|
||||
// 이력 + 기본 정보 새로고침
|
||||
loadAdjustments();
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '재고 조정 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
toast.error('재고 조정 등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsAdjustmentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 읽기 전용 필드 렌더링 (수정 모드에서 구분용)
|
||||
const renderReadOnlyField = (label: string, value: string | number, isEditMode = false) => (
|
||||
<div>
|
||||
@@ -178,114 +255,178 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 재고 조정 섹션 (view/edit 공통)
|
||||
const renderAdjustmentSection = () => (
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">재고 조정</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdjustmentDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center w-[50px]">No</TableHead>
|
||||
<TableHead className="text-center min-w-[140px]">조정일시</TableHead>
|
||||
<TableHead className="text-center min-w-[100px]">증감 수량</TableHead>
|
||||
<TableHead className="text-center min-w-[100px]">조정 후 재고</TableHead>
|
||||
<TableHead className="min-w-[150px]">사유</TableHead>
|
||||
<TableHead className="text-center min-w-[80px]">검수자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.length > 0 ? (
|
||||
adjustments.map((adj, idx) => (
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center text-sm">{adj.adjusted_at}</TableCell>
|
||||
<TableCell className={`text-center font-medium ${adj.quantity > 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{adj.quantity > 0 ? `+${adj.quantity}` : adj.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{adj.balance_qty}</TableCell>
|
||||
<TableCell className="text-sm">{adj.remark || '-'}</TableCell>
|
||||
<TableCell className="text-center">{adj.inspector}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">
|
||||
재고 조정 이력이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// 상세 보기 모드 렌더링
|
||||
const renderViewContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
|
||||
{renderReadOnlyField('품목명', detail.itemName)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification)}
|
||||
{renderReadOnlyField('단위', detail.unit)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty)}
|
||||
{renderReadOnlyField('안전재고', detail.safetyStock)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: 재공품, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
{/* Row 3: 재공품, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus])}
|
||||
{renderReadOnlyField('상태', USE_STATUS_LABELS[detail.useStatus])}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderAdjustmentSection()}
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [detail, adjustments]);
|
||||
|
||||
// 수정 모드 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: 자재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('자재번호', detail.stockNumber, true)}
|
||||
{renderReadOnlyField('품목코드', detail.itemCode, true)}
|
||||
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
|
||||
{renderReadOnlyField('품목명', detail.itemName, true)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{renderReadOnlyField('규격', detail.specification, true)}
|
||||
{renderReadOnlyField('단위', detail.unit, true)}
|
||||
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
|
||||
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
|
||||
안전재고
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
{/* 안전재고 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="safetyStock" className="text-sm text-muted-foreground">
|
||||
안전재고
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => handleInputChange('safetyStock', e.target.value)}
|
||||
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 재공품 (읽기 전용) */}
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
</Label>
|
||||
<Select
|
||||
key={`useStatus-${formData.useStatus}`}
|
||||
value={formData.useStatus}
|
||||
onValueChange={(value) => handleInputChange('useStatus', value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">사용</SelectItem>
|
||||
<SelectItem value="inactive">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Row 3: 재공품 (읽기 전용), 상태 (수정 가능) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* 재공품 (읽기 전용) */}
|
||||
{renderReadOnlyField('재공품', USE_STATUS_LABELS[detail.wipStatus], true)}
|
||||
{/* 상태 (수정 가능) */}
|
||||
<div>
|
||||
<Label htmlFor="useStatus" className="text-sm text-muted-foreground">
|
||||
상태
|
||||
</Label>
|
||||
<Select
|
||||
key={`useStatus-${formData.useStatus}`}
|
||||
value={formData.useStatus}
|
||||
onValueChange={(value) => handleInputChange('useStatus', value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">사용</SelectItem>
|
||||
<SelectItem value="inactive">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{renderAdjustmentSection()}
|
||||
</div>
|
||||
);
|
||||
}, [detail, formData]);
|
||||
}, [detail, formData, adjustments]);
|
||||
|
||||
// 에러 상태 표시
|
||||
if (!isLoading && (error || !detail)) {
|
||||
@@ -301,15 +442,70 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={stockStatusConfig}
|
||||
mode={initialMode as 'view' | 'edit'}
|
||||
initialData={(detail || undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSubmit={async () => { await handleSave(); return { success: true }; }}
|
||||
/>
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={stockStatusConfig}
|
||||
mode={initialMode as 'view' | 'edit'}
|
||||
initialData={(detail || undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
renderView={() => renderViewContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
onSubmit={async () => { await handleSave(); return { success: true }; }}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 등록 다이얼로그 */}
|
||||
<Dialog open={isAdjustmentDialogOpen} onOpenChange={setIsAdjustmentDialogOpen}>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재고 조정</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label className="text-sm">
|
||||
증감 수량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={adjustmentForm.quantity}
|
||||
onChange={(e) => setAdjustmentForm((prev) => ({ ...prev, quantity: e.target.value }))}
|
||||
placeholder="양수: 증가, 음수: 감소"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">사유</Label>
|
||||
<Textarea
|
||||
value={adjustmentForm.remark}
|
||||
onChange={(e) => setAdjustmentForm((prev) => ({ ...prev, remark: e.target.value }))}
|
||||
placeholder="조정 사유를 입력하세요 (선택)"
|
||||
className="mt-1.5"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAdjustmentDialogOpen(false);
|
||||
setAdjustmentForm({ quantity: '', remark: '' });
|
||||
}}
|
||||
disabled={isAdjustmentSaving}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdjustmentSave}
|
||||
disabled={isAdjustmentSaving}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
{isAdjustmentSaving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +292,41 @@ export async function updateStock(
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 조정 이력 조회 =====
|
||||
export interface StockAdjustmentRecord {
|
||||
id: number;
|
||||
adjusted_at: string;
|
||||
quantity: number;
|
||||
balance_qty: number;
|
||||
remark: string | null;
|
||||
inspector: string;
|
||||
}
|
||||
|
||||
export async function getStockAdjustments(stockId: string): Promise<{ success: boolean; data?: StockAdjustmentRecord[]; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction<{ data: StockAdjustmentRecord[] }, StockAdjustmentRecord[]>({
|
||||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||||
transform: (d) => d.data || [],
|
||||
errorMessage: '재고 조정 이력 조회에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 조정 등록 =====
|
||||
export async function createStockAdjustment(
|
||||
stockId: string,
|
||||
data: { quantity: number; remark?: string }
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/stocks/${stockId}/adjustments`),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '재고 조정 등록에 실패했습니다.',
|
||||
});
|
||||
if (result.__authError) return { success: false, __authError: true };
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 재고 실사 (일괄 업데이트) =====
|
||||
export async function updateStockAudit(updates: { id: string; actualQty: number }[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
const result = await executeServerAction({
|
||||
|
||||
Reference in New Issue
Block a user