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';
|
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_history (
|
`INSERT INTO inventory_history (
|
||||||
id, company_code, item_number, warehouse_code, location_code,
|
id, company_code, item_code, warehouse_code, location_code,
|
||||||
history_type, history_date, change_qty, after_qty, reason,
|
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||||
created_by, created_date
|
writer, created_date
|
||||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW()::date, $5, $6, $7, $8, NOW())`,
|
) 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]
|
[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(
|
const result = await pool.query(
|
||||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||||
FROM warehouse_info
|
FROM warehouse_info
|
||||||
WHERE company_code = $1 AND status != '삭제'
|
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
||||||
ORDER BY warehouse_name`,
|
ORDER BY warehouse_name`,
|
||||||
[companyCode]
|
[companyCode]
|
||||||
);
|
);
|
||||||
@@ -526,3 +526,25 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
|||||||
return res.status(500).json({ success: false, message: error.message });
|
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);
|
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_history (
|
`INSERT INTO inventory_history (
|
||||||
id, company_code, item_number, warehouse_code, location_code,
|
id, company_code, item_code, warehouse_code, location_code,
|
||||||
history_type, history_date, change_qty, after_qty, reason,
|
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||||
created_by, created_date
|
writer, created_date
|
||||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW()::date, $5, $6, $7, $8, NOW())`,
|
) 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]
|
[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';
|
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO inventory_history (
|
`INSERT INTO inventory_history (
|
||||||
id, company_code, item_number, warehouse_code, location_code,
|
id, company_code, item_code, warehouse_code, location_code,
|
||||||
history_type, history_date, change_qty, after_qty, reason,
|
transaction_type, transaction_date, quantity, balance_qty, remark,
|
||||||
created_by, created_date
|
writer, created_date
|
||||||
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW()::date, $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
|
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
|
||||||
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
|
[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("/warehouses", outboundController.getWarehouses);
|
||||||
|
|
||||||
|
// 위치 목록 조회
|
||||||
|
router.get("/locations", outboundController.getLocations);
|
||||||
|
|
||||||
// 소스 데이터: 출하지시 (판매출고)
|
// 소스 데이터: 출하지시 (판매출고)
|
||||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||||
|
|
||||||
|
|||||||
@@ -79,15 +79,17 @@ export default function EquipmentInfoPage() {
|
|||||||
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||||
const [infoSaving, setInfoSaving] = useState(false);
|
const [infoSaving, setInfoSaving] = useState(false);
|
||||||
|
|
||||||
// 점검항목 추가 모달
|
// 점검항목 추가/수정 모달
|
||||||
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||||
|
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||||
|
|
||||||
// 소모품 추가 모달
|
// 소모품 추가/수정 모달
|
||||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||||
|
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||||
|
|
||||||
// 점검항목 복사
|
// 점검항목 복사
|
||||||
@@ -267,10 +269,37 @@ export default function EquipmentInfoPage() {
|
|||||||
// 점검항목 추가
|
// 점검항목 추가
|
||||||
const handleInspectionSave = async () => {
|
const handleInspectionSave = async () => {
|
||||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
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`, {
|
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||||
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
|
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
|
||||||
});
|
});
|
||||||
toast.success("추가되었습니다.");
|
toast.success("추가되었습니다.");
|
||||||
if (inspectionContinuous) {
|
if (inspectionContinuous) {
|
||||||
@@ -278,6 +307,7 @@ export default function EquipmentInfoPage() {
|
|||||||
} else {
|
} else {
|
||||||
setInspectionModalOpen(false);
|
setInspectionModalOpen(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refreshRight();
|
refreshRight();
|
||||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
@@ -320,8 +350,15 @@ export default function EquipmentInfoPage() {
|
|||||||
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
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`, {
|
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||||
...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||||
});
|
});
|
||||||
toast.success("추가되었습니다.");
|
toast.success("추가되었습니다.");
|
||||||
if (consumableContinuous) {
|
if (consumableContinuous) {
|
||||||
@@ -329,6 +366,7 @@ export default function EquipmentInfoPage() {
|
|||||||
} else {
|
} else {
|
||||||
setConsumableModalOpen(false);
|
setConsumableModalOpen(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refreshRight();
|
refreshRight();
|
||||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
} 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">
|
<div className="flex gap-1.5">
|
||||||
{rightTab === "inspection" && (
|
{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" /> 추가
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
<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" && (
|
{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" /> 추가
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -580,7 +618,13 @@ export default function EquipmentInfoPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{inspections.map((item) => (
|
{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-sm">{item.inspection_item || "-"}</TableCell>
|
||||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||||
@@ -618,7 +662,12 @@ export default function EquipmentInfoPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{consumables.map((item) => (
|
{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-sm">{item.consumable_name || "-"}</TableCell>
|
||||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||||
@@ -678,24 +727,62 @@ export default function EquipmentInfoPage() {
|
|||||||
{/* 점검항목 추가 모달 */}
|
{/* 점검항목 추가 모달 */}
|
||||||
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||||
<DialogContent className="max-w-lg">
|
<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-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="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>
|
{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>
|
||||||
|
<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>
|
<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>
|
</div>
|
||||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -713,7 +800,7 @@ export default function EquipmentInfoPage() {
|
|||||||
{/* 소모품 추가 모달 */}
|
{/* 소모품 추가 모달 */}
|
||||||
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||||
<DialogContent className="max-w-lg">
|
<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="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>
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||||
{consumableItemOptions.length > 0 ? (
|
{consumableItemOptions.length > 0 ? (
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
const STOCK_TABLE = "inventory_stock";
|
const STOCK_TABLE = "inventory_stock";
|
||||||
|
|
||||||
const STOCK_COLUMNS = [
|
const STOCK_COLUMNS = [
|
||||||
{ key: "item_number", label: "품목코드" },
|
{ key: "item_code", label: "품목코드" },
|
||||||
{ key: "item_name", label: "품명" },
|
{ key: "item_name", label: "품명" },
|
||||||
{ key: "warehouse_name", label: "창고" },
|
{ key: "warehouse_code", label: "창고" },
|
||||||
{ key: "location_name", label: "위치" },
|
{ key: "location_code", label: "위치" },
|
||||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||||
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
||||||
{ key: "unit", label: "단위" },
|
{ key: "unit", label: "단위" },
|
||||||
@@ -102,6 +102,7 @@ const getHistoryTypeVariant = (
|
|||||||
return "secondary";
|
return "secondary";
|
||||||
case "조정":
|
case "조정":
|
||||||
return "outline";
|
return "outline";
|
||||||
|
case "입고취소":
|
||||||
case "이동":
|
case "이동":
|
||||||
return "destructive";
|
return "destructive";
|
||||||
default:
|
default:
|
||||||
@@ -171,27 +172,36 @@ export default function InventoryStatusPage() {
|
|||||||
setStockLoading(true);
|
setStockLoading(true);
|
||||||
try {
|
try {
|
||||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
const res = await apiClient.post(
|
const [stockRes, itemRes, whRes] = await Promise.all([
|
||||||
`/table-management/tables/${STOCK_TABLE}/data`,
|
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
||||||
{
|
page: 1, size: 500,
|
||||||
page: 1,
|
|
||||||
size: 500,
|
|
||||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
sort: { columnName: "item_number", order: "asc" },
|
sort: { columnName: "item_code", order: "asc" },
|
||||||
}
|
}),
|
||||||
);
|
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
||||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
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) => {
|
const resolve = (col: string, code: string) => {
|
||||||
if (!code) return "";
|
if (!code) return "";
|
||||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
};
|
};
|
||||||
const data = raw.map((r: any) => ({
|
const data = raw.map((r: any) => {
|
||||||
|
const itemInfo = itemMap.get(r.item_code) as any;
|
||||||
|
return {
|
||||||
...r,
|
...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),
|
status: resolve("status", r.status),
|
||||||
unit: resolve("unit", r.unit),
|
|
||||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
setStockItems(data);
|
setStockItems(data);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("재고 목록을 불러오지 못했어요");
|
toast.error("재고 목록을 불러오지 못했어요");
|
||||||
@@ -209,7 +219,7 @@ export default function InventoryStatusPage() {
|
|||||||
|
|
||||||
// 이력 조회
|
// 이력 조회
|
||||||
const fetchHistory = useCallback(async () => {
|
const fetchHistory = useCallback(async () => {
|
||||||
if (!selectedStock?.item_number) {
|
if (!selectedStock?.item_code) {
|
||||||
setHistoryItems([]);
|
setHistoryItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,9 +227,9 @@ export default function InventoryStatusPage() {
|
|||||||
try {
|
try {
|
||||||
const historyFilters: any[] = [
|
const historyFilters: any[] = [
|
||||||
{
|
{
|
||||||
columnName: "item_number",
|
columnName: "item_code",
|
||||||
operator: "equals",
|
operator: "equals",
|
||||||
value: selectedStock.item_number,
|
value: selectedStock.item_code,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (selectedStock.warehouse_code) {
|
if (selectedStock.warehouse_code) {
|
||||||
@@ -236,7 +246,7 @@ export default function InventoryStatusPage() {
|
|||||||
size: 500,
|
size: 500,
|
||||||
dataFilter: { enabled: true, filters: historyFilters },
|
dataFilter: { enabled: true, filters: historyFilters },
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
sort: { columnName: "history_date", order: "desc" },
|
sort: { columnName: "transaction_date", order: "desc" },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
@@ -246,7 +256,7 @@ export default function InventoryStatusPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false);
|
setHistoryLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedStock?.item_number, selectedStock?.warehouse_code]);
|
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
@@ -273,15 +283,14 @@ export default function InventoryStatusPage() {
|
|||||||
`/table-management/tables/${HISTORY_TABLE}/add`,
|
`/table-management/tables/${HISTORY_TABLE}/add`,
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
item_number: selectedStock.item_number,
|
item_code: selectedStock.item_code,
|
||||||
warehouse_code: selectedStock.warehouse_code || "",
|
warehouse_code: selectedStock.warehouse_code || "",
|
||||||
location_code: selectedStock.location_code || "",
|
location_code: selectedStock.location_code || "",
|
||||||
history_type: "조정",
|
transaction_type: "조정",
|
||||||
history_date: new Date().toISOString().slice(0, 10),
|
transaction_date: new Date().toISOString(),
|
||||||
change_qty: changeQty,
|
quantity: String(changeQty),
|
||||||
after_qty: afterQty,
|
balance_qty: String(afterQty),
|
||||||
reason: adjustForm.reason.trim(),
|
remark: adjustForm.reason.trim(),
|
||||||
created_by: user?.userId || "",
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -351,10 +360,10 @@ export default function InventoryStatusPage() {
|
|||||||
}
|
}
|
||||||
exportToExcel(
|
exportToExcel(
|
||||||
stockItems.map((r) => ({
|
stockItems.map((r) => ({
|
||||||
품목코드: r.item_number,
|
품목코드: r.item_code,
|
||||||
품명: r.item_name,
|
품명: r.item_name,
|
||||||
창고: r.warehouse_name,
|
창고: r.warehouse_name || r.warehouse_code,
|
||||||
위치: r.location_name,
|
위치: r.location_code,
|
||||||
현재수량: r.current_qty,
|
현재수량: r.current_qty,
|
||||||
안전재고: r.safety_qty,
|
안전재고: r.safety_qty,
|
||||||
단위: r.unit,
|
단위: r.unit,
|
||||||
@@ -446,13 +455,13 @@ export default function InventoryStatusPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<History className="h-4 w-4 text-muted-foreground" />
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-[13px] font-bold">
|
<span className="text-[13px] font-bold">
|
||||||
{selectedStock.item_name || selectedStock.item_number}
|
{selectedStock.item_name || selectedStock.item_code}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full text-[11px] font-mono"
|
className="rounded-full text-[11px] font-mono"
|
||||||
>
|
>
|
||||||
{selectedStock.item_number}
|
{selectedStock.item_code}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -574,37 +583,37 @@ export default function InventoryStatusPage() {
|
|||||||
{idx + 1}
|
{idx + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono">
|
<TableCell className="font-mono">
|
||||||
{h.history_date}
|
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={getHistoryTypeVariant(h.history_type)}
|
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
|
||||||
className="text-[10px]"
|
className="text-[10px]"
|
||||||
>
|
>
|
||||||
{h.history_type}
|
{h.transaction_type || h.history_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-right font-mono",
|
"text-right font-mono",
|
||||||
Number(h.change_qty) > 0
|
Number(h.quantity ?? h.change_qty) > 0
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: "text-destructive"
|
: "text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Number(h.change_qty) > 0 ? "+" : ""}
|
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
|
||||||
{Number(h.change_qty || 0).toLocaleString()}
|
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono">
|
<TableCell className="text-right font-mono">
|
||||||
{Number(h.after_qty || 0).toLocaleString()}
|
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono truncate max-w-[120px]">
|
<TableCell className="font-mono truncate max-w-[120px]">
|
||||||
{h.reference_no}
|
{h.reference_number || h.reference_no || ""}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="truncate max-w-[150px]">
|
<TableCell className="truncate max-w-[150px]">
|
||||||
{h.reason}
|
{h.remark || h.reason || ""}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{h.created_by}</TableCell>
|
<TableCell>{h.writer || h.created_by || ""}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -634,7 +643,7 @@ export default function InventoryStatusPage() {
|
|||||||
<DialogTitle>재고 조정</DialogTitle>
|
<DialogTitle>재고 조정</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{selectedStock
|
{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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
deleteOutbound,
|
deleteOutbound,
|
||||||
generateOutboundNumber,
|
generateOutboundNumber,
|
||||||
getOutboundWarehouses,
|
getOutboundWarehouses,
|
||||||
|
getOutboundLocations,
|
||||||
getShipmentInstructionSources,
|
getShipmentInstructionSources,
|
||||||
getPurchaseOrderSources,
|
getPurchaseOrderSources,
|
||||||
getItemSources,
|
getItemSources,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
type ShipmentInstructionSource,
|
type ShipmentInstructionSource,
|
||||||
type PurchaseOrderSource,
|
type PurchaseOrderSource,
|
||||||
type ItemSource,
|
type ItemSource,
|
||||||
|
type LocationOption,
|
||||||
type WarehouseOption,
|
type WarehouseOption,
|
||||||
} from "@/lib/api/outbound";
|
} from "@/lib/api/outbound";
|
||||||
|
|
||||||
@@ -160,6 +162,7 @@ export default function OutboundPage() {
|
|||||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSource[]>([]);
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSource[]>([]);
|
||||||
const [items, setItems] = useState<ItemSource[]>([]);
|
const [items, setItems] = useState<ItemSource[]>([]);
|
||||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
|
const [locations, setLocations] = useState<LocationOption[]>([]);
|
||||||
|
|
||||||
// 소스 데이터 페이징 (클라이언트 사이드)
|
// 소스 데이터 페이징 (클라이언트 사이드)
|
||||||
const [sourcePage, setSourcePage] = useState(1);
|
const [sourcePage, setSourcePage] = useState(1);
|
||||||
@@ -316,7 +319,9 @@ export default function OutboundPage() {
|
|||||||
source_id: g.source_id || "",
|
source_id: g.source_id || "",
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
setSourceKeyword("");
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
loadSourceData(first.outbound_type || "판매출고");
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchSourceData = useCallback(async () => {
|
const searchSourceData = useCallback(async () => {
|
||||||
@@ -492,9 +497,17 @@ export default function OutboundPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
// 수정 모드: 각 아이템별 update
|
const currentKeys = new Set(selectedItems.map((i) => i.key));
|
||||||
await Promise.all(
|
// 삭제: editItemIds에 있지만 selectedItems에 없는 것
|
||||||
selectedItems.map((item) =>
|
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, {
|
updateOutbound(item.key, {
|
||||||
outbound_date: modalOutboundDate,
|
outbound_date: modalOutboundDate,
|
||||||
outbound_qty: item.outbound_qty,
|
outbound_qty: item.outbound_qty,
|
||||||
@@ -505,8 +518,35 @@ export default function OutboundPage() {
|
|||||||
manager_id: modalManager || undefined,
|
manager_id: modalManager || undefined,
|
||||||
memo: modalMemo || undefined,
|
memo: modalMemo || undefined,
|
||||||
} as any)
|
} 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("출고 정보를 수정했어요");
|
toast.success("출고 정보를 수정했어요");
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchList();
|
fetchList();
|
||||||
@@ -648,8 +688,8 @@ export default function OutboundPage() {
|
|||||||
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
|
<DialogDescription>{editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."}</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">출고유형</span>
|
||||||
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
<Select value={modalOutboundType} onValueChange={handleOutboundTypeChange}>
|
||||||
<SelectTrigger className="h-9 w-[160px] text-sm">
|
<SelectTrigger className="h-9 w-[160px] text-sm">
|
||||||
@@ -670,13 +710,13 @@ export default function OutboundPage() {
|
|||||||
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
? "발주(입고) 데이터에서 반품 출고 처리해요"
|
||||||
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
: "품목 데이터를 직접 선택하여 출고 처리해요"}
|
||||||
</span>
|
</span>
|
||||||
</div>}
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
{/* 좌측: 소스 데이터 (수정 모드에서는 숨김) */}
|
{/* 좌측: 소스 데이터 */}
|
||||||
{!editMode && <ResizablePanel defaultSize={60} minSize={35}>
|
<ResizablePanel defaultSize={60} minSize={35}>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -780,12 +820,12 @@ export default function OutboundPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="flex h-full flex-col">
|
||||||
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
|
<div className="space-y-3 border-b bg-muted/30 px-4 py-3">
|
||||||
<h4 className="text-[13px] font-bold text-foreground">출고 정보</h4>
|
<h4 className="text-[13px] font-bold text-foreground">출고 정보</h4>
|
||||||
@@ -811,7 +851,15 @@ export default function OutboundPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">창고</span>
|
<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">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="창고 선택" />
|
<SelectValue placeholder="창고 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -826,12 +874,19 @@ export default function OutboundPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">위치</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">위치</span>
|
||||||
<Input
|
<Select value={modalLocation || "__none__"} onValueChange={(v) => setModalLocation(v === "__none__" ? "" : v)}>
|
||||||
value={modalLocation}
|
<SelectTrigger className="h-8 text-xs">
|
||||||
onChange={(e) => setModalLocation(e.target.value)}
|
<SelectValue placeholder="위치 선택" />
|
||||||
placeholder="위치 입력"
|
</SelectTrigger>
|
||||||
className="h-8 text-xs"
|
<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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자</span>
|
<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">
|
<TableHeader className="sticky top-0 z-10">
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
<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="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="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-[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>
|
<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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{selectedItems.map((item, idx) => (
|
{selectedItems.map((item, idx) => (
|
||||||
<TableRow key={item.key} className="text-xs">
|
<TableRow key={item.key} className="text-xs">
|
||||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
<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">
|
<TableCell className="max-w-[180px] p-2">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="truncate font-medium" title={item.item_name}>
|
<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">
|
<TableCell className="p-2 text-right text-[13px] font-semibold">
|
||||||
{item.total_amount.toLocaleString()}
|
{item.total_amount.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{!editMode && <TableCell className="p-2 text-center">
|
<TableCell className="p-2 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -926,7 +987,7 @@ export default function OutboundPage() {
|
|||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>}
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -348,6 +348,16 @@ export default function WarehouseManagementPage() {
|
|||||||
description: warehouseForm.description || "",
|
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) {
|
if (warehouseEditMode && warehouseForm.id) {
|
||||||
await apiClient.put(
|
await apiClient.put(
|
||||||
`/table-management/tables/${WAREHOUSE_TABLE}/edit`,
|
`/table-management/tables/${WAREHOUSE_TABLE}/edit`,
|
||||||
@@ -466,15 +476,17 @@ export default function WarehouseManagementPage() {
|
|||||||
const ok = await confirm(`${locationCheckedIds.length}건의 위치를 삭제할까요?`);
|
const ok = await confirm(`${locationCheckedIds.length}건의 위치를 삭제할까요?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
|
for (const id of locationCheckedIds) {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/table-management/tables/${LOCATION_TABLE}/delete`,
|
`/table-management/tables/${LOCATION_TABLE}/delete`,
|
||||||
{ data: locationCheckedIds.map((id) => ({ id })) }
|
{ data: [{ id }] }
|
||||||
);
|
);
|
||||||
toast.success("위치가 삭제되었어요");
|
}
|
||||||
|
toast.success(`${locationCheckedIds.length}건의 위치가 삭제되었어요`);
|
||||||
setLocationCheckedIds([]);
|
setLocationCheckedIds([]);
|
||||||
fetchLocations();
|
fetchLocations();
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
toast.error("위치 삭제에 실패했어요");
|
toast.error(err?.response?.data?.message || "위치 삭제에 실패했어요");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -541,12 +553,25 @@ export default function WarehouseManagementPage() {
|
|||||||
const floorCode = floorLabel.replace(/층$/, "");
|
const floorCode = floorLabel.replace(/층$/, "");
|
||||||
const zoneCode = zoneLabel.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 items: any[] = [];
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
|
||||||
for (const cond of rackConditions) {
|
for (const cond of rackConditions) {
|
||||||
for (let row = cond.startRow; row <= cond.endRow; row++) {
|
for (let row = cond.startRow; row <= cond.endRow; row++) {
|
||||||
for (let level = 1; level <= cond.levels; level++) {
|
for (let level = 1; level <= cond.levels; level++) {
|
||||||
const rowStr = String(row).padStart(2, "0");
|
const rowStr = String(row).padStart(2, "0");
|
||||||
const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`;
|
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}단`;
|
const locationName = `${zoneCode}구역-${rowStr}열-${level}단`;
|
||||||
items.push({
|
items.push({
|
||||||
location_code: locationCode,
|
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);
|
setRackPreview(items);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -769,15 +797,6 @@ export default function WarehouseManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<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"
|
size="sm"
|
||||||
className="h-7 gap-1 text-xs"
|
className="h-7 gap-1 text-xs"
|
||||||
onClick={openRackModal}
|
onClick={openRackModal}
|
||||||
@@ -1180,18 +1199,18 @@ export default function WarehouseManagementPage() {
|
|||||||
|
|
||||||
{/* 랙 구조 일괄 등록 Dialog */}
|
{/* 랙 구조 일괄 등록 Dialog */}
|
||||||
<Dialog open={rackModalOpen} onOpenChange={setRackModalOpen}>
|
<Dialog open={rackModalOpen} onOpenChange={setRackModalOpen}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-y-auto p-0">
|
<DialogContent className="max-w-[95vw] sm:max-w-3xl p-0 !gap-0" style={{ maxHeight: "90vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
<DialogHeader className="px-6 pt-6 pb-0">
|
<div className="px-6 pt-6 pb-3 shrink-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Layers className="h-5 w-5" />
|
<Layers className="h-5 w-5" />
|
||||||
랙 구조 일괄 등록
|
랙 구조 일괄 등록
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="mt-1">
|
||||||
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code}) 창고에 랙 구조를 일괄 등록합니다
|
{selectedWarehouse?.warehouse_name} ({selectedWarehouse?.warehouse_code}) 창고에 랙 구조를 일괄 등록합니다
|
||||||
</DialogDescription>
|
</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 className="space-y-6 px-6 py-4">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div>
|
<div>
|
||||||
@@ -1499,9 +1518,9 @@ export default function WarehouseManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 variant="outline" onClick={() => setRackModalOpen(false)}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1512,7 +1531,7 @@ export default function WarehouseManagementPage() {
|
|||||||
{rackSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
{rackSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||||
일괄 등록 ({rackPreview.length}건)
|
일괄 등록 ({rackPreview.length}건)
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ export async function getOutboundWarehouses() {
|
|||||||
return res.data as { success: boolean; data: WarehouseOption[] };
|
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) {
|
export async function getShipmentInstructionSources(keyword?: string) {
|
||||||
const res = await apiClient.get("/outbound/source/shipment-instructions", {
|
const res = await apiClient.get("/outbound/source/shipment-instructions", {
|
||||||
|
|||||||
Reference in New Issue
Block a user