feat: Add report cell value management functionality
- Introduced a new controller for managing custom input values in report cells, allowing users to retrieve and upsert values associated with specific reports and targets. - Implemented API routes for fetching and saving report cell values, ensuring proper authentication and data handling. - Enhanced the frontend components to support the new report cell input functionality, including the ability to edit and save input values in a modal. - Updated inventory and equipment management pages to include new features for handling missing items and managing warehouse locations effectively.
This commit is contained in:
@@ -157,6 +157,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시
|
||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
@@ -381,6 +382,7 @@ app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
|
||||
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
|
||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 리포트 셀 커스텀 입력값 컨트롤러
|
||||
*
|
||||
* 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해
|
||||
* 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import type { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { report_id, target_type, target_id } = req.query;
|
||||
|
||||
if (!report_id || !target_type || !target_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "report_id, target_type, target_id는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT id, report_id, target_type, target_id, component_id, cell_id, value
|
||||
FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`,
|
||||
[companyCode, report_id, target_type, target_id],
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT
|
||||
export async function upsert(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { report_id, target_type, target_id, component_id, cell_id, value } =
|
||||
req.body;
|
||||
|
||||
if (!report_id || !target_type || !target_id || !component_id || !cell_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드 누락",
|
||||
});
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// value가 빈 문자열이면 DELETE (오버라이드 해제)
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
await pool.query(
|
||||
`DELETE FROM report_cell_values
|
||||
WHERE company_code = $1 AND report_id = $2 AND target_type = $3
|
||||
AND target_id = $4 AND component_id = $5 AND cell_id = $6`,
|
||||
[companyCode, report_id, target_type, target_id, component_id, cell_id],
|
||||
);
|
||||
return res.json({ success: true, data: null });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO report_cell_values
|
||||
(id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by
|
||||
RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
report_id,
|
||||
target_type,
|
||||
target_id,
|
||||
component_id,
|
||||
cell_id,
|
||||
value,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("리포트 셀 값 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as controller from "../controllers/reportCellValueController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/", controller.getList);
|
||||
router.post("/", controller.upsert);
|
||||
|
||||
export default router;
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
// 새 재고 레코드 생성
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ResizablePanelGroup, ResizablePanel, ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ReportInlineViewer } from "@/components/report/ReportInlineViewer";
|
||||
import { ReportMaster, ComponentConfig } from "@/types/report";
|
||||
import { ReportMaster, ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
const MASTER_TABLE = "quote_mng";
|
||||
|
||||
@@ -83,6 +83,12 @@ export default function QuoteManagementPage() {
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" });
|
||||
|
||||
// 리포트 셀 input 오버라이드
|
||||
const [cellOverrides, setCellOverrides] = useState<Record<string, Record<string, string>>>({});
|
||||
const [inputCellOpen, setInputCellOpen] = useState(false);
|
||||
const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null);
|
||||
const [inputCellValues, setInputCellValues] = useState<Record<string, string>>({});
|
||||
|
||||
// 엑셀 / 리포트
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [reportList, setReportList] = useState<ReportMaster[]>([]);
|
||||
@@ -170,6 +176,104 @@ export default function QuoteManagementPage() {
|
||||
})();
|
||||
}, [current2ndLevelMenuObjid]);
|
||||
|
||||
// ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ──
|
||||
useEffect(() => {
|
||||
if (!selectedRow?.objid || !selectedReportId) {
|
||||
setCellOverrides({});
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/report-cell-values", {
|
||||
params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) },
|
||||
});
|
||||
const rows = res.data?.data || [];
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
for (const r of rows) {
|
||||
if (!map[r.component_id]) map[r.component_id] = {};
|
||||
map[r.component_id][r.cell_id] = r.value ?? "";
|
||||
}
|
||||
setCellOverrides(map);
|
||||
} catch {
|
||||
setCellOverrides({});
|
||||
}
|
||||
})();
|
||||
}, [selectedRow?.objid, selectedReportId]);
|
||||
|
||||
// ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ──
|
||||
const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => {
|
||||
const allCells = ((comp as any).gridCells || []) as GridCell[];
|
||||
const inputCells = allCells
|
||||
.filter((c) => c.cellType === "input" && !c.merged)
|
||||
.sort((a, b) => (a.row - b.row) || (a.col - b.col));
|
||||
const vals: Record<string, string> = {};
|
||||
for (const c of inputCells) {
|
||||
vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? "";
|
||||
}
|
||||
setInputCellCtx({ comp, cells: inputCells });
|
||||
setInputCellValues(vals);
|
||||
setInputCellOpen(true);
|
||||
};
|
||||
|
||||
// ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ──
|
||||
const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => {
|
||||
const allCells = ((comp as any).gridCells || []) as GridCell[];
|
||||
const labelCell = allCells
|
||||
.filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged)
|
||||
.sort((a, b) => b.col - a.col)[0];
|
||||
if (labelCell?.value) return String(labelCell.value).trim();
|
||||
return cell.inputPlaceholder || "값";
|
||||
};
|
||||
|
||||
// ── input 셀 저장: 변경된 셀들만 일괄 저장 ──
|
||||
const handleInputCellSave = async () => {
|
||||
if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return;
|
||||
const { comp, cells } = inputCellCtx;
|
||||
const existing = cellOverrides[comp.id] || {};
|
||||
const toSave: { cellId: string; value: string }[] = [];
|
||||
for (const c of cells) {
|
||||
const newVal = inputCellValues[c.id] ?? "";
|
||||
const oldVal = existing[c.id] ?? "";
|
||||
if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal });
|
||||
}
|
||||
if (toSave.length === 0) {
|
||||
setInputCellOpen(false);
|
||||
setInputCellCtx(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
toSave.map((t) =>
|
||||
apiClient.post("/report-cell-values", {
|
||||
report_id: selectedReportId,
|
||||
target_type: "quote",
|
||||
target_id: String(selectedRow.objid),
|
||||
component_id: comp.id,
|
||||
cell_id: t.cellId,
|
||||
value: t.value,
|
||||
})
|
||||
)
|
||||
);
|
||||
setCellOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
const curr = { ...(next[comp.id] || {}) };
|
||||
for (const c of cells) {
|
||||
const v = inputCellValues[c.id] ?? "";
|
||||
if (v === "") delete curr[c.id];
|
||||
else curr[c.id] = v;
|
||||
}
|
||||
if (Object.keys(curr).length === 0) delete next[comp.id];
|
||||
else next[comp.id] = curr;
|
||||
return next;
|
||||
});
|
||||
toast.success(`${toSave.length}개 항목이 저장됐어요`);
|
||||
setInputCellOpen(false);
|
||||
setInputCellCtx(null);
|
||||
} catch {
|
||||
toast.error("저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ──
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -218,6 +322,15 @@ export default function QuoteManagementPage() {
|
||||
setEditComp(comp);
|
||||
|
||||
if (comp.type === "table") {
|
||||
// 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상
|
||||
const cols = (comp as any).tableColumns || [];
|
||||
const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]);
|
||||
const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase()));
|
||||
if (!isItemTable) {
|
||||
toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)");
|
||||
setEditComp(null);
|
||||
return;
|
||||
}
|
||||
// 테이블 → 품목 편집
|
||||
try {
|
||||
const res = await apiClient.get(`/quotes/${selectedRow.objid}`);
|
||||
@@ -696,6 +809,8 @@ export default function QuoteManagementPage() {
|
||||
reportId={selectedReportId}
|
||||
contextParams={contextParams}
|
||||
onComponentClick={handleComponentClick}
|
||||
cellOverrides={cellOverrides}
|
||||
onInputCellClick={handleInputCellClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -852,6 +967,42 @@ export default function QuoteManagementPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */}
|
||||
<Dialog open={inputCellOpen} onOpenChange={(o) => { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>값 입력</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 테이블의 입력 항목을 한 번에 편집할 수 있어요. 빈 값으로 저장하면 해당 항목은 리포트에서 숨겨져요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 space-y-3 overflow-auto py-2">
|
||||
{inputCellCtx?.cells.map((c) => (
|
||||
<div key={c.id} className="space-y-1">
|
||||
<Label className="text-xs font-semibold">
|
||||
{inputCellCtx ? getInputCellLabel(inputCellCtx.comp, c) : ""}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={inputCellValues[c.id] ?? ""}
|
||||
onChange={(e) => setInputCellValues((prev) => ({ ...prev, [c.id]: e.target.value }))}
|
||||
placeholder={c.inputPlaceholder || "값"}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{inputCellCtx?.cells.length === 0 && (
|
||||
<p className="text-center text-xs text-muted-foreground">입력 가능한 셀이 없어요</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setInputCellOpen(false); setInputCellCtx(null); }}>취소</Button>
|
||||
<Button onClick={handleInputCellSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" /> 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
|
||||
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
|
||||
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -91,6 +92,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -198,6 +200,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -215,6 +218,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -270,6 +274,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -524,15 +556,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -611,6 +651,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -638,6 +688,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -666,6 +730,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -681,6 +755,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -90,6 +92,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -122,6 +126,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -135,7 +148,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -204,8 +219,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -216,6 +232,7 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
width: itemInfo?.width || "",
|
||||
height: itemInfo?.height || "",
|
||||
thickness: itemInfo?.thickness || "",
|
||||
@@ -225,13 +242,46 @@ export default function InventoryStatusPage() {
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
width: i.width || "",
|
||||
height: i.height || "",
|
||||
thickness: i.thickness || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -285,6 +335,35 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -297,6 +376,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -307,8 +400,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -317,17 +410,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -391,6 +500,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -444,6 +554,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -519,6 +636,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -678,6 +797,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -687,6 +868,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -696,6 +878,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [showExcelUpload, setShowExcelUpload] = useState(false);
|
||||
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
|
||||
setTreeHasChanges(true);
|
||||
};
|
||||
|
||||
// 하위 품목 추가 시작
|
||||
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
|
||||
const handleTreeAddChild = (parentId: string | null) => {
|
||||
setAddTargetParentId(parentId);
|
||||
setTreeItemSearchOpen(true);
|
||||
searchItems("");
|
||||
setItemSearchTarget("tree");
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchPage(1);
|
||||
setShowItemSearchModal(true);
|
||||
searchItems(1);
|
||||
};
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
@@ -1191,33 +1198,37 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 품목 검색 ───────────────────────────────
|
||||
const searchItems = async (keyword?: string) => {
|
||||
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
|
||||
const searchItems = async (pageOverride?: number, keyword?: string) => {
|
||||
const kw = (keyword ?? itemSearchKeyword).trim();
|
||||
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
|
||||
? pageOverride
|
||||
: itemSearchPage;
|
||||
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
// 키워드를 품명 또는 품목코드 어느 쪽에든 매칭 (OR 조건 불가 시 품명 우선)
|
||||
const filters: any[] = [];
|
||||
if (kw) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
}
|
||||
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1,
|
||||
size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
let total = res.data?.data?.total || 0;
|
||||
|
||||
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
|
||||
// 키워드가 있고 품명 매칭이 없으면 품목코드로 재시도
|
||||
if (kw && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
total = res2.data?.data?.total || 0;
|
||||
}
|
||||
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
|
||||
// 렌더 전 코드 → 라벨 변환 (division + inventory_unit)
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
@@ -1229,9 +1240,17 @@ export default function BomManagementPage() {
|
||||
if (out.inventory_unit) {
|
||||
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
|
||||
}
|
||||
// 규격: width x height x thickness 우선, 없으면 size 필드
|
||||
const w = out.width, h = out.height, t = out.thickness;
|
||||
if (w || h || t) {
|
||||
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
|
||||
} else {
|
||||
out._spec = out.size || "";
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(total);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1239,6 +1258,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (showItemSearchModal) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -1246,6 +1271,13 @@ export default function BomManagementPage() {
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.inventory_unit);
|
||||
if (itemSearchTarget === "tree") {
|
||||
handleTreeItemSelect(item);
|
||||
setShowItemSearchModal(false);
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchResults([]);
|
||||
return;
|
||||
}
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
@@ -1972,7 +2004,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -1983,7 +2015,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
@@ -2084,7 +2116,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("detail");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
@@ -2186,8 +2218,8 @@ export default function BomManagementPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* ─── 품목 검색 모달 ──────────────────────── */}
|
||||
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogDescription>품목코드 또는 품명으로 검색해주세요</DialogDescription>
|
||||
@@ -2198,13 +2230,13 @@ export default function BomManagementPage() {
|
||||
placeholder="품목코드 또는 품명 입력"
|
||||
value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
|
||||
/>
|
||||
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
|
||||
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
|
||||
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-lg">
|
||||
<div className="max-h-[320px] overflow-auto border rounded-lg">
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
@@ -2216,20 +2248,35 @@ export default function BomManagementPage() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
|
||||
<span className="text-xs">{item.item_name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
|
||||
<span className="text-xs truncate flex-1">{item.item_name}</span>
|
||||
{item._spec && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
{itemSearchTotal > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
+2
-2
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -216,9 +216,16 @@ export default function ChunganSalesOrderPage() {
|
||||
// 품목 선택 모달
|
||||
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchWidth, setItemSearchWidth] = useState("");
|
||||
const [itemSearchHeight, setItemSearchHeight] = useState("");
|
||||
const [itemSearchThickness, setItemSearchThickness] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 기타
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
@@ -707,24 +714,54 @@ export default function ChunganSalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
// COMPANY_30: type 컬럼에 라벨 "제품"이 직접 저장돼 있어 label로 equals 필터
|
||||
const searchItems = async (pageOverride?: number) => {
|
||||
setItemSearchLoading(true);
|
||||
const page = pageOverride ?? itemSearchPage;
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
|
||||
filters.push({ columnName: "type", operator: "equals", value: "제품" });
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
|
||||
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
|
||||
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setItemSearchResults([]); }
|
||||
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 렌더 전 코드→라벨 변환 (단위)
|
||||
const resolved = raw.map((r) => ({
|
||||
...r,
|
||||
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
|
||||
}));
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(res.data?.data?.total || 0);
|
||||
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
|
||||
finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (itemSelectOpen) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
const t = setTimeout(() => {
|
||||
setItemSearchPage(1);
|
||||
searchItems(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
|
||||
|
||||
// 품목 선택 → 리피터에 추가
|
||||
const addSelectedItemsToDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
@@ -762,14 +799,18 @@ export default function ChunganSalesOrderPage() {
|
||||
setItemCheckedIds(new Set());
|
||||
};
|
||||
|
||||
// 빈 행 추가 (품명 직접 입력용)
|
||||
// 빈 행 추가 (품명 직접 입력용) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
const addEmptyRow = () => {
|
||||
const divisionCode = "CAT_ML8ZFVEL_1TOR";
|
||||
const typeCode = "CAT_MLYPJFO9_36XG";
|
||||
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
|
||||
const typeLabel = categoryOptions["item_type"]?.find((o) => o.code === typeCode)?.label || "제품";
|
||||
setModalDetailRows((prev) => [...prev, {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: false,
|
||||
part_code: "", part_name: "", spec: "",
|
||||
division: "", _divisionLabel: "",
|
||||
type: "", _typeLabel: "",
|
||||
division: divisionCode, _divisionLabel: divisionLabel,
|
||||
type: typeCode, _typeLabel: typeLabel,
|
||||
unit: "㎡",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: "", amount: "",
|
||||
@@ -820,27 +861,11 @@ export default function ChunganSalesOrderPage() {
|
||||
const renderModalCell = (colKey: string, row: any, idx: number) => {
|
||||
switch (colKey) {
|
||||
case "division":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="관리품목" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// 관리품목=영업관리 고정 (행추가/검색추가 모두 동일)
|
||||
return <span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>;
|
||||
case "type":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._typeLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.type || ""} onValueChange={(v) => updateDetailRow(idx, "type", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="품목구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{(categoryOptions["item_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
// 품목구분=제품 고정 (행추가/검색추가 모두 동일)
|
||||
return <span className="text-sm px-2">{row._typeLabel || "제품"}</span>;
|
||||
case "part_name":
|
||||
return row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row.part_name || "-"}</span>
|
||||
@@ -1205,7 +1230,7 @@ export default function ChunganSalesOrderPage() {
|
||||
<Button size="sm" variant="outline" onClick={addEmptyRow}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 행 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
|
||||
<Search className="w-4 h-4 mr-1" /> 품목 검색
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1288,14 +1313,30 @@ export default function ChunganSalesOrderPage() {
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>수주에 추가할 품목을 선택하세요. 품목이 없으면 "행 추가"로 직접 입력도 가능합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명 검색" value={itemSearchKeyword}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input placeholder="품명" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 flex-1 min-w-[140px]" />
|
||||
<Input type="number" placeholder="가로" value={itemSearchWidth}
|
||||
onChange={(e) => setItemSearchWidth(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="세로" value={itemSearchHeight}
|
||||
onChange={(e) => setItemSearchHeight(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="두께" value={itemSearchThickness}
|
||||
onChange={(e) => setItemSearchThickness(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[80px]" />
|
||||
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-9"
|
||||
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
@@ -1341,6 +1382,16 @@ export default function ChunganSalesOrderPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · 페이지 {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +403,34 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
// 새 재고 레코드 생성
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +494,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +548,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +630,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +791,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +862,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +872,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,51 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
// 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +327,36 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용)
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +369,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +393,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +403,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +493,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +547,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +629,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +790,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +861,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +871,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function EquipmentInfoPage() {
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
const [inspectionEditMode, setInspectionEditMode] = useState(false);
|
||||
const [checkedInspectionIds, setCheckedInspectionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 소모품 추가/수정 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
@@ -93,6 +94,7 @@ export default function EquipmentInfoPage() {
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
const [consumableEditMode, setConsumableEditMode] = useState(false);
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
const [checkedConsumableIds, setCheckedConsumableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
@@ -200,6 +202,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
setCheckedInspectionIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetchData = async () => {
|
||||
setInspectionLoading(true);
|
||||
@@ -217,6 +220,7 @@ export default function EquipmentInfoPage() {
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
setCheckedConsumableIds(new Set());
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = async () => {
|
||||
setConsumableLoading(true);
|
||||
@@ -292,6 +296,34 @@ export default function EquipmentInfoPage() {
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 삭제
|
||||
const handleInspectionDelete = async () => {
|
||||
const ids = Array.from(checkedInspectionIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedInspectionIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 소모품 삭제
|
||||
const handleConsumableDelete = async () => {
|
||||
const ids = Array.from(checkedConsumableIds);
|
||||
if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; }
|
||||
const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) });
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedConsumableIds(new Set());
|
||||
refreshRight();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
@@ -546,15 +578,23 @@ export default function EquipmentInfoPage() {
|
||||
<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={checkedInspectionIds.size === 0} onClick={handleInspectionDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 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); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" disabled={checkedConsumableIds.size === 0} onClick={handleConsumableDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10">
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,6 +673,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length;
|
||||
if (allChecked) setCheckedInspectionIds(new Set());
|
||||
else setCheckedInspectionIds(new Set(inspections.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={inspections.length > 0 && checkedInspectionIds.size === inspections.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
@@ -660,6 +710,20 @@ export default function EquipmentInfoPage() {
|
||||
setInspectionEditMode(true);
|
||||
setInspectionModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedInspectionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedInspectionIds.has(item.id)} />
|
||||
</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_method", item.inspection_method)}</TableCell>
|
||||
@@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[40px] text-center cursor-pointer"
|
||||
onClick={() => {
|
||||
const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length;
|
||||
if (allChecked) setCheckedConsumableIds(new Set());
|
||||
else setCheckedConsumableIds(new Set(consumables.map((i) => i.id)));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={consumables.length > 0 && checkedConsumableIds.size === consumables.length} />
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -703,6 +777,20 @@ export default function EquipmentInfoPage() {
|
||||
loadConsumableItems();
|
||||
setConsumableModalOpen(true);
|
||||
}}>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedConsumableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checkedConsumableIds.has(item.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock";
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "warehouse_code", label: "창고" },
|
||||
{ key: "location_code", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
@@ -87,6 +89,8 @@ const getStatusVariant = (
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
case "미등록":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
@@ -119,6 +123,15 @@ export default function InventoryStatusPage() {
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 재고 없는 품목 표시 여부
|
||||
const [showMissingItems, setShowMissingItems] = useState(false);
|
||||
|
||||
// 창고 목록 (조정 모달에서 사용)
|
||||
const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 선택된 창고의 위치 목록 (조정 모달에서 사용)
|
||||
const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
@@ -132,7 +145,9 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
warehouse_code: string;
|
||||
location_code: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
@@ -201,8 +216,9 @@ export default function InventoryStatusPage() {
|
||||
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.inventory_unit || "" }]));
|
||||
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }]));
|
||||
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
||||
setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: 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;
|
||||
@@ -213,19 +229,50 @@ export default function InventoryStatusPage() {
|
||||
return {
|
||||
...r,
|
||||
item_name: itemInfo?.name || "",
|
||||
spec: itemInfo?.spec || "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
||||
status: resolve("status", r.status),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
};
|
||||
});
|
||||
setStockItems(data);
|
||||
|
||||
if (showMissingItems) {
|
||||
const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean));
|
||||
const missingRows = items
|
||||
.filter((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
return code && !existingCodes.has(code);
|
||||
})
|
||||
.map((i: any) => {
|
||||
const code = i.item_number || i.item_code;
|
||||
const rawUnit = i.inventory_unit || "";
|
||||
return {
|
||||
id: `missing-${code}`,
|
||||
item_code: code,
|
||||
item_name: i.item_name || "",
|
||||
spec: i.size || "",
|
||||
warehouse_code: "",
|
||||
warehouse_name: "",
|
||||
location_code: "",
|
||||
current_qty: "0",
|
||||
safety_qty: "",
|
||||
unit: resolve("item_inventory_unit", rawUnit) || rawUnit,
|
||||
status: "미등록",
|
||||
_isLow: false,
|
||||
_isMissing: true,
|
||||
};
|
||||
});
|
||||
setStockItems([...data, ...missingRows]);
|
||||
} else {
|
||||
setStockItems(data);
|
||||
}
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
}, [categoryOptions, searchFilters, showMissingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
@@ -279,6 +326,35 @@ export default function InventoryStatusPage() {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const whCode = adjustForm.warehouse_code;
|
||||
if (!whCode) {
|
||||
setLocationList([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setLocationList(
|
||||
rows
|
||||
.filter((r: any) => r.location_code)
|
||||
.map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code }))
|
||||
);
|
||||
} catch {
|
||||
setLocationList([]);
|
||||
}
|
||||
})();
|
||||
}, [adjustForm.warehouse_code]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
@@ -291,6 +367,20 @@ export default function InventoryStatusPage() {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const isMissing = !!selectedStock._isMissing;
|
||||
const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || "");
|
||||
const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || "");
|
||||
|
||||
if (isMissing && !targetWhCode) {
|
||||
toast.error("창고를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (isMissing && adjustForm.adjust_type === "감소") {
|
||||
toast.error("미등록 품목은 감소 조정이 불가해요");
|
||||
return;
|
||||
}
|
||||
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
@@ -301,8 +391,8 @@ export default function InventoryStatusPage() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
transaction_type: "조정",
|
||||
transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
quantity: String(changeQty),
|
||||
@@ -311,17 +401,33 @@ export default function InventoryStatusPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
if (isMissing) {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_code: selectedStock.item_code,
|
||||
warehouse_code: targetWhCode,
|
||||
location_code: targetLocCode,
|
||||
current_qty: String(afterQty),
|
||||
safety_qty: "0",
|
||||
last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" });
|
||||
setSelectedStockId(null);
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
@@ -385,6 +491,7 @@ export default function InventoryStatusPage() {
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_code,
|
||||
품명: r.item_name,
|
||||
규격: r.spec || "",
|
||||
창고: r.warehouse_name || r.warehouse_code,
|
||||
위치: r.location_code,
|
||||
현재수량: r.current_qty,
|
||||
@@ -438,6 +545,13 @@ export default function InventoryStatusPage() {
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={showMissingItems}
|
||||
onCheckedChange={(v) => setShowMissingItems(!!v)}
|
||||
/>
|
||||
<span>재고 없는 품목 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<EDataTable
|
||||
@@ -513,6 +627,8 @@ export default function InventoryStatusPage() {
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
warehouse_code: selectedStock._isMissing ? "" : (selectedStock.warehouse_code || ""),
|
||||
location_code: selectedStock._isMissing ? "" : (selectedStock.location_code || ""),
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
@@ -672,6 +788,68 @@ export default function InventoryStatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{selectedStock?._isMissing && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
창고 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.warehouse_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
warehouse_code: v,
|
||||
location_code: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="창고를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouseList.map((w) => (
|
||||
<SelectItem key={w.code} value={w.code}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
위치
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.location_code}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, location_code: v }))
|
||||
}
|
||||
disabled={!adjustForm.warehouse_code || locationList.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!adjustForm.warehouse_code
|
||||
? "창고를 먼저 선택하세요"
|
||||
: locationList.length === 0
|
||||
? "등록된 위치가 없어요"
|
||||
: "위치 선택 (선택 사항)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locationList.map((l) => (
|
||||
<SelectItem key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
@@ -681,6 +859,7 @@ export default function InventoryStatusPage() {
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
disabled={!!selectedStock?._isMissing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
@@ -690,6 +869,11 @@ export default function InventoryStatusPage() {
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStock?._isMissing && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
미등록 품목은 증가(신규 등록)만 가능해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
@@ -297,7 +297,11 @@ export default function BomManagementPage() {
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail">("master");
|
||||
const [itemSearchTarget, setItemSearchTarget] = useState<"master" | "detail" | "tree">("master");
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [showExcelUpload, setShowExcelUpload] = useState(false);
|
||||
@@ -780,11 +784,14 @@ export default function BomManagementPage() {
|
||||
setTreeHasChanges(true);
|
||||
};
|
||||
|
||||
// 하위 품목 추가 시작
|
||||
// 하위 품목 추가 시작 (BOM 등록 품목검색 모달 재사용)
|
||||
const handleTreeAddChild = (parentId: string | null) => {
|
||||
setAddTargetParentId(parentId);
|
||||
setTreeItemSearchOpen(true);
|
||||
searchItems("");
|
||||
setItemSearchTarget("tree");
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchPage(1);
|
||||
setShowItemSearchModal(true);
|
||||
searchItems(1);
|
||||
};
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
@@ -1191,33 +1198,34 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 품목 검색 ───────────────────────────────
|
||||
const searchItems = async (keyword?: string) => {
|
||||
// ─── 품목 검색 (서버 페이지네이션) ───────────────────────────────
|
||||
const searchItems = async (pageOverride?: number, keyword?: string) => {
|
||||
const kw = (keyword ?? itemSearchKeyword).trim();
|
||||
const pageCandidate = typeof pageOverride === "number" && Number.isFinite(pageOverride) && pageOverride > 0
|
||||
? pageOverride
|
||||
: itemSearchPage;
|
||||
const page = Number.isFinite(pageCandidate) && pageCandidate > 0 ? pageCandidate : 1;
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (kw) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
}
|
||||
if (kw) filters.push({ columnName: "item_name", operator: "contains", value: kw });
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1,
|
||||
size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
let total = res.data?.data?.total || 0;
|
||||
|
||||
// 키워드가 있고 품명으로 못 찾으면 품목코드로 재시도
|
||||
if (kw && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: kw }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
total = res2.data?.data?.total || 0;
|
||||
}
|
||||
// 카테고리 코드 → 라벨 변환 (division + inventory_unit)
|
||||
const resolved = rows.map((r: any) => {
|
||||
const out = { ...r };
|
||||
if (out.division) {
|
||||
@@ -1229,9 +1237,16 @@ export default function BomManagementPage() {
|
||||
if (out.inventory_unit) {
|
||||
out.inventory_unit = categoryOptions["inventory_unit"]?.find((o) => o.code === out.inventory_unit)?.label || out.inventory_unit;
|
||||
}
|
||||
const w = out.width, h = out.height, t = out.thickness;
|
||||
if (w || h || t) {
|
||||
out._spec = [w || "-", h || "-", t || "-"].join(" x ");
|
||||
} else {
|
||||
out._spec = out.size || "";
|
||||
}
|
||||
return out;
|
||||
});
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(total);
|
||||
} catch {
|
||||
toast.error("품목 검색에 실패했어요");
|
||||
} finally {
|
||||
@@ -1239,6 +1254,12 @@ export default function BomManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (showItemSearchModal) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
const resolveUnit = (code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions["inventory_unit"]?.find((o) => o.code === code)?.label || code;
|
||||
@@ -1246,6 +1267,13 @@ export default function BomManagementPage() {
|
||||
|
||||
const selectItem = (item: any) => {
|
||||
const unitLabel = resolveUnit(item.inventory_unit);
|
||||
if (itemSearchTarget === "tree") {
|
||||
handleTreeItemSelect(item);
|
||||
setShowItemSearchModal(false);
|
||||
setItemSearchKeyword("");
|
||||
setItemSearchResults([]);
|
||||
return;
|
||||
}
|
||||
if (itemSearchTarget === "master") {
|
||||
setMasterForm((prev) => ({
|
||||
...prev,
|
||||
@@ -1972,7 +2000,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -1983,7 +2011,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("master");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
@@ -2084,7 +2112,7 @@ export default function BomManagementPage() {
|
||||
setItemSearchTarget("detail");
|
||||
setItemSearchKeyword("");
|
||||
setShowItemSearchModal(true);
|
||||
searchItems("");
|
||||
searchItems();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
@@ -2186,8 +2214,8 @@ export default function BomManagementPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* ─── 품목 검색 모달 ──────────────────────── */}
|
||||
<Dialog open={showItemSearchModal} onOpenChange={setShowItemSearchModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<Dialog open={showItemSearchModal} onOpenChange={(o) => { if (!o) { setItemSearchKeyword(""); setItemSearchResults([]); setItemSearchPage(1); setItemSearchTotal(0); } setShowItemSearchModal(o); }}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
<DialogDescription>품목코드 또는 품명으로 검색해주세요</DialogDescription>
|
||||
@@ -2198,13 +2226,13 @@ export default function BomManagementPage() {
|
||||
placeholder="품목코드 또는 품명 입력"
|
||||
value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { setItemSearchPage(1); searchItems(1); } }}
|
||||
/>
|
||||
<Button size="sm" className="h-9" onClick={() => searchItems()} disabled={itemSearchLoading}>
|
||||
<Button size="sm" className="h-9" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading}>
|
||||
{itemSearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto border rounded-lg">
|
||||
<div className="max-h-[320px] overflow-auto border rounded-lg">
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-1">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
@@ -2216,20 +2244,35 @@ export default function BomManagementPage() {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{item.item_number}</span>
|
||||
<span className="text-xs">{item.item_name}</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.inventory_unit || ""}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{item.item_number}</span>
|
||||
<span className="text-xs truncate flex-1">{item.item_name}</span>
|
||||
{item._spec && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono shrink-0" title={item._spec}>[{item._spec}]</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">{item.inventory_unit || ""}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
{itemSearchTotal > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · {itemSearchPage}/{Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))} 페이지</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -586,7 +586,7 @@ export function WorkStandardEditModal({
|
||||
<thead className="sticky top-0 bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">순서</th>
|
||||
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="w-24 px-2 py-2 text-left font-medium text-muted-foreground">유형</th>
|
||||
<th className="px-2 py-2 text-left font-medium text-muted-foreground">내용</th>
|
||||
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">필수</th>
|
||||
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">관리</th>
|
||||
@@ -597,7 +597,7 @@ export function WorkStandardEditModal({
|
||||
<tr key={detail.id || idx} className="border-b transition-colors hover:bg-muted/30">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
<Badge variant="outline" className="text-[10px] font-normal whitespace-nowrap">
|
||||
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
@@ -119,9 +119,16 @@ export default function JeilGlassOrderPage() {
|
||||
// 품목 선택 모달
|
||||
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchWidth, setItemSearchWidth] = useState("");
|
||||
const [itemSearchHeight, setItemSearchHeight] = useState("");
|
||||
const [itemSearchThickness, setItemSearchThickness] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
// 서버사이드 페이지네이션
|
||||
const [itemSearchPage, setItemSearchPage] = useState(1);
|
||||
const [itemSearchPageSize] = useState(20);
|
||||
const [itemSearchTotal, setItemSearchTotal] = useState(0);
|
||||
|
||||
// 기타
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
@@ -559,23 +566,55 @@ export default function JeilGlassOrderPage() {
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
const searchItems = async () => {
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
// COMPANY_9: type 컬럼에 코드가 저장돼 있어 코드값으로 equals 필터
|
||||
const searchItems = async (pageOverride?: number) => {
|
||||
setItemSearchLoading(true);
|
||||
const page = pageOverride ?? itemSearchPage;
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const productCode = categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code;
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
|
||||
if (productCode) filters.push({ columnName: "type", operator: "equals", value: productCode });
|
||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
if (itemSearchWidth) filters.push({ columnName: "width", operator: "equals", value: itemSearchWidth });
|
||||
if (itemSearchHeight) filters.push({ columnName: "height", operator: "equals", value: itemSearchHeight });
|
||||
if (itemSearchThickness) filters.push({ columnName: "thickness", operator: "equals", value: itemSearchThickness });
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
page, size: itemSearchPageSize,
|
||||
dataFilter: { enabled: true, filters },
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setItemSearchResults([]); }
|
||||
const raw: any[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 렌더 전 코드→라벨 변환 (단위)
|
||||
const resolved = raw.map((r) => ({
|
||||
...r,
|
||||
unit: categoryOptions["item_unit"]?.find((o) => o.code === r.unit)?.label || r.unit || "",
|
||||
}));
|
||||
setItemSearchResults(resolved);
|
||||
setItemSearchTotal(res.data?.data?.total || 0);
|
||||
} catch { setItemSearchResults([]); setItemSearchTotal(0); }
|
||||
finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 페이지 변경 시 재조회
|
||||
useEffect(() => {
|
||||
if (itemSelectOpen) searchItems();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchPage]);
|
||||
|
||||
// 필터 입력 시 자동 조회 (debounce 350ms, 1페이지로 리셋)
|
||||
useEffect(() => {
|
||||
if (!itemSelectOpen) return;
|
||||
const t = setTimeout(() => {
|
||||
setItemSearchPage(1);
|
||||
searchItems(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemSearchKeyword, itemSearchWidth, itemSearchHeight, itemSearchThickness]);
|
||||
|
||||
// 품목 선택 → 리피터에 추가
|
||||
const addSelectedItemsToDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
@@ -614,11 +653,14 @@ export default function JeilGlassOrderPage() {
|
||||
};
|
||||
|
||||
// 빈 행 추가 (품명 직접 입력용)
|
||||
// 빈 행 추가 — 관리품목=영업관리 고정
|
||||
const addEmptyRow = () => {
|
||||
const divisionCode = "CAT_ML8ZFVEL_1TOR";
|
||||
const divisionLabel = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || "영업관리";
|
||||
setModalDetailRows((prev) => [...prev, {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
_fromItemInfo: false,
|
||||
part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡",
|
||||
part_code: "", part_name: "", spec: "", division: divisionCode, _divisionLabel: divisionLabel, unit: "㎡",
|
||||
width: "", height: "", thickness: "", area: "",
|
||||
qty: "", unit_price: "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
@@ -990,7 +1032,7 @@ export default function JeilGlassOrderPage() {
|
||||
<Button size="sm" variant="outline" onClick={addEmptyRow}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 행 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); setItemSelectOpen(true); searchItems(1); }}>
|
||||
<Search className="w-4 h-4 mr-1" /> 품목 검색
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1050,18 +1092,9 @@ export default function JeilGlassOrderPage() {
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
|
||||
{/* 구분: 영업관리 고정 */}
|
||||
<TableCell>
|
||||
{row._fromItemInfo ? (
|
||||
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
|
||||
) : (
|
||||
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
|
||||
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<span className="text-sm px-2">{row._divisionLabel || "영업관리"}</span>
|
||||
</TableCell>
|
||||
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
|
||||
<TableCell>
|
||||
@@ -1148,14 +1181,30 @@ export default function JeilGlassOrderPage() {
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>수주에 추가할 품목을 선택하세요. 품목이 없으면 "행 추가"로 직접 입력도 가능합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명 검색" value={itemSearchKeyword}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Input placeholder="품명" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 flex-1 min-w-[140px]" />
|
||||
<Input type="number" placeholder="가로" value={itemSearchWidth}
|
||||
onChange={(e) => setItemSearchWidth(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="세로" value={itemSearchHeight}
|
||||
onChange={(e) => setItemSearchHeight(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[90px]" />
|
||||
<Input type="number" placeholder="두께" value={itemSearchThickness}
|
||||
onChange={(e) => setItemSearchThickness(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setItemSearchPage(1), searchItems(1))}
|
||||
className="h-9 w-[80px]" />
|
||||
<Button size="sm" onClick={() => { setItemSearchPage(1); searchItems(1); }} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-9"
|
||||
onClick={() => { setItemSearchKeyword(""); setItemSearchWidth(""); setItemSearchHeight(""); setItemSearchThickness(""); setItemSearchPage(1); searchItems(1); }}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
@@ -1201,6 +1250,16 @@ export default function JeilGlassOrderPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1 pt-2">
|
||||
<span>전체 <b className="text-foreground">{itemSearchTotal.toLocaleString()}</b>건 · 페이지 {itemSearchPage} / {Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize))}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage(1)}>«</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage === 1 || itemSearchLoading} onClick={() => setItemSearchPage((p) => Math.max(1, p - 1))}>‹</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage((p) => p + 1)}>›</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" disabled={itemSearchPage >= Math.ceil(itemSearchTotal / itemSearchPageSize) || itemSearchLoading} onClick={() => setItemSearchPage(Math.max(1, Math.ceil(itemSearchTotal / itemSearchPageSize)))}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
* SmartSelect
|
||||
*
|
||||
* 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트.
|
||||
* - 옵션 5개 미만: 기본 Select (드롭다운)
|
||||
* - 옵션 5개 이상: Combobox (검색 + 드롭다운)
|
||||
* - 옵션 5개 미만: 기본 Select
|
||||
* - 옵션 5개 이상: 검색 + 가상 스크롤 Combobox (대용량 옵션도 빠르게 처리)
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
const SEARCH_THRESHOLD = 5;
|
||||
const ITEM_HEIGHT = 36;
|
||||
const LIST_HEIGHT = 280;
|
||||
|
||||
export interface SmartSelectOption {
|
||||
code: string;
|
||||
@@ -41,12 +44,40 @@ export function SmartSelect({
|
||||
className,
|
||||
}: SmartSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedLabel = useMemo(
|
||||
() => options.find((o) => o.code === value)?.label,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
// 팝오버 닫힐 때 검색어 리셋
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
// 검색어로 옵션 필터 (대소문자 무시)
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [options, search]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
// 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = requestAnimationFrame(() => virtualizer.measure());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, virtualizer, filtered.length]);
|
||||
|
||||
if (options.length < SEARCH_THRESHOLD) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
@@ -85,37 +116,59 @@ export function SmartSelect({
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command
|
||||
filter={(val, search) => {
|
||||
if (!search) return 1;
|
||||
return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o.code}
|
||||
value={o.label}
|
||||
onSelect={() => {
|
||||
onValueChange(o.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
<div className="flex items-center border-b px-2">
|
||||
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto py-1"
|
||||
style={{ height: LIST_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const o = filtered[vItem.index];
|
||||
const isSelected = value === o.code;
|
||||
return (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onValueChange(o.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === o.code ? "opacity-100" : "opacity-0",
|
||||
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
|
||||
isSelected && "bg-accent/60",
|
||||
)}
|
||||
/>
|
||||
{o.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
style={{
|
||||
height: `${vItem.size}px`,
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FileDown, FileText, Loader2, Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComponentConfig, ReportPage, WatermarkConfig } from "@/types/report";
|
||||
import { ComponentConfig, GridCell, ReportPage, WatermarkConfig } from "@/types/report";
|
||||
import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import {
|
||||
@@ -28,6 +28,10 @@ interface ReportInlineViewerProps {
|
||||
showToolbar?: boolean;
|
||||
/** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */
|
||||
onComponentClick?: (component: ComponentConfig) => void;
|
||||
/** cellType="input" 셀의 커스텀 값 오버라이드: { [componentId]: { [cellId]: value } } */
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
/** input 셀 클릭 콜백 */
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}
|
||||
|
||||
export function ReportInlineViewer({
|
||||
@@ -36,6 +40,8 @@ export function ReportInlineViewer({
|
||||
className = "",
|
||||
showToolbar = true,
|
||||
onComponentClick,
|
||||
cellOverrides,
|
||||
onInputCellClick,
|
||||
}: ReportInlineViewerProps) {
|
||||
const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams);
|
||||
|
||||
@@ -190,6 +196,7 @@ export function ReportInlineViewer({
|
||||
page={page} pageIndex={pageIndex} totalPages={pages.length}
|
||||
pages={pages} watermark={watermark} getQueryResult={getQueryResult}
|
||||
editable={editable} onComponentClick={onComponentClick}
|
||||
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,10 +233,12 @@ function WatermarkLayer({ watermark, pageWidth, pageHeight }: { watermark: Water
|
||||
return null;
|
||||
}
|
||||
|
||||
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick }: {
|
||||
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick }: {
|
||||
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
|
||||
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
|
||||
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}) {
|
||||
const comps = page.components ?? [];
|
||||
const sortedByY = [...comps].sort((a, b) => a.y - b.y);
|
||||
@@ -286,6 +295,7 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
|
||||
{sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => (
|
||||
<ComponentRenderer key={comp.id} comp={comp} pageIndex={pageIndex} totalPages={totalPages}
|
||||
pages={pages} getQueryResult={getQueryResult} editable={editable} onComponentClick={onComponentClick}
|
||||
cellOverrides={cellOverrides} onInputCellClick={onInputCellClick}
|
||||
yOffset={offsets[comp.id] || 0}
|
||||
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
|
||||
))}
|
||||
@@ -293,10 +303,12 @@ function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryRe
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, yOffset = 0, measureRef }: {
|
||||
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, cellOverrides, onInputCellClick, yOffset = 0, measureRef }: {
|
||||
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -360,7 +372,7 @@ function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult,
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{(comp.type === "text" || comp.type === "label") && <TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />}
|
||||
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} />}
|
||||
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} cellOverrides={cellOverrides} onInputCellClick={onInputCellClick} />}
|
||||
{comp.type === "image" && <ImageRenderer component={comp} />}
|
||||
{comp.type === "divider" && <DividerRenderer component={comp} />}
|
||||
{comp.type === "signature" && <SignatureRenderer component={comp} />}
|
||||
|
||||
@@ -782,6 +782,9 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
|
||||
<SelectItem value="field">
|
||||
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> 데이터 연결</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="input">
|
||||
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> 사용자 입력</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -812,6 +815,19 @@ export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEdit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCell.cellType === "input" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">입력 힌트(placeholder)</label>
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={selectedCell.inputPlaceholder ?? ""}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { inputPlaceholder: e.target.value })}
|
||||
placeholder="예: 결제 조건"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400">뷰어에서 빈 셀 클릭 시 입력 모달이 열립니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string
|
||||
function getGridCellValue(
|
||||
cell: GridCell,
|
||||
row?: Record<string, unknown>,
|
||||
override?: string,
|
||||
): string {
|
||||
if (cell.cellType === "input") {
|
||||
return override ?? "";
|
||||
}
|
||||
|
||||
if (cell.cellType === "static") return cell.value ?? "";
|
||||
|
||||
if (cell.cellType === "field") {
|
||||
@@ -67,7 +72,7 @@ function getGridCellValue(
|
||||
|
||||
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
|
||||
|
||||
function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
function GridTableRenderer({ component, getQueryResult, cellOverrides, onInputCellClick }: TableRendererProps) {
|
||||
const cells = component.gridCells ?? [];
|
||||
const rowCount = component.gridRowCount ?? 0;
|
||||
const colCount = component.gridColCount ?? 0;
|
||||
@@ -118,28 +123,38 @@ function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
|
||||
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
|
||||
|
||||
const displayValue = getGridCellValue(cell, dataRow);
|
||||
const overrideValue = cellOverrides?.[component.id]?.[cell.id];
|
||||
const displayValue = getGridCellValue(cell, dataRow, overrideValue);
|
||||
const isInputCell = cell.cellType === "input";
|
||||
const showPlaceholder = isInputCell && !overrideValue;
|
||||
|
||||
tds.push(
|
||||
<td
|
||||
key={cell.id}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
onClick={
|
||||
isInputCell && onInputCellClick
|
||||
? (e) => { e.stopPropagation(); onInputCellClick(component, cell); }
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
backgroundColor: cellBg,
|
||||
backgroundColor: isInputCell && onInputCellClick && !overrideValue ? "#fffbe6" : cellBg,
|
||||
border: `${borderW}px solid #d1d5db`,
|
||||
padding: "2px 4px",
|
||||
fontSize: cell.fontSize ?? 12,
|
||||
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
|
||||
color: cellColor,
|
||||
color: showPlaceholder ? "#9ca3af" : cellColor,
|
||||
textAlign: cell.align || "center",
|
||||
verticalAlign: cell.verticalAlign || "middle",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
fontStyle: showPlaceholder ? "italic" : undefined,
|
||||
cursor: isInputCell && onInputCellClick ? "pointer" : undefined,
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
{showPlaceholder ? (cell.inputPlaceholder || "입력") : displayValue}
|
||||
</td>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
export interface QueryResult {
|
||||
fields: string[];
|
||||
@@ -14,7 +14,12 @@ export interface TextRendererProps extends BaseRendererProps {
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
export interface TableRendererProps extends BaseRendererProps {}
|
||||
export interface TableRendererProps extends BaseRendererProps {
|
||||
/** cellType="input" 셀의 커스텀 값 맵: { [componentId]: { [cellId]: value } } */
|
||||
cellOverrides?: Record<string, Record<string, string>>;
|
||||
/** input 셀 클릭 시 호출 */
|
||||
onInputCellClick?: (component: ComponentConfig, cell: GridCell) => void;
|
||||
}
|
||||
|
||||
export interface ImageRendererProps {
|
||||
component: ComponentConfig;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
|
||||
|
||||
function stripCompanyPrefix(pathname: string): string {
|
||||
return pathname.replace(/^\/COMPANY_\d+/, "") || "/";
|
||||
@@ -12,10 +13,14 @@ function stripCompanyPrefix(pathname: string): string {
|
||||
* 현재 경로가 속한 "대분류" 메뉴의 objid 반환.
|
||||
* - 메뉴 트리에서 parent_obj_id를 따라 올라가며, 트리에 부모가 더 이상 존재하지 않는 루트 바로 직전의 노드를 반환
|
||||
* - lev 필드에 의존하지 않음 (백엔드 재귀 쿼리 결과와 무관)
|
||||
* - 탭 시스템(/main에서 탭으로 페이지를 띄우는 경우) 대응: usePathname이 '/main'을 반환하므로
|
||||
* 활성 탭의 adminUrl을 fallback으로 사용
|
||||
*/
|
||||
export function useCurrent2ndLevelMenuObjid(): number | null {
|
||||
const pathname = usePathname();
|
||||
const { userMenus, adminMenus } = useMenu();
|
||||
const tabs = useTabStore(selectTabs);
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!pathname) return null;
|
||||
@@ -23,7 +28,15 @@ export function useCurrent2ndLevelMenuObjid(): number | null {
|
||||
const all: any[] = [...(userMenus as any[]), ...(adminMenus as any[])];
|
||||
if (all.length === 0) return null;
|
||||
|
||||
const targetUrl = stripCompanyPrefix(pathname);
|
||||
// 1차: 실제 pathname 기준. 2차(탭 컨테이너 경로 등): 활성 탭 URL 기준
|
||||
let targetUrl = stripCompanyPrefix(pathname);
|
||||
const isRootLikePath = pathname === "/main" || pathname === "/" || pathname === "";
|
||||
if (isRootLikePath) {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab?.adminUrl) {
|
||||
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const byObjid = new Map<string, any>();
|
||||
for (const m of all) {
|
||||
@@ -61,5 +74,5 @@ export function useCurrent2ndLevelMenuObjid(): number | null {
|
||||
resultName: prev ? prev.menu_name_kor : node.menu_name_kor,
|
||||
});
|
||||
return resultObjid;
|
||||
}, [pathname, userMenus, adminMenus]);
|
||||
}, [pathname, userMenus, adminMenus, tabs, activeTabId]);
|
||||
}
|
||||
|
||||
@@ -388,10 +388,12 @@ export interface GridCell {
|
||||
col: number;
|
||||
rowSpan?: number;
|
||||
colSpan?: number;
|
||||
cellType: "static" | "field" | "formula";
|
||||
cellType: "static" | "field" | "formula" | "input";
|
||||
value?: string;
|
||||
field?: string;
|
||||
formula?: string;
|
||||
/** input 셀의 placeholder 힌트 */
|
||||
inputPlaceholder?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
verticalAlign?: "top" | "middle" | "bottom";
|
||||
fontWeight?: "normal" | "bold";
|
||||
|
||||
Reference in New Issue
Block a user