feat: enhance design task management page with employee selection and filtering

- Integrated a combobox for selecting employees, allowing users to assign tasks more efficiently.
- Implemented fetching of employee data to populate the selection options, improving user experience.
- Added functionality to filter tasks based on the current user's related tasks, enhancing task management capabilities.
- Updated the modal states for better handling of designer assignments and task interactions.

These changes aim to streamline the design task management process, facilitating better organization and assignment of tasks within the application.
This commit is contained in:
kjs 2026-03-20 11:58:01 +09:00
parent 460757e3a0
commit ffcede7e66
13 changed files with 3732 additions and 256 deletions

View File

@ -149,6 +149,8 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -321,6 +323,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/material-status", materialStatusRoutes); // 자재현황
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리

View File

@ -0,0 +1,352 @@
/**
*
* - ()
* - BOM +
* -
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 생산계획(작업지시) 조회 ───
export async function getWorkOrders(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, itemCode, itemName } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
logger.info("최고 관리자 전체 작업지시 조회");
} else {
conditions.push(`p.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`p.plan_date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`p.plan_date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (itemCode) {
conditions.push(`p.item_code ILIKE $${paramIndex}`);
params.push(`%${itemCode}%`);
paramIndex++;
}
if (itemName) {
conditions.push(`p.item_name ILIKE $${paramIndex}`);
params.push(`%${itemName}%`);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id,
p.plan_no,
p.item_code,
p.item_name,
p.plan_qty,
p.completed_qty,
p.plan_date,
p.start_date,
p.end_date,
p.status,
p.work_order_no,
p.company_code
FROM production_plan_mng p
${whereClause}
ORDER BY p.plan_date DESC, p.created_date DESC
`;
const result = await pool.query(query, params);
logger.info("작업지시 조회 완료", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업지시 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 선택된 작업지시의 자재소요 + 재고 현황 조회 ───
export async function getMaterialStatus(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { planIds, warehouseCode } = req.body;
if (!planIds || !Array.isArray(planIds) || planIds.length === 0) {
return res
.status(400)
.json({ success: false, message: "작업지시를 선택해주세요." });
}
// 1) 선택된 작업지시의 품목코드 + 수량 조회
const planPlaceholders = planIds
.map((_, i) => `$${i + 1}`)
.join(",");
let paramIndex = planIds.length + 1;
const companyCondition =
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
const planParams: any[] = [...planIds];
if (companyCode !== "*") {
planParams.push(companyCode);
paramIndex++;
}
const planQuery = `
SELECT p.item_code, p.item_name, p.plan_qty
FROM production_plan_mng p
WHERE p.id IN (${planPlaceholders})
${companyCondition}
`;
const planResult = await pool.query(planQuery, planParams);
if (planResult.rowCount === 0) {
return res.json({ success: true, data: [] });
}
// 2) 해당 품목들의 BOM에서 필요 자재 목록 조회
const itemCodes = planResult.rows.map((r: any) => r.item_code);
const planQtyMap: Record<string, number> = {};
for (const row of planResult.rows) {
const code = row.item_code;
planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0);
}
const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(",");
// BOM 조인: bom -> bom_detail -> item_info (자재 정보)
const bomCompanyCondition =
companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`;
const bomParams: any[] = [...itemCodes];
if (companyCode !== "*") {
bomParams.push(companyCode);
}
const bomQuery = `
SELECT
b.item_code AS parent_item_code,
b.base_qty AS bom_base_qty,
bd.child_item_id,
bd.quantity AS bom_qty,
bd.unit AS bom_unit,
bd.loss_rate,
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
WHERE b.item_code IN (${itemPlaceholders})
${bomCompanyCondition}
ORDER BY b.item_code, bd.seq_no
`;
const bomResult = await pool.query(bomQuery, bomParams);
// 3) 자재별 필요수량 계산
interface MaterialNeed {
childItemId: string;
materialCode: string;
materialName: string;
unit: string;
requiredQty: number;
}
const materialMap: Record<string, MaterialNeed> = {};
for (const bomRow of bomResult.rows) {
const parentQty = planQtyMap[bomRow.parent_item_code] || 0;
const baseQty = Number(bomRow.bom_base_qty) || 1;
const bomQty = Number(bomRow.bom_qty) || 0;
const lossRate = Number(bomRow.loss_rate) || 0;
// 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100)
const requiredQty =
(parentQty / baseQty) * bomQty * (1 + lossRate / 100);
const key = bomRow.child_item_id;
if (materialMap[key]) {
materialMap[key].requiredQty += requiredQty;
} else {
materialMap[key] = {
childItemId: bomRow.child_item_id,
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
requiredQty,
};
}
}
const materialIds = Object.keys(materialMap);
if (materialIds.length === 0) {
return res.json({ success: true, data: [] });
}
// 4) 재고 조회 (창고/위치별)
const stockPlaceholders = materialIds
.map((_, i) => `$${i + 1}`)
.join(",");
const stockParams: any[] = [...materialIds];
let stockParamIdx = materialIds.length + 1;
const stockConditions: string[] = [
`s.item_code IN (${stockPlaceholders})`,
];
if (companyCode !== "*") {
stockConditions.push(`s.company_code = $${stockParamIdx}`);
stockParams.push(companyCode);
stockParamIdx++;
}
if (warehouseCode) {
stockConditions.push(`s.warehouse_code = $${stockParamIdx}`);
stockParams.push(warehouseCode);
stockParamIdx++;
}
const stockQuery = `
SELECT
s.item_code,
s.warehouse_code,
s.location_code,
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
FROM inventory_stock s
WHERE ${stockConditions.join(" AND ")}
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
ORDER BY s.item_code, s.warehouse_code, s.location_code
`;
const stockResult = await pool.query(stockQuery, stockParams);
// 5) 결과 조합
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
const stockByItem: Record<
string,
{ location: string; warehouse: string; qty: number }[]
> = {};
for (const stockRow of stockResult.rows) {
const code = stockRow.item_code;
if (!stockByItem[code]) {
stockByItem[code] = [];
}
stockByItem[code].push({
location: stockRow.location_code || "",
warehouse: stockRow.warehouse_code || "",
qty: Number(stockRow.current_qty),
});
}
const resultData = materialIds.map((id) => {
const material = materialMap[id];
// inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음
const locations =
stockByItem[material.materialCode] ||
stockByItem[id] ||
[];
const totalCurrentQty = locations.reduce(
(sum, loc) => sum + loc.qty,
0
);
return {
code: material.materialCode,
name: material.materialName,
required: Math.round(material.requiredQty * 100) / 100,
current: totalCurrentQty,
unit: material.unit,
locations,
};
});
logger.info("자재현황 조회 완료", {
companyCode,
planCount: planIds.length,
materialCount: resultData.length,
});
return res.json({ success: true, data: resultData });
} catch (error: any) {
logger.error("자재현황 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 창고 목록 조회 ───
export async function getWarehouses(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
ORDER BY warehouse_code
`;
params = [];
} else {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1
ORDER BY warehouse_code
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info("창고 목록 조회 완료", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -0,0 +1,422 @@
/**
*
* - CRUD
* -
* -
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ═══════════════════════════════════════════
// 공정 마스터 CRUD
// ═══════════════════════════════════════════
export async function getProcessList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode, processName, processType, useYn } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (processCode) {
conditions.push(`process_code ILIKE $${idx++}`);
params.push(`%${processCode}%`);
}
if (processName) {
conditions.push(`process_name ILIKE $${idx++}`);
params.push(`%${processName}%`);
}
if (processType) {
conditions.push(`process_type = $${idx++}`);
params.push(processType);
}
if (useYn) {
conditions.push(`use_yn = $${idx++}`);
params.push(useYn);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT * FROM process_mng ${where} ORDER BY process_code`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("공정 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function createProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
// 공정코드 자동 채번: PROC-001, PROC-002, ...
const seqRes = await pool.query(
`SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`,
[companyCode]
);
let nextNum = 1;
if (seqRes.rowCount! > 0) {
const lastCode = seqRes.rows[0].process_code;
const numPart = parseInt(lastCode.replace("PROC-", ""), 10);
if (!isNaN(numPart)) nextNum = numPart + 1;
}
const processCode = `PROC-${String(nextNum).padStart(3, "0")}`;
const result = await pool.query(
`INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function updateProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
const result = await pool.query(
`UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW()
WHERE id=$6 AND company_code=$7 RETURNING *`,
[process_name, process_type, standard_time, worker_count, use_yn, id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteProcesses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." });
}
const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(",");
// 설비 매핑도 삭제
await pool.query(
`DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`,
[...ids, companyCode]
);
const result = await pool.query(
`DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`,
[...ids, companyCode]
);
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("공정 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// 공정별 설비 관리
// ═══════════════════════════════════════════
export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode } = req.params;
const result = await pool.query(
`SELECT pe.*, ei.equipment_name
FROM process_equipment pe
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("공정 설비 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_code, equipment_code } = req.body;
const dupCheck = await pool.query(
`SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`,
[process_code, equipment_code, companyCode]
);
if (dupCheck.rowCount! > 0) {
return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." });
}
const result = await pool.query(
`INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`,
[companyCode, process_code, equipment_code, writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 설비 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("공정 설비 제거 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("설비 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// 품목별 라우팅 관리
// ═══════════════════════════════════════════
export async function getItemsForRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = ["i.company_code = rv.company_code"];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`i.company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type
FROM item_info i
INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code
${where}
ORDER BY i.item_number LIMIT 200`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 등록 품목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function searchAllItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("전체 품목 검색 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
const result = await pool.query(
`SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`,
[itemCode, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 버전 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { item_code, version_name, description, is_default } = req.body;
if (is_default) {
await pool.query(
`UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`,
[item_code, companyCode]
);
}
const result = await pool.query(
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`,
[companyCode, item_code, version_name, description || "", is_default || false, writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("라우팅 버전 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[id, companyCode]
);
await pool.query(
`DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("라우팅 버전 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { versionId } = req.params;
const result = await pool.query(
`SELECT rd.*, pm.process_name
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code
WHERE rd.routing_version_id=$1 AND rd.company_code=$2
ORDER BY CAST(rd.seq_no AS INTEGER)`,
[versionId, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { versionId } = req.params;
const { details } = req.body;
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 상세 삭제 후 재입력
await client.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[versionId, companyCode]
);
for (const d of details) {
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]
);
}
await client.query("COMMIT");
return res.json({ success: true });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} catch (error: any) {
logger.error("라우팅 상세 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -0,0 +1,22 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as materialStatusController from "../controllers/materialStatusController";
const router = Router();
router.use(authenticateToken);
// 생산계획(작업지시) 목록 조회
router.get("/work-orders", materialStatusController.getWorkOrders);
// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달)
router.post("/materials", materialStatusController.getMaterialStatus);
// 창고 목록 조회
router.get("/warehouses", materialStatusController.getWarehouses);
export default router;

View File

@ -0,0 +1,42 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processInfoController";
const router = Router();
router.use(authenticateToken);
// 공정 마스터 CRUD
router.get("/processes", ctrl.getProcessList);
router.post("/processes", ctrl.createProcess);
router.put("/processes/:id", ctrl.updateProcess);
router.post("/processes/delete", ctrl.deleteProcesses);
// 공정별 설비 관리
router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments);
router.post("/process-equipments", ctrl.addProcessEquipment);
router.delete("/process-equipments/:id", ctrl.removeProcessEquipment);
// 설비 목록 (드롭다운용)
router.get("/equipments", ctrl.getEquipmentList);
// 품목 목록 (라우팅 등록된 품목만)
router.get("/items", ctrl.getItemsForRouting);
// 전체 품목 검색 (등록 모달용)
router.get("/items/search-all", ctrl.searchAllItems);
// 라우팅 버전
router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.post("/routing-versions", ctrl.createRoutingVersion);
router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
// 라우팅 상세
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
export default router;

View File

@ -34,6 +34,19 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Search,
RotateCcw,
@ -47,6 +60,12 @@ import {
Eye,
ChevronRight,
ArrowRight,
Check,
ChevronsUpDown,
UserCircle,
Loader2,
User,
Users,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@ -54,8 +73,10 @@ import {
getDesignRequestList,
updateDesignRequest,
addRequestHistory,
createProject,
} from "@/lib/api/design";
import { Loader2 } from "lucide-react";
import { getUserList } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
// --- Types ---
type SourceType = "dr" | "ecr";
@ -202,11 +223,36 @@ const STAT_CARDS: { label: string; status: TaskStatus; color: string; textColor:
{ label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" },
];
interface EmployeeOption {
userId: string;
userName: string;
deptName: string;
}
export default function DesignTaskManagementPage() {
const { user, userName, loading: authLoading } = useAuth();
const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<MainTab>("all");
const [employees, setEmployees] = useState<EmployeeOption[]>([]);
const [myTasksOnly, setMyTasksOnly] = useState(true);
const fetchEmployees = useCallback(async () => {
try {
const res = await getUserList({ size: 1000 });
if (res.success && res.data) {
const list = (res.data as any[]).map((u: any) => ({
userId: u.user_id || u.userId,
userName: u.user_name || u.userName || "",
deptName: u.dept_name || u.deptName || "",
}));
setEmployees(list);
}
} catch {
// 사원 목록 로드 실패 시 빈 배열 유지
}
}, []);
const fetchTasks = useCallback(async () => {
setLoading(true);
@ -223,7 +269,8 @@ export default function DesignTaskManagementPage() {
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
fetchEmployees();
}, [fetchTasks, fetchEmployees]);
// 검색 필터
const [searchStatus, setSearchStatus] = useState<string>("all");
@ -231,12 +278,19 @@ export default function DesignTaskManagementPage() {
const [searchReqDept, setSearchReqDept] = useState<string>("all");
const [searchKeyword, setSearchKeyword] = useState("");
// 담당자 선택 모달 상태
const [designerModalOpen, setDesignerModalOpen] = useState(false);
const [designerModalTaskId, setDesignerModalTaskId] = useState<string | null>(null);
const [designerModalValue, setDesignerModalValue] = useState("");
const [designerComboOpen, setDesignerComboOpen] = useState(false);
// 모달 상태
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTaskId, setRejectTaskId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [projectModalOpen, setProjectModalOpen] = useState(false);
const [projectTaskId, setProjectTaskId] = useState<string | null>(null);
const [pmComboOpen, setPmComboOpen] = useState(false);
const [projectForm, setProjectForm] = useState({
projNo: "",
projName: "",
@ -251,25 +305,40 @@ export default function DesignTaskManagementPage() {
// 검토 메모
const [reviewMemoText, setReviewMemoText] = useState("");
// 현재 사용자 관련 업무만 필터링
const myRelatedTasks = useMemo(() => {
if (!myTasksOnly || !userName) return allTasks;
const currentUserName = userName;
const currentDeptName = user?.deptName || "";
return allTasks.filter((item) => {
if (item.requester === currentUserName) return true;
if (item.designer === currentUserName) return true;
if (currentDeptName && item.reqDept === currentDeptName) return true;
const inHistory = item.history.some((h) => h.user === currentUserName);
if (inHistory) return true;
return false;
});
}, [allTasks, myTasksOnly, userName, user?.deptName]);
// 탭별 카운트
const tabCounts = useMemo(() => {
const drItems = allTasks.filter((t) => t.sourceType === "dr");
const ecrItems = allTasks.filter((t) => t.sourceType === "ecr");
const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr");
const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr");
const newDR = drItems.filter((t) => t.status === "신규접수").length;
const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
return {
all: newDR + newECR || allTasks.length,
all: newDR + newECR || myRelatedTasks.length,
allIsNew: newDR + newECR > 0,
dr: newDR || drItems.length,
drIsNew: newDR > 0,
ecr: newECR || ecrItems.length,
ecrIsNew: newECR > 0,
};
}, [allTasks]);
}, [myRelatedTasks]);
// 필터링된 데이터
const filteredData = useMemo(() => {
return allTasks.filter((item) => {
return myRelatedTasks.filter((item) => {
if (currentTab === "dr" && item.sourceType !== "dr") return false;
if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
if (searchStatus !== "all" && item.status !== searchStatus) return false;
@ -283,18 +352,18 @@ export default function DesignTaskManagementPage() {
}
return true;
});
}, [allTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
}, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
// 현황 통계
const stats = useMemo(() => {
return {
신규접수: allTasks.filter((t) => t.status === "신규접수").length,
검토중: allTasks.filter((t) => t.status === "검토중").length,
승인완료: allTasks.filter((t) => t.status === "승인완료").length,
반려: allTasks.filter((t) => t.status === "반려").length,
프로젝트생성: allTasks.filter((t) => t.status === "프로젝트생성").length,
신규접수: myRelatedTasks.filter((t) => t.status === "신규접수").length,
검토중: myRelatedTasks.filter((t) => t.status === "검토중").length,
승인완료: myRelatedTasks.filter((t) => t.status === "승인완료").length,
반려: myRelatedTasks.filter((t) => t.status === "반려").length,
프로젝트생성: myRelatedTasks.filter((t) => t.status === "프로젝트생성").length,
};
}, [allTasks]);
}, [myRelatedTasks]);
const selectedTask = useMemo(
() => allTasks.find((t) => t.dbId === selectedTaskId) || null,
@ -313,16 +382,28 @@ export default function DesignTaskManagementPage() {
setSelectedTaskId(dbId);
}, []);
const handleStartReview = useCallback(
async (dbId: string) => {
const designer = prompt("설계 담당자를 입력하세요:");
if (designer === null) return;
const handleOpenDesignerModal = useCallback((dbId: string) => {
setDesignerModalTaskId(dbId);
setDesignerModalValue("");
setDesignerComboOpen(false);
setDesignerModalOpen(true);
}, []);
const handleConfirmDesigner = useCallback(async () => {
if (!designerModalValue) {
toast.error("설계 담당자를 선택하세요.");
return;
}
if (!designerModalTaskId) return;
const selected = employees.find((e) => e.userId === designerModalValue);
const designerName = selected?.userName || designerModalValue;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(dbId, {
const historyRes = await addRequestHistory(designerModalTaskId, {
step: "검토",
history_date: historyDate,
user_name: designer || "시스템",
user_name: designerName,
description: "검토 착수 - 담당자 배정",
});
if (!historyRes.success) {
@ -330,20 +411,19 @@ export default function DesignTaskManagementPage() {
return;
}
const updateRes = await updateDesignRequest(dbId, {
const updateRes = await updateDesignRequest(designerModalTaskId, {
status: "검토중",
approval_step: 1,
designer: designer || "",
designer: designerName,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setDesignerModalOpen(false);
toast.success("검토가 착수되었습니다.");
fetchTasks();
},
[fetchTasks]
);
}, [designerModalTaskId, designerModalValue, employees, fetchTasks]);
const handleApprove = useCallback(
async (dbId: string) => {
@ -424,19 +504,20 @@ export default function DesignTaskManagementPage() {
const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`;
setProjectTaskId(dbId);
const matchedEmployee = employees.find((e) => e.userName === task.designer);
setProjectForm({
projNo,
projName: task.targetName,
projSourceNo: task.id,
projStartDate: new Date().toISOString().split("T")[0],
projEndDate: task.dueDate,
projPM: task.designer || "",
projPM: matchedEmployee?.userId || "",
projCustomer: task.customer || task.reqDept,
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
});
setProjectModalOpen(true);
},
[allTasks]
[allTasks, employees]
);
const handleCreateProject = useCallback(async () => {
@ -446,11 +527,35 @@ export default function DesignTaskManagementPage() {
if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; }
if (!projectTaskId) return;
const pmEmployee = employees.find((e) => e.userId === projectForm.projPM);
const pmName = pmEmployee?.userName || projectForm.projPM;
// 1) 실제 프로젝트 테이블(dsn_project)에 INSERT
const projectRes = await createProject({
project_no: projectForm.projNo,
name: projectForm.projName,
status: "계획",
pm: pmName,
customer: projectForm.projCustomer,
start_date: projectForm.projStartDate,
end_date: projectForm.projEndDate,
source_no: projectForm.projSourceNo,
description: projectForm.projDesc,
progress: "0",
});
if (!projectRes.success) {
toast.error(projectRes.message || "프로젝트 생성에 실패했습니다.");
return;
}
const createdProjectId = projectRes.data?.id || projectForm.projNo;
// 2) 이력 추가
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(projectTaskId, {
step: "프로젝트",
history_date: historyDate,
user_name: projectForm.projPM,
user_name: pmName,
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
});
if (!historyRes.success) {
@ -458,10 +563,11 @@ export default function DesignTaskManagementPage() {
return;
}
// 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결
const updateRes = await updateDesignRequest(projectTaskId, {
status: "프로젝트생성",
approval_step: 4,
project_id: projectForm.projNo,
project_id: createdProjectId,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
@ -470,7 +576,7 @@ export default function DesignTaskManagementPage() {
setProjectModalOpen(false);
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
fetchTasks();
}, [projectForm, projectTaskId, fetchTasks]);
}, [projectForm, projectTaskId, employees, fetchTasks]);
const handleSaveReviewMemo = useCallback(async () => {
if (!selectedTaskId) return;
@ -542,11 +648,46 @@ export default function DesignTaskManagementPage() {
</button>
))}
<div className="flex-1" />
<div className="flex items-center gap-3">
{userName && (
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<UserCircle className="h-3.5 w-3.5" />
{userName}
{user?.deptName && <span className="text-primary/60">({user.deptName})</span>}
</div>
)}
<div className="flex items-center overflow-hidden rounded-full border border-border">
<button
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
myTasksOnly
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
)}
onClick={() => setMyTasksOnly(true)}
>
<User className="h-3 w-3" />
</button>
<button
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
!myTasksOnly
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
)}
onClick={() => setMyTasksOnly(false)}
>
<Users className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
</div>
</div>
</div>
{/* 검색 섹션 */}
<div className="border-b border-border bg-card px-5 py-3">
@ -618,7 +759,12 @@ export default function DesignTaskManagementPage() {
<div className="flex h-full flex-col bg-card">
<div className="flex items-center justify-between border-b-2 border-border px-5 py-3">
<h2 className="text-base font-bold text-foreground">
({filteredData.length})
{myTasksOnly ? "내 관련 업무" : "접수 업무 목록"} ({filteredData.length})
{myTasksOnly && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{allTasks.length}
</span>
)}
</h2>
<Button size="sm" variant="outline" onClick={fetchTasks} disabled={loading}>
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
@ -666,10 +812,10 @@ export default function DesignTaskManagementPage() {
key={item.id}
className={cn(
"cursor-pointer transition-colors",
selectedTaskId === item.id && "bg-primary/5",
selectedTaskId === item.dbId && "bg-primary/5",
item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
)}
onClick={() => handleSelectTask(item.id)}
onClick={() => handleSelectTask(item.dbId)}
>
<TableCell className="text-center">
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
@ -770,7 +916,7 @@ export default function DesignTaskManagementPage() {
<div className="flex flex-wrap gap-2">
{selectedTask.status === "신규접수" && (
<>
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleStartReview(selectedTask.dbId)}>
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleOpenDesignerModal(selectedTask.dbId)}>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
@ -962,6 +1108,77 @@ export default function DesignTaskManagementPage() {
</ResizablePanelGroup>
</div>
{/* 설계 담당자 선택 모달 */}
<Dialog open={designerModalOpen} onOpenChange={setDesignerModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> .</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Popover open={designerComboOpen} onOpenChange={setDesignerComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={designerComboOpen}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{designerModalValue
? (() => {
const emp = employees.find((e) => e.userId === designerModalValue);
return emp ? `${emp.userName} (${emp.deptName || "부서 미지정"})` : designerModalValue;
})()
: "사원 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="이름, 부서로 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{employees.map((emp) => (
<CommandItem
key={emp.userId}
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
onSelect={() => {
setDesignerModalValue(emp.userId);
setDesignerComboOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", designerModalValue === emp.userId ? "opacity-100" : "opacity-0")} />
<UserCircle className="mr-2 h-4 w-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{emp.userName}</span>
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setDesignerModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleConfirmDesigner} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 반려 사유 모달 */}
<Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
@ -1052,17 +1269,48 @@ export default function DesignTaskManagementPage() {
<Label className="text-xs sm:text-sm">
PM <span className="text-destructive">*</span>
</Label>
<Select value={projectForm.projPM} onValueChange={(v) => setProjectForm((p) => ({ ...p, projPM: v }))}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="이설계"></SelectItem>
<SelectItem value="박도면"></SelectItem>
<SelectItem value="최기구"></SelectItem>
<SelectItem value="김전장"></SelectItem>
</SelectContent>
</Select>
<Popover open={pmComboOpen} onOpenChange={setPmComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={pmComboOpen}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{projectForm.projPM
? employees.find((e) => e.userId === projectForm.projPM)?.userName || projectForm.projPM
: "PM 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="사원 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-3 text-center"> .</CommandEmpty>
<CommandGroup>
{employees.map((emp) => (
<CommandItem
key={emp.userId}
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
onSelect={() => {
setProjectForm((p) => ({ ...p, projPM: emp.userId }));
setPmComboOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", projectForm.projPM === emp.userId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{emp.userName}</span>
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>

View File

@ -28,138 +28,17 @@ import {
MapPin,
AlertTriangle,
CheckCircle2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
// --- Types ---
type WorkOrderStatus = "pending" | "in_progress";
interface WorkOrder {
id: string;
itemCode: string;
itemName: string;
quantity: number;
date: string;
status: WorkOrderStatus;
}
interface MaterialLocation {
location: string;
qty: number;
}
interface Material {
code: string;
name: string;
required: number;
current: number;
unit: string;
locations: MaterialLocation[];
}
interface Warehouse {
code: string;
name: string;
}
// --- Sample Data ---
const sampleWarehouses: Warehouse[] = [
{ code: "WH001", name: "제1창고 (위치관리)" },
{ code: "WH002", name: "제2창고 (위치관리)" },
{ code: "WH003", name: "제3창고 (위치관리)" },
];
const sampleWorkOrders: WorkOrder[] = [
{
id: "WO2024001",
itemCode: "PROD-A001",
itemName: "상품 A",
quantity: 1000,
date: "2024-11-06",
status: "pending",
},
{
id: "WO2024002",
itemCode: "PROD-A002",
itemName: "상품 B",
quantity: 500,
date: "2024-11-07",
status: "pending",
},
{
id: "WO2024003",
itemCode: "PROD-A003",
itemName: "상품 C",
quantity: 800,
date: "2024-11-08",
status: "pending",
},
{
id: "WO2024004",
itemCode: "PROD-A004",
itemName: "상품 D",
quantity: 1200,
date: "2024-11-09",
status: "in_progress",
},
];
const sampleMaterials: Material[] = [
{
code: "MAT-R001",
name: "원자재 A",
required: 5000,
current: 4200,
unit: "kg",
locations: [
{ location: "A-01-01", qty: 2000 },
{ location: "A-01-02", qty: 1500 },
{ location: "A-01-03", qty: 700 },
],
},
{
code: "MAT-R002",
name: "원자재 B",
required: 3000,
current: 3500,
unit: "kg",
locations: [
{ location: "A-02-01", qty: 2000 },
{ location: "A-02-02", qty: 1500 },
],
},
{
code: "MAT-R003",
name: "원자재 C",
required: 2000,
current: 800,
unit: "EA",
locations: [
{ location: "B-01-01", qty: 500 },
{ location: "B-01-02", qty: 300 },
],
},
{
code: "MAT-R004",
name: "원자재 D",
required: 1500,
current: 1500,
unit: "L",
locations: [{ location: "C-01-01", qty: 1500 }],
},
{
code: "MAT-R005",
name: "원자재 E",
required: 4000,
current: 2500,
unit: "kg",
locations: [
{ location: "A-03-01", qty: 1000 },
{ location: "A-03-02", qty: 1000 },
{ location: "A-03-03", qty: 500 },
],
},
];
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
const formatDate = (date: Date) => {
const y = date.getFullYear();
@ -168,32 +47,85 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: WorkOrderStatus) =>
status === "pending" ? "대기" : "진행중";
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: WorkOrderStatus) =>
status === "pending"
? "bg-amber-100 text-amber-700 border-amber-200"
: "bg-blue-100 text-blue-700 border-blue-200";
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-amber-100 text-amber-700 border-amber-200",
pending: "bg-amber-100 text-amber-700 border-amber-200",
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
};
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
};
export default function MaterialStatusPage() {
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo));
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders] = useState<WorkOrder[]>(sampleWorkOrders);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
const [warehouse, setWarehouse] = useState(sampleWarehouses[0]?.code || "");
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials] = useState<Material[]>(sampleMaterials);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
// 창고 목록 초기 로드
useEffect(() => {
(async () => {
const res = await getWarehouses();
if (res.success && res.data) {
setWarehouses(res.data);
}
})();
}, []);
// 작업지시 검색
const handleSearch = useCallback(async () => {
setWorkOrdersLoading(true);
try {
const res = await getWorkOrders({
dateFrom: searchDateFrom,
dateTo: searchDateTo,
itemCode: searchItemCode || undefined,
itemName: searchItemName || undefined,
});
if (res.success && res.data) {
setWorkOrders(res.data);
setCheckedWoIds([]);
setSelectedWoId(null);
setMaterials([]);
}
} finally {
setWorkOrdersLoading(false);
}
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
// 초기 로드
useEffect(() => {
handleSearch();
}, []);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
@ -205,29 +137,42 @@ export default function MaterialStatusPage() {
[workOrders]
);
const handleCheckWo = useCallback((id: string, checked: boolean) => {
const handleCheckWo = useCallback((id: number, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
const handleSelectWo = useCallback((id: number) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
const handleLoadSelectedMaterials = useCallback(() => {
// 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
console.log("선택된 작업지시:", checkedWoIds);
}, [checkedWoIds]);
setMaterialsLoading(true);
try {
const res = await getMaterialStatus({
planIds: checkedWoIds,
warehouseCode: warehouse || undefined,
});
if (res.success && res.data) {
setMaterials(res.data);
}
} finally {
setMaterialsLoading(false);
}
}, [checkedWoIds, warehouse]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const w = new Date(t);
w.setDate(t.getDate() - 7);
setSearchDateFrom(formatDate(w));
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
@ -236,7 +181,6 @@ export default function MaterialStatusPage() {
}, []);
const filteredMaterials = useMemo(() => {
if (!warehouse) return [];
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
@ -246,7 +190,7 @@ export default function MaterialStatusPage() {
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, warehouse, materialSearch, showShortageOnly]);
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
@ -317,8 +261,17 @@ export default function MaterialStatusPage() {
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button size="sm" className="h-9">
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
</Button>
</div>
@ -349,8 +302,13 @@ export default function MaterialStatusPage() {
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{materialsLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
</Button>
</div>
@ -358,7 +316,14 @@ export default function MaterialStatusPage() {
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrders.length === 0 ? (
{workOrdersLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : workOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
@ -392,7 +357,7 @@ export default function MaterialStatusPage() {
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.id}
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
@ -405,21 +370,25 @@ export default function MaterialStatusPage() {
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.itemName}
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.itemCode})
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{wo.quantity.toLocaleString()}
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.date}
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</div>
</div>
@ -451,12 +420,19 @@ export default function MaterialStatusPage() {
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="창고 선택" />
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
{sampleWarehouses.map((wh) => (
<SelectItem key={wh.code} value={wh.code}>
{wh.name}
<SelectItem value="__all__"> </SelectItem>
{warehouses.map((wh) => (
<SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem>
))}
</SelectContent>
@ -475,11 +451,18 @@ export default function MaterialStatusPage() {
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{!warehouse ? (
{materialsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Factory className="mb-3 h-10 w-10 text-muted-foreground/30" />
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : materials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
@ -493,10 +476,13 @@ export default function MaterialStatusPage() {
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage = Math.min(
const percentage =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
);
)
: 100;
return (
<div
@ -578,6 +564,7 @@ export default function MaterialStatusPage() {
</div>
{/* 위치별 재고 */}
{material.locations.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
@ -586,7 +573,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location}
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@ -595,6 +582,7 @@ export default function MaterialStatusPage() {
</span>
))}
</div>
)}
</div>
);
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,845 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Loader2,
Settings,
Plus,
Pencil,
Trash2,
Search,
RotateCcw,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
getProcessList,
createProcess,
updateProcess,
deleteProcesses,
getProcessEquipments,
addProcessEquipment,
removeProcessEquipment,
getEquipmentList,
type ProcessMaster,
type ProcessEquipment,
type Equipment,
} from "@/lib/api/processInfo";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const ALL_VALUE = "__all__";
export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
const [equipmentPick, setEquipmentPick] = useState<string>("");
const [addingEquipment, setAddingEquipment] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [savingForm, setSavingForm] = useState(false);
const [formProcessCode, setFormProcessCode] = useState("");
const [formProcessName, setFormProcessName] = useState("");
const [formProcessType, setFormProcessType] = useState<string>("");
const [formStandardTime, setFormStandardTime] = useState("");
const [formWorkerCount, setFormWorkerCount] = useState("");
const [formUseYn, setFormUseYn] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const processTypeMap = useMemo(() => {
const m = new Map<string, string>();
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
return m;
}, [processTypeOptions]);
const getProcessTypeLabel = useCallback(
(code: string) => processTypeMap.get(code) ?? code,
[processTypeMap]
);
const loadProcesses = useCallback(async () => {
setLoadingList(true);
try {
const res = await getProcessList({
processCode: filterCode.trim() || undefined,
processName: filterName.trim() || undefined,
processType: filterType === ALL_VALUE ? undefined : filterType,
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
});
if (!res.success) {
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
return;
}
setProcesses(res.data ?? []);
} finally {
setLoadingList(false);
}
}, [filterCode, filterName, filterType, filterUseYn]);
const loadInitial = useCallback(async () => {
setLoadingInitial(true);
try {
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
if (!procRes.success) {
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
} else {
setProcesses(procRes.data ?? []);
}
if (!eqRes.success) {
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
} else {
setEquipmentMaster(eqRes.data ?? []);
}
const ptRes = await getCategoryValues("process_mng", "process_type");
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
}, []);
useEffect(() => {
void loadInitial();
}, [loadInitial]);
useEffect(() => {
setSelectedProcess((prev) => {
if (!prev) return prev;
if (!processes.some((p) => p.id === prev.id)) return null;
return prev;
});
}, [processes]);
useEffect(() => {
setEquipmentPick("");
}, [selectedProcess?.id]);
useEffect(() => {
if (!selectedProcess) {
setProcessEquipments([]);
return;
}
let cancelled = false;
setLoadingEquipments(true);
void (async () => {
const res = await getProcessEquipments(selectedProcess.process_code);
if (cancelled) return;
if (!res.success) {
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
setProcessEquipments([]);
} else {
setProcessEquipments(res.data ?? []);
}
setLoadingEquipments(false);
})();
return () => {
cancelled = true;
};
}, [selectedProcess?.process_code]);
const allSelected = useMemo(() => {
if (processes.length === 0) return false;
return processes.every((p) => selectedIds.has(p.id));
}, [processes, selectedIds]);
const toggleAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(processes.map((p) => p.id)));
} else {
setSelectedIds(new Set());
}
};
const toggleOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
};
const handleResetFilters = () => {
setFilterCode("");
setFilterName("");
setFilterType(ALL_VALUE);
setFilterUseYn(ALL_VALUE);
};
const handleSearch = () => {
void loadProcesses();
};
const openAdd = () => {
setFormMode("add");
setEditingId(null);
setFormProcessCode("");
setFormProcessName("");
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
setFormStandardTime("");
setFormWorkerCount("");
setFormUseYn("Y");
setFormOpen(true);
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
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);
setFormOpen(true);
};
const submitForm = async () => {
if (!formProcessName.trim()) {
toast.error("공정명을 입력하세요.");
return;
}
setSavingForm(true);
try {
if (formMode === "add") {
const res = await createProcess({
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "등록에 실패했습니다.");
return;
}
toast.success("공정이 등록되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
setSelectedIds(new Set());
} else if (editingId) {
const res = await updateProcess(editingId, {
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "수정에 실패했습니다.");
return;
}
toast.success("공정이 수정되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
}
} finally {
setSavingForm(false);
}
};
const openDelete = () => {
if (selectedIds.size === 0) {
toast.message("삭제할 공정을 체크박스로 선택하세요.");
return;
}
setDeleteOpen(true);
};
const confirmDelete = async () => {
const ids = Array.from(selectedIds);
setDeleting(true);
try {
const res = await deleteProcesses(ids);
if (!res.success) {
toast.error(res.message || "삭제에 실패했습니다.");
return;
}
toast.success(`${ids.length}건 삭제되었습니다.`);
setDeleteOpen(false);
setSelectedIds(new Set());
if (selectedProcess && ids.includes(selectedProcess.id)) {
setSelectedProcess(null);
}
await loadProcesses();
} finally {
setDeleting(false);
}
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
if (!selectedProcess) return;
if (!equipmentPick) {
toast.message("추가할 설비를 선택하세요.");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했습니다.");
return;
}
toast.success("설비가 등록되었습니다.");
setEquipmentPick("");
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
} finally {
setAddingEquipment(false);
}
};
const handleRemoveEquipment = async (row: ProcessEquipment) => {
const res = await removeProcessEquipment(row.id);
if (!res.success) {
toast.error(res.message || "설비 제거에 실패했습니다.");
return;
}
toast.success("설비가 제거되었습니다.");
if (selectedProcess) {
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
}
};
const listBusy = loadingInitial || loadingList;
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="text-sm font-semibold sm:text-base"> </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterCode}
onChange={(e) => setFilterCode(e.target.value)}
placeholder="코드"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder="이름"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleResetFilters}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleSearch}
disabled={listBusy}
>
<Search className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openAdd}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openEdit}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2 sm:p-3">
{listBusy ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-center">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="전체 선택"
className="mx-auto"
/>
</TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-right text-xs sm:text-sm">()</TableHead>
<TableHead className="text-right text-xs sm:text-sm"></TableHead>
<TableHead className="text-center text-xs sm:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
<p className="text-xs sm:text-sm"> .</p>
</TableCell>
</TableRow>
) : (
processes.map((row) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-colors",
selectedProcess?.id === row.id && "bg-accent"
)}
onClick={() => setSelectedProcess(row)}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={(v) => toggleOne(row.id, v === true)}
aria-label={`${row.process_code} 선택`}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-xs font-medium sm:text-sm">
{row.process_code}
</TableCell>
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
<TableCell className="text-xs sm:text-sm">
<Badge variant="secondary" className="text-[10px] sm:text-xs">
{getProcessTypeLabel(row.process_type)}
</Badge>
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.standard_time ?? "-"}
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.worker_count ?? "-"}
</TableCell>
<TableCell className="text-center text-xs sm:text-sm">
<Badge
variant={row.use_yn === "N" ? "outline" : "default"}
className="text-[10px] sm:text-xs"
>
{row.use_yn === "Y" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold sm:text-base"> </p>
{selectedProcess ? (
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{selectedProcess.process_name}{" "}
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
</p>
) : (
<p className="text-xs text-muted-foreground sm:text-sm"> </p>
)}
</div>
</div>
{!selectedProcess ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
<Settings className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs sm:text-sm">
.
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
<Label className="text-xs sm:text-sm"> </Label>
<Select
key={selectedProcess.id}
value={equipmentPick || undefined}
onValueChange={setEquipmentPick}
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="설비를 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem
key={eq.id}
value={eq.equipment_code}
className="text-xs sm:text-sm"
>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={() => void handleAddEquipment()}
disabled={addingEquipment || !equipmentPick}
>
{addingEquipment ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="min-h-0 flex-1">
{loadingEquipments ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : processEquipments.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
. .
</p>
) : (
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
<ul className="space-y-2">
{processEquipments.map((pe) => (
<li key={pe.id}>
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{pe.equipment_code}
</p>
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{pe.equipment_name || "설비명 없음"}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
onClick={() => void handleRemoveEquipment(pe)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{formMode === "add" ? "공정 추가" : "공정 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="pm-process-name"
value={formProcessName}
onChange={(e) => setFormProcessName(e.target.value)}
placeholder="공정명"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formProcessType} onValueChange={setFormProcessType}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
()
</Label>
<Input
id="pm-standard-time"
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
</Label>
<Input
id="pm-worker-count"
value={formWorkerCount}
onChange={(e) => setFormWorkerCount(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formUseYn} onValueChange={setFormUseYn}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
</Button>
<Button
type="button"
onClick={() => void submitForm()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedIds.size} . - .
.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void confirmDelete()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
export function ProcessWorkStandardTab() {
return (
<div className="h-[calc(100vh-12rem)]">
<ProcessWorkStandardComponent
config={{
itemListMode: "registered",
screenCode: "screen_1599",
leftPanelTitle: "등록 품목 및 공정",
}}
/>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Settings, GitBranch, ClipboardList } from "lucide-react";
import { ProcessMasterTab } from "./ProcessMasterTab";
import { ItemRoutingTab } from "./ItemRoutingTab";
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
export default function ProcessInfoPage() {
const [activeTab, setActiveTab] = useState("process");
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="process"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<Settings className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="routing"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<GitBranch className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="workstandard"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<ClipboardList className="mr-2 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
</TabsContent>
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
<ItemRoutingTab />
</TabsContent>
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
<ProcessWorkStandardTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,93 @@
/**
* API
*/
import { apiClient } from "./client";
export interface WorkOrder {
id: number;
plan_no: string;
item_code: string;
item_name: string;
plan_qty: number;
completed_qty: number;
plan_date: string;
start_date: string | null;
end_date: string | null;
status: string;
work_order_no: string | null;
company_code: string;
}
export interface MaterialLocation {
location: string;
warehouse: string;
qty: number;
}
export interface MaterialData {
code: string;
name: string;
required: number;
current: number;
unit: string;
locations: MaterialLocation[];
}
export interface WarehouseData {
warehouse_code: string;
warehouse_name: string;
warehouse_type: string | null;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
export async function getWorkOrders(params: {
dateFrom?: string;
dateTo?: string;
itemCode?: string;
itemName?: string;
}): Promise<ApiResponse<WorkOrder[]>> {
try {
const queryParams = new URLSearchParams();
if (params.dateFrom) queryParams.append("dateFrom", params.dateFrom);
if (params.dateTo) queryParams.append("dateTo", params.dateTo);
if (params.itemCode) queryParams.append("itemCode", params.itemCode);
if (params.itemName) queryParams.append("itemName", params.itemName);
const qs = queryParams.toString();
const url = `/material-status/work-orders${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return { success: false, message: error.message };
}
}
export async function getMaterialStatus(params: {
planIds: number[];
warehouseCode?: string;
}): Promise<ApiResponse<MaterialData[]>> {
try {
const response = await apiClient.post(
"/material-status/materials",
params
);
return response.data;
} catch (error: any) {
return { success: false, message: error.message };
}
}
export async function getWarehouses(): Promise<ApiResponse<WarehouseData[]>> {
try {
const response = await apiClient.get("/material-status/warehouses");
return response.data;
} catch (error: any) {
return { success: false, message: error.message };
}
}

View File

@ -0,0 +1,276 @@
/**
* API
*/
import { apiClient } from "./client";
// ═══ Types ═══
export interface ProcessMaster {
id: string;
company_code: string;
process_code: string;
process_name: string;
process_type: string;
standard_time: string;
worker_count: string;
use_yn: string;
}
export interface ProcessEquipment {
id: string;
process_code: string;
equipment_code: string;
equipment_name?: string;
}
export interface Equipment {
id: string;
equipment_code: string;
equipment_name: string;
}
export interface ItemForRouting {
id: string;
item_number: string;
item_name: string;
size: string;
unit: string;
type: string;
}
export interface RoutingVersion {
id: string;
item_code: string;
version_name: string;
description: string;
is_default: boolean;
}
export interface RoutingDetail {
id: string;
routing_version_id: string;
seq_no: string;
process_code: string;
process_name?: string;
is_required: string;
is_fixed_order: string;
work_type: string;
standard_time: string;
outsource_supplier: string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
const BASE = "/process-info";
// ═══ 공정 마스터 ═══
export async function getProcessList(params?: {
processCode?: string;
processName?: string;
processType?: string;
useYn?: string;
}): Promise<ApiResponse<ProcessMaster[]>> {
try {
const qp = new URLSearchParams();
if (params?.processCode) qp.append("processCode", params.processCode);
if (params?.processName) qp.append("processName", params.processName);
if (params?.processType) qp.append("processType", params.processType);
if (params?.useYn) qp.append("useYn", params.useYn);
const qs = qp.toString();
const res = await apiClient.get(`${BASE}/processes${qs ? `?${qs}` : ""}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createProcess(data: Partial<ProcessMaster>): Promise<ApiResponse<ProcessMaster>> {
try {
const res = await apiClient.post(`${BASE}/processes`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.response?.data?.message || e.message };
}
}
export async function updateProcess(id: string, data: Partial<ProcessMaster>): Promise<ApiResponse<ProcessMaster>> {
try {
const res = await apiClient.put(`${BASE}/processes/${id}`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.response?.data?.message || e.message };
}
}
export async function deleteProcesses(ids: string[]): Promise<ApiResponse<{ deletedCount: number }>> {
try {
const res = await apiClient.post(`${BASE}/processes/delete`, { ids });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ═══ 공정별 설비 ═══
export async function getProcessEquipments(processCode: string): Promise<ApiResponse<ProcessEquipment[]>> {
try {
const res = await apiClient.get(`${BASE}/processes/${processCode}/equipments`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function addProcessEquipment(data: { process_code: string; equipment_code: string }): Promise<ApiResponse<ProcessEquipment>> {
try {
const res = await apiClient.post(`${BASE}/process-equipments`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.response?.data?.message || e.message };
}
}
export async function removeProcessEquipment(id: string): Promise<ApiResponse<void>> {
try {
const res = await apiClient.delete(`${BASE}/process-equipments/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function getEquipmentList(): Promise<ApiResponse<Equipment[]>> {
try {
const res = await apiClient.get(`${BASE}/equipments`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ═══ 등록 품목 관리 (item_routing_registered) ═══
export const ROUTING_SCREEN_CODE = "screen_1599";
export interface RegisteredItem {
registered_id: string;
sort_order: string;
id: string;
item_name: string;
item_code: string;
routing_count: string;
}
const PWS_BASE = "/process-work-standard";
export async function getRegisteredItems(search?: string): Promise<ApiResponse<RegisteredItem[]>> {
try {
const qs = new URLSearchParams({
tableName: "item_info",
nameColumn: "item_name",
codeColumn: "item_number",
routingTable: "item_routing_version",
routingFkColumn: "item_code",
});
if (search) qs.set("search", search);
const res = await apiClient.get(`${PWS_BASE}/registered-items/${ROUTING_SCREEN_CODE}?${qs.toString()}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function registerItemsBatch(
items: Array<{ itemId: string; itemCode: string }>
): Promise<ApiResponse<any[]>> {
try {
const res = await apiClient.post(`${PWS_BASE}/registered-items/batch`, {
screenCode: ROUTING_SCREEN_CODE,
items,
});
return res.data;
} catch (e: any) {
return { success: false, message: e.response?.data?.message || e.message };
}
}
export async function unregisterItem(registeredId: string): Promise<ApiResponse<void>> {
try {
const res = await apiClient.delete(`${PWS_BASE}/registered-items/${registeredId}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
// ═══ 품목별 라우팅 ═══
export async function searchAllItems(search?: string): Promise<ApiResponse<ItemForRouting[]>> {
try {
const qs = search ? `?search=${encodeURIComponent(search)}` : "";
const res = await apiClient.get(`${BASE}/items/search-all${qs}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function getRoutingVersions(itemCode: string): Promise<ApiResponse<RoutingVersion[]>> {
try {
const res = await apiClient.get(`${BASE}/routing-versions/${itemCode}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function createRoutingVersion(data: {
item_code: string;
version_name: string;
description?: string;
is_default?: boolean;
}): Promise<ApiResponse<RoutingVersion>> {
try {
const res = await apiClient.post(`${BASE}/routing-versions`, data);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function deleteRoutingVersion(id: string): Promise<ApiResponse<void>> {
try {
const res = await apiClient.delete(`${BASE}/routing-versions/${id}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function getRoutingDetails(versionId: string): Promise<ApiResponse<RoutingDetail[]>> {
try {
const res = await apiClient.get(`${BASE}/routing-details/${versionId}`);
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}
export async function saveRoutingDetails(
versionId: string,
details: Partial<RoutingDetail>[]
): Promise<ApiResponse<void>> {
try {
const res = await apiClient.put(`${BASE}/routing-details/${versionId}`, { details });
return res.data;
} catch (e: any) {
return { success: false, message: e.message };
}
}