jskim-node #423

Merged
kjs merged 27 commits from jskim-node into main 2026-03-20 16:10:33 +09:00
13 changed files with 3732 additions and 256 deletions
Showing only changes of commit ffcede7e66 - Show all commits

View File

@ -149,6 +149,8 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -321,6 +323,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리 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/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 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, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { import {
Search, Search,
RotateCcw, RotateCcw,
@ -47,6 +60,12 @@ import {
Eye, Eye,
ChevronRight, ChevronRight,
ArrowRight, ArrowRight,
Check,
ChevronsUpDown,
UserCircle,
Loader2,
User,
Users,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
@ -54,8 +73,10 @@ import {
getDesignRequestList, getDesignRequestList,
updateDesignRequest, updateDesignRequest,
addRequestHistory, addRequestHistory,
createProject,
} from "@/lib/api/design"; } from "@/lib/api/design";
import { Loader2 } from "lucide-react"; import { getUserList } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
// --- Types --- // --- Types ---
type SourceType = "dr" | "ecr"; 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" }, { label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" },
]; ];
interface EmployeeOption {
userId: string;
userName: string;
deptName: string;
}
export default function DesignTaskManagementPage() { export default function DesignTaskManagementPage() {
const { user, userName, loading: authLoading } = useAuth();
const [allTasks, setAllTasks] = useState<TaskItem[]>([]); const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<MainTab>("all"); 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 () => { const fetchTasks = useCallback(async () => {
setLoading(true); setLoading(true);
@ -223,7 +269,8 @@ export default function DesignTaskManagementPage() {
useEffect(() => { useEffect(() => {
fetchTasks(); fetchTasks();
}, [fetchTasks]); fetchEmployees();
}, [fetchTasks, fetchEmployees]);
// 검색 필터 // 검색 필터
const [searchStatus, setSearchStatus] = useState<string>("all"); const [searchStatus, setSearchStatus] = useState<string>("all");
@ -231,12 +278,19 @@ export default function DesignTaskManagementPage() {
const [searchReqDept, setSearchReqDept] = useState<string>("all"); const [searchReqDept, setSearchReqDept] = useState<string>("all");
const [searchKeyword, setSearchKeyword] = useState(""); 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 [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTaskId, setRejectTaskId] = useState<string | null>(null); const [rejectTaskId, setRejectTaskId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState(""); const [rejectReason, setRejectReason] = useState("");
const [projectModalOpen, setProjectModalOpen] = useState(false); const [projectModalOpen, setProjectModalOpen] = useState(false);
const [projectTaskId, setProjectTaskId] = useState<string | null>(null); const [projectTaskId, setProjectTaskId] = useState<string | null>(null);
const [pmComboOpen, setPmComboOpen] = useState(false);
const [projectForm, setProjectForm] = useState({ const [projectForm, setProjectForm] = useState({
projNo: "", projNo: "",
projName: "", projName: "",
@ -251,25 +305,40 @@ export default function DesignTaskManagementPage() {
// 검토 메모 // 검토 메모
const [reviewMemoText, setReviewMemoText] = useState(""); 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 tabCounts = useMemo(() => {
const drItems = allTasks.filter((t) => t.sourceType === "dr"); const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr");
const ecrItems = allTasks.filter((t) => t.sourceType === "ecr"); const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr");
const newDR = drItems.filter((t) => t.status === "신규접수").length; const newDR = drItems.filter((t) => t.status === "신규접수").length;
const newECR = ecrItems.filter((t) => t.status === "신규접수").length; const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
return { return {
all: newDR + newECR || allTasks.length, all: newDR + newECR || myRelatedTasks.length,
allIsNew: newDR + newECR > 0, allIsNew: newDR + newECR > 0,
dr: newDR || drItems.length, dr: newDR || drItems.length,
drIsNew: newDR > 0, drIsNew: newDR > 0,
ecr: newECR || ecrItems.length, ecr: newECR || ecrItems.length,
ecrIsNew: newECR > 0, ecrIsNew: newECR > 0,
}; };
}, [allTasks]); }, [myRelatedTasks]);
// 필터링된 데이터 // 필터링된 데이터
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return allTasks.filter((item) => { return myRelatedTasks.filter((item) => {
if (currentTab === "dr" && item.sourceType !== "dr") return false; if (currentTab === "dr" && item.sourceType !== "dr") return false;
if (currentTab === "ecr" && item.sourceType !== "ecr") return false; if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
if (searchStatus !== "all" && item.status !== searchStatus) return false; if (searchStatus !== "all" && item.status !== searchStatus) return false;
@ -283,18 +352,18 @@ export default function DesignTaskManagementPage() {
} }
return true; return true;
}); });
}, [allTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]); }, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
// 현황 통계 // 현황 통계
const stats = useMemo(() => { const stats = useMemo(() => {
return { return {
신규접수: allTasks.filter((t) => t.status === "신규접수").length, 신규접수: myRelatedTasks.filter((t) => t.status === "신규접수").length,
검토중: allTasks.filter((t) => t.status === "검토중").length, 검토중: myRelatedTasks.filter((t) => t.status === "검토중").length,
승인완료: allTasks.filter((t) => t.status === "승인완료").length, 승인완료: myRelatedTasks.filter((t) => t.status === "승인완료").length,
반려: allTasks.filter((t) => t.status === "반려").length, 반려: myRelatedTasks.filter((t) => t.status === "반려").length,
프로젝트생성: allTasks.filter((t) => t.status === "프로젝트생성").length, 프로젝트생성: myRelatedTasks.filter((t) => t.status === "프로젝트생성").length,
}; };
}, [allTasks]); }, [myRelatedTasks]);
const selectedTask = useMemo( const selectedTask = useMemo(
() => allTasks.find((t) => t.dbId === selectedTaskId) || null, () => allTasks.find((t) => t.dbId === selectedTaskId) || null,
@ -313,37 +382,48 @@ export default function DesignTaskManagementPage() {
setSelectedTaskId(dbId); setSelectedTaskId(dbId);
}, []); }, []);
const handleStartReview = useCallback( const handleOpenDesignerModal = useCallback((dbId: string) => {
async (dbId: string) => { setDesignerModalTaskId(dbId);
const designer = prompt("설계 담당자를 입력하세요:"); setDesignerModalValue("");
if (designer === null) return; setDesignerComboOpen(false);
setDesignerModalOpen(true);
}, []);
const historyDate = new Date().toISOString().split("T")[0]; const handleConfirmDesigner = useCallback(async () => {
const historyRes = await addRequestHistory(dbId, { if (!designerModalValue) {
step: "검토", toast.error("설계 담당자를 선택하세요.");
history_date: historyDate, return;
user_name: designer || "시스템", }
description: "검토 착수 - 담당자 배정", if (!designerModalTaskId) return;
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(dbId, { const selected = employees.find((e) => e.userId === designerModalValue);
status: "검토중", const designerName = selected?.userName || designerModalValue;
approval_step: 1,
designer: designer || "", const historyDate = new Date().toISOString().split("T")[0];
}); const historyRes = await addRequestHistory(designerModalTaskId, {
if (!updateRes.success) { step: "검토",
toast.error(updateRes.message || "상태 업데이트에 실패했습니다."); history_date: historyDate,
return; user_name: designerName,
} description: "검토 착수 - 담당자 배정",
toast.success("검토가 착수되었습니다."); });
fetchTasks(); if (!historyRes.success) {
}, toast.error(historyRes.message || "이력 추가에 실패했습니다.");
[fetchTasks] return;
); }
const updateRes = await updateDesignRequest(designerModalTaskId, {
status: "검토중",
approval_step: 1,
designer: designerName,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setDesignerModalOpen(false);
toast.success("검토가 착수되었습니다.");
fetchTasks();
}, [designerModalTaskId, designerModalValue, employees, fetchTasks]);
const handleApprove = useCallback( const handleApprove = useCallback(
async (dbId: string) => { async (dbId: string) => {
@ -424,19 +504,20 @@ export default function DesignTaskManagementPage() {
const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`; const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`;
setProjectTaskId(dbId); setProjectTaskId(dbId);
const matchedEmployee = employees.find((e) => e.userName === task.designer);
setProjectForm({ setProjectForm({
projNo, projNo,
projName: task.targetName, projName: task.targetName,
projSourceNo: task.id, projSourceNo: task.id,
projStartDate: new Date().toISOString().split("T")[0], projStartDate: new Date().toISOString().split("T")[0],
projEndDate: task.dueDate, projEndDate: task.dueDate,
projPM: task.designer || "", projPM: matchedEmployee?.userId || "",
projCustomer: task.customer || task.reqDept, projCustomer: task.customer || task.reqDept,
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "", projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
}); });
setProjectModalOpen(true); setProjectModalOpen(true);
}, },
[allTasks] [allTasks, employees]
); );
const handleCreateProject = useCallback(async () => { const handleCreateProject = useCallback(async () => {
@ -446,11 +527,35 @@ export default function DesignTaskManagementPage() {
if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; } if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; }
if (!projectTaskId) 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 historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(projectTaskId, { const historyRes = await addRequestHistory(projectTaskId, {
step: "프로젝트", step: "프로젝트",
history_date: historyDate, history_date: historyDate,
user_name: projectForm.projPM, user_name: pmName,
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`, description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
}); });
if (!historyRes.success) { if (!historyRes.success) {
@ -458,10 +563,11 @@ export default function DesignTaskManagementPage() {
return; return;
} }
// 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결
const updateRes = await updateDesignRequest(projectTaskId, { const updateRes = await updateDesignRequest(projectTaskId, {
status: "프로젝트생성", status: "프로젝트생성",
approval_step: 4, approval_step: 4,
project_id: projectForm.projNo, project_id: createdProjectId,
}); });
if (!updateRes.success) { if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다."); toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
@ -470,7 +576,7 @@ export default function DesignTaskManagementPage() {
setProjectModalOpen(false); setProjectModalOpen(false);
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`); toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
fetchTasks(); fetchTasks();
}, [projectForm, projectTaskId, fetchTasks]); }, [projectForm, projectTaskId, employees, fetchTasks]);
const handleSaveReviewMemo = useCallback(async () => { const handleSaveReviewMemo = useCallback(async () => {
if (!selectedTaskId) return; if (!selectedTaskId) return;
@ -542,9 +648,44 @@ export default function DesignTaskManagementPage() {
</button> </button>
))} ))}
<div className="flex-1" /> <div className="flex-1" />
<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"> <div className="flex items-center gap-3">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" /> {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> </div>
@ -618,7 +759,12 @@ export default function DesignTaskManagementPage() {
<div className="flex h-full flex-col bg-card"> <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"> <div className="flex items-center justify-between border-b-2 border-border px-5 py-3">
<h2 className="text-base font-bold text-foreground"> <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> </h2>
<Button size="sm" variant="outline" onClick={fetchTasks} disabled={loading}> <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" />} {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} key={item.id}
className={cn( className={cn(
"cursor-pointer transition-colors", "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" item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
)} )}
onClick={() => handleSelectTask(item.id)} onClick={() => handleSelectTask(item.dbId)}
> >
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}> <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"> <div className="flex flex-wrap gap-2">
{selectedTask.status === "신규접수" && ( {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" /> <Eye className="mr-1 h-3.5 w-3.5" />
</Button> </Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}> <Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
@ -962,6 +1108,77 @@ export default function DesignTaskManagementPage() {
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </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}> <Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
@ -1052,17 +1269,48 @@ export default function DesignTaskManagementPage() {
<Label className="text-xs sm:text-sm"> <Label className="text-xs sm:text-sm">
PM <span className="text-destructive">*</span> PM <span className="text-destructive">*</span>
</Label> </Label>
<Select value={projectForm.projPM} onValueChange={(v) => setProjectForm((p) => ({ ...p, projPM: v }))}> <Popover open={pmComboOpen} onOpenChange={setPmComboOpen}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"> <PopoverTrigger asChild>
<SelectValue placeholder="선택" /> <Button
</SelectTrigger> variant="outline"
<SelectContent> role="combobox"
<SelectItem value="이설계"></SelectItem> aria-expanded={pmComboOpen}
<SelectItem value="박도면"></SelectItem> className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
<SelectItem value="최기구"></SelectItem> >
<SelectItem value="김전장"></SelectItem> {projectForm.projPM
</SelectContent> ? employees.find((e) => e.userId === projectForm.projPM)?.userName || projectForm.projPM
</Select> : "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>
<div> <div>
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>

View File

@ -28,138 +28,17 @@ import {
MapPin, MapPin,
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
// --- Types --- getWorkOrders,
type WorkOrderStatus = "pending" | "in_progress"; getMaterialStatus,
getWarehouses,
interface WorkOrder { type WorkOrder,
id: string; type MaterialData,
itemCode: string; type WarehouseData,
itemName: string; } from "@/lib/api/materialStatus";
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 },
],
},
];
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
const y = date.getFullYear(); const y = date.getFullYear();
@ -168,32 +47,85 @@ const formatDate = (date: Date) => {
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
}; };
const getStatusLabel = (status: WorkOrderStatus) => const getStatusLabel = (status: string) => {
status === "pending" ? "대기" : "진행중"; const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: WorkOrderStatus) => const getStatusStyle = (status: string) => {
status === "pending" const map: Record<string, string> = {
? "bg-amber-100 text-amber-700 border-amber-200" planned: "bg-amber-100 text-amber-700 border-amber-200",
: "bg-blue-100 text-blue-700 border-blue-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() { export default function MaterialStatusPage() {
const today = new Date(); const today = new Date();
const weekAgo = new Date(today); const monthAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7); monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo)); const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today)); const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState(""); const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState(""); const [searchItemName, setSearchItemName] = useState("");
const [workOrders] = useState<WorkOrder[]>(sampleWorkOrders); const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]); const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null); 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 [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false); 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 = const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length; workOrders.length > 0 && checkedWoIds.length === workOrders.length;
@ -205,29 +137,42 @@ export default function MaterialStatusPage() {
[workOrders] [workOrders]
); );
const handleCheckWo = useCallback((id: string, checked: boolean) => { const handleCheckWo = useCallback((id: number, checked: boolean) => {
setCheckedWoIds((prev) => setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id) checked ? [...prev, id] : prev.filter((i) => i !== id)
); );
}, []); }, []);
const handleSelectWo = useCallback((id: string) => { const handleSelectWo = useCallback((id: number) => {
setSelectedWoId((prev) => (prev === id ? null : id)); setSelectedWoId((prev) => (prev === id ? null : id));
}, []); }, []);
const handleLoadSelectedMaterials = useCallback(() => { // 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) { if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요."); alert("자재를 조회할 작업지시를 선택해주세요.");
return; 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 handleResetSearch = useCallback(() => {
const t = new Date(); const t = new Date();
const w = new Date(t); const m = new Date(t);
w.setDate(t.getDate() - 7); m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(w)); setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t)); setSearchDateTo(formatDate(t));
setSearchItemCode(""); setSearchItemCode("");
setSearchItemName(""); setSearchItemName("");
@ -236,7 +181,6 @@ export default function MaterialStatusPage() {
}, []); }, []);
const filteredMaterials = useMemo(() => { const filteredMaterials = useMemo(() => {
if (!warehouse) return [];
return materials.filter((m) => { return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase(); const searchLower = materialSearch.toLowerCase();
const matchesSearch = const matchesSearch =
@ -246,7 +190,7 @@ export default function MaterialStatusPage() {
const matchesShortage = !showShortageOnly || m.current < m.required; const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage; return matchesSearch && matchesShortage;
}); });
}, [materials, warehouse, materialSearch, showShortageOnly]); }, [materials, materialSearch, showShortageOnly]);
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4"> <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" /> <RotateCcw className="mr-2 h-4 w-4" />
</Button> </Button>
<Button size="sm" className="h-9"> <Button
<Search className="mr-2 h-4 w-4" /> 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> </Button>
</div> </div>
@ -349,8 +302,13 @@ export default function MaterialStatusPage() {
size="sm" size="sm"
className="h-8" className="h-8"
onClick={handleLoadSelectedMaterials} onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
> >
<Search className="mr-1.5 h-3.5 w-3.5" /> {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> </Button>
</div> </div>
@ -358,7 +316,14 @@ export default function MaterialStatusPage() {
{/* 작업지시 목록 */} {/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3"> <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"> <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" /> <ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"> <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 flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary"> <span className="text-sm font-bold text-primary">
{wo.id} {wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span> </span>
<span <span
className={cn( className={cn(
@ -405,21 +370,25 @@ export default function MaterialStatusPage() {
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
{wo.itemName} {wo.item_name}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
({wo.itemCode}) ({wo.item_code})
</span> </span>
</div> </div>
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span> <span>:</span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{wo.quantity.toLocaleString()} {Number(wo.plan_qty).toLocaleString()}
</span> </span>
<span className="mx-1">|</span> <span className="mx-1">|</span>
<span>:</span> <span>:</span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{wo.date} {wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span> </span>
</div> </div>
</div> </div>
@ -451,12 +420,19 @@ export default function MaterialStatusPage() {
/> />
<Select value={warehouse} onValueChange={setWarehouse}> <Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]"> <SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="창고 선택" /> <SelectValue placeholder="전체 창고" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{sampleWarehouses.map((wh) => ( <SelectItem value="__all__"> </SelectItem>
<SelectItem key={wh.code} value={wh.code}> {warehouses.map((wh) => (
{wh.name} <SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -475,11 +451,18 @@ export default function MaterialStatusPage() {
{/* 원자재 목록 */} {/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3"> <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"> <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 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> </p>
</div> </div>
) : filteredMaterials.length === 0 ? ( ) : filteredMaterials.length === 0 ? (
@ -493,10 +476,13 @@ export default function MaterialStatusPage() {
filteredMaterials.map((material) => { filteredMaterials.map((material) => {
const shortage = material.required - material.current; const shortage = material.required - material.current;
const isShortage = shortage > 0; const isShortage = shortage > 0;
const percentage = Math.min( const percentage =
(material.current / material.required) * 100, material.required > 0
100 ? Math.min(
); (material.current / material.required) * 100,
100
)
: 100;
return ( return (
<div <div
@ -578,23 +564,25 @@ export default function MaterialStatusPage() {
</div> </div>
{/* 위치별 재고 */} {/* 위치별 재고 */}
<div className="mt-2 flex flex-wrap items-center gap-1.5"> {material.locations.length > 0 && (
<MapPin className="h-3.5 w-3.5 text-muted-foreground" /> <div className="mt-2 flex flex-wrap items-center gap-1.5">
{material.locations.map((loc, idx) => ( <MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<span {material.locations.map((loc, idx) => (
key={idx} <span
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60" key={idx}
> 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} <span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span> </span>
<span className="font-semibold"> ))}
{loc.qty.toLocaleString()} </div>
{material.unit} )}
</span>
</span>
))}
</div>
</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 };
}
}