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