Merge pull request 'jskim-node' (#30) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/30
This commit is contained in:
@@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { moldCode } = req.params;
|
||||
|
||||
// 카테고리 코드/영문코드/한글라벨 모두 대응
|
||||
// 먼저 카테고리 값 조회하여 매핑
|
||||
// mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회
|
||||
const catSql = `SELECT value_code, value_label FROM category_values
|
||||
WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status'))
|
||||
AND company_code=$1`;
|
||||
const catRows = await query(catSql, [companyCode]);
|
||||
|
||||
// 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성
|
||||
const codesByLabel: Record<string, string[]> = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] };
|
||||
for (const cat of catRows) {
|
||||
const label = cat.value_label || "";
|
||||
if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code);
|
||||
else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code);
|
||||
else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code);
|
||||
else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code);
|
||||
}
|
||||
|
||||
const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(",");
|
||||
const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(",");
|
||||
const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(",");
|
||||
const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(",");
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
|
||||
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
|
||||
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
|
||||
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
|
||||
COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use,
|
||||
COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair,
|
||||
COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored,
|
||||
COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed
|
||||
FROM mold_serial
|
||||
WHERE mold_code = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
@@ -228,10 +228,11 @@ export async function deletePkgUnitItem(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
@@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg(
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const query = companyCode === "*"
|
||||
? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id`
|
||||
: `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`;
|
||||
const params = companyCode === "*" ? [id] : [id, companyCode];
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
|
||||
@@ -382,7 +382,31 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
const rows = result.rows;
|
||||
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
|
||||
let mappingByDetail: Record<string, string[]> = {};
|
||||
if (detailIds.length > 0) {
|
||||
const mapRes = await pool.query(
|
||||
`SELECT routing_detail_id, subcontractor_code
|
||||
FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id = ANY($1::uuid[])
|
||||
ORDER BY seq_order`,
|
||||
[detailIds]
|
||||
);
|
||||
for (const m of mapRes.rows) {
|
||||
const key = String(m.routing_detail_id);
|
||||
if (!mappingByDetail[key]) mappingByDetail[key] = [];
|
||||
mappingByDetail[key].push(m.subcontractor_code);
|
||||
}
|
||||
}
|
||||
const enriched = rows.map((r: any) => {
|
||||
const list = mappingByDetail[String(r.id)] || [];
|
||||
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장
|
||||
if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier);
|
||||
return { ...r, outsource_supplier_list: list };
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: enriched });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
@@ -400,6 +424,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세의 외주업체 매핑을 먼저 제거
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_subcontractor
|
||||
WHERE routing_detail_id IN (
|
||||
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
|
||||
)`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
@@ -407,11 +440,26 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
await client.query(
|
||||
const suppliers: string[] = Array.isArray(d.outsource_supplier_list)
|
||||
? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
||||
: (d.outsource_supplier ? [d.outsource_supplier] : []);
|
||||
const primaryLegacy = suppliers[0] || d.outsource_supplier || "";
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer]
|
||||
);
|
||||
const newDetailId = insertRes.rows[0].id;
|
||||
|
||||
for (let i = 0; i < suppliers.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
|
||||
[companyCode, newDetailId, suppliers[i], i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
@@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined;
|
||||
const filterCompanyCode = req.query.filterCompanyCode as string | undefined;
|
||||
const topLevelOnly = req.query.topLevelOnly === "true";
|
||||
|
||||
// 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode)
|
||||
@@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response
|
||||
columnName,
|
||||
effectiveCompanyCode,
|
||||
includeInactive,
|
||||
menuObjid
|
||||
menuObjid,
|
||||
topLevelOnly
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) {
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type,
|
||||
COALESCE(b.unit, i.unit) as unit,
|
||||
COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit,
|
||||
i.unit as item_unit,
|
||||
i.inventory_unit as item_inventory_unit,
|
||||
i.division, i.size, i.material
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
|
||||
@@ -223,13 +223,14 @@ class CategoryTreeService {
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
value_id, table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
(SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
|
||||
@@ -694,13 +694,16 @@ export async function mergeSchedules(
|
||||
[companyCode, ...scheduleIds]
|
||||
);
|
||||
|
||||
// 병합된 스케줄 생성
|
||||
// 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
@@ -1017,13 +1020,16 @@ export async function splitSchedule(
|
||||
[originalQty - splitQty, splitBy, planId, companyCode]
|
||||
);
|
||||
|
||||
// 분할된 새 계획 생성
|
||||
// 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
|
||||
@@ -167,7 +167,8 @@ class TableCategoryValueService {
|
||||
columnName: string,
|
||||
companyCode: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
): Promise<TableCategoryValue[]> {
|
||||
try {
|
||||
logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
|
||||
@@ -235,6 +236,10 @@ class TableCategoryValueService {
|
||||
query += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
if (topLevelOnly) {
|
||||
query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY value_order, value_label`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -150,17 +150,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -173,11 +173,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -482,9 +478,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,13 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
// 하위분류 사용 여부 (카테고리별 자동감지 + 수동 토글)
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +63,72 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 카테고리 선택 변경 시 하위분류 존재 여부 자동감지
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +186,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +219,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -145,17 +145,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -168,11 +168,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -415,9 +411,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
|
||||
@@ -323,7 +323,7 @@ export default function ChunganSalesOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
|
||||
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -247,6 +247,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -353,7 +355,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -383,10 +389,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -738,7 +744,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1050,9 +1056,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1078,24 +1084,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import {
|
||||
@@ -91,7 +92,8 @@ export function ItemRoutingTab() {
|
||||
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [formOutsources, setFormOutsources] = useState<string[]>([]);
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +109,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -267,7 +282,7 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder("Y");
|
||||
setFormWorkType("내부");
|
||||
setFormStandardTime("");
|
||||
setFormOutsource("");
|
||||
setFormOutsources([]);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -294,7 +309,10 @@ export function ItemRoutingTab() {
|
||||
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||||
setFormWorkType(row.work_type || "내부");
|
||||
setFormStandardTime(row.standard_time || "");
|
||||
setFormOutsource(row.outsource_supplier || "");
|
||||
const loaded = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
|
||||
? row.outsource_supplier_list
|
||||
: (row.outsource_supplier ? [row.outsource_supplier] : []);
|
||||
setFormOutsources(loaded);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -315,7 +333,8 @@ export function ItemRoutingTab() {
|
||||
return;
|
||||
}
|
||||
const proc = processes.find((p) => p.process_code === formProcessCode);
|
||||
const outsource = showOutsourceField ? formOutsource.trim() : "";
|
||||
const outsourceList = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
|
||||
const outsourcePrimary = outsourceList[0] || "";
|
||||
|
||||
setDetailSubmitting(true);
|
||||
try {
|
||||
@@ -330,7 +349,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimary,
|
||||
outsource_supplier_list: outsourceList,
|
||||
};
|
||||
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||||
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
|
||||
@@ -348,7 +368,8 @@ export function ItemRoutingTab() {
|
||||
is_fixed_order: formFixedOrder,
|
||||
work_type: formWorkType,
|
||||
standard_time: st || "0",
|
||||
outsource_supplier: outsource,
|
||||
outsource_supplier: outsourcePrimary,
|
||||
outsource_supplier_list: outsourceList,
|
||||
}
|
||||
: d,
|
||||
),
|
||||
@@ -385,6 +406,7 @@ export function ItemRoutingTab() {
|
||||
work_type: d.work_type || "내부",
|
||||
standard_time: String(d.standard_time ?? "0"),
|
||||
outsource_supplier: d.outsource_supplier || "",
|
||||
outsource_supplier_list: d.outsource_supplier_list || (d.outsource_supplier ? [d.outsource_supplier] : []),
|
||||
}));
|
||||
|
||||
setSaving(true);
|
||||
@@ -466,12 +488,20 @@ export function ItemRoutingTab() {
|
||||
|
||||
const detailsGridData = useMemo(
|
||||
() =>
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
details.map((d) => {
|
||||
const codes = Array.isArray(d.outsource_supplier_list) && d.outsource_supplier_list.length > 0
|
||||
? d.outsource_supplier_list
|
||||
: (d.outsource_supplier ? [d.outsource_supplier] : []);
|
||||
const names = codes
|
||||
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
|
||||
.filter(Boolean);
|
||||
return {
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: names.length === 0 ? "—" : names.join(", "),
|
||||
};
|
||||
}),
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -895,13 +925,46 @@ export function ItemRoutingTab() {
|
||||
</div>
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체 (다중 선택)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between font-normal">
|
||||
<span className="truncate text-left text-sm">
|
||||
{formOutsources.length === 0
|
||||
? "외주업체 선택"
|
||||
: formOutsources
|
||||
.map((c) => subcontractorOptions.find((s) => s.code === c)?.name || c)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
||||
{subcontractorOptions.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-3">등록된 외주업체가 없어요</div>
|
||||
) : subcontractorOptions.map((s) => {
|
||||
const checked = formOutsources.includes(s.code);
|
||||
return (
|
||||
<label
|
||||
key={s.code}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
setFormOutsources((prev) =>
|
||||
v ? [...prev, s.code] : prev.filter((c) => c !== s.code),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const FORM_FIELDS = [
|
||||
{ key: "user_type01", label: "대분류", type: "category" },
|
||||
{ key: "user_type02", label: "중분류", type: "category" },
|
||||
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
|
||||
{ key: "expiry", label: "유효기간", type: "expiry" },
|
||||
{ key: "image", label: "품목 이미지", type: "image" },
|
||||
{ key: "meno", label: "메모", type: "textarea" },
|
||||
] as const;
|
||||
@@ -170,6 +171,21 @@ const formatNum = (val: any): string => {
|
||||
return isNaN(n) ? String(val) : n.toLocaleString();
|
||||
};
|
||||
|
||||
// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략)
|
||||
const formatExpirySummary = (y: any, m: any, d: any): string => {
|
||||
const toInt = (v: any) => {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? 0 : Math.floor(n);
|
||||
};
|
||||
const years = toInt(y), months = toInt(m), days = toInt(d);
|
||||
const parts: string[] = [];
|
||||
if (years) parts.push(`${years}년`);
|
||||
if (months) parts.push(`${months}개월`);
|
||||
if (days) parts.push(`${days}일`);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const ITEM_GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
@@ -177,6 +193,7 @@ const ITEM_GRID_COLUMNS = [
|
||||
{ key: "inventory_unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가/구매단가" },
|
||||
{ key: "currency_code", label: "통화" },
|
||||
{ key: "expiry_summary", label: "유효기간" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
@@ -339,6 +356,7 @@ export default function PurchaseItemPage() {
|
||||
for (const col of CATEGORY_COLUMNS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days);
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
@@ -550,7 +568,7 @@ export default function PurchaseItemPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: updateFields,
|
||||
@@ -583,7 +601,7 @@ export default function PurchaseItemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
const { id, created_date, updated_date, expiry_summary, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
@@ -1166,6 +1184,7 @@ export default function PurchaseItemPage() {
|
||||
inventory_unit: { width: "w-[60px]" },
|
||||
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
|
||||
currency_code: { width: "w-[50px]" },
|
||||
expiry_summary: { width: "w-[110px]" },
|
||||
status: { width: "w-[60px]" },
|
||||
};
|
||||
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
@@ -1596,6 +1615,33 @@ export default function PurchaseItemPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "expiry" ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ key: "expiry_years", unit: "년" },
|
||||
{ key: "expiry_months", unit: "개월" },
|
||||
{ key: "expiry_days", unit: "일" },
|
||||
].map(({ key, unit }) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={formData[key] ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: v === "" ? null : Math.max(0, Math.floor(Number(v))),
|
||||
}));
|
||||
}}
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -48,6 +49,7 @@ const INSPECTION_COLUMNS = [
|
||||
{ key: "inspection_code", label: "검사코드" },
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "inspection_criteria", label: "검사기준" },
|
||||
{ key: "criteria_detail", label: "기준상세" },
|
||||
{ key: "inspection_item", label: "검사항목" },
|
||||
{ key: "inspection_method", label: "검사방법" },
|
||||
{ key: "judgment_criteria", label: "판단기준" },
|
||||
@@ -59,11 +61,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +116,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +870,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +948,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +986,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1021,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1439,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1471,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1729,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,8 +12,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -43,6 +47,7 @@ type InspectionRow = {
|
||||
inspection_detail: string;
|
||||
inspection_method: string;
|
||||
apply_process: string;
|
||||
classification: string;
|
||||
acceptance_criteria: string;
|
||||
is_required: boolean;
|
||||
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
|
||||
@@ -50,10 +55,36 @@ type InspectionRow = {
|
||||
unit?: string; // 검사 단위
|
||||
};
|
||||
|
||||
function SortableInspectionTableRow({ id, children }: { id: string; children: (dragHandle: React.ReactNode) => React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
const handle = (
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-muted-foreground/60 hover:text-muted-foreground"
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style}>
|
||||
{children(handle)}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
||||
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -253,6 +284,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -300,7 +433,13 @@ export default function ItemInspectionInfoPage() {
|
||||
// 선택된 탭의 검사항목 행
|
||||
const selectedTabRows = useMemo(() => {
|
||||
if (!selectedGroup || !selectedTypeTab) return [];
|
||||
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
|
||||
return [...filtered].sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
}, [selectedGroup, selectedTypeTab]);
|
||||
|
||||
// 검사기준 ID → 라벨
|
||||
@@ -334,6 +473,13 @@ export default function ItemInspectionInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
|
||||
allRows.sort((a: any, b: any) => {
|
||||
const av = parseInt(String(a.sort_order || "9999"), 10);
|
||||
const bv = parseInt(String(b.sort_order || "9999"), 10);
|
||||
if (av === bv) return String(a.id).localeCompare(String(b.id));
|
||||
return av - bv;
|
||||
});
|
||||
const rowMap: Record<string, InspectionRow[]> = {};
|
||||
const typeFlags: Record<string, boolean> = {};
|
||||
|
||||
@@ -360,7 +506,8 @@ export default function ItemInspectionInfoPage() {
|
||||
inspection_standard_id: r.inspection_standard_id || "",
|
||||
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
|
||||
inspection_method: mLabel,
|
||||
apply_process: "",
|
||||
apply_process: r.apply_process || "",
|
||||
classification: r.classification || "",
|
||||
acceptance_criteria: r.pass_criteria || "",
|
||||
is_required: r.is_required === "true" || r.is_required === true,
|
||||
judgment_criteria: jcLabel,
|
||||
@@ -378,9 +525,18 @@ export default function ItemInspectionInfoPage() {
|
||||
const addInspRow = (typeKey: string) => {
|
||||
setInspectionRows(prev => ({
|
||||
...prev,
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
|
||||
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
|
||||
}));
|
||||
};
|
||||
const reorderInspRows = (typeKey: string, fromId: string, toId: string) => {
|
||||
setInspectionRows(prev => {
|
||||
const list = prev[typeKey] || [];
|
||||
const fromIdx = list.findIndex(r => r.id === fromId);
|
||||
const toIdx = list.findIndex(r => r.id === toId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return prev;
|
||||
return { ...prev, [typeKey]: arrayMove(list, fromIdx, toIdx) };
|
||||
});
|
||||
};
|
||||
const removeInspRow = (typeKey: string, rowId: string) => {
|
||||
setInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
|
||||
};
|
||||
@@ -440,18 +596,23 @@ export default function ItemInspectionInfoPage() {
|
||||
}
|
||||
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
|
||||
const rows: any[] = [];
|
||||
let globalOrder = 0;
|
||||
for (const t of enabledTypes) {
|
||||
const typeRows = inspectionRows[t.key] || [];
|
||||
if (typeRows.length === 0) {
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
|
||||
globalOrder += 1;
|
||||
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
|
||||
} else {
|
||||
for (const r of typeRows) {
|
||||
globalOrder += 1;
|
||||
rows.push({
|
||||
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
|
||||
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
|
||||
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
|
||||
apply_process: r.apply_process || "", classification: r.classification || "",
|
||||
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
|
||||
manager_id: form.manager_id || "", memo: form.remarks || "",
|
||||
sort_order: String(globalOrder).padStart(4, "0"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -732,7 +893,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +974,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -872,15 +1033,17 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">검사기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -899,6 +1062,7 @@ export default function ItemInspectionInfoPage() {
|
||||
const proc = processOptions.find(p => p.code === code);
|
||||
return proc?.name || code;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
@@ -913,6 +1077,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1070,21 +1242,38 @@ export default function ItemInspectionInfoPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="text-[10px] font-bold w-[28px]" />
|
||||
<TableHead className="text-[10px] font-bold w-[170px]">검사기준 선택</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[130px]">검사기준 상세</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">검사방법</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[90px]">적용공정</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[100px]">구분</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableRow><TableCell colSpan={11} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={dndSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
if (over && active.id !== over.id) {
|
||||
reorderInspRows(key, String(active.id), String(over.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext items={inspectionRows[key].map(r => r.id)} strategy={verticalListSortingStrategy}>
|
||||
{inspectionRows[key].map((row) => (
|
||||
<SortableInspectionTableRow key={row.id} id={row.id}>
|
||||
{(dragHandle) => (<>
|
||||
<TableCell className="p-1 text-center">{dragHandle}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateInspRow(key, row.id, "inspection_standard_id", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="검사기준 선택" /></SelectTrigger>
|
||||
@@ -1107,6 +1296,9 @@ export default function ItemInspectionInfoPage() {
|
||||
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
@@ -1148,11 +1340,16 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>)}
|
||||
</SortableInspectionTableRow>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1172,6 +1369,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -145,6 +145,21 @@ const formatNum = (val: any): string => {
|
||||
return isNaN(n) ? String(val) : n.toLocaleString();
|
||||
};
|
||||
|
||||
// 유효기간 요약 문자열 (NULL/0은 해당 단위 생략)
|
||||
const formatExpirySummary = (y: any, m: any, d: any): string => {
|
||||
const toInt = (v: any) => {
|
||||
if (v === null || v === undefined || v === "") return 0;
|
||||
const n = Number(v);
|
||||
return isNaN(n) ? 0 : Math.floor(n);
|
||||
};
|
||||
const years = toInt(y), months = toInt(m), days = toInt(d);
|
||||
const parts: string[] = [];
|
||||
if (years) parts.push(`${years}년`);
|
||||
if (months) parts.push(`${months}개월`);
|
||||
if (days) parts.push(`${days}일`);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const ITEM_GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
@@ -153,6 +168,7 @@ const ITEM_GRID_COLUMNS = [
|
||||
{ key: "standard_price", label: "기준단가" },
|
||||
{ key: "selling_price", label: "판매가격" },
|
||||
{ key: "currency_code", label: "통화" },
|
||||
{ key: "expiry_summary", label: "유효기간" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
@@ -175,6 +191,7 @@ const FORM_FIELDS = [
|
||||
{ key: "user_type01", label: "대분류", type: "category" },
|
||||
{ key: "user_type02", label: "중분류", type: "category" },
|
||||
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
|
||||
{ key: "expiry", label: "유효기간", type: "expiry" },
|
||||
{ key: "image", label: "품목 이미지", type: "image" },
|
||||
{ key: "meno", label: "메모", type: "textarea" },
|
||||
];
|
||||
@@ -340,6 +357,7 @@ export default function SalesItemPage() {
|
||||
for (const col of CATS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
converted.expiry_summary = formatExpirySummary(r.expiry_years, r.expiry_months, r.expiry_days);
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
@@ -1044,7 +1062,7 @@ export default function SalesItemPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = editItemForm;
|
||||
const { id, created_date, updated_date, writer, company_code, expiry_summary, ...updateFields } = editItemForm;
|
||||
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: updateFields,
|
||||
@@ -1077,7 +1095,7 @@ export default function SalesItemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { id, created_date, updated_date, ...insertFields } = editItemForm;
|
||||
const { id, created_date, updated_date, expiry_summary, ...insertFields } = editItemForm;
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
...insertFields,
|
||||
@@ -1175,6 +1193,7 @@ export default function SalesItemPage() {
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
{ key: "expiry_summary", label: "유효기간", width: "w-[110px]" },
|
||||
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||
];
|
||||
|
||||
@@ -1598,6 +1617,33 @@ export default function SalesItemPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "expiry" ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ key: "expiry_years", unit: "년" },
|
||||
{ key: "expiry_months", unit: "개월" },
|
||||
{ key: "expiry_days", unit: "일" },
|
||||
].map(({ key, unit }) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editItemForm[key] ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEditItemForm((prev) => ({
|
||||
...prev,
|
||||
[key]: v === "" ? null : Math.max(0, Math.floor(Number(v))),
|
||||
}));
|
||||
}}
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={editItemForm[field.key] ? Number(String(editItemForm[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
|
||||
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { PdfUpload } from "@/components/common/PdfUpload";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -202,6 +203,7 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -218,6 +220,7 @@ export default function ItemInfoPage() {
|
||||
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 채번 관련 상태
|
||||
const [numberingRule, setNumberingRule] = useState<any>(null);
|
||||
@@ -560,10 +563,14 @@ export default function ItemInfoPage() {
|
||||
});
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
// 신규 등록: allocateCode 호출하여 실제 순번 확보
|
||||
// 신규 등록: 사용자가 직접 입력한 코드가 있으면 그대로 사용, 없으면 채번
|
||||
let finalItemNumber = formData.item_number || "";
|
||||
|
||||
if (numberingRuleIdRef.current) {
|
||||
// 채번 미리보기와 사용자 입력값이 다르면 사용자가 직접 수정한 것 → 그대로 사용
|
||||
const previewCode = numberingParts.length > 0 ? buildCodeFromParts(numberingParts, manualInputValue) : "";
|
||||
const userModified = finalItemNumber && finalItemNumber !== previewCode;
|
||||
|
||||
if (numberingRuleIdRef.current && !userModified) {
|
||||
try {
|
||||
const hasManual = numberingParts.some(p => p.isManual);
|
||||
const userInputCode = hasManual && manualInputValue
|
||||
@@ -604,19 +611,25 @@ export default function ItemInfoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
// 삭제 (다중)
|
||||
const handleDelete = async () => {
|
||||
if (!selectedId) {
|
||||
if (checkedIds.length === 0) {
|
||||
toast.error("삭제할 품목을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("선택한 품목을 삭제할까요?")) return;
|
||||
const ok = await confirm(`${checkedIds.length}건의 품목을 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: [{ id: selectedId }],
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success("삭제되었어요.");
|
||||
toast.success(`${checkedIds.length}건 삭제되었어요.`);
|
||||
setCheckedIds([]);
|
||||
setSelectedId(null);
|
||||
fetchItems();
|
||||
} catch (err) {
|
||||
@@ -691,8 +704,8 @@ export default function ItemInfoPage() {
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제{checkedIds.length > 0 && ` (${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
@@ -721,6 +734,9 @@ export default function ItemInfoPage() {
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
onRowDoubleClick={(row) => openEditModal(row)}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
|
||||
@@ -793,7 +809,7 @@ export default function ItemInfoPage() {
|
||||
rows={3}
|
||||
/>
|
||||
) : field.type === "numbering" ? (
|
||||
// 채번 세그먼트 UI
|
||||
// 채번 UI: 자동 채번값이 기본 표시되지만 사용자가 임의로 수정 가능
|
||||
isEditMode ? (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
@@ -805,8 +821,18 @@ export default function ItemInfoPage() {
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">생성 중...</span>
|
||||
</div>
|
||||
) : numberingParts.some(p => p.isManual) ? (
|
||||
// 파트별 세그먼트 렌더링 (수동 입력 파트 있음)
|
||||
) : (
|
||||
// 자동 채번값 표시 + 사용자 직접 수정 가능
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="자동 채번 (직접 입력 가능)"
|
||||
className="h-9"
|
||||
/>
|
||||
)
|
||||
/* 기존 세그먼트 UI 비활성화 — 대진산업은 직접 입력 허용
|
||||
numberingParts.some(p => p.isManual) ? (
|
||||
<div className="flex h-9 items-center rounded-md border border-input">
|
||||
{numberingParts.map((part, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
@@ -852,7 +878,6 @@ export default function ItemInfoPage() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// 전체 auto: 읽기전용 표시
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
disabled
|
||||
@@ -860,6 +885,7 @@ export default function ItemInfoPage() {
|
||||
className="h-9 bg-muted"
|
||||
/>
|
||||
)
|
||||
*/
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
@@ -916,6 +942,7 @@ export default function ItemInfoPage() {
|
||||
fetchItems();
|
||||
}}
|
||||
/>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1096,6 +1096,33 @@ export default function SubcontractorManagementPage() {
|
||||
/>
|
||||
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">대표이름</Label>
|
||||
<Input
|
||||
value={subcontractorForm.representative || ""}
|
||||
onChange={(e) => setSubcontractorForm((p) => ({ ...p, representative: e.target.value }))}
|
||||
placeholder="대표이름"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">회사 전화번호</Label>
|
||||
<Input
|
||||
value={subcontractorForm.phone || ""}
|
||||
onChange={(e) => handleFormChange("phone", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">팩스번호</Label>
|
||||
<Input
|
||||
value={subcontractorForm.fax || ""}
|
||||
onChange={(e) => handleFormChange("fax", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs">주소</Label>
|
||||
<Input
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,29 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
// 체크박스 기준: 1개만 체크된 경우 수정
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -1896,6 +1896,33 @@ export default function SupplierManagementPage() {
|
||||
/>
|
||||
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">대표이름</Label>
|
||||
<Input
|
||||
value={supplierForm.representative_name || ""}
|
||||
onChange={(e) => setSupplierForm((p) => ({ ...p, representative_name: e.target.value }))}
|
||||
placeholder="대표이름"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">회사 전화번호</Label>
|
||||
<Input
|
||||
value={supplierForm.phone || ""}
|
||||
onChange={(e) => handleFormChange("phone", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">팩스번호</Label>
|
||||
<Input
|
||||
value={supplierForm.fax_number || ""}
|
||||
onChange={(e) => handleFormChange("fax_number", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<Input
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -1887,6 +1887,43 @@ export default function CustomerManagementPage() {
|
||||
/>
|
||||
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">대표이름</Label>
|
||||
<Input
|
||||
value={customerForm.representative_name || ""}
|
||||
onChange={(e) => setCustomerForm((p) => ({ ...p, representative_name: e.target.value }))}
|
||||
placeholder="대표이름"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">회사 전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.phone || ""}
|
||||
onChange={(e) => handleFormChange("phone", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">팩스번호</Label>
|
||||
<Input
|
||||
value={customerForm.fax || ""}
|
||||
onChange={(e) => handleFormChange("fax", e.target.value)}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input
|
||||
value={customerForm.email || ""}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className={cn("h-9", formErrors.email && "border-destructive")}
|
||||
/>
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<Input
|
||||
|
||||
@@ -147,17 +147,17 @@ export default function EquipmentInfoPage() {
|
||||
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
||||
equipment_code: { width: "w-[110px]" },
|
||||
equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => v || "-" },
|
||||
equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" },
|
||||
manufacturer: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
installation_location: { width: "w-[100px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => v || "-" },
|
||||
operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" },
|
||||
};
|
||||
return ts.visibleColumns.map((col) => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
...colProps[col.key],
|
||||
}));
|
||||
}, [ts.visibleColumns]);
|
||||
}, [ts.visibleColumns, catOptions]);
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
@@ -170,11 +170,7 @@ export default function EquipmentInfoPage() {
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipments(raw);
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
@@ -437,9 +433,9 @@ export default function EquipmentInfoPage() {
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type),
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status),
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [
|
||||
{ key: "warehouse_code", label: "창고코드" },
|
||||
{ key: "warehouse_name", label: "창고명" },
|
||||
{ key: "warehouse_type", label: "유형" },
|
||||
{ key: "manager", label: "관리자" },
|
||||
{ key: "manager_name", label: "관리자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const LOCATION_TABLE = "warehouse_location";
|
||||
@@ -239,6 +239,8 @@ export default function WarehouseManagementPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const data = raw.map((r: any) => ({
|
||||
...r,
|
||||
_warehouse_type_code: r.warehouse_type,
|
||||
_status_code: r.status,
|
||||
warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type),
|
||||
status: resolveCategory(categoryOptions, "status", r.status),
|
||||
}));
|
||||
@@ -344,7 +346,11 @@ export default function WarehouseManagementPage() {
|
||||
|
||||
const openWarehouseEditModal = (row: any) => {
|
||||
setWarehouseEditMode(true);
|
||||
setWarehouseForm({ ...row });
|
||||
setWarehouseForm({
|
||||
...row,
|
||||
warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "",
|
||||
status: row._status_code ?? row.status ?? "",
|
||||
});
|
||||
setWarehouseModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -374,10 +380,10 @@ export default function WarehouseManagementPage() {
|
||||
warehouse_code: finalWarehouseCode,
|
||||
warehouse_name: warehouseForm.warehouse_name?.trim(),
|
||||
warehouse_type: warehouseForm.warehouse_type || "",
|
||||
manager: warehouseForm.manager || "",
|
||||
address: warehouseForm.address || "",
|
||||
manager_name: warehouseForm.manager_name || "",
|
||||
contact: warehouseForm.contact || "",
|
||||
status: warehouseForm.status || "",
|
||||
description: warehouseForm.description || "",
|
||||
memo: warehouseForm.memo || "",
|
||||
};
|
||||
|
||||
// 신규 등록 시 창고코드 중복 체크
|
||||
@@ -729,7 +735,7 @@ export default function WarehouseManagementPage() {
|
||||
창고코드: r.warehouse_code,
|
||||
창고명: r.warehouse_name,
|
||||
유형: r.warehouse_type,
|
||||
관리자: r.manager,
|
||||
관리자: r.manager_name,
|
||||
상태: r.status,
|
||||
})),
|
||||
"창고정보"
|
||||
@@ -1041,9 +1047,9 @@ export default function WarehouseManagementPage() {
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">관리자</Label>
|
||||
<Input
|
||||
value={warehouseForm.manager || ""}
|
||||
value={warehouseForm.manager_name || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value }))
|
||||
}
|
||||
placeholder="관리자를 입력해주세요"
|
||||
/>
|
||||
@@ -1069,24 +1075,24 @@ export default function WarehouseManagementPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 주소 (전체 너비) */}
|
||||
{/* 연락처 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">주소</Label>
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input
|
||||
value={warehouseForm.address || ""}
|
||||
value={warehouseForm.contact || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
placeholder="주소를 입력해주세요"
|
||||
placeholder="연락처를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
{/* 비고 (전체 너비) */}
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input
|
||||
value={warehouseForm.description || ""}
|
||||
value={warehouseForm.memo || ""}
|
||||
onChange={(e) =>
|
||||
setWarehouseForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
setWarehouseForm((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
|
||||
@@ -563,10 +563,6 @@ export default function CompanyPage() {
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
@@ -21,6 +26,12 @@ export default function OptionsSettingPage() {
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [useHierarchy, setUseHierarchy] = useState(false);
|
||||
const [hasChildRows, setHasChildRows] = useState(false);
|
||||
const [detectingHierarchy, setDetectingHierarchy] = useState(false);
|
||||
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,6 +62,71 @@ export default function OptionsSettingPage() {
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColumn || !selectedTableName) {
|
||||
setUseHierarchy(false);
|
||||
setHasChildRows(false);
|
||||
return;
|
||||
}
|
||||
const columnNameOnly = selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn;
|
||||
let cancelled = false;
|
||||
setDetectingHierarchy(true);
|
||||
(async () => {
|
||||
const res = await getCategoryValues(selectedTableName, columnNameOnly, true);
|
||||
if (cancelled) return;
|
||||
const values = (res as any)?.data || [];
|
||||
const hasChild = Array.isArray(values)
|
||||
? values.some(
|
||||
(v: any) =>
|
||||
(typeof v.depth === "number" && v.depth > 1) ||
|
||||
(v.parentValueId !== null && v.parentValueId !== undefined),
|
||||
)
|
||||
: false;
|
||||
setHasChildRows(hasChild);
|
||||
setUseHierarchy(hasChild);
|
||||
setDetectingHierarchy(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedColumn, selectedTableName]);
|
||||
|
||||
const handleToggleHierarchy = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (!checked && hasChildRows) {
|
||||
const ok = await confirm(
|
||||
"이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?",
|
||||
{ variant: "destructive", confirmText: "해제" },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
setUseHierarchy(checked);
|
||||
},
|
||||
[hasChildRows, confirm],
|
||||
);
|
||||
|
||||
const columnNameOnly = selectedColumn
|
||||
? selectedColumn.includes(".")
|
||||
? selectedColumn.split(".").pop()!
|
||||
: selectedColumn
|
||||
: "";
|
||||
|
||||
const headerRight = selectedColumn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="use-hierarchy-switch" className="cursor-pointer text-xs">
|
||||
하위분류 사용
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-hierarchy-switch"
|
||||
checked={useHierarchy}
|
||||
onCheckedChange={handleToggleHierarchy}
|
||||
disabled={detectingHierarchy}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
useHierarchy ? (
|
||||
<CategoryValueManagerTree
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={columnNameOnly}
|
||||
columnLabel={selectedColumnLabel}
|
||||
headerRight={headerRight}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,36 @@ export default function MoldInfoPage() {
|
||||
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
// ─── 카테고리 옵션 (금형유형, 운영상태) ───
|
||||
const [moldTypeCatOptions, setMoldTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [operationStatusCatOptions, setOperationStatusCatOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const flatten = (arr: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of arr) { result.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) result.push(...flatten(v.children)); }
|
||||
return result;
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const [typeRes, statusRes] = await Promise.all([
|
||||
apiClient.get("/table-categories/mold_mng/mold_type/values"),
|
||||
apiClient.get("/table-categories/mold_mng/operation_status/values"),
|
||||
]);
|
||||
if (typeRes.data?.success) setMoldTypeCatOptions(flatten(typeRes.data.data || []));
|
||||
if (statusRes.data?.success) setOperationStatusCatOptions(flatten(statusRes.data.data || []));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resolveMoldType = (code: string) => moldTypeCatOptions.find((o) => o.code === code)?.label || code;
|
||||
const resolveOpStatus = (code: string) => {
|
||||
const catLabel = operationStatusCatOptions.find((o) => o.code === code)?.label;
|
||||
if (catLabel) return catLabel;
|
||||
const legacyMap: Record<string, string> = { ACTIVE: "사용중", INACTIVE: "미사용", REPAIR: "수리중", DISPOSED: "폐기", IN_USE: "사용중" };
|
||||
return legacyMap[code] || code;
|
||||
};
|
||||
|
||||
// ─── 검색 필터 ───
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
@@ -426,7 +456,7 @@ export default function MoldInfoPage() {
|
||||
// ─── 카드 렌더링 ───
|
||||
const renderCard = (mold: any) => {
|
||||
const pct = calcLifePct(mold);
|
||||
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
|
||||
const stLabel = resolveOpStatus(mold.operation_status);
|
||||
const isSelected = selectedMoldCode === mold.mold_code;
|
||||
|
||||
return (
|
||||
@@ -460,7 +490,7 @@ export default function MoldInfoPage() {
|
||||
<Box className="w-8 h-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{stLabel}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +500,7 @@ export default function MoldInfoPage() {
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
|
||||
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
|
||||
{mold.mold_type && (
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] mt-1">{resolveMoldType(mold.mold_type)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -531,10 +561,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -546,10 +573,7 @@ export default function MoldInfoPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체</SelectItem>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {
|
||||
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
|
||||
<div className="flex gap-1.5 mb-4 flex-wrap">
|
||||
{selectedMold.mold_type && (
|
||||
<Badge variant="outline">{selectedMold.mold_type}</Badge>
|
||||
<Badge variant="outline">{resolveMoldType(selectedMold.mold_type)}</Badge>
|
||||
)}
|
||||
{selectedMold.category && (
|
||||
<Badge variant="secondary">{selectedMold.category}</Badge>
|
||||
)}
|
||||
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
|
||||
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
|
||||
<Badge variant="secondary">
|
||||
{resolveOpStatus(selectedMold.operation_status) || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -811,15 +835,15 @@ export default function MoldInfoPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serials.map((s: any) => {
|
||||
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
|
||||
const maxShot = detail?.shot_count || 0;
|
||||
const ssLabel = resolveOpStatus(s.status);
|
||||
const maxShot = selectedMold?.shot_count || 0;
|
||||
const curShot = s.current_shot_count || 0;
|
||||
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">{ssLabel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{maxShot > 0 ? (
|
||||
@@ -1043,10 +1067,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사출금형">사출금형</SelectItem>
|
||||
<SelectItem value="프레스금형">프레스금형</SelectItem>
|
||||
<SelectItem value="다이캐스팅">다이캐스팅</SelectItem>
|
||||
<SelectItem value="단조금형">단조금형</SelectItem>
|
||||
{moldTypeCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">사용중</SelectItem>
|
||||
<SelectItem value="INSPECTION">점검중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="IN_USE">사용중</SelectItem>
|
||||
<SelectItem value="STORED">보관중</SelectItem>
|
||||
<SelectItem value="REPAIR">수리중</SelectItem>
|
||||
<SelectItem value="DISPOSED">폐기</SelectItem>
|
||||
{operationStatusCatOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -497,12 +497,14 @@ export default function BomManagementPage() {
|
||||
const c = code.trim();
|
||||
return categoryOptions["division"]?.find((o) => o.code === c)?.label || c;
|
||||
}).filter((v: string) => v && v !== "s").join(", ");
|
||||
const rawUnit = d.unit || item?.inventory_unit || "";
|
||||
const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit;
|
||||
return {
|
||||
...d,
|
||||
item_number: item?.item_number || "",
|
||||
item_name: item?.item_name || "",
|
||||
item_type: divisionLabel,
|
||||
unit: d.unit || item?.inventory_unit || "",
|
||||
unit: unitLabel,
|
||||
spec: item?.size || item?.spec || "",
|
||||
writer: d.writer || "",
|
||||
updated_date: d.updated_at || d.updated_date || "",
|
||||
@@ -818,6 +820,15 @@ export default function BomManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 레벨(같은 부모) 중복 품목 체크
|
||||
const siblings = addTargetParentId
|
||||
? (findNodeById(editingTree, addTargetParentId)?.children || [])
|
||||
: editingTree;
|
||||
if (siblings.some((n) => n.child_item_id === item.id)) {
|
||||
toast.error("같은 레벨에 이미 동일 품목이 존재합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -1530,53 +1541,6 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 상세 카드 */}
|
||||
<div className="border-b shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<h3 className="text-[13px] font-bold text-foreground">BOM 상세정보</h3>
|
||||
<Button size="sm" variant="ghost" onClick={openEditModal}>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomHeader ? (
|
||||
<div className="grid grid-cols-2 text-sm">
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품목코드</span>
|
||||
<span className="font-mono text-xs">{bomHeader.item_code || bomHeader.item_number || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">품명</span>
|
||||
<span className="text-xs">{bomHeader.item_name || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">BOM 유형</span>
|
||||
<span className="text-xs">{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">버전</span>
|
||||
<span className="text-xs">{bomHeader.version || "-"}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b border-r">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">기준수량</span>
|
||||
<span className="text-xs">{bomHeader.base_qty || "1"} {bomHeader.unit || ""}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 border-b">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">상태</span>
|
||||
{renderStatusBadge(bomHeader.status)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3 col-span-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">메모</span>
|
||||
<span className="text-xs text-muted-foreground">{bomHeader.remark || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 하단 탭: 트리뷰 / 버전 / 이력 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<Tabs value={rightTab} onValueChange={(v) => {
|
||||
|
||||
@@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() {
|
||||
const [modalQuantity, setModalQuantity] = useState(0);
|
||||
const [modalStartDate, setModalStartDate] = useState("");
|
||||
const [modalEndDate, setModalEndDate] = useState("");
|
||||
const [modalManager, setModalManager] = useState("");
|
||||
const [modalWorkOrderNo, setModalWorkOrderNo] = useState("");
|
||||
const [modalRemarks, setModalRemarks] = useState("");
|
||||
const [modalEquipmentId, setModalEquipmentId] = useState("");
|
||||
|
||||
// 미리보기 데이터
|
||||
@@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() {
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// useConfirmDialog
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
||||
|
||||
// 수량 지정 분할 입력값
|
||||
const [customSplitQty, setCustomSplitQty] = useState<number | "">("");
|
||||
|
||||
// ========== 데이터 로드 ==========
|
||||
|
||||
@@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() {
|
||||
setModalQuantity(Number(plan.plan_qty));
|
||||
setModalStartDate(plan.start_date?.split("T")[0] || "");
|
||||
setModalEndDate(plan.end_date?.split("T")[0] || "");
|
||||
setModalManager((plan as any).manager_name || "");
|
||||
setModalWorkOrderNo((plan as any).work_order_no || "");
|
||||
setModalRemarks(plan.remarks || "");
|
||||
setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : ""));
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
manager_name: modalManager,
|
||||
work_order_no: modalWorkOrderNo,
|
||||
remarks: modalRemarks,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
@@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("생산계획이 수정되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("수정 실패: " + (err.message || ""));
|
||||
toast.error("수정 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]);
|
||||
|
||||
const handleDeletePlan = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
@@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("삭제되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlan, fetchPlans, confirm]);
|
||||
}, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 에러 메시지 추출 헬퍼
|
||||
const extractErrMsg = (err: any): string => {
|
||||
return err?.response?.data?.message || err?.message || "";
|
||||
};
|
||||
|
||||
// modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크)
|
||||
const isModalDirty = useCallback((): boolean => {
|
||||
if (!selectedPlan) return false;
|
||||
const planQty = Number(selectedPlan.plan_qty) || 0;
|
||||
const planStart = selectedPlan.start_date?.split("T")[0] || "";
|
||||
const planEnd = selectedPlan.end_date?.split("T")[0] || "";
|
||||
const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : "");
|
||||
return (
|
||||
planQty !== Number(modalQuantity) ||
|
||||
planStart !== modalStartDate ||
|
||||
planEnd !== modalEndDate ||
|
||||
planEq !== modalEquipmentId
|
||||
);
|
||||
}, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]);
|
||||
|
||||
// dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신
|
||||
const ensureSavedBeforeSplit = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedPlan) return false;
|
||||
if (!isModalDirty()) return true;
|
||||
try {
|
||||
const res = await updatePlan(selectedPlan.id, {
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
equipment_name: modalEquipmentId && modalEquipmentId !== "none"
|
||||
? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null
|
||||
: null,
|
||||
} as any);
|
||||
if (!res.success) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다");
|
||||
return false;
|
||||
}
|
||||
// selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조)
|
||||
setSelectedPlan((prev) => prev ? ({
|
||||
...prev,
|
||||
plan_qty: modalQuantity,
|
||||
start_date: modalStartDate,
|
||||
end_date: modalEndDate,
|
||||
equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null,
|
||||
} as any) : prev);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err));
|
||||
return false;
|
||||
}
|
||||
}, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]);
|
||||
|
||||
// 균등 분할 (2/3/4분할 버튼)
|
||||
const handleSplitSchedule = useCallback(async (splitCount: number) => {
|
||||
if (!selectedPlan || splitCount < 2) return;
|
||||
// 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실)
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (originalQty < splitCount) {
|
||||
toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`);
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, {
|
||||
description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// dirty 면 자동 저장
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const eachQty = Math.floor(originalQty / splitCount);
|
||||
if (eachQty <= 0) {
|
||||
toast.error("분할 수량이 부족합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
try {
|
||||
// N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성
|
||||
for (let i = 0; i < splitCount - 1; i++) {
|
||||
const res = await splitSchedule(selectedPlan.id, eachQty);
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
successCount++;
|
||||
}
|
||||
toast.success(`계획이 ${splitCount}개로 분할되었습니다`);
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
const msg = extractErrMsg(err);
|
||||
if (successCount > 0) {
|
||||
toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`);
|
||||
} else {
|
||||
toast.error("분할 실패: " + msg);
|
||||
}
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기)
|
||||
const handleCustomSplit = useCallback(async () => {
|
||||
if (!selectedPlan) return;
|
||||
const splitQty = Number(customSplitQty);
|
||||
const originalQty = Number(modalQuantity) || 0;
|
||||
if (!splitQty || splitQty < 1) {
|
||||
toast.error("떼어낼 수량을 1 이상으로 입력하세요");
|
||||
return;
|
||||
}
|
||||
if (splitQty >= originalQty) {
|
||||
toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다");
|
||||
return;
|
||||
}
|
||||
if (selectedPlan.status && selectedPlan.status !== "planned") {
|
||||
toast.error("계획 상태인 건만 분할할 수 있습니다");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, {
|
||||
description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`,
|
||||
confirmText: "분할",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const saved = await ensureSavedBeforeSplit();
|
||||
if (!saved) return;
|
||||
|
||||
const handleSplitSchedule = useCallback(async (splitQty: number) => {
|
||||
if (!selectedPlan || splitQty <= 0) return;
|
||||
try {
|
||||
const res = await splitSchedule(selectedPlan.id, splitQty);
|
||||
if (res.success) {
|
||||
toast.success("계획이 분할되었습니다");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
}
|
||||
if (!res.success) throw new Error("분할 응답 실패");
|
||||
toast.success(`${splitQty} 수량이 분리되었습니다`);
|
||||
setCustomSplitQty("");
|
||||
setScheduleModalOpen(false);
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
} catch (err: any) {
|
||||
toast.error("분할 실패: " + (err.message || ""));
|
||||
toast.error("분할 실패: " + extractErrMsg(err));
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
}, [selectedPlan, fetchPlans]);
|
||||
}, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]);
|
||||
|
||||
// 병합 핸들러
|
||||
const handleMergeSchedules = useCallback(async () => {
|
||||
@@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() {
|
||||
toast.success("계획이 병합되었습니다");
|
||||
setSelectedPlanIds(new Set());
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("병합 실패: " + (err.message || ""));
|
||||
toast.error("병합 실패: " + (err?.response?.data?.message || err.message || ""));
|
||||
}
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, confirm]);
|
||||
}, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]);
|
||||
|
||||
// 타임라인 이벤트 드래그 이동
|
||||
const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("일정이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("일정 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 타임라인 이벤트 리사이즈
|
||||
const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => {
|
||||
@@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() {
|
||||
if (res.success) {
|
||||
toast.success("기간이 변경되었습니다");
|
||||
fetchPlans();
|
||||
fetchOrderSummary();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("기간 변경 실패: " + (err.message || ""));
|
||||
}
|
||||
}, [fetchPlans]);
|
||||
}, [fetchPlans, fetchOrderSummary]);
|
||||
|
||||
// 불러오기 처리
|
||||
const handleImportOrderItems = useCallback(async () => {
|
||||
@@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() {
|
||||
{/* ========== 모달들 ========== */}
|
||||
|
||||
{/* 스케줄 상세/편집 모달 */}
|
||||
<Dialog open={scheduleModalOpen} onOpenChange={setScheduleModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto">
|
||||
<Dialog
|
||||
open={scheduleModalOpen}
|
||||
onOpenChange={(v) => {
|
||||
// confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시
|
||||
if (!v && isConfirmOpenRef.current) return;
|
||||
setScheduleModalOpen(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] sm:max-w-[650px] max-h-[85vh] overflow-y-auto"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
onFocusOutside={(e) => {
|
||||
if (isConfirmOpenRef.current) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
@@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() {
|
||||
<Scissors className="h-4 w-4" />
|
||||
계획 분할
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
{[2, 3, 4].map((n) => {
|
||||
const canSplit =
|
||||
modalQuantity >= n &&
|
||||
(selectedPlan?.status === "planned" || !selectedPlan?.status);
|
||||
return (
|
||||
<Button
|
||||
key={n}
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
disabled={!canSplit}
|
||||
onClick={() => handleSplitSchedule(n)}
|
||||
>
|
||||
{n}분할
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-foreground mb-2">
|
||||
하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가)
|
||||
</p>
|
||||
{/* 수량 지정 분할 */}
|
||||
<div className="flex items-center gap-1.5 pt-2 border-t border-warning/20">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">수량 지정:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customSplitQty}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setCustomSplitQty("");
|
||||
else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0)));
|
||||
}}
|
||||
className="h-7 w-28 text-xs"
|
||||
placeholder="떼어낼 수량"
|
||||
min={1}
|
||||
max={Math.max(0, modalQuantity - 1)}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {modalQuantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="warning"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const qty = Math.floor(modalQuantity / 2);
|
||||
if (qty > 0) handleSplitSchedule(qty);
|
||||
}}
|
||||
className="h-7 text-xs ml-auto"
|
||||
disabled={
|
||||
!customSplitQty ||
|
||||
Number(customSplitQty) < 1 ||
|
||||
Number(customSplitQty) >= modalQuantity ||
|
||||
!(selectedPlan?.status === "planned" || !selectedPlan?.status)
|
||||
}
|
||||
onClick={handleCustomSplit}
|
||||
>
|
||||
2분할
|
||||
분할
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground">하나의 생산계획을 여러 개로 분할합니다.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3 pb-2 border-b">추가 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">담당자</Label>
|
||||
<Input value={modalManager} onChange={(e) => setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">작업지시번호</Label>
|
||||
<Input value={modalWorkOrderNo} onChange={(e) => setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs">비고</Label>
|
||||
<Input value={modalRemarks} onChange={(e) => setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5">
|
||||
입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function ItemRoutingTab() {
|
||||
const [formWorkType, setFormWorkType] = useState("내부");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formOutsource, setFormOutsource] = useState("");
|
||||
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
|
||||
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||||
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
@@ -107,6 +108,19 @@ export function ItemRoutingTab() {
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 외주사 목록 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
|
||||
} catch { /* skip */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||||
return () => window.clearTimeout(t);
|
||||
@@ -469,9 +483,9 @@ export function ItemRoutingTab() {
|
||||
details.map((d) => ({
|
||||
...d,
|
||||
process_display: d.process_name || d.process_code,
|
||||
outsource_display: d.outsource_supplier || "—",
|
||||
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
|
||||
})),
|
||||
[details],
|
||||
[details, subcontractorOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -896,12 +910,14 @@ export function ItemRoutingTab() {
|
||||
{showOutsourceField && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">외주업체</Label>
|
||||
<Input
|
||||
value={formOutsource}
|
||||
onChange={(e) => setFormOutsource(e.target.value)}
|
||||
placeholder="외주 업체명"
|
||||
className="h-9"
|
||||
/>
|
||||
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{subcontractorOptions.map((s) => (
|
||||
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -221,18 +221,28 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택해주세요");
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("수정할 공정을 체크박스로 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 1) {
|
||||
toast.message("수정은 1건만 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const targetId = Array.from(selectedIds)[0];
|
||||
const target = processes.find((p) => p.id === targetId);
|
||||
if (!target) {
|
||||
toast.error("선택한 공정을 찾을 수 없습니다");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setEditingId(target.id);
|
||||
setFormProcessCode(target.process_code);
|
||||
setFormProcessName(target.process_name);
|
||||
setFormProcessType(target.process_type);
|
||||
setFormStandardTime(target.standard_time ?? "");
|
||||
setFormWorkerCount(target.worker_count ?? "");
|
||||
setFormUseYn(target.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
@@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
type CatOption = { code: string; label: string; depth: number; parentCode?: string };
|
||||
const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => {
|
||||
const result: CatOption[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children));
|
||||
result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode });
|
||||
if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -113,13 +115,13 @@ export default function InspectionManagementPage() {
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, CatOption[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
@@ -867,7 +869,7 @@ export default function InspectionManagementPage() {
|
||||
.filter(Boolean)
|
||||
.map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
{getCatLabel(DEFECT_TABLE, "inspection_type", t)}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
@@ -945,6 +947,9 @@ export default function InspectionManagementPage() {
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
|
||||
이미지
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
|
||||
장비코드
|
||||
</TableHead>
|
||||
@@ -980,13 +985,13 @@ export default function InspectionManagementPage() {
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="py-8 text-center">
|
||||
<TableCell colSpan={12} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={12} className="text-muted-foreground py-10 text-center">
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사장비가 없어요</p>
|
||||
</TableCell>
|
||||
@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{row.image_path ? (
|
||||
<img
|
||||
src={String(row.image_path).startsWith("http") || String(row.image_path).startsWith("/") ? row.image_path : `/api/files/preview/${row.image_path}`}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover border border-border mx-auto"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-primary font-semibold">{row.equipment_code || "-"}</TableCell>
|
||||
<TableCell>{row.equipment_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() {
|
||||
검사유형 <span className="text-destructive">*</span> (다중선택)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-3 rounded-md border p-3">
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(catOptions[`${DEFECT_TABLE}.inspection_type`] || [])
|
||||
.filter((o) => o.depth === 1)
|
||||
.map((o) => {
|
||||
const types: string[] = defForm.inspection_type
|
||||
? defForm.inspection_type.split(",").filter(Boolean)
|
||||
: [];
|
||||
const checked = types.includes(o.code);
|
||||
return (
|
||||
<div key={o.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...types, o.code] : types.filter((t) => t !== o.code);
|
||||
setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{o.label}</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 적용대상 (다중선택, 검사유형별 동적) */}
|
||||
@@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() {
|
||||
: [];
|
||||
if (selectedTypes.length === 0)
|
||||
return <p className="text-muted-foreground text-xs">검사유형을 먼저 선택하세요</p>;
|
||||
const typeTargetMap: Record<string, string[]> = {};
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
for (const code of selectedTypes) {
|
||||
const label = defInspOpts.find((o) => o.code === code)?.label || "";
|
||||
if (label.includes("수입"))
|
||||
typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"];
|
||||
else if (label.includes("공정"))
|
||||
typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"];
|
||||
else if (label.includes("출하"))
|
||||
typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"];
|
||||
else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"];
|
||||
}
|
||||
const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : [];
|
||||
return Object.entries(typeTargetMap).map(([typeName, opts]) => (
|
||||
<div key={typeName} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{typeName}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{opts.map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t] : targets.filter((x) => x !== t);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t}</Label>
|
||||
return selectedTypes.map((parentCode: string) => {
|
||||
const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode;
|
||||
const children = defInspOpts.filter((o) => o.parentCode === parentCode);
|
||||
return (
|
||||
<div key={parentCode} className="mb-2 last:mb-0">
|
||||
<p className="mb-2 border-b pb-1 text-xs font-semibold">{parentLabel}</p>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{children.map((t) => (
|
||||
<div key={t.code} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={targets.includes(t.code)}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code);
|
||||
setDefForm((p) => ({ ...p, apply_target: next.join(",") }));
|
||||
}}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{t.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div />
|
||||
{/* Row 5: 비고 (full width) */}
|
||||
{/* Row 5: 이미지 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">이미지</Label>
|
||||
<ImageUpload
|
||||
value={eqForm.image_path}
|
||||
onChange={(v) => setEqForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIPMENT_TABLE}
|
||||
recordId={eqForm.id}
|
||||
columnName="image_path"
|
||||
/>
|
||||
</div>
|
||||
{/* Row 6: 비고 (full width) */}
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<Label className="text-xs font-semibold">비고</Label>
|
||||
<textarea
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -253,6 +253,108 @@ export default function ItemInspectionInfoPage() {
|
||||
loadProcessOptions(item.code);
|
||||
};
|
||||
|
||||
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<typeof itemOptions>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
const searchCopyTargets = async (page?: number) => {
|
||||
const p = page ?? copyPage;
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (copySearchKeyword.trim()) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: copySearchKeyword.trim() });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const resData = res.data?.data;
|
||||
const rows = resData?.data || resData?.rows || [];
|
||||
const cm = itemCatMapRef.current;
|
||||
const list = rows
|
||||
.filter((r: any) => r.item_number !== selectedItemCode)
|
||||
.map((r: any) => ({
|
||||
code: r.item_number,
|
||||
name: r.item_name,
|
||||
item_type: cm["type"]?.[r.type] || r.type || "",
|
||||
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
|
||||
}));
|
||||
setCopyFilteredItems(list);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
|
||||
} catch { /* skip */ } finally { setCopySearchLoading(false); }
|
||||
};
|
||||
const openCopyModal = () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
|
||||
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
|
||||
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
|
||||
setCopyModalOpen(true);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
const handleCopySearch = () => { setCopyPage(1); searchCopyTargets(1); };
|
||||
const toggleCopyChecked = (code: string) => {
|
||||
setCopyCheckedIds(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]);
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
|
||||
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
|
||||
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
|
||||
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
|
||||
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
|
||||
);
|
||||
if (!ok) return;
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
try {
|
||||
for (let i = 0; i < copyCheckedIds.length; i++) {
|
||||
const targetCode = copyCheckedIds[i];
|
||||
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
|
||||
const targetName = target?.name || "";
|
||||
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
|
||||
}
|
||||
for (const r of sourceGroup.rows) {
|
||||
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
|
||||
...rest,
|
||||
id: crypto.randomUUID(),
|
||||
item_code: targetCode,
|
||||
item_name: targetName,
|
||||
});
|
||||
}
|
||||
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success(`${copyCheckedIds.length}개 품목에 복사했어요`);
|
||||
setCopyModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("복사에 실패했어요"); }
|
||||
finally {
|
||||
setCopying(false);
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -732,7 +834,6 @@ export default function ItemInspectionInfoPage() {
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openExcelUpload}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => ts.setOpen(true)}><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
@@ -814,6 +915,7 @@ export default function ItemInspectionInfoPage() {
|
||||
{selectedGroup && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openEdit()}><Pencil className="w-3.5 h-3.5 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openCopyModal}><Copy className="w-3.5 h-3.5 mr-1" />복사</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={handleDelete}><Trash2 className="w-3.5 h-3.5 mr-1" />삭제</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,12 +977,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold h-8">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8">합격기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[50px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold h-8 w-[70px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTabRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground">등록된 검사항목이 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : selectedTabRows.map((row: any) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -913,6 +1016,14 @@ export default function ItemInspectionInfoPage() {
|
||||
<Badge variant="destructive" className="text-[9px]">필수</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs py-2">
|
||||
{(() => {
|
||||
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
|
||||
const unitCode = insp?.unit || "";
|
||||
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
|
||||
return unitLabel || "-";
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -1077,12 +1188,13 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableHead className="text-[10px] font-bold w-[80px]">판단기준</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[200px]">합격기준 (판단기준별)</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[40px]">필수</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[70px]">단위</TableHead>
|
||||
<TableHead className="text-[10px] font-bold w-[36px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground">항목추가 버튼으로 검사항목을 추가하세요</TableCell></TableRow>
|
||||
) : inspectionRows[key].map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="p-1">
|
||||
@@ -1148,6 +1260,7 @@ export default function ItemInspectionInfoPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateInspRow(key, row.id, "is_required", !!v)} /></TableCell>
|
||||
<TableCell className="p-1 text-xs text-muted-foreground">{row.unit || "-"}</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Button type="button" variant="destructive" size="sm" className="h-7 w-7 p-0" onClick={() => removeInspRow(key, row.id)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</TableCell>
|
||||
@@ -1172,6 +1285,130 @@ export default function ItemInspectionInfoPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
|
||||
<DialogContent
|
||||
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
|
||||
<span className="text-muted-foreground"> ({selectedItemCode})</span>
|
||||
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{copying ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-8 px-4">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">복사 진행 중...</span>
|
||||
<span className="text-xs text-blue-600 ml-auto">
|
||||
{copyProgress.current.toLocaleString()} / {copyProgress.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${copyProgress.total > 0 ? Math.round((copyProgress.current / copyProgress.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
모달을 닫지 마세요. 완료 후 자동으로 닫혀요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
|
||||
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
|
||||
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" />검색</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-lg overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
|
||||
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[140px]">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold">품목명</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[100px]">품목유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold w-[80px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyFilteredItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
||||
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
|
||||
</TableCell></TableRow>
|
||||
) : copyFilteredItems.map((item) => (
|
||||
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
|
||||
onClick={() => toggleCopyChecked(item.code)}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{item.code}</TableCell>
|
||||
<TableCell className="text-sm">{item.name}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_type}</TableCell>
|
||||
<TableCell className="text-sm">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
전체 <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>건
|
||||
{copyCheckedIds.length > 0 && <span className="ml-2">선택 <span className="font-medium text-primary">{copyCheckedIds.length}</span>건</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
|
||||
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
|
||||
const p = start + i;
|
||||
if (p > copyTotalPages) return null;
|
||||
return (
|
||||
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
|
||||
{copying ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Copy className="w-4 h-4 mr-1" />}
|
||||
{copying
|
||||
? `복사 중 (${copyProgress.current}/${copyProgress.total})`
|
||||
: copyCheckedIds.length > 0 ? `${copyCheckedIds.length}개 품목에 복사` : "복사"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal open={ts.open} onOpenChange={ts.setOpen} tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} onSave={ts.applySettings} />
|
||||
|
||||
{/* ═══════ 엑셀 업로드 모달 ═══════ */}
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function CustomerManagementPage() {
|
||||
const fetchMainContacts = useCallback(async () => {
|
||||
try {
|
||||
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
||||
});
|
||||
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 거래처
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 5000, autoFilter: true });
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 0, autoFilter: true });
|
||||
const custs = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({
|
||||
code: c.customer_code,
|
||||
|
||||
@@ -427,8 +427,8 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
return result;
|
||||
}, [data, headerFilters, sortState, onSortChange]);
|
||||
|
||||
// 필터/데이터 변경 시 1페이지 리셋
|
||||
useEffect(() => { setCurrentPage(1); }, [data, headerFilters]);
|
||||
// 필터/데이터 건수 변경 시 1페이지 리셋 (참조만 바뀐 경우는 리셋 안 함)
|
||||
useEffect(() => { setCurrentPage(1); }, [data.length, headerFilters]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalItems = processedData.length;
|
||||
@@ -715,16 +715,17 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
const id = getRowId(row, rowKey);
|
||||
const isSelected = selectedId != null && String(selectedId) === String(id);
|
||||
const isChecked = checkedIds.includes(id);
|
||||
const highlighted = isSelected || isChecked;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id || rowIdx}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
|
||||
highlighted
|
||||
isSelected
|
||||
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
|
||||
: "hover:bg-accent"
|
||||
: isChecked
|
||||
? "bg-muted/50"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(id);
|
||||
|
||||
@@ -223,6 +223,9 @@ export default function TimelineScheduler({
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// 드래그 이동(move) 직후 자동 발생하는 click 이벤트 1회를 무시하기 위한 플래그.
|
||||
// 드래그로 일정이 변경된 직후에 모달이 자동 오픈되면서 이전 날짜가 표시되는 버그(TASK:ERP-006) 방지용.
|
||||
const justDraggedRef = useRef(false);
|
||||
|
||||
// 줌 레벨 동기화
|
||||
useEffect(() => {
|
||||
@@ -404,6 +407,12 @@ export default function TimelineScheduler({
|
||||
const newStart = toDateStr(addDays(origStart, dayOffset));
|
||||
const newEnd = toDateStr(addDays(origEnd, dayOffset));
|
||||
onEventMove?.(dragState.eventId, newStart, newEnd);
|
||||
// 드래그 직후 브라우저가 자동 디스패치하는 click 이벤트 1회를 무시해
|
||||
// 모달이 이전 날짜로 자동 오픈되는 버그(TASK:ERP-006) 방지.
|
||||
justDraggedRef.current = true;
|
||||
setTimeout(() => {
|
||||
justDraggedRef.current = false;
|
||||
}, 0);
|
||||
} else if (dragState.mode === "resize-left") {
|
||||
const newStart = toDateStr(addDays(origStart, dayOffset));
|
||||
const newEnd = dragState.origEndDate.split("T")[0];
|
||||
@@ -411,12 +420,20 @@ export default function TimelineScheduler({
|
||||
if (parseDate(newStart) <= parseDate(newEnd)) {
|
||||
onEventResize?.(dragState.eventId, newStart, newEnd);
|
||||
}
|
||||
justDraggedRef.current = true;
|
||||
setTimeout(() => {
|
||||
justDraggedRef.current = false;
|
||||
}, 0);
|
||||
} else if (dragState.mode === "resize-right") {
|
||||
const newStart = dragState.origStartDate.split("T")[0];
|
||||
const newEnd = toDateStr(addDays(origEnd, dayOffset));
|
||||
if (parseDate(newStart) <= parseDate(newEnd)) {
|
||||
onEventResize?.(dragState.eventId, newStart, newEnd);
|
||||
}
|
||||
justDraggedRef.current = true;
|
||||
setTimeout(() => {
|
||||
justDraggedRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,6 +787,11 @@ export default function TimelineScheduler({
|
||||
}}
|
||||
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
|
||||
onClick={(e) => {
|
||||
// 드래그 직후 자동 발생하는 click은 무시 (TASK:ERP-006).
|
||||
if (justDraggedRef.current) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (!isDragging) {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(ev);
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface RoutingDetail {
|
||||
work_type: string;
|
||||
standard_time: string;
|
||||
outsource_supplier: string;
|
||||
outsource_supplier_list?: string[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
|
||||
@@ -51,13 +51,17 @@ export async function getCategoryValues(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
includeInactive: boolean = false,
|
||||
menuObjid?: number
|
||||
menuObjid?: number,
|
||||
topLevelOnly: boolean = false
|
||||
) {
|
||||
try {
|
||||
const params: any = { includeInactive };
|
||||
if (menuObjid) {
|
||||
params.menuObjid = menuObjid;
|
||||
}
|
||||
if (topLevelOnly) {
|
||||
params.topLevelOnly = true;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
|
||||
@@ -191,7 +191,7 @@ export function BomTreeComponent({
|
||||
item_number: headerData.item_code || "",
|
||||
quantity: "-",
|
||||
base_qty: headerData.base_qty || "",
|
||||
unit: headerData.unit || "",
|
||||
unit: headerData.unit || (headerData as any).item_unit || (headerData as any).item_inventory_unit || (headerData as any).inventory_unit || "",
|
||||
revision: headerData.revision || "",
|
||||
loss_rate: "",
|
||||
process_type: "",
|
||||
@@ -311,7 +311,7 @@ export function BomTreeComponent({
|
||||
item_name: raw.item_name || "",
|
||||
item_code: raw.item_number || raw.item_code || "",
|
||||
item_type: raw.item_type || raw.division || "",
|
||||
unit: raw.unit || raw.item_unit || "",
|
||||
unit: raw.unit || raw.item_unit || raw.item_inventory_unit || raw.inventory_unit || "",
|
||||
} as BomHeaderInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user