jskim-node #423
|
|
@ -76,6 +76,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
COALESCE(itm.size, '') AS item_spec,
|
COALESCE(itm.size, '') AS item_spec,
|
||||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||||
|
wi.routing AS routing_version_id,
|
||||||
|
COALESCE(rv.version_name, '') AS routing_name,
|
||||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||||
FROM work_instruction wi
|
FROM work_instruction wi
|
||||||
|
|
@ -86,6 +88,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||||
) itm ON true
|
) itm ON true
|
||||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||||
|
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||||
`;
|
`;
|
||||||
|
|
@ -130,7 +133,7 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items } = req.body;
|
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||||
|
|
@ -149,8 +152,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||||
wiId = editId;
|
wiId = editId;
|
||||||
wiNo = check.rows[0].work_instruction_no;
|
wiNo = check.rows[0].work_instruction_no;
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, updated_date=NOW(), writer=$10 WHERE id=$11 AND company_code=$12`,
|
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
|
||||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId, editId, companyCode]
|
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
|
||||||
);
|
);
|
||||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -164,8 +167,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||||
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||||
}
|
}
|
||||||
const insertRes = await client.query(
|
const insertRes = await client.query(
|
||||||
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12) RETURNING id`,
|
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
|
||||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId]
|
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
|
||||||
);
|
);
|
||||||
wiId = insertRes.rows[0].id;
|
wiId = insertRes.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
@ -306,3 +309,342 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
|
||||||
return res.json({ success: true, data: result.rows });
|
return res.json({ success: true, data: result.rows });
|
||||||
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
} catch (error: any) { 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 pool = getPool();
|
||||||
|
|
||||||
|
const versionsResult = await pool.query(
|
||||||
|
`SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||||
|
FROM item_routing_version
|
||||||
|
WHERE item_code = $1 AND company_code = $2
|
||||||
|
ORDER BY is_default DESC, created_date DESC`,
|
||||||
|
[itemCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const routings = [];
|
||||||
|
for (const version of versionsResult.rows) {
|
||||||
|
const detailsResult = await pool.query(
|
||||||
|
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
||||||
|
rd.is_required, rd.work_type,
|
||||||
|
COALESCE(p.process_name, rd.process_code) AS process_name
|
||||||
|
FROM item_routing_detail rd
|
||||||
|
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||||
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||||
|
ORDER BY rd.seq_no::integer`,
|
||||||
|
[version.id, companyCode]
|
||||||
|
);
|
||||||
|
routings.push({ ...version, processes: detailsResult.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: routings });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("라우팅 버전 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작업지시 라우팅 변경 ───
|
||||||
|
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { wiNo } = req.params;
|
||||||
|
const { routingVersionId } = req.body;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`,
|
||||||
|
[routingVersionId || null, wiNo, 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 getWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { wiNo } = req.params;
|
||||||
|
const { routingVersionId } = req.query;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
if (!routingVersionId) {
|
||||||
|
return res.status(400).json({ success: false, message: "routingVersionId 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라우팅 디테일(공정) 목록 조회
|
||||||
|
const processesResult = await pool.query(
|
||||||
|
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
||||||
|
COALESCE(p.process_name, rd.process_code) AS process_name
|
||||||
|
FROM item_routing_detail rd
|
||||||
|
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
||||||
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||||
|
ORDER BY rd.seq_no::integer`,
|
||||||
|
[routingVersionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 커스텀 작업기준이 있는지 확인
|
||||||
|
const customCheck = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||||
|
[wiNo, companyCode]
|
||||||
|
);
|
||||||
|
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
|
||||||
|
|
||||||
|
const processes = [];
|
||||||
|
for (const proc of processesResult.rows) {
|
||||||
|
let workItems;
|
||||||
|
|
||||||
|
if (hasCustom) {
|
||||||
|
// 커스텀 버전에서 조회
|
||||||
|
const wiResult = await pool.query(
|
||||||
|
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
||||||
|
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
||||||
|
FROM wi_process_work_item wi
|
||||||
|
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
|
||||||
|
ORDER BY wi.work_phase, wi.sort_order`,
|
||||||
|
[wiNo, proc.routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
workItems = wiResult.rows;
|
||||||
|
|
||||||
|
// 각 work_item의 상세도 로드
|
||||||
|
for (const wi of workItems) {
|
||||||
|
const detailsResult = await pool.query(
|
||||||
|
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields
|
||||||
|
FROM wi_process_work_item_detail
|
||||||
|
WHERE wi_work_item_id = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order`,
|
||||||
|
[wi.id, companyCode]
|
||||||
|
);
|
||||||
|
wi.details = detailsResult.rows;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 원본에서 조회
|
||||||
|
const origResult = await pool.query(
|
||||||
|
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
||||||
|
(SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
||||||
|
FROM process_work_item wi
|
||||||
|
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
||||||
|
ORDER BY wi.work_phase, wi.sort_order`,
|
||||||
|
[proc.routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
workItems = origResult.rows;
|
||||||
|
|
||||||
|
for (const wi of workItems) {
|
||||||
|
const detailsResult = await pool.query(
|
||||||
|
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields
|
||||||
|
FROM process_work_item_detail
|
||||||
|
WHERE work_item_id = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order`,
|
||||||
|
[wi.id, companyCode]
|
||||||
|
);
|
||||||
|
wi.details = detailsResult.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processes.push({
|
||||||
|
...proc,
|
||||||
|
workItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: { processes, isCustom: hasCustom } });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시 공정작업기준 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
|
||||||
|
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { wiNo } = req.params;
|
||||||
|
const { routingVersionId } = req.body;
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 기존 커스텀 데이터 삭제
|
||||||
|
const existingItems = await client.query(
|
||||||
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||||
|
[wiNo, companyCode]
|
||||||
|
);
|
||||||
|
for (const row of existingItems.rows) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||||
|
[row.id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||||
|
[wiNo, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라우팅 디테일 목록 조회
|
||||||
|
const routingDetails = await client.query(
|
||||||
|
`SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`,
|
||||||
|
[routingVersionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 공정(routing_detail)별 원본 작업항목 복사
|
||||||
|
for (const rd of routingDetails.rows) {
|
||||||
|
const origItems = await client.query(
|
||||||
|
`SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`,
|
||||||
|
[rd.id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const origItem of origItems.rows) {
|
||||||
|
const newItemResult = await client.query(
|
||||||
|
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||||
|
[companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId]
|
||||||
|
);
|
||||||
|
const newItemId = newItemResult.rows[0].id;
|
||||||
|
|
||||||
|
// 상세 복사
|
||||||
|
const origDetails = await client.query(
|
||||||
|
`SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`,
|
||||||
|
[origItem.id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const origDetail of origDetails.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||||
|
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (txErr) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw txErr;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("공정작업기준 복사 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
|
||||||
|
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { wiNo } = req.params;
|
||||||
|
const { routingDetailId, workItems } = req.body;
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 해당 공정의 기존 커스텀 데이터 삭제
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||||
|
[wiNo, routingDetailId, companyCode]
|
||||||
|
);
|
||||||
|
for (const row of existing.rows) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||||
|
[row.id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
|
||||||
|
[wiNo, routingDetailId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 데이터 삽입
|
||||||
|
for (const wi of workItems) {
|
||||||
|
const wiResult = await client.query(
|
||||||
|
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||||
|
[companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId]
|
||||||
|
);
|
||||||
|
const newId = wiResult.rows[0].id;
|
||||||
|
|
||||||
|
if (wi.details && Array.isArray(wi.details)) {
|
||||||
|
for (const d of wi.details) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||||
|
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (txErr) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw txErr;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시 공정작업기준 저장 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ───
|
||||||
|
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { wiNo } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const items = await client.query(
|
||||||
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||||
|
[wiNo, companyCode]
|
||||||
|
);
|
||||||
|
for (const row of items.rows) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
||||||
|
[row.id, companyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||||
|
[wiNo, companyCode]
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (txErr) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw txErr;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,12 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource);
|
||||||
router.get("/equipment", ctrl.getEquipmentList);
|
router.get("/equipment", ctrl.getEquipmentList);
|
||||||
router.get("/employees", ctrl.getEmployeeList);
|
router.get("/employees", ctrl.getEmployeeList);
|
||||||
|
|
||||||
|
// 라우팅 & 공정작업기준
|
||||||
|
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
|
||||||
|
router.put("/:wiNo/routing", ctrl.updateRouting);
|
||||||
|
router.get("/:wiNo/work-standard", ctrl.getWorkStandard);
|
||||||
|
router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
|
||||||
|
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
|
||||||
|
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck,
|
||||||
|
ChevronRight, GripVertical, AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard,
|
||||||
|
WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess,
|
||||||
|
} from "@/lib/api/workInstruction";
|
||||||
|
|
||||||
|
interface WorkStandardEditModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
workInstructionNo: string;
|
||||||
|
routingVersionId: string;
|
||||||
|
routingName: string;
|
||||||
|
itemName: string;
|
||||||
|
itemCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASES = [
|
||||||
|
{ key: "PRE", label: "사전작업" },
|
||||||
|
{ key: "MAIN", label: "본작업" },
|
||||||
|
{ key: "POST", label: "후작업" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DETAIL_TYPES = [
|
||||||
|
{ value: "checklist", label: "체크리스트" },
|
||||||
|
{ value: "inspection", label: "검사항목" },
|
||||||
|
{ value: "procedure", label: "작업절차" },
|
||||||
|
{ value: "input", label: "직접입력" },
|
||||||
|
{ value: "lookup", label: "문서참조" },
|
||||||
|
{ value: "equip_inspection", label: "설비점검" },
|
||||||
|
{ value: "equip_condition", label: "설비조건" },
|
||||||
|
{ value: "production_result", label: "실적등록" },
|
||||||
|
{ value: "material_input", label: "자재투입" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WorkStandardEditModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
workInstructionNo,
|
||||||
|
routingVersionId,
|
||||||
|
routingName,
|
||||||
|
itemName,
|
||||||
|
itemCode,
|
||||||
|
}: WorkStandardEditModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [processes, setProcesses] = useState<WIWorkStandardProcess[]>([]);
|
||||||
|
const [isCustom, setIsCustom] = useState(false);
|
||||||
|
const [selectedProcessIdx, setSelectedProcessIdx] = useState(0);
|
||||||
|
const [selectedPhase, setSelectedPhase] = useState("PRE");
|
||||||
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
// 작업항목 추가 모달
|
||||||
|
const [addItemOpen, setAddItemOpen] = useState(false);
|
||||||
|
const [addItemTitle, setAddItemTitle] = useState("");
|
||||||
|
const [addItemRequired, setAddItemRequired] = useState("Y");
|
||||||
|
|
||||||
|
// 상세 추가 모달
|
||||||
|
const [addDetailOpen, setAddDetailOpen] = useState(false);
|
||||||
|
const [addDetailType, setAddDetailType] = useState("checklist");
|
||||||
|
const [addDetailContent, setAddDetailContent] = useState("");
|
||||||
|
const [addDetailRequired, setAddDetailRequired] = useState("N");
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!workInstructionNo || !routingVersionId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getWIWorkStandard(workInstructionNo, routingVersionId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setProcesses(res.data.processes);
|
||||||
|
setIsCustom(res.data.isCustom);
|
||||||
|
setSelectedProcessIdx(0);
|
||||||
|
setSelectedPhase("PRE");
|
||||||
|
setSelectedWorkItemId(null);
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정작업기준 로드 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [workInstructionNo, routingVersionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) loadData();
|
||||||
|
}, [open, loadData]);
|
||||||
|
|
||||||
|
const currentProcess = processes[selectedProcessIdx] || null;
|
||||||
|
const currentWorkItems = useMemo(() => {
|
||||||
|
if (!currentProcess) return [];
|
||||||
|
return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase);
|
||||||
|
}, [currentProcess, selectedPhase]);
|
||||||
|
|
||||||
|
const selectedWorkItem = useMemo(() => {
|
||||||
|
if (!selectedWorkItemId || !currentProcess) return null;
|
||||||
|
return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null;
|
||||||
|
}, [selectedWorkItemId, currentProcess]);
|
||||||
|
|
||||||
|
// 커스텀 복사 확인 후 수정
|
||||||
|
const ensureCustom = useCallback(async () => {
|
||||||
|
if (isCustom) return true;
|
||||||
|
try {
|
||||||
|
const res = await copyWorkStandard(workInstructionNo, routingVersionId);
|
||||||
|
if (res.success) {
|
||||||
|
await loadData();
|
||||||
|
setIsCustom(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("원본 복사에 실패했습니다");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [isCustom, workInstructionNo, routingVersionId, loadData]);
|
||||||
|
|
||||||
|
// 작업항목 추가
|
||||||
|
const handleAddWorkItem = useCallback(async () => {
|
||||||
|
if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; }
|
||||||
|
const ok = await ensureCustom();
|
||||||
|
if (!ok || !currentProcess) return;
|
||||||
|
|
||||||
|
const newItem: WIWorkItem = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
routing_detail_id: currentProcess.routing_detail_id,
|
||||||
|
work_phase: selectedPhase,
|
||||||
|
title: addItemTitle.trim(),
|
||||||
|
is_required: addItemRequired,
|
||||||
|
sort_order: currentWorkItems.length + 1,
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcesses(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[selectedProcessIdx] = {
|
||||||
|
...next[selectedProcessIdx],
|
||||||
|
workItems: [...next[selectedProcessIdx].workItems, newItem],
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setAddItemTitle("");
|
||||||
|
setAddItemRequired("Y");
|
||||||
|
setAddItemOpen(false);
|
||||||
|
setDirty(true);
|
||||||
|
setSelectedWorkItemId(newItem.id!);
|
||||||
|
}, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]);
|
||||||
|
|
||||||
|
// 작업항목 삭제
|
||||||
|
const handleDeleteWorkItem = useCallback(async (id: string) => {
|
||||||
|
const ok = await ensureCustom();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setProcesses(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[selectedProcessIdx] = {
|
||||||
|
...next[selectedProcessIdx],
|
||||||
|
workItems: next[selectedProcessIdx].workItems.filter(wi => wi.id !== id),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (selectedWorkItemId === id) setSelectedWorkItemId(null);
|
||||||
|
setDirty(true);
|
||||||
|
}, [ensureCustom, selectedProcessIdx, selectedWorkItemId]);
|
||||||
|
|
||||||
|
// 상세 추가
|
||||||
|
const handleAddDetail = useCallback(async () => {
|
||||||
|
if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") {
|
||||||
|
toast.error("내용을 입력하세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedWorkItemId) return;
|
||||||
|
const ok = await ensureCustom();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const content = addDetailContent.trim() ||
|
||||||
|
DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType;
|
||||||
|
|
||||||
|
const newDetail: WIWorkItemDetail = {
|
||||||
|
id: `temp-detail-${Date.now()}`,
|
||||||
|
work_item_id: selectedWorkItemId,
|
||||||
|
detail_type: addDetailType,
|
||||||
|
content,
|
||||||
|
is_required: addDetailRequired,
|
||||||
|
sort_order: (selectedWorkItem?.details?.length || 0) + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcesses(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
const workItems = [...next[selectedProcessIdx].workItems];
|
||||||
|
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
|
||||||
|
if (wiIdx >= 0) {
|
||||||
|
workItems[wiIdx] = {
|
||||||
|
...workItems[wiIdx],
|
||||||
|
details: [...(workItems[wiIdx].details || []), newDetail],
|
||||||
|
detail_count: (workItems[wiIdx].detail_count || 0) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setAddDetailContent("");
|
||||||
|
setAddDetailType("checklist");
|
||||||
|
setAddDetailRequired("N");
|
||||||
|
setAddDetailOpen(false);
|
||||||
|
setDirty(true);
|
||||||
|
}, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]);
|
||||||
|
|
||||||
|
// 상세 삭제
|
||||||
|
const handleDeleteDetail = useCallback(async (detailId: string) => {
|
||||||
|
if (!selectedWorkItemId) return;
|
||||||
|
const ok = await ensureCustom();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setProcesses(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
const workItems = [...next[selectedProcessIdx].workItems];
|
||||||
|
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
|
||||||
|
if (wiIdx >= 0) {
|
||||||
|
workItems[wiIdx] = {
|
||||||
|
...workItems[wiIdx],
|
||||||
|
details: (workItems[wiIdx].details || []).filter(d => d.id !== detailId),
|
||||||
|
detail_count: Math.max(0, (workItems[wiIdx].detail_count || 1) - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
}, [selectedWorkItemId, ensureCustom, selectedProcessIdx]);
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!currentProcess) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const ok = await ensureCustom();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const res = await saveWIWorkStandard(
|
||||||
|
workInstructionNo,
|
||||||
|
currentProcess.routing_detail_id,
|
||||||
|
currentProcess.workItems
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("공정작업기준이 저장되었습니다");
|
||||||
|
setDirty(false);
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
toast.error("저장에 실패했습니다");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [currentProcess, ensureCustom, workInstructionNo, loadData]);
|
||||||
|
|
||||||
|
// 원본으로 초기화
|
||||||
|
const handleReset = useCallback(async () => {
|
||||||
|
if (!confirm("커스터마이징한 내용을 모두 삭제하고 원본으로 되돌리시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
const res = await resetWIWorkStandard(workInstructionNo);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("원본으로 초기화되었습니다");
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("초기화에 실패했습니다");
|
||||||
|
}
|
||||||
|
}, [workInstructionNo, loadData]);
|
||||||
|
|
||||||
|
const getDetailTypeLabel = (type: string) =>
|
||||||
|
DETAIL_TYPES.find(d => d.value === type)?.label || type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[85vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||||
|
<DialogTitle className="text-base flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="w-4 h-4" />
|
||||||
|
공정작업기준 수정 - {itemName}
|
||||||
|
{routingName && <Badge variant="secondary" className="text-xs ml-2">{routingName}</Badge>}
|
||||||
|
{isCustom && <Badge variant="outline" className="text-xs ml-1 border-amber-300 text-amber-700">커스텀</Badge>}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
작업지시 [{workInstructionNo}]에 대한 공정작업기준을 수정합니다. 원본에 영향을 주지 않습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : processes.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<AlertCircle className="w-10 h-10 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">라우팅에 등록된 공정이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* 공정 탭 */}
|
||||||
|
<div className="flex items-center gap-1 px-4 py-2 border-b bg-muted/30 overflow-x-auto shrink-0">
|
||||||
|
{processes.map((proc, idx) => (
|
||||||
|
<Button
|
||||||
|
key={proc.routing_detail_id}
|
||||||
|
variant={selectedProcessIdx === idx ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProcessIdx(idx);
|
||||||
|
setSelectedWorkItemId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-1.5 font-mono text-[10px] opacity-70">{proc.seq_no}.</span>
|
||||||
|
{proc.process_name}
|
||||||
|
{proc.workItems.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1">{proc.workItems.length}</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 단계 탭 */}
|
||||||
|
<div className="flex items-center gap-1 px-4 py-2 border-b shrink-0">
|
||||||
|
{PHASES.map(phase => {
|
||||||
|
const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={phase.key}
|
||||||
|
variant={selectedPhase === phase.key ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7"
|
||||||
|
onClick={() => { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }}
|
||||||
|
>
|
||||||
|
{phase.label}
|
||||||
|
{count > 0 && <Badge variant="outline" className="ml-1 text-[10px] h-4 px-1">{count}</Badge>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업항목 + 상세 split */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 좌측: 작업항목 목록 */}
|
||||||
|
<div className="w-[280px] shrink-0 border-r flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/20 shrink-0">
|
||||||
|
<span className="text-xs font-semibold">작업항목</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setAddItemTitle(""); setAddItemOpen(true); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
{currentWorkItems.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-6">작업항목이 없습니다</div>
|
||||||
|
) : currentWorkItems.map((wi) => (
|
||||||
|
<div
|
||||||
|
key={wi.id}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-md border p-2.5 cursor-pointer transition-colors",
|
||||||
|
selectedWorkItemId === wi.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedWorkItemId(wi.id!)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-1">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium truncate">{wi.title}</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
{wi.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1">필수</Badge>}
|
||||||
|
<span className="text-[10px] text-muted-foreground">상세 {wi.details?.length || wi.detail_count || 0}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
onClick={e => { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세 목록 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{!selectedWorkItem ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<ChevronRight className="w-8 h-8 mb-2 opacity-20" />
|
||||||
|
<p className="text-xs">좌측에서 작업항목을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/20 shrink-0">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold">{selectedWorkItem.title}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-2">상세 항목</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> 상세 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
{(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8">상세 항목이 없습니다</div>
|
||||||
|
) : selectedWorkItem.details.map((detail, dIdx) => (
|
||||||
|
<div key={detail.id || dIdx} className="group flex items-start gap-2 rounded-md border p-3 hover:bg-muted/30">
|
||||||
|
<GripVertical className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/30 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px] h-4 px-1.5 shrink-0">
|
||||||
|
{getDetailTypeLabel(detail.detail_type || "checklist")}
|
||||||
|
</Badge>
|
||||||
|
{detail.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1">필수</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1 break-all">{detail.content || "-"}</p>
|
||||||
|
{detail.remark && <p className="text-[10px] text-muted-foreground mt-0.5">{detail.remark}</p>}
|
||||||
|
{detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
범위: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
|
||||||
|
onClick={() => handleDeleteDetail(detail.id!)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="px-6 py-3 border-t shrink-0 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{isCustom && (
|
||||||
|
<Button variant="outline" size="sm" className="text-xs" onClick={handleReset}>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 mr-1.5" /> 원본으로 초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>닫기</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || (!dirty && isCustom)}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
{/* 작업항목 추가 다이얼로그 */}
|
||||||
|
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]" onClick={e => e.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base">작업항목 추가</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
{PHASES.find(p => p.key === selectedPhase)?.label} 단계에 작업항목을 추가합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">제목 *</Label>
|
||||||
|
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
|
||||||
|
<Label className="text-xs">필수 항목</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}>취소</Button>
|
||||||
|
<Button size="sm" onClick={handleAddWorkItem}>추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 상세 추가 다이얼로그 */}
|
||||||
|
<Dialog open={addDetailOpen} onOpenChange={setAddDetailOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[450px]" onClick={e => e.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base">상세 항목 추가</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
"{selectedWorkItem?.title}"에 상세 항목을 추가합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">유형</Label>
|
||||||
|
<Select value={addDetailType} onValueChange={setAddDetailType}>
|
||||||
|
<SelectTrigger className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DETAIL_TYPES.map(dt => (
|
||||||
|
<SelectItem key={dt.value} value={dt.value} className="text-xs">{dt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">내용</Label>
|
||||||
|
<Input value={addDetailContent} onChange={e => setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={addDetailRequired === "Y"} onCheckedChange={v => setAddDetailRequired(v ? "Y" : "N")} />
|
||||||
|
<Label className="text-xs">필수 항목</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddDetailOpen(false)}>취소</Button>
|
||||||
|
<Button size="sm" onClick={handleAddDetail}>추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
|
@ -18,7 +18,9 @@ import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
import {
|
import {
|
||||||
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||||
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||||
|
getRoutingVersions, RoutingVersionData,
|
||||||
} from "@/lib/api/workInstruction";
|
} from "@/lib/api/workInstruction";
|
||||||
|
import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
||||||
|
|
||||||
type SourceType = "production" | "order" | "item";
|
type SourceType = "production" | "order" | "item";
|
||||||
|
|
||||||
|
|
@ -99,6 +101,20 @@ export default function WorkInstructionPage() {
|
||||||
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
|
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
|
||||||
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
|
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 라우팅 관련 상태
|
||||||
|
const [confirmRouting, setConfirmRouting] = useState("");
|
||||||
|
const [confirmRoutingOptions, setConfirmRoutingOptions] = useState<RoutingVersionData[]>([]);
|
||||||
|
const [editRouting, setEditRouting] = useState("");
|
||||||
|
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
|
||||||
|
|
||||||
|
// 공정작업기준 모달 상태
|
||||||
|
const [wsModalOpen, setWsModalOpen] = useState(false);
|
||||||
|
const [wsModalWiNo, setWsModalWiNo] = useState("");
|
||||||
|
const [wsModalRoutingId, setWsModalRoutingId] = useState("");
|
||||||
|
const [wsModalRoutingName, setWsModalRoutingName] = useState("");
|
||||||
|
const [wsModalItemName, setWsModalItemName] = useState("");
|
||||||
|
const [wsModalItemCode, setWsModalItemCode] = useState("");
|
||||||
|
|
||||||
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -183,7 +199,21 @@ export default function WorkInstructionPage() {
|
||||||
setConfirmWiNo("불러오는 중...");
|
setConfirmWiNo("불러오는 중...");
|
||||||
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
||||||
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
||||||
|
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||||
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||||
|
|
||||||
|
// 첫 번째 품목의 라우팅 로드
|
||||||
|
const firstItem = items.length > 0 ? items[0] : null;
|
||||||
|
if (firstItem) {
|
||||||
|
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
|
||||||
|
if (r.success && r.data) {
|
||||||
|
setConfirmRoutingOptions(r.data);
|
||||||
|
const defaultRouting = r.data.find(rv => rv.is_default);
|
||||||
|
if (defaultRouting) setConfirmRouting(defaultRouting.id);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,6 +225,7 @@ export default function WorkInstructionPage() {
|
||||||
const payload = {
|
const payload = {
|
||||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||||
|
routing: confirmRouting || null,
|
||||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||||
};
|
};
|
||||||
const r = await saveWorkInstruction(payload);
|
const r = await saveWorkInstruction(payload);
|
||||||
|
|
@ -218,6 +249,17 @@ export default function WorkInstructionPage() {
|
||||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||||
})));
|
})));
|
||||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||||
|
setEditRouting(order.routing_version_id || "");
|
||||||
|
setEditRoutingOptions([]);
|
||||||
|
|
||||||
|
// 라우팅 옵션 로드
|
||||||
|
const itemCode = order.item_number || order.part_code || "";
|
||||||
|
if (itemCode) {
|
||||||
|
getRoutingVersions(wiNo, itemCode).then(r => {
|
||||||
|
if (r.success && r.data) setEditRoutingOptions(r.data);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -237,6 +279,7 @@ export default function WorkInstructionPage() {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||||
|
routing: editRouting || null,
|
||||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
||||||
};
|
};
|
||||||
const r = await saveWorkInstruction(payload);
|
const r = await saveWorkInstruction(payload);
|
||||||
|
|
@ -265,6 +308,16 @@ export default function WorkInstructionPage() {
|
||||||
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
|
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => {
|
||||||
|
if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; }
|
||||||
|
setWsModalWiNo(wiNo);
|
||||||
|
setWsModalRoutingId(routingVersionId);
|
||||||
|
setWsModalRoutingName(routingName);
|
||||||
|
setWsModalItemName(itemName);
|
||||||
|
setWsModalItemCode(itemCode);
|
||||||
|
setWsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getWorkerName = (userId: string) => {
|
const getWorkerName = (userId: string) => {
|
||||||
if (!userId) return "-";
|
if (!userId) return "-";
|
||||||
const emp = employeeOptions.find(e => e.user_id === userId);
|
const emp = employeeOptions.find(e => e.user_id === userId);
|
||||||
|
|
@ -369,6 +422,7 @@ export default function WorkInstructionPage() {
|
||||||
<TableHead className="w-[100px]">규격</TableHead>
|
<TableHead className="w-[100px]">규격</TableHead>
|
||||||
<TableHead className="w-[80px] text-right">수량</TableHead>
|
<TableHead className="w-[80px] text-right">수량</TableHead>
|
||||||
<TableHead className="w-[120px]">설비</TableHead>
|
<TableHead className="w-[120px]">설비</TableHead>
|
||||||
|
<TableHead className="w-[120px]">라우팅</TableHead>
|
||||||
<TableHead className="w-[80px] text-center">작업조</TableHead>
|
<TableHead className="w-[80px] text-center">작업조</TableHead>
|
||||||
<TableHead className="w-[100px]">작업자</TableHead>
|
<TableHead className="w-[100px]">작업자</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">시작일</TableHead>
|
<TableHead className="w-[100px] text-center">시작일</TableHead>
|
||||||
|
|
@ -378,9 +432,9 @@ export default function WorkInstructionPage() {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow><TableCell colSpan={12} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||||
) : orders.length === 0 ? (
|
) : orders.length === 0 ? (
|
||||||
<TableRow><TableCell colSpan={12} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
||||||
) : orders.map((o, rowIdx) => {
|
) : orders.map((o, rowIdx) => {
|
||||||
const pct = getProgress(o);
|
const pct = getProgress(o);
|
||||||
const pLabel = getProgressLabel(o);
|
const pLabel = getProgressLabel(o);
|
||||||
|
|
@ -406,6 +460,27 @@ export default function WorkInstructionPage() {
|
||||||
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
|
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
|
||||||
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
|
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
|
||||||
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
|
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{isFirstOfGroup ? (
|
||||||
|
o.routing_version_id ? (
|
||||||
|
<button
|
||||||
|
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openWorkStandardModal(
|
||||||
|
o.work_instruction_no,
|
||||||
|
o.routing_version_id,
|
||||||
|
o.routing_name || "",
|
||||||
|
o.item_name || o.item_number || "",
|
||||||
|
o.item_number || ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||||
|
</button>
|
||||||
|
) : <span className="text-muted-foreground">-</span>
|
||||||
|
) : ""}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
|
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
|
||||||
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
|
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
|
||||||
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
|
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
|
||||||
|
|
@ -534,7 +609,19 @@ export default function WorkInstructionPage() {
|
||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5"><Label className="text-xs">총 품목 수</Label><Input value={`${confirmItems.length}건`} readOnly className="h-9 bg-muted/50 font-semibold" /></div>
|
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||||
|
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{confirmRoutingOptions.map(rv => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-5">
|
<div className="border rounded-lg p-5">
|
||||||
|
|
@ -587,6 +674,39 @@ export default function WorkInstructionPage() {
|
||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
||||||
|
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{editRoutingOptions.map(rv => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">공정작업기준</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full text-xs"
|
||||||
|
disabled={!editRouting}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editOrder || !editRouting) return;
|
||||||
|
const rv = editRoutingOptions.find(r => r.id === editRouting);
|
||||||
|
openWorkStandardModal(
|
||||||
|
editOrder.work_instruction_no,
|
||||||
|
editRouting,
|
||||||
|
rv?.version_name || "",
|
||||||
|
editOrder.item_name || editOrder.item_number || "",
|
||||||
|
editOrder.item_number || ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" /> 작업기준 수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -644,6 +764,17 @@ export default function WorkInstructionPage() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 공정작업기준 수정 모달 */}
|
||||||
|
<WorkStandardEditModal
|
||||||
|
open={wsModalOpen}
|
||||||
|
onClose={() => setWsModalOpen(false)}
|
||||||
|
workInstructionNo={wsModalWiNo}
|
||||||
|
routingVersionId={wsModalRoutingId}
|
||||||
|
routingName={wsModalRoutingName}
|
||||||
|
itemName={wsModalItemName}
|
||||||
|
itemCode={wsModalItemCode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,11 +209,6 @@ export default function ClaimManagementPage() {
|
||||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = new Date();
|
|
||||||
const thirtyDaysAgo = new Date(today);
|
|
||||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
||||||
setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]);
|
|
||||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 거래처 목록 조회
|
// 거래처 목록 조회
|
||||||
|
|
@ -425,8 +420,8 @@ export default function ClaimManagementPage() {
|
||||||
{
|
{
|
||||||
label: "처리중",
|
label: "처리중",
|
||||||
value: statusCounts["처리중"],
|
value: statusCounts["처리중"],
|
||||||
gradient: "from-amber-300 to-amber-500",
|
gradient: "from-amber-400 to-orange-500",
|
||||||
textColor: "text-amber-900",
|
textColor: "text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "완료",
|
label: "완료",
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,92 @@ export async function getEmployeeList() {
|
||||||
const res = await apiClient.get("/work-instruction/employees");
|
const res = await apiClient.get("/work-instruction/employees");
|
||||||
return res.data as { success: boolean; data: { user_id: string; user_name: string; dept_name: string | null }[] };
|
return res.data as { success: boolean; data: { user_id: string; user_name: string; dept_name: string | null }[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 라우팅 & 공정작업기준 API ───
|
||||||
|
|
||||||
|
export interface RoutingProcess {
|
||||||
|
routing_detail_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
process_name: string;
|
||||||
|
is_required?: string;
|
||||||
|
work_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingVersionData {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
description?: string;
|
||||||
|
is_default: boolean;
|
||||||
|
processes: RoutingProcess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WIWorkItemDetail {
|
||||||
|
id?: string;
|
||||||
|
work_item_id?: string;
|
||||||
|
detail_type?: string;
|
||||||
|
content?: string;
|
||||||
|
is_required?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
remark?: string;
|
||||||
|
inspection_code?: string;
|
||||||
|
inspection_method?: string;
|
||||||
|
unit?: string;
|
||||||
|
lower_limit?: string;
|
||||||
|
upper_limit?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
input_type?: string;
|
||||||
|
lookup_target?: string;
|
||||||
|
display_fields?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WIWorkItem {
|
||||||
|
id?: string;
|
||||||
|
routing_detail_id?: string;
|
||||||
|
work_phase: string;
|
||||||
|
title: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
description?: string;
|
||||||
|
detail_count?: number;
|
||||||
|
details?: WIWorkItemDetail[];
|
||||||
|
source_work_item_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WIWorkStandardProcess {
|
||||||
|
routing_detail_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
process_name: string;
|
||||||
|
workItems: WIWorkItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoutingVersions(wiNo: string, itemCode: string) {
|
||||||
|
const res = await apiClient.get(`/work-instruction/${wiNo}/routing-versions/${encodeURIComponent(itemCode)}`);
|
||||||
|
return res.data as { success: boolean; data: RoutingVersionData[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWIRouting(wiNo: string, routingVersionId: string) {
|
||||||
|
const res = await apiClient.put(`/work-instruction/${wiNo}/routing`, { routingVersionId });
|
||||||
|
return res.data as { success: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWIWorkStandard(wiNo: string, routingVersionId: string) {
|
||||||
|
const res = await apiClient.get(`/work-instruction/${wiNo}/work-standard`, { params: { routingVersionId } });
|
||||||
|
return res.data as { success: boolean; data: { processes: WIWorkStandardProcess[]; isCustom: boolean } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyWorkStandard(wiNo: string, routingVersionId: string) {
|
||||||
|
const res = await apiClient.post(`/work-instruction/${wiNo}/work-standard/copy`, { routingVersionId });
|
||||||
|
return res.data as { success: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWIWorkStandard(wiNo: string, routingDetailId: string, workItems: WIWorkItem[]) {
|
||||||
|
const res = await apiClient.put(`/work-instruction/${wiNo}/work-standard/save`, { routingDetailId, workItems });
|
||||||
|
return res.data as { success: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetWIWorkStandard(wiNo: string) {
|
||||||
|
const res = await apiClient.delete(`/work-instruction/${wiNo}/work-standard/reset`);
|
||||||
|
return res.data as { success: boolean };
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue