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:
kjs
2026-04-20 17:59:28 +09:00
parent 725aa976bf
commit 51c4fddde0
35 changed files with 2696 additions and 295 deletions
+2
View File
@@ -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">
@@ -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">
@@ -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">
@@ -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>
@@ -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> . &quot; &quot; .</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> . &quot; &quot; .</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>
+87 -34
View File
@@ -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;
+15 -2
View File
@@ -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]);
}
+3 -1
View File
@@ -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";