feat: [material] 재고현황 상세 개선 + 입고관리 정리 + BOM 트리뷰어 추가

- 재고현황 상세 페이지 대폭 개선
- 입고관리 상세/목록 코드 정리
- BomTreeViewer 컴포넌트 신규
- 품목 상세 수정
This commit is contained in:
유병철
2026-03-18 11:15:19 +09:00
parent 87287552fd
commit 0bcc7c5417
9 changed files with 654 additions and 333 deletions

View File

@@ -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">

View File

@@ -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에서 아이콘으로 제공하므로 중복 제거

View File

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

View File

@@ -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,

View File

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

View File

@@ -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({