jskim-node #423
|
|
@ -149,6 +149,8 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지
|
|||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -321,6 +323,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
|||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||
app.use("/api/material-status", materialStatusRoutes); // 자재현황
|
||||
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -34,6 +34,19 @@ import {
|
|||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
|
|
@ -47,6 +60,12 @@ import {
|
|||
Eye,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
UserCircle,
|
||||
Loader2,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -54,8 +73,10 @@ import {
|
|||
getDesignRequestList,
|
||||
updateDesignRequest,
|
||||
addRequestHistory,
|
||||
createProject,
|
||||
} from "@/lib/api/design";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getUserList } from "@/lib/api/user";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// --- Types ---
|
||||
type SourceType = "dr" | "ecr";
|
||||
|
|
@ -202,11 +223,36 @@ const STAT_CARDS: { label: string; status: TaskStatus; color: string; textColor:
|
|||
{ label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" },
|
||||
];
|
||||
|
||||
interface EmployeeOption {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName: string;
|
||||
}
|
||||
|
||||
export default function DesignTaskManagementPage() {
|
||||
const { user, userName, loading: authLoading } = useAuth();
|
||||
const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [currentTab, setCurrentTab] = useState<MainTab>("all");
|
||||
const [employees, setEmployees] = useState<EmployeeOption[]>([]);
|
||||
const [myTasksOnly, setMyTasksOnly] = useState(true);
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
try {
|
||||
const res = await getUserList({ size: 1000 });
|
||||
if (res.success && res.data) {
|
||||
const list = (res.data as any[]).map((u: any) => ({
|
||||
userId: u.user_id || u.userId,
|
||||
userName: u.user_name || u.userName || "",
|
||||
deptName: u.dept_name || u.deptName || "",
|
||||
}));
|
||||
setEmployees(list);
|
||||
}
|
||||
} catch {
|
||||
// 사원 목록 로드 실패 시 빈 배열 유지
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -223,7 +269,8 @@ export default function DesignTaskManagementPage() {
|
|||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
fetchEmployees();
|
||||
}, [fetchTasks, fetchEmployees]);
|
||||
|
||||
// 검색 필터
|
||||
const [searchStatus, setSearchStatus] = useState<string>("all");
|
||||
|
|
@ -231,12 +278,19 @@ export default function DesignTaskManagementPage() {
|
|||
const [searchReqDept, setSearchReqDept] = useState<string>("all");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
// 담당자 선택 모달 상태
|
||||
const [designerModalOpen, setDesignerModalOpen] = useState(false);
|
||||
const [designerModalTaskId, setDesignerModalTaskId] = useState<string | null>(null);
|
||||
const [designerModalValue, setDesignerModalValue] = useState("");
|
||||
const [designerComboOpen, setDesignerComboOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||
const [rejectTaskId, setRejectTaskId] = useState<string | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [projectModalOpen, setProjectModalOpen] = useState(false);
|
||||
const [projectTaskId, setProjectTaskId] = useState<string | null>(null);
|
||||
const [pmComboOpen, setPmComboOpen] = useState(false);
|
||||
const [projectForm, setProjectForm] = useState({
|
||||
projNo: "",
|
||||
projName: "",
|
||||
|
|
@ -251,25 +305,40 @@ export default function DesignTaskManagementPage() {
|
|||
// 검토 메모
|
||||
const [reviewMemoText, setReviewMemoText] = useState("");
|
||||
|
||||
// 현재 사용자 관련 업무만 필터링
|
||||
const myRelatedTasks = useMemo(() => {
|
||||
if (!myTasksOnly || !userName) return allTasks;
|
||||
const currentUserName = userName;
|
||||
const currentDeptName = user?.deptName || "";
|
||||
return allTasks.filter((item) => {
|
||||
if (item.requester === currentUserName) return true;
|
||||
if (item.designer === currentUserName) return true;
|
||||
if (currentDeptName && item.reqDept === currentDeptName) return true;
|
||||
const inHistory = item.history.some((h) => h.user === currentUserName);
|
||||
if (inHistory) return true;
|
||||
return false;
|
||||
});
|
||||
}, [allTasks, myTasksOnly, userName, user?.deptName]);
|
||||
|
||||
// 탭별 카운트
|
||||
const tabCounts = useMemo(() => {
|
||||
const drItems = allTasks.filter((t) => t.sourceType === "dr");
|
||||
const ecrItems = allTasks.filter((t) => t.sourceType === "ecr");
|
||||
const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr");
|
||||
const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr");
|
||||
const newDR = drItems.filter((t) => t.status === "신규접수").length;
|
||||
const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
|
||||
return {
|
||||
all: newDR + newECR || allTasks.length,
|
||||
all: newDR + newECR || myRelatedTasks.length,
|
||||
allIsNew: newDR + newECR > 0,
|
||||
dr: newDR || drItems.length,
|
||||
drIsNew: newDR > 0,
|
||||
ecr: newECR || ecrItems.length,
|
||||
ecrIsNew: newECR > 0,
|
||||
};
|
||||
}, [allTasks]);
|
||||
}, [myRelatedTasks]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = useMemo(() => {
|
||||
return allTasks.filter((item) => {
|
||||
return myRelatedTasks.filter((item) => {
|
||||
if (currentTab === "dr" && item.sourceType !== "dr") return false;
|
||||
if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
|
||||
if (searchStatus !== "all" && item.status !== searchStatus) return false;
|
||||
|
|
@ -283,18 +352,18 @@ export default function DesignTaskManagementPage() {
|
|||
}
|
||||
return true;
|
||||
});
|
||||
}, [allTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
|
||||
}, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
|
||||
|
||||
// 현황 통계
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
신규접수: allTasks.filter((t) => t.status === "신규접수").length,
|
||||
검토중: allTasks.filter((t) => t.status === "검토중").length,
|
||||
승인완료: allTasks.filter((t) => t.status === "승인완료").length,
|
||||
반려: allTasks.filter((t) => t.status === "반려").length,
|
||||
프로젝트생성: allTasks.filter((t) => t.status === "프로젝트생성").length,
|
||||
신규접수: myRelatedTasks.filter((t) => t.status === "신규접수").length,
|
||||
검토중: myRelatedTasks.filter((t) => t.status === "검토중").length,
|
||||
승인완료: myRelatedTasks.filter((t) => t.status === "승인완료").length,
|
||||
반려: myRelatedTasks.filter((t) => t.status === "반려").length,
|
||||
프로젝트생성: myRelatedTasks.filter((t) => t.status === "프로젝트생성").length,
|
||||
};
|
||||
}, [allTasks]);
|
||||
}, [myRelatedTasks]);
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => allTasks.find((t) => t.dbId === selectedTaskId) || null,
|
||||
|
|
@ -313,16 +382,28 @@ export default function DesignTaskManagementPage() {
|
|||
setSelectedTaskId(dbId);
|
||||
}, []);
|
||||
|
||||
const handleStartReview = useCallback(
|
||||
async (dbId: string) => {
|
||||
const designer = prompt("설계 담당자를 입력하세요:");
|
||||
if (designer === null) return;
|
||||
const handleOpenDesignerModal = useCallback((dbId: string) => {
|
||||
setDesignerModalTaskId(dbId);
|
||||
setDesignerModalValue("");
|
||||
setDesignerComboOpen(false);
|
||||
setDesignerModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDesigner = useCallback(async () => {
|
||||
if (!designerModalValue) {
|
||||
toast.error("설계 담당자를 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (!designerModalTaskId) return;
|
||||
|
||||
const selected = employees.find((e) => e.userId === designerModalValue);
|
||||
const designerName = selected?.userName || designerModalValue;
|
||||
|
||||
const historyDate = new Date().toISOString().split("T")[0];
|
||||
const historyRes = await addRequestHistory(dbId, {
|
||||
const historyRes = await addRequestHistory(designerModalTaskId, {
|
||||
step: "검토",
|
||||
history_date: historyDate,
|
||||
user_name: designer || "시스템",
|
||||
user_name: designerName,
|
||||
description: "검토 착수 - 담당자 배정",
|
||||
});
|
||||
if (!historyRes.success) {
|
||||
|
|
@ -330,20 +411,19 @@ export default function DesignTaskManagementPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
const updateRes = await updateDesignRequest(dbId, {
|
||||
const updateRes = await updateDesignRequest(designerModalTaskId, {
|
||||
status: "검토중",
|
||||
approval_step: 1,
|
||||
designer: designer || "",
|
||||
designer: designerName,
|
||||
});
|
||||
if (!updateRes.success) {
|
||||
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
setDesignerModalOpen(false);
|
||||
toast.success("검토가 착수되었습니다.");
|
||||
fetchTasks();
|
||||
},
|
||||
[fetchTasks]
|
||||
);
|
||||
}, [designerModalTaskId, designerModalValue, employees, fetchTasks]);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (dbId: string) => {
|
||||
|
|
@ -424,19 +504,20 @@ export default function DesignTaskManagementPage() {
|
|||
const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`;
|
||||
|
||||
setProjectTaskId(dbId);
|
||||
const matchedEmployee = employees.find((e) => e.userName === task.designer);
|
||||
setProjectForm({
|
||||
projNo,
|
||||
projName: task.targetName,
|
||||
projSourceNo: task.id,
|
||||
projStartDate: new Date().toISOString().split("T")[0],
|
||||
projEndDate: task.dueDate,
|
||||
projPM: task.designer || "",
|
||||
projPM: matchedEmployee?.userId || "",
|
||||
projCustomer: task.customer || task.reqDept,
|
||||
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
|
||||
});
|
||||
setProjectModalOpen(true);
|
||||
},
|
||||
[allTasks]
|
||||
[allTasks, employees]
|
||||
);
|
||||
|
||||
const handleCreateProject = useCallback(async () => {
|
||||
|
|
@ -446,11 +527,35 @@ export default function DesignTaskManagementPage() {
|
|||
if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; }
|
||||
if (!projectTaskId) return;
|
||||
|
||||
const pmEmployee = employees.find((e) => e.userId === projectForm.projPM);
|
||||
const pmName = pmEmployee?.userName || projectForm.projPM;
|
||||
|
||||
// 1) 실제 프로젝트 테이블(dsn_project)에 INSERT
|
||||
const projectRes = await createProject({
|
||||
project_no: projectForm.projNo,
|
||||
name: projectForm.projName,
|
||||
status: "계획",
|
||||
pm: pmName,
|
||||
customer: projectForm.projCustomer,
|
||||
start_date: projectForm.projStartDate,
|
||||
end_date: projectForm.projEndDate,
|
||||
source_no: projectForm.projSourceNo,
|
||||
description: projectForm.projDesc,
|
||||
progress: "0",
|
||||
});
|
||||
if (!projectRes.success) {
|
||||
toast.error(projectRes.message || "프로젝트 생성에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const createdProjectId = projectRes.data?.id || projectForm.projNo;
|
||||
|
||||
// 2) 이력 추가
|
||||
const historyDate = new Date().toISOString().split("T")[0];
|
||||
const historyRes = await addRequestHistory(projectTaskId, {
|
||||
step: "프로젝트",
|
||||
history_date: historyDate,
|
||||
user_name: projectForm.projPM,
|
||||
user_name: pmName,
|
||||
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
|
||||
});
|
||||
if (!historyRes.success) {
|
||||
|
|
@ -458,10 +563,11 @@ export default function DesignTaskManagementPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결
|
||||
const updateRes = await updateDesignRequest(projectTaskId, {
|
||||
status: "프로젝트생성",
|
||||
approval_step: 4,
|
||||
project_id: projectForm.projNo,
|
||||
project_id: createdProjectId,
|
||||
});
|
||||
if (!updateRes.success) {
|
||||
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
|
||||
|
|
@ -470,7 +576,7 @@ export default function DesignTaskManagementPage() {
|
|||
setProjectModalOpen(false);
|
||||
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
|
||||
fetchTasks();
|
||||
}, [projectForm, projectTaskId, fetchTasks]);
|
||||
}, [projectForm, projectTaskId, employees, fetchTasks]);
|
||||
|
||||
const handleSaveReviewMemo = useCallback(async () => {
|
||||
if (!selectedTaskId) return;
|
||||
|
|
@ -542,11 +648,46 @@ export default function DesignTaskManagementPage() {
|
|||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-3">
|
||||
{userName && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
<UserCircle className="h-3.5 w-3.5" />
|
||||
{userName}
|
||||
{user?.deptName && <span className="text-primary/60">({user.deptName})</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center overflow-hidden rounded-full border border-border">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
myTasksOnly
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setMyTasksOnly(true)}
|
||||
>
|
||||
<User className="h-3 w-3" />
|
||||
내 업무
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
!myTasksOnly
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setMyTasksOnly(false)}
|
||||
>
|
||||
<Users className="h-3 w-3" />
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
|
||||
실시간 동기화 중
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 섹션 */}
|
||||
<div className="border-b border-border bg-card px-5 py-3">
|
||||
|
|
@ -618,7 +759,12 @@ export default function DesignTaskManagementPage() {
|
|||
<div className="flex h-full flex-col bg-card">
|
||||
<div className="flex items-center justify-between border-b-2 border-border px-5 py-3">
|
||||
<h2 className="text-base font-bold text-foreground">
|
||||
접수 업무 목록 ({filteredData.length}건)
|
||||
{myTasksOnly ? "내 관련 업무" : "접수 업무 목록"} ({filteredData.length}건)
|
||||
{myTasksOnly && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
전체 {allTasks.length}건 중
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<Button size="sm" variant="outline" onClick={fetchTasks} disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
|
||||
|
|
@ -666,10 +812,10 @@ export default function DesignTaskManagementPage() {
|
|||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedTaskId === item.id && "bg-primary/5",
|
||||
selectedTaskId === item.dbId && "bg-primary/5",
|
||||
item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
|
||||
)}
|
||||
onClick={() => handleSelectTask(item.id)}
|
||||
onClick={() => handleSelectTask(item.dbId)}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
|
||||
|
|
@ -770,7 +916,7 @@ export default function DesignTaskManagementPage() {
|
|||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTask.status === "신규접수" && (
|
||||
<>
|
||||
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleStartReview(selectedTask.dbId)}>
|
||||
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleOpenDesignerModal(selectedTask.dbId)}>
|
||||
<Eye className="mr-1 h-3.5 w-3.5" /> 검토 착수
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
|
||||
|
|
@ -962,6 +1108,77 @@ export default function DesignTaskManagementPage() {
|
|||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 설계 담당자 선택 모달 */}
|
||||
<Dialog open={designerModalOpen} onOpenChange={setDesignerModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">설계 담당자 배정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">검토를 진행할 설계 담당자를 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
설계 담당자 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Popover open={designerComboOpen} onOpenChange={setDesignerComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={designerComboOpen}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{designerModalValue
|
||||
? (() => {
|
||||
const emp = employees.find((e) => e.userId === designerModalValue);
|
||||
return emp ? `${emp.userName} (${emp.deptName || "부서 미지정"})` : designerModalValue;
|
||||
})()
|
||||
: "사원 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="이름, 부서로 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs sm:text-sm">사원을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{employees.map((emp) => (
|
||||
<CommandItem
|
||||
key={emp.userId}
|
||||
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
|
||||
onSelect={() => {
|
||||
setDesignerModalValue(emp.userId);
|
||||
setDesignerComboOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", designerModalValue === emp.userId ? "opacity-100" : "opacity-0")} />
|
||||
<UserCircle className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{emp.userName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setDesignerModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirmDesigner} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
검토 착수
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 반려 사유 모달 */}
|
||||
<Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
|
|
@ -1052,17 +1269,48 @@ export default function DesignTaskManagementPage() {
|
|||
<Label className="text-xs sm:text-sm">
|
||||
PM <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={projectForm.projPM} onValueChange={(v) => setProjectForm((p) => ({ ...p, projPM: v }))}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="이설계">이설계</SelectItem>
|
||||
<SelectItem value="박도면">박도면</SelectItem>
|
||||
<SelectItem value="최기구">최기구</SelectItem>
|
||||
<SelectItem value="김전장">김전장</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={pmComboOpen} onOpenChange={setPmComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={pmComboOpen}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{projectForm.projPM
|
||||
? employees.find((e) => e.userId === projectForm.projPM)?.userName || projectForm.projPM
|
||||
: "PM 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="사원 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm py-3 text-center">사원을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{employees.map((emp) => (
|
||||
<CommandItem
|
||||
key={emp.userId}
|
||||
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
|
||||
onSelect={() => {
|
||||
setProjectForm((p) => ({ ...p, projPM: emp.userId }));
|
||||
setPmComboOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", projectForm.projPM === emp.userId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{emp.userName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">고객명</Label>
|
||||
|
|
|
|||
|
|
@ -28,138 +28,17 @@ import {
|
|||
MapPin,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// --- Types ---
|
||||
type WorkOrderStatus = "pending" | "in_progress";
|
||||
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
date: string;
|
||||
status: WorkOrderStatus;
|
||||
}
|
||||
|
||||
interface MaterialLocation {
|
||||
location: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface Material {
|
||||
code: string;
|
||||
name: string;
|
||||
required: number;
|
||||
current: number;
|
||||
unit: string;
|
||||
locations: MaterialLocation[];
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// --- Sample Data ---
|
||||
const sampleWarehouses: Warehouse[] = [
|
||||
{ code: "WH001", name: "제1창고 (위치관리)" },
|
||||
{ code: "WH002", name: "제2창고 (위치관리)" },
|
||||
{ code: "WH003", name: "제3창고 (위치관리)" },
|
||||
];
|
||||
|
||||
const sampleWorkOrders: WorkOrder[] = [
|
||||
{
|
||||
id: "WO2024001",
|
||||
itemCode: "PROD-A001",
|
||||
itemName: "상품 A",
|
||||
quantity: 1000,
|
||||
date: "2024-11-06",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "WO2024002",
|
||||
itemCode: "PROD-A002",
|
||||
itemName: "상품 B",
|
||||
quantity: 500,
|
||||
date: "2024-11-07",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "WO2024003",
|
||||
itemCode: "PROD-A003",
|
||||
itemName: "상품 C",
|
||||
quantity: 800,
|
||||
date: "2024-11-08",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "WO2024004",
|
||||
itemCode: "PROD-A004",
|
||||
itemName: "상품 D",
|
||||
quantity: 1200,
|
||||
date: "2024-11-09",
|
||||
status: "in_progress",
|
||||
},
|
||||
];
|
||||
|
||||
const sampleMaterials: Material[] = [
|
||||
{
|
||||
code: "MAT-R001",
|
||||
name: "원자재 A",
|
||||
required: 5000,
|
||||
current: 4200,
|
||||
unit: "kg",
|
||||
locations: [
|
||||
{ location: "A-01-01", qty: 2000 },
|
||||
{ location: "A-01-02", qty: 1500 },
|
||||
{ location: "A-01-03", qty: 700 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "MAT-R002",
|
||||
name: "원자재 B",
|
||||
required: 3000,
|
||||
current: 3500,
|
||||
unit: "kg",
|
||||
locations: [
|
||||
{ location: "A-02-01", qty: 2000 },
|
||||
{ location: "A-02-02", qty: 1500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "MAT-R003",
|
||||
name: "원자재 C",
|
||||
required: 2000,
|
||||
current: 800,
|
||||
unit: "EA",
|
||||
locations: [
|
||||
{ location: "B-01-01", qty: 500 },
|
||||
{ location: "B-01-02", qty: 300 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "MAT-R004",
|
||||
name: "원자재 D",
|
||||
required: 1500,
|
||||
current: 1500,
|
||||
unit: "L",
|
||||
locations: [{ location: "C-01-01", qty: 1500 }],
|
||||
},
|
||||
{
|
||||
code: "MAT-R005",
|
||||
name: "원자재 E",
|
||||
required: 4000,
|
||||
current: 2500,
|
||||
unit: "kg",
|
||||
locations: [
|
||||
{ location: "A-03-01", qty: 1000 },
|
||||
{ location: "A-03-02", qty: 1000 },
|
||||
{ location: "A-03-03", qty: 500 },
|
||||
],
|
||||
},
|
||||
];
|
||||
import {
|
||||
getWorkOrders,
|
||||
getMaterialStatus,
|
||||
getWarehouses,
|
||||
type WorkOrder,
|
||||
type MaterialData,
|
||||
type WarehouseData,
|
||||
} from "@/lib/api/materialStatus";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
|
|
@ -168,32 +47,85 @@ const formatDate = (date: Date) => {
|
|||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: WorkOrderStatus) =>
|
||||
status === "pending" ? "대기" : "진행중";
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: WorkOrderStatus) =>
|
||||
status === "pending"
|
||||
? "bg-amber-100 text-amber-700 border-amber-200"
|
||||
: "bg-blue-100 text-blue-700 border-blue-200";
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
};
|
||||
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
const today = new Date();
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(today.getDate() - 7);
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(today.getMonth() - 1);
|
||||
|
||||
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo));
|
||||
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
||||
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
const [workOrders] = useState<WorkOrder[]>(sampleWorkOrders);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
|
||||
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
|
||||
|
||||
const [warehouse, setWarehouse] = useState(sampleWarehouses[0]?.code || "");
|
||||
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||||
const [warehouse, setWarehouse] = useState("");
|
||||
const [materialSearch, setMaterialSearch] = useState("");
|
||||
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
||||
const [materials] = useState<Material[]>(sampleMaterials);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||
|
||||
// 창고 목록 초기 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getWarehouses();
|
||||
if (res.success && res.data) {
|
||||
setWarehouses(res.data);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 작업지시 검색
|
||||
const handleSearch = useCallback(async () => {
|
||||
setWorkOrdersLoading(true);
|
||||
try {
|
||||
const res = await getWorkOrders({
|
||||
dateFrom: searchDateFrom,
|
||||
dateTo: searchDateTo,
|
||||
itemCode: searchItemCode || undefined,
|
||||
itemName: searchItemName || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setWorkOrders(res.data);
|
||||
setCheckedWoIds([]);
|
||||
setSelectedWoId(null);
|
||||
setMaterials([]);
|
||||
}
|
||||
} finally {
|
||||
setWorkOrdersLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
handleSearch();
|
||||
}, []);
|
||||
|
||||
const isAllChecked =
|
||||
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
||||
|
|
@ -205,29 +137,42 @@ export default function MaterialStatusPage() {
|
|||
[workOrders]
|
||||
);
|
||||
|
||||
const handleCheckWo = useCallback((id: string, checked: boolean) => {
|
||||
const handleCheckWo = useCallback((id: number, checked: boolean) => {
|
||||
setCheckedWoIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSelectWo = useCallback((id: string) => {
|
||||
const handleSelectWo = useCallback((id: number) => {
|
||||
setSelectedWoId((prev) => (prev === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const handleLoadSelectedMaterials = useCallback(() => {
|
||||
// 선택된 작업지시의 자재 조회
|
||||
const handleLoadSelectedMaterials = useCallback(async () => {
|
||||
if (checkedWoIds.length === 0) {
|
||||
alert("자재를 조회할 작업지시를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
console.log("선택된 작업지시:", checkedWoIds);
|
||||
}, [checkedWoIds]);
|
||||
|
||||
setMaterialsLoading(true);
|
||||
try {
|
||||
const res = await getMaterialStatus({
|
||||
planIds: checkedWoIds,
|
||||
warehouseCode: warehouse || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setMaterials(res.data);
|
||||
}
|
||||
} finally {
|
||||
setMaterialsLoading(false);
|
||||
}
|
||||
}, [checkedWoIds, warehouse]);
|
||||
|
||||
const handleResetSearch = useCallback(() => {
|
||||
const t = new Date();
|
||||
const w = new Date(t);
|
||||
w.setDate(t.getDate() - 7);
|
||||
setSearchDateFrom(formatDate(w));
|
||||
const m = new Date(t);
|
||||
m.setMonth(t.getMonth() - 1);
|
||||
setSearchDateFrom(formatDate(m));
|
||||
setSearchDateTo(formatDate(t));
|
||||
setSearchItemCode("");
|
||||
setSearchItemName("");
|
||||
|
|
@ -236,7 +181,6 @@ export default function MaterialStatusPage() {
|
|||
}, []);
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!warehouse) return [];
|
||||
return materials.filter((m) => {
|
||||
const searchLower = materialSearch.toLowerCase();
|
||||
const matchesSearch =
|
||||
|
|
@ -246,7 +190,7 @@ export default function MaterialStatusPage() {
|
|||
const matchesShortage = !showShortageOnly || m.current < m.required;
|
||||
return matchesSearch && matchesShortage;
|
||||
});
|
||||
}, [materials, warehouse, materialSearch, showShortageOnly]);
|
||||
}, [materials, materialSearch, showShortageOnly]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
|
||||
|
|
@ -317,8 +261,17 @@ export default function MaterialStatusPage() {
|
|||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button size="sm" className="h-9">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleSearch}
|
||||
disabled={workOrdersLoading}
|
||||
>
|
||||
{workOrdersLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -349,8 +302,13 @@ export default function MaterialStatusPage() {
|
|||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleLoadSelectedMaterials}
|
||||
disabled={materialsLoading}
|
||||
>
|
||||
{materialsLoading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
자재조회
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -358,7 +316,14 @@ export default function MaterialStatusPage() {
|
|||
|
||||
{/* 작업지시 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{workOrders.length === 0 ? (
|
||||
{workOrdersLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 조회하고 있습니다...
|
||||
</p>
|
||||
</div>
|
||||
) : workOrders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -392,7 +357,7 @@ export default function MaterialStatusPage() {
|
|||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{wo.id}
|
||||
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -405,21 +370,25 @@ export default function MaterialStatusPage() {
|
|||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold">
|
||||
{wo.itemName}
|
||||
{wo.item_name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({wo.itemCode})
|
||||
({wo.item_code})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>수량:</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{wo.quantity.toLocaleString()}개
|
||||
{Number(wo.plan_qty).toLocaleString()}개
|
||||
</span>
|
||||
<span className="mx-1">|</span>
|
||||
<span>일자:</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{wo.date}
|
||||
{wo.plan_date
|
||||
? new Date(wo.plan_date)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -451,12 +420,19 @@ export default function MaterialStatusPage() {
|
|||
/>
|
||||
<Select value={warehouse} onValueChange={setWarehouse}>
|
||||
<SelectTrigger className="h-9 w-[200px]">
|
||||
<SelectValue placeholder="창고 선택" />
|
||||
<SelectValue placeholder="전체 창고" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sampleWarehouses.map((wh) => (
|
||||
<SelectItem key={wh.code} value={wh.code}>
|
||||
{wh.name}
|
||||
<SelectItem value="__all__">전체 창고</SelectItem>
|
||||
{warehouses.map((wh) => (
|
||||
<SelectItem
|
||||
key={wh.warehouse_code}
|
||||
value={wh.warehouse_code}
|
||||
>
|
||||
{wh.warehouse_name}
|
||||
{wh.warehouse_type
|
||||
? ` (${wh.warehouse_type})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -475,11 +451,18 @@ export default function MaterialStatusPage() {
|
|||
|
||||
{/* 원자재 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{!warehouse ? (
|
||||
{materialsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Factory className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
창고를 선택해주세요
|
||||
자재현황을 조회하고 있습니다...
|
||||
</p>
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
|
|
@ -493,10 +476,13 @@ export default function MaterialStatusPage() {
|
|||
filteredMaterials.map((material) => {
|
||||
const shortage = material.required - material.current;
|
||||
const isShortage = shortage > 0;
|
||||
const percentage = Math.min(
|
||||
const percentage =
|
||||
material.required > 0
|
||||
? Math.min(
|
||||
(material.current / material.required) * 100,
|
||||
100
|
||||
);
|
||||
)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -578,6 +564,7 @@ export default function MaterialStatusPage() {
|
|||
</div>
|
||||
|
||||
{/* 위치별 재고 */}
|
||||
{material.locations.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{material.locations.map((loc, idx) => (
|
||||
|
|
@ -586,7 +573,7 @@ export default function MaterialStatusPage() {
|
|||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<span className="font-semibold font-mono text-primary">
|
||||
{loc.location}
|
||||
{loc.location || loc.warehouse}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{loc.qty.toLocaleString()}
|
||||
|
|
@ -595,6 +582,7 @@ export default function MaterialStatusPage() {
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue