feat: Enhance outbound and receiving functionalities
- Updated inventory history insertion logic in both outbound and receiving controllers to use consistent field names and types. - Added a new endpoint for retrieving warehouse locations, improving the ability to manage inventory locations. - Enhanced the outbound page to include location selection based on the selected warehouse, improving user experience and data accuracy. - Implemented validation for warehouse code duplication during new warehouse registration in the warehouse management page. These changes aim to streamline inventory management processes and enhance the overall functionality of the logistics module.
This commit is contained in:
@@ -78,15 +78,17 @@ export default function EquipmentInfoPage() {
|
||||
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||
const [infoSaving, setInfoSaving] = useState(false);
|
||||
|
||||
// 점검항목 추가 모달
|
||||
// 점검항목 추가/수정 모달
|
||||
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
|
||||
// 소모품 추가 모달
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
|
||||
// 점검항목 복사
|
||||
@@ -255,16 +257,44 @@ export default function EquipmentInfoPage() {
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
if (!inspectionForm.inspection_cycle) { toast.error("점검주기는 필수입니다."); return; }
|
||||
if (!inspectionForm.inspection_method) { toast.error("점검방법은 필수입니다."); return; }
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (isNumeric && !inspectionForm.unit) { toast.error("숫자 점검방법은 측정단위가 필수입니다."); return; }
|
||||
// 기준값/오차범위 → 하한치/상한치 자동 계산
|
||||
const saveData = { ...inspectionForm };
|
||||
if (isNumeric && saveData.standard_value) {
|
||||
const std = Number(saveData.standard_value) || 0;
|
||||
const tol = Number(saveData.tolerance) || 0;
|
||||
saveData.lower_limit = String(std - tol);
|
||||
saveData.upper_limit = String(std + tol);
|
||||
}
|
||||
if (!isNumeric) {
|
||||
saveData.unit = "";
|
||||
saveData.standard_value = "";
|
||||
saveData.tolerance = "";
|
||||
saveData.lower_limit = "";
|
||||
saveData.upper_limit = "";
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (inspectionContinuous) {
|
||||
setInspectionForm({});
|
||||
} else {
|
||||
if (inspectionEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: saveData.id }, updatedData: { ...saveData, equipment_code: selectedEquip?.equipment_code },
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setInspectionModalOpen(false);
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (inspectionContinuous) {
|
||||
setInspectionForm({});
|
||||
} else {
|
||||
setInspectionModalOpen(false);
|
||||
}
|
||||
}
|
||||
refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
@@ -308,14 +338,22 @@ export default function EquipmentInfoPage() {
|
||||
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||
...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (consumableContinuous) {
|
||||
setConsumableForm({});
|
||||
} else {
|
||||
if (consumableEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${CONSUMABLE_TABLE}/edit`, {
|
||||
originalData: { id: consumableForm.id }, updatedData: { ...consumableForm, equipment_code: selectedEquip?.equipment_code },
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setConsumableModalOpen(false);
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다.");
|
||||
if (consumableContinuous) {
|
||||
setConsumableForm({});
|
||||
} else {
|
||||
setConsumableModalOpen(false);
|
||||
}
|
||||
}
|
||||
refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
@@ -497,7 +535,7 @@ export default function EquipmentInfoPage() {
|
||||
<div className="flex gap-1.5">
|
||||
{rightTab === "inspection" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionEditMode(false); setInspectionModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||
@@ -506,7 +544,7 @@ export default function EquipmentInfoPage() {
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); setConsumableEditMode(false); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
)}
|
||||
@@ -598,7 +636,13 @@ export default function EquipmentInfoPage() {
|
||||
</thead>
|
||||
<TableBody>
|
||||
{inspections.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
||||
const std = item.standard_value || "";
|
||||
const tol = item.tolerance || "";
|
||||
setInspectionForm({ ...item, standard_value: std, tolerance: tol });
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
@@ -636,7 +680,12 @@ export default function EquipmentInfoPage() {
|
||||
</thead>
|
||||
<TableBody>
|
||||
{consumables.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-primary/5" onDoubleClick={() => {
|
||||
setConsumableForm({ ...item });
|
||||
setConsumableEditMode(true);
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
@@ -696,24 +745,62 @@ export default function EquipmentInfoPage() {
|
||||
{/* 점검항목 추가 모달 */}
|
||||
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>점검항목 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{inspectionEditMode ? "점검항목 수정" : "점검항목 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 {inspectionEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검항목 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검주기</Label>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검항목명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="예: 온도점검, 진동점검" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검주기 <span className="text-destructive">*</span></Label>
|
||||
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검방법</Label>
|
||||
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">하한치</Label>
|
||||
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">상한치</Label>
|
||||
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검방법 <span className="text-destructive">*</span></Label>
|
||||
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => {
|
||||
const label = resolve("inspection_method", v);
|
||||
const isNum = label === "숫자" || v === "숫자";
|
||||
if (!isNum) {
|
||||
setInspectionForm((p) => ({ ...p, inspection_method: v, unit: "", standard_value: "", tolerance: "", lower_limit: "", upper_limit: "" }));
|
||||
} else {
|
||||
setInspectionForm((p) => ({ ...p, inspection_method: v }));
|
||||
}
|
||||
}, "점검방법")}</div>
|
||||
{(() => {
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (!isNumeric) return null;
|
||||
return (
|
||||
<div className="space-y-1.5"><Label className="text-sm">측정 단위 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, mm, V" className="h-9" /></div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const methodLabel = resolve("inspection_method", inspectionForm.inspection_method);
|
||||
const isNumeric = methodLabel === "숫자" || inspectionForm.inspection_method === "숫자";
|
||||
if (!isNumeric) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">기준값</Label>
|
||||
<Input value={inspectionForm.standard_value || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, standard_value: e.target.value }))} placeholder="기준값 입력" className="h-9" type="number" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">±오차범위</Label>
|
||||
<Input value={inspectionForm.tolerance || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, tolerance: e.target.value }))} placeholder="허용 오차범위" className="h-9" type="number" /></div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={inspectionForm.inspection_content || ""}
|
||||
onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))}
|
||||
placeholder="점검 항목 및 내용 입력"
|
||||
/></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">체크리스트 (선택사항)</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm min-h-[70px] resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={inspectionForm.checklist || ""}
|
||||
onChange={(e) => setInspectionForm((p) => ({ ...p, checklist: e.target.value }))}
|
||||
placeholder="점검 체크리스트 입력 (줄바꿈으로 구분)"
|
||||
/></div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -731,7 +818,7 @@ export default function EquipmentInfoPage() {
|
||||
{/* 소모품 추가 모달 */}
|
||||
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>소모품 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{consumableEditMode ? "소모품 수정" : "소모품 추가"}</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 {consumableEditMode ? "수정" : "추가"}합니다.</DialogDescription></DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||
{consumableItemOptions.length > 0 ? (
|
||||
|
||||
@@ -65,10 +65,10 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
const STOCK_TABLE = "inventory_stock";
|
||||
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "warehouse_name", label: "창고" },
|
||||
{ key: "location_name", label: "위치" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
||||
{ key: "unit", label: "단위" },
|
||||
@@ -101,6 +101,7 @@ const getHistoryTypeVariant = (
|
||||
return "secondary";
|
||||
case "조정":
|
||||
return "outline";
|
||||
case "입고취소":
|
||||
case "이동":
|
||||
return "destructive";
|
||||
default:
|
||||
@@ -170,27 +171,36 @@ export default function InventoryStatusPage() {
|
||||
setStockLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
const [stockRes, itemRes, whRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "item_number", order: "asc" },
|
||||
}
|
||||
);
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
sort: { columnName: "item_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
]);
|
||||
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
status: resolve("status", r.status),
|
||||
unit: resolve("unit", r.unit),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
}));
|
||||
const data = raw.map((r: any) => {
|
||||
const itemInfo = itemMap.get(r.item_code) as any;
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
unit: itemInfo?.unit || resolve("unit", r.unit),
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
@@ -208,7 +218,7 @@ export default function InventoryStatusPage() {
|
||||
|
||||
// 이력 조회
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!selectedStock?.item_number) {
|
||||
if (!selectedStock?.item_code) {
|
||||
setHistoryItems([]);
|
||||
return;
|
||||
}
|
||||
@@ -216,9 +226,9 @@ export default function InventoryStatusPage() {
|
||||
try {
|
||||
const historyFilters: any[] = [
|
||||
{
|
||||
columnName: "item_number",
|
||||
columnName: "item_code",
|
||||
operator: "equals",
|
||||
value: selectedStock.item_number,
|
||||
value: selectedStock.item_code,
|
||||
},
|
||||
];
|
||||
if (selectedStock.warehouse_code) {
|
||||
@@ -235,7 +245,7 @@ export default function InventoryStatusPage() {
|
||||
size: 500,
|
||||
dataFilter: { enabled: true, filters: historyFilters },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "history_date", order: "desc" },
|
||||
sort: { columnName: "transaction_date", order: "desc" },
|
||||
}
|
||||
);
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
@@ -245,7 +255,7 @@ export default function InventoryStatusPage() {
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [selectedStock?.item_number, selectedStock?.warehouse_code]);
|
||||
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
@@ -272,15 +282,14 @@ export default function InventoryStatusPage() {
|
||||
`/table-management/tables/${HISTORY_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_number: selectedStock.item_number,
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
history_type: "조정",
|
||||
history_date: new Date().toISOString().slice(0, 10),
|
||||
change_qty: changeQty,
|
||||
after_qty: afterQty,
|
||||
reason: adjustForm.reason.trim(),
|
||||
created_by: user?.userId || "",
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date().toISOString(),
|
||||
quantity: String(changeQty),
|
||||
balance_qty: String(afterQty),
|
||||
remark: adjustForm.reason.trim(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -311,10 +320,10 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
exportToExcel(
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_number,
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
창고: r.warehouse_name,
|
||||
위치: r.location_name,
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
안전재고: r.safety_qty,
|
||||
단위: r.unit,
|
||||
@@ -473,13 +482,13 @@ export default function InventoryStatusPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold">
|
||||
{selectedStock.item_name || selectedStock.item_number}
|
||||
{selectedStock.item_name || selectedStock.item_code}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full text-[11px] font-mono"
|
||||
>
|
||||
{selectedStock.item_number}
|
||||
{selectedStock.item_code}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -601,37 +610,37 @@ export default function InventoryStatusPage() {
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{h.history_date}
|
||||
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getHistoryTypeVariant(h.history_type)}
|
||||
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{h.history_type}
|
||||
{h.transaction_type || h.history_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right font-mono",
|
||||
Number(h.change_qty) > 0
|
||||
Number(h.quantity ?? h.change_qty) > 0
|
||||
? "text-primary"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{Number(h.change_qty) > 0 ? "+" : ""}
|
||||
{Number(h.change_qty || 0).toLocaleString()}
|
||||
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
|
||||
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{Number(h.after_qty || 0).toLocaleString()}
|
||||
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono truncate max-w-[120px]">
|
||||
{h.reference_no}
|
||||
{h.reference_number || h.reference_no || ""}
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[150px]">
|
||||
{h.reason}
|
||||
{h.remark || h.reason || ""}
|
||||
</TableCell>
|
||||
<TableCell>{h.created_by}</TableCell>
|
||||
<TableCell>{h.writer || h.created_by || ""}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -661,7 +670,7 @@ export default function InventoryStatusPage() {
|
||||
<DialogTitle>재고 조정</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedStock
|
||||
? `${selectedStock.item_name || selectedStock.item_number} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
|
||||
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
deleteOutbound,
|
||||
generateOutboundNumber,
|
||||
getOutboundWarehouses,
|
||||
getOutboundLocations,
|
||||
getShipmentInstructionSources,
|
||||
getPurchaseOrderSources,
|
||||
getItemSources,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
type ShipmentInstructionSource,
|
||||
type PurchaseOrderSource,
|
||||
type ItemSource,
|
||||
type LocationOption,
|
||||
type WarehouseOption,
|
||||
} from "@/lib/api/outbound";
|
||||
|
||||
@@ -159,6 +161,7 @@ export default function OutboundPage() {
|
||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSource[]>([]);
|
||||
const [items, setItems] = useState<ItemSource[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [locations, setLocations] = useState<LocationOption[]>([]);
|
||||
|
||||
// 소스 데이터 페이징 (클라이언트 사이드)
|
||||
const [sourcePage, setSourcePage] = useState(1);
|
||||
@@ -315,7 +318,9 @@ export default function OutboundPage() {
|
||||
source_id: g.source_id || "",
|
||||
}))
|
||||
);
|
||||
setSourceKeyword("");
|
||||
setIsModalOpen(true);
|
||||
loadSourceData(first.outbound_type || "판매출고");
|
||||
};
|
||||
|
||||
const searchSourceData = useCallback(async () => {
|
||||
@@ -491,9 +496,17 @@ export default function OutboundPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editMode) {
|
||||
// 수정 모드: 각 아이템별 update
|
||||
await Promise.all(
|
||||
selectedItems.map((item) =>
|
||||
const currentKeys = new Set(selectedItems.map((i) => i.key));
|
||||
// 삭제: editItemIds에 있지만 selectedItems에 없는 것
|
||||
const toDelete = editItemIds.filter((id) => !currentKeys.has(id));
|
||||
// 수정: editItemIds에도 있고 selectedItems에도 있는 것
|
||||
const toUpdate = selectedItems.filter((i) => editItemIds.includes(i.key));
|
||||
// 추가: editItemIds에 없는 새 아이템
|
||||
const toCreate = selectedItems.filter((i) => !editItemIds.includes(i.key));
|
||||
|
||||
await Promise.all([
|
||||
...toDelete.map((id) => deleteOutbound(id)),
|
||||
...toUpdate.map((item) =>
|
||||
updateOutbound(item.key, {
|
||||
outbound_date: modalOutboundDate,
|
||||
outbound_qty: item.outbound_qty,
|
||||
@@ -504,8 +517,35 @@ export default function OutboundPage() {
|
||||
manager_id: modalManager || undefined,
|
||||
memo: modalMemo || undefined,
|
||||
} as any)
|
||||
)
|
||||
);
|
||||
),
|
||||
...(toCreate.length > 0
|
||||
? [createOutbound({
|
||||
outbound_number: modalOutboundNo,
|
||||
outbound_date: modalOutboundDate,
|
||||
warehouse_code: modalWarehouse || undefined,
|
||||
location_code: modalLocation || undefined,
|
||||
manager_id: modalManager || undefined,
|
||||
memo: modalMemo || undefined,
|
||||
items: toCreate.map((item) => ({
|
||||
outbound_type: item.outbound_type,
|
||||
reference_number: item.reference_number,
|
||||
customer_code: item.customer_code,
|
||||
customer_name: item.customer_name,
|
||||
item_code: item.item_number,
|
||||
item_name: item.item_name,
|
||||
spec: item.spec,
|
||||
material: item.material,
|
||||
unit: item.unit,
|
||||
outbound_qty: item.outbound_qty,
|
||||
unit_price: item.unit_price,
|
||||
total_amount: item.total_amount,
|
||||
source_type: item.source_type,
|
||||
source_id: item.source_id,
|
||||
outbound_status: "출고완료",
|
||||
})),
|
||||
})]
|
||||
: []),
|
||||
]);
|
||||
toast.success("출고 정보를 수정했어요");
|
||||
setIsModalOpen(false);
|
||||
fetchList();
|
||||
@@ -746,8 +786,8 @@ export default function OutboundPage() {
|
||||
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 출고유형 선택 (수정 모드에서는 숨김) */}
|
||||
{!editMode && <div className="flex shrink-0 items-center gap-4 border-b bg-muted/30 px-6 py-3">
|
||||
{/* 출고유형 선택 */}
|
||||
<div className="flex shrink-0 items-center gap-4 border-b bg-muted/30 px-6 py-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고유형</span>
|
||||
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
||||
<SelectTrigger className="h-9 w-[160px] text-sm">
|
||||
@@ -768,13 +808,13 @@ export default function OutboundPage() {
|
||||
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
||||
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
||||
</span>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 소스 데이터 (수정 모드에서는 숨김) */}
|
||||
{!editMode && <ResizablePanel defaultSize={60} minSize={35}>
|
||||
{/* 좌측: 소스 데이터 */}
|
||||
<ResizablePanel defaultSize={60} minSize={35}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Input
|
||||
@@ -878,12 +918,12 @@ export default function OutboundPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>}
|
||||
</ResizablePanel>
|
||||
|
||||
{!editMode && <ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />}
|
||||
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
|
||||
|
||||
{/* 우측: 출고 정보 + 선택 품목 */}
|
||||
<ResizablePanel defaultSize={editMode ? 100 : 40} minSize={25}>
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
|
||||
<h4 className="text-[13px] font-bold text-foreground">출고 정보</h4>
|
||||
@@ -909,7 +949,15 @@ export default function OutboundPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">창고</span>
|
||||
<Select value={modalWarehouse} onValueChange={setModalWarehouse}>
|
||||
<Select value={modalWarehouse} onValueChange={(v) => {
|
||||
setModalWarehouse(v);
|
||||
setModalLocation("");
|
||||
if (v) {
|
||||
getOutboundLocations(v).then((r) => { if (r.success) setLocations(r.data); }).catch(() => {});
|
||||
} else {
|
||||
setLocations([]);
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="창고 선택" />
|
||||
</SelectTrigger>
|
||||
@@ -924,12 +972,19 @@ export default function OutboundPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">위치</span>
|
||||
<Input
|
||||
value={modalLocation}
|
||||
onChange={(e) => setModalLocation(e.target.value)}
|
||||
placeholder="위치 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Select value={modalLocation || "__none__"} onValueChange={(v) => setModalLocation(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="위치 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">위치 선택</SelectItem>
|
||||
{locations.map((l) => (
|
||||
<SelectItem key={l.location_code} value={l.location_code}>
|
||||
{l.location_name || l.location_code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자</span>
|
||||
@@ -970,18 +1025,24 @@ export default function OutboundPage() {
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[30px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출고유형</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
{!editMode && <TableHead className="w-[30px] p-2" />}
|
||||
<TableHead className="w-[30px] p-2" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedItems.map((item, idx) => (
|
||||
<TableRow key={item.key} className="text-xs">
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium" title={item.item_name}>
|
||||
@@ -1015,7 +1076,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-right text-[13px] font-semibold">
|
||||
{item.total_amount.toLocaleString()}
|
||||
</TableCell>
|
||||
{!editMode && <TableCell className="p-2 text-center">
|
||||
<TableCell className="p-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1024,7 +1085,7 @@ export default function OutboundPage() {
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -347,6 +347,16 @@ export default function WarehouseManagementPage() {
|
||||
description: warehouseForm.description || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
if (!warehouseEditMode) {
|
||||
const dup = warehouses.find(w => w.warehouse_code === fields.warehouse_code);
|
||||
if (dup) {
|
||||
toast.error(`창고코드 "${fields.warehouse_code}"가 이미 존재해요`);
|
||||
setWarehouseSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (warehouseEditMode && warehouseForm.id) {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${WAREHOUSE_TABLE}/edit`,
|
||||
@@ -465,15 +475,17 @@ export default function WarehouseManagementPage() {
|
||||
const ok = await confirm(`${locationCheckedIds.length}건의 위치를 삭제할까요?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${LOCATION_TABLE}/delete`,
|
||||
{ data: locationCheckedIds.map((id) => ({ id })) }
|
||||
);
|
||||
toast.success("위치가 삭제되었어요");
|
||||
for (const id of locationCheckedIds) {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${LOCATION_TABLE}/delete`,
|
||||
{ data: [{ id }] }
|
||||
);
|
||||
}
|
||||
toast.success(`${locationCheckedIds.length}건의 위치가 삭제되었어요`);
|
||||
setLocationCheckedIds([]);
|
||||
fetchLocations();
|
||||
} catch {
|
||||
toast.error("위치 삭제에 실패했어요");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "위치 삭제에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -540,12 +552,25 @@ export default function WarehouseManagementPage() {
|
||||
const floorCode = floorLabel.replace(/층$/, "");
|
||||
const zoneCode = zoneLabel.replace(/구역$/, "");
|
||||
|
||||
// 기존 위치코드 Set (중복 체크용)
|
||||
const existingCodes = new Set(locations.map((l: any) => l.location_code));
|
||||
const seen = new Set<string>();
|
||||
const items: any[] = [];
|
||||
const duplicates: string[] = [];
|
||||
|
||||
for (const cond of rackConditions) {
|
||||
for (let row = cond.startRow; row <= cond.endRow; row++) {
|
||||
for (let level = 1; level <= cond.levels; level++) {
|
||||
const rowStr = String(row).padStart(2, "0");
|
||||
const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`;
|
||||
// 미리보기 내부 중복 제거
|
||||
if (seen.has(locationCode)) continue;
|
||||
seen.add(locationCode);
|
||||
// 기존 DB 데이터와 중복 체크
|
||||
if (existingCodes.has(locationCode)) {
|
||||
duplicates.push(locationCode);
|
||||
continue;
|
||||
}
|
||||
const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
|
||||
items.push({
|
||||
location_code: locationCode,
|
||||
@@ -561,6 +586,9 @@ export default function WarehouseManagementPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
toast.error(`이미 등록된 위치 ${duplicates.length}건이 제외되었어요 (예: ${duplicates.slice(0, 3).join(", ")})`);
|
||||
}
|
||||
setRackPreview(items);
|
||||
};
|
||||
|
||||
@@ -792,15 +820,6 @@ export default function WarehouseManagementPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={openLocationCreateModal}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
위치 등록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={openRackModal}
|
||||
@@ -1203,18 +1222,18 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
{/* 랙 구조 일괄 등록 Dialog */}
|
||||
<Dialog open={rackModalOpen} onOpenChange={setRackModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-y-auto p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-3xl p-0 !gap-0" style={{ maxHeight: "90vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div className="px-6 pt-6 pb-3 shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" />
|
||||
랙 구조 일괄 등록
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="mt-1">
|
||||
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code}) 창고에 랙 구조를 일괄 등록합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-160px)]">
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="space-y-6 px-6 py-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
@@ -1522,9 +1541,9 @@ export default function WarehouseManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 pb-6 pt-2 border-t">
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t shrink-0 bg-background">
|
||||
<Button variant="outline" onClick={() => setRackModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
@@ -1535,7 +1554,7 @@ export default function WarehouseManagementPage() {
|
||||
{rackSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
일괄 등록 ({rackPreview.length}건)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -165,6 +165,19 @@ export async function getOutboundWarehouses() {
|
||||
return res.data as { success: boolean; data: WarehouseOption[] };
|
||||
}
|
||||
|
||||
export interface LocationOption {
|
||||
location_code: string;
|
||||
location_name: string;
|
||||
warehouse_code: string;
|
||||
}
|
||||
|
||||
export async function getOutboundLocations(warehouseCode?: string) {
|
||||
const res = await apiClient.get("/outbound/locations", {
|
||||
params: warehouseCode ? { warehouse_code: warehouseCode } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: LocationOption[] };
|
||||
}
|
||||
|
||||
// 소스 데이터 조회
|
||||
export async function getShipmentInstructionSources(keyword?: string) {
|
||||
const res = await apiClient.get("/outbound/source/shipment-instructions", {
|
||||
|
||||
Reference in New Issue
Block a user