Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
DDD1542
2026-04-05 01:59:56 +09:00
8 changed files with 354 additions and 140 deletions
@@ -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,10 +269,37 @@ 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 {
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`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
id: crypto.randomUUID(), ...saveData, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (inspectionContinuous) {
@@ -278,6 +307,7 @@ export default function EquipmentInfoPage() {
} else {
setInspectionModalOpen(false);
}
}
refreshRight();
} 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; }
setSaving(true);
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`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
id: crypto.randomUUID(), ...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다.");
if (consumableContinuous) {
@@ -329,6 +366,7 @@ export default function EquipmentInfoPage() {
} 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) => ({
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),
unit: resolve("unit", r.unit),
_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 {
for (const id of locationCheckedIds) {
await apiClient.delete(
`/table-management/tables/${LOCATION_TABLE}/delete`,
{ data: locationCheckedIds.map((id) => ({ id })) }
{ data: [{ id }] }
);
toast.success("위치가 삭제되었어요");
}
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>
+13
View File
@@ -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", {