Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -221,10 +221,10 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_number, warehouse_code, location_code,
|
||||
history_type, history_date, change_qty, after_qty, reason,
|
||||
created_by, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW()::date, $5, $6, $7, $8, NOW())`,
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW(), $5, $6, $7, $8, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId]
|
||||
);
|
||||
}
|
||||
@@ -515,7 +515,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
@@ -526,3 +526,25 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고별 위치 목록 조회
|
||||
export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const warehouseCode = req.query.warehouse_code as string;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT location_code, location_name, warehouse_code
|
||||
FROM warehouse_location
|
||||
WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""}
|
||||
ORDER BY location_code`,
|
||||
warehouseCode ? [companyCode, warehouseCode] : [companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +266,10 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_number, warehouse_code, location_code,
|
||||
history_type, history_date, change_qty, after_qty, reason,
|
||||
created_by, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW()::date, $5, $6, $7, $8, NOW())`,
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId]
|
||||
);
|
||||
}
|
||||
@@ -548,10 +548,10 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
id, company_code, item_number, warehouse_code, location_code,
|
||||
history_type, history_date, change_qty, after_qty, reason,
|
||||
created_by, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW()::date, $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||
writer, created_date
|
||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
|
||||
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ router.get("/generate-number", outboundController.generateNumber);
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", outboundController.getWarehouses);
|
||||
|
||||
// 위치 목록 조회
|
||||
router.get("/locations", outboundController.getLocations);
|
||||
|
||||
// 소스 데이터: 출하지시 (판매출고)
|
||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||
|
||||
|
||||
@@ -79,15 +79,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[]>([]);
|
||||
|
||||
// 점검항목 복사
|
||||
@@ -267,16 +269,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); }
|
||||
@@ -320,14 +350,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); }
|
||||
@@ -479,7 +517,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); }}>
|
||||
@@ -488,7 +526,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>
|
||||
)}
|
||||
@@ -580,7 +618,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>
|
||||
@@ -618,7 +662,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>
|
||||
@@ -678,24 +727,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">
|
||||
@@ -713,7 +800,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 ? (
|
||||
|
||||
@@ -66,10 +66,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: "단위" },
|
||||
@@ -102,6 +102,7 @@ const getHistoryTypeVariant = (
|
||||
return "secondary";
|
||||
case "조정":
|
||||
return "outline";
|
||||
case "입고취소":
|
||||
case "이동":
|
||||
return "destructive";
|
||||
default:
|
||||
@@ -171,27 +172,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("재고 목록을 불러오지 못했어요");
|
||||
@@ -209,7 +219,7 @@ export default function InventoryStatusPage() {
|
||||
|
||||
// 이력 조회
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!selectedStock?.item_number) {
|
||||
if (!selectedStock?.item_code) {
|
||||
setHistoryItems([]);
|
||||
return;
|
||||
}
|
||||
@@ -217,9 +227,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) {
|
||||
@@ -236,7 +246,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 || [];
|
||||
@@ -246,7 +256,7 @@ export default function InventoryStatusPage() {
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [selectedStock?.item_number, selectedStock?.warehouse_code]);
|
||||
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
@@ -273,15 +283,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(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -351,10 +360,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,
|
||||
@@ -446,13 +455,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">
|
||||
@@ -574,37 +583,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>
|
||||
@@ -634,7 +643,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>
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
deleteOutbound,
|
||||
generateOutboundNumber,
|
||||
getOutboundWarehouses,
|
||||
getOutboundLocations,
|
||||
getShipmentInstructionSources,
|
||||
getPurchaseOrderSources,
|
||||
getItemSources,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
type ShipmentInstructionSource,
|
||||
type PurchaseOrderSource,
|
||||
type ItemSource,
|
||||
type LocationOption,
|
||||
type WarehouseOption,
|
||||
} from "@/lib/api/outbound";
|
||||
|
||||
@@ -160,6 +162,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);
|
||||
@@ -316,7 +319,9 @@ export default function OutboundPage() {
|
||||
source_id: g.source_id || "",
|
||||
}))
|
||||
);
|
||||
setSourceKeyword("");
|
||||
setIsModalOpen(true);
|
||||
loadSourceData(first.outbound_type || "판매출고");
|
||||
};
|
||||
|
||||
const searchSourceData = useCallback(async () => {
|
||||
@@ -492,9 +497,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,
|
||||
@@ -505,8 +518,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();
|
||||
@@ -648,8 +688,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">
|
||||
@@ -670,13 +710,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
|
||||
@@ -780,12 +820,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>
|
||||
@@ -811,7 +851,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>
|
||||
@@ -826,12 +874,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>
|
||||
@@ -872,18 +927,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}>
|
||||
@@ -917,7 +978,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"
|
||||
@@ -926,7 +987,7 @@ export default function OutboundPage() {
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -348,6 +348,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`,
|
||||
@@ -466,15 +476,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 || "위치 삭제에 실패했어요");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -541,12 +553,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,
|
||||
@@ -562,6 +587,9 @@ export default function WarehouseManagementPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
toast.error(`이미 등록된 위치 ${duplicates.length}건이 제외되었어요 (예: ${duplicates.slice(0, 3).join(", ")})`);
|
||||
}
|
||||
setRackPreview(items);
|
||||
};
|
||||
|
||||
@@ -769,15 +797,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}
|
||||
@@ -1180,18 +1199,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>
|
||||
@@ -1499,9 +1518,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>
|
||||
@@ -1512,7 +1531,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