jskim-node #423
|
|
@ -76,6 +76,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
|||
COALESCE(itm.size, '') AS item_spec,
|
||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||
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,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
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
|
||||
) itm ON true
|
||||
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}
|
||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||
`;
|
||||
|
|
@ -130,7 +133,7 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
|||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
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) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
|
|
@ -149,8 +152,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
|||
wiId = editId;
|
||||
wiNo = check.rows[0].work_instruction_no;
|
||||
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`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId, editId, companyCode]
|
||||
`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||"", routingVersionId||null, userId, editId, companyCode]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
||||
} else {
|
||||
|
|
@ -164,8 +167,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
|||
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||
}
|
||||
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`,
|
||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId]
|
||||
`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||"", routingVersionId||null, userId]
|
||||
);
|
||||
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 });
|
||||
} 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("/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;
|
||||
|
|
|
|||
|
|
@ -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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
|
@ -18,7 +18,9 @@ import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
|||
import {
|
||||
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||
getRoutingVersions, RoutingVersionData,
|
||||
} from "@/lib/api/workInstruction";
|
||||
import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
||||
|
||||
type SourceType = "production" | "order" | "item";
|
||||
|
||||
|
|
@ -99,6 +101,20 @@ export default function WorkInstructionPage() {
|
|||
const [editWorkerOpen, setEditWorkerOpen] = 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]);
|
||||
|
||||
|
||||
|
|
@ -183,7 +199,21 @@ export default function WorkInstructionPage() {
|
|||
setConfirmWiNo("불러오는 중...");
|
||||
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
||||
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
||||
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -195,6 +225,7 @@ export default function WorkInstructionPage() {
|
|||
const payload = {
|
||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||
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 })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
|
|
@ -218,6 +249,17 @@ export default function WorkInstructionPage() {
|
|||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||
})));
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -237,6 +279,7 @@ export default function WorkInstructionPage() {
|
|||
const payload = {
|
||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||
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 })),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
|
|
@ -265,6 +308,16 @@ export default function WorkInstructionPage() {
|
|||
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) => {
|
||||
if (!userId) return "-";
|
||||
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-[80px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[120px]">설비</TableHead>
|
||||
<TableHead className="w-[120px]">라우팅</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업조</TableHead>
|
||||
<TableHead className="w-[100px]">작업자</TableHead>
|
||||
<TableHead className="w-[100px] text-center">시작일</TableHead>
|
||||
|
|
@ -378,9 +432,9 @@ export default function WorkInstructionPage() {
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 ? (
|
||||
<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) => {
|
||||
const pct = getProgress(o);
|
||||
const pLabel = getProgressLabel(o);
|
||||
|
|
@ -406,6 +460,27 @@ export default function WorkInstructionPage() {
|
|||
<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-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-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</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>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</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 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>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -644,6 +764,17 @@ export default function WorkInstructionPage() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공정작업기준 수정 모달 */}
|
||||
<WorkStandardEditModal
|
||||
open={wsModalOpen}
|
||||
onClose={() => setWsModalOpen(false)}
|
||||
workInstructionNo={wsModalWiNo}
|
||||
routingVersionId={wsModalRoutingId}
|
||||
routingName={wsModalRoutingName}
|
||||
itemName={wsModalItemName}
|
||||
itemCode={wsModalItemCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,11 +209,6 @@ export default function ClaimManagementPage() {
|
|||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
|
||||
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: "처리중",
|
||||
value: statusCounts["처리중"],
|
||||
gradient: "from-amber-300 to-amber-500",
|
||||
textColor: "text-amber-900",
|
||||
gradient: "from-amber-400 to-orange-500",
|
||||
textColor: "text-white",
|
||||
},
|
||||
{
|
||||
label: "완료",
|
||||
|
|
|
|||
|
|
@ -46,3 +46,92 @@ export async function getEmployeeList() {
|
|||
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 }[] };
|
||||
}
|
||||
|
||||
// ─── 라우팅 & 공정작업기준 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