jskim-node #423
|
|
@ -145,6 +145,7 @@ import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||||
|
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||||
|
|
@ -342,6 +343,7 @@ app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||||
app.use("/api/mold", moldRoutes); // 금형 관리
|
app.use("/api/mold", moldRoutes); // 금형 관리
|
||||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||||
|
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||||
app.use("/api/design", designRoutes); // 설계 모듈
|
app.use("/api/design", designRoutes); // 설계 모듈
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
/**
|
||||||
|
* 작업지시 컨트롤러 (work_instruction + work_instruction_detail)
|
||||||
|
*/
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { numberingRuleService } from "../services/numberingRuleService";
|
||||||
|
|
||||||
|
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||||
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
conditions.push(`wi.company_code = $${idx}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
conditions.push(`wi.start_date >= $${idx}`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
conditions.push(`wi.end_date <= $${idx}`);
|
||||||
|
params.push(dateTo);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (status && status !== "all") {
|
||||||
|
conditions.push(`wi.status = $${idx}`);
|
||||||
|
params.push(status);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (progressStatus && progressStatus !== "all") {
|
||||||
|
conditions.push(`wi.progress_status = $${idx}`);
|
||||||
|
params.push(progressStatus);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
|
||||||
|
params.push(`%${keyword}%`);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
wi.id AS wi_id,
|
||||||
|
wi.work_instruction_no,
|
||||||
|
wi.status,
|
||||||
|
wi.progress_status,
|
||||||
|
wi.qty AS total_qty,
|
||||||
|
wi.completed_qty,
|
||||||
|
wi.start_date,
|
||||||
|
wi.end_date,
|
||||||
|
wi.equipment_id,
|
||||||
|
wi.work_team,
|
||||||
|
wi.worker,
|
||||||
|
wi.remark AS wi_remark,
|
||||||
|
wi.created_date,
|
||||||
|
d.id AS detail_id,
|
||||||
|
d.item_number,
|
||||||
|
d.qty AS detail_qty,
|
||||||
|
d.remark AS detail_remark,
|
||||||
|
d.part_code,
|
||||||
|
d.source_table,
|
||||||
|
d.source_id,
|
||||||
|
COALESCE(itm.item_name, '') AS item_name,
|
||||||
|
COALESCE(itm.size, '') AS item_spec,
|
||||||
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||||
|
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||||
|
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
|
||||||
|
INNER JOIN work_instruction_detail d
|
||||||
|
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT item_name, size FROM item_info
|
||||||
|
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
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 다음 작업지시번호 미리보기 ───
|
||||||
|
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
let wiNo: string;
|
||||||
|
try {
|
||||||
|
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
||||||
|
if (rule) {
|
||||||
|
wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {});
|
||||||
|
} else { throw new Error("채번 규칙 없음"); }
|
||||||
|
} catch {
|
||||||
|
const pool = getPool();
|
||||||
|
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||||
|
const seqRes = await pool.query(
|
||||||
|
`SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`,
|
||||||
|
[companyCode, `WI-${today}-%`]
|
||||||
|
);
|
||||||
|
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
return res.json({ success: true, instructionNo: wiNo });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시번호 미리보기 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작업지시 저장 (신규/수정) ───
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
let wiId: string;
|
||||||
|
let wiNo: string;
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
|
||||||
|
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
||||||
|
if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); }
|
||||||
|
else { throw new Error("채번 규칙 없음 - 폴백"); }
|
||||||
|
} catch {
|
||||||
|
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||||
|
const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]);
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
wiId = insertRes.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
|
||||||
|
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
|
||||||
|
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
||||||
|
finally { client.release(); }
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 작업지시 삭제 ───
|
||||||
|
export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
||||||
|
for (const row of wiNos.rows) {
|
||||||
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
|
||||||
|
}
|
||||||
|
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return res.json({ success: true, deletedCount: result.rowCount });
|
||||||
|
} 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 getItemSource(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||||
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||||
|
if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||||
|
const w = conds.join(" AND ");
|
||||||
|
const pool = getPool();
|
||||||
|
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params);
|
||||||
|
params.push(pageSize, offset);
|
||||||
|
const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||||
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||||
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수주 소스 (페이징) ───
|
||||||
|
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||||
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||||
|
if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||||
|
const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`;
|
||||||
|
const pool = getPool();
|
||||||
|
const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||||
|
params.push(pageSize, offset);
|
||||||
|
const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||||
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||||
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 생산계획 소스 (페이징) ───
|
||||||
|
export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
||||||
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
||||||
|
if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
||||||
|
const w = conds.join(" AND ");
|
||||||
|
const pool = getPool();
|
||||||
|
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
|
||||||
|
params.push(pageSize, offset);
|
||||||
|
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
||||||
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
||||||
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 사원 목록 (작업자 Select용) ───
|
||||||
|
export async function getEmployeeList(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const pool = getPool();
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`;
|
||||||
|
params = [companyCode];
|
||||||
|
} else {
|
||||||
|
query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`;
|
||||||
|
params = [];
|
||||||
|
}
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("사원 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 설비 목록 (Select용) ───
|
||||||
|
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const pool = getPool();
|
||||||
|
const cond = companyCode !== "*" ? "WHERE company_code = $1" : "";
|
||||||
|
const params = companyCode !== "*" ? [companyCode] : [];
|
||||||
|
const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as ctrl from "../controllers/workInstructionController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.get("/list", ctrl.getList);
|
||||||
|
router.get("/preview-no", ctrl.previewNextNo);
|
||||||
|
router.post("/save", ctrl.save);
|
||||||
|
router.post("/delete", ctrl.remove);
|
||||||
|
router.get("/source/item", ctrl.getItemSource);
|
||||||
|
router.get("/source/sales-order", ctrl.getSalesOrderSource);
|
||||||
|
router.get("/source/production-plan", ctrl.getProductionPlanSource);
|
||||||
|
router.get("/equipment", ctrl.getEquipmentList);
|
||||||
|
router.get("/employees", ctrl.getEmployeeList);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,649 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import {
|
||||||
|
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||||
|
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||||
|
} from "@/lib/api/workInstruction";
|
||||||
|
|
||||||
|
type SourceType = "production" | "order" | "item";
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
|
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||||
|
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
|
||||||
|
};
|
||||||
|
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
|
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
|
||||||
|
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
|
||||||
|
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
|
||||||
|
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
|
||||||
|
interface SelectedItem {
|
||||||
|
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||||
|
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkInstructionPage() {
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
||||||
|
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||||
|
const [searchStatus, setSearchStatus] = useState("all");
|
||||||
|
const [searchProgress, setSearchProgress] = useState("all");
|
||||||
|
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||||
|
const [searchDateTo, setSearchDateTo] = useState("");
|
||||||
|
|
||||||
|
// 1단계: 등록 모달
|
||||||
|
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
|
||||||
|
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
|
||||||
|
const [regSourceData, setRegSourceData] = useState<any[]>([]);
|
||||||
|
const [regSourceLoading, setRegSourceLoading] = useState(false);
|
||||||
|
const [regKeyword, setRegKeyword] = useState("");
|
||||||
|
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
|
||||||
|
const [regPage, setRegPage] = useState(1);
|
||||||
|
const [regPageSize] = useState(20);
|
||||||
|
const [regTotalCount, setRegTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 2단계: 확인 모달
|
||||||
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||||||
|
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
|
||||||
|
const [confirmWiNo, setConfirmWiNo] = useState("");
|
||||||
|
const [confirmStatus, setConfirmStatus] = useState("일반");
|
||||||
|
const [confirmStartDate, setConfirmStartDate] = useState("");
|
||||||
|
const [confirmEndDate, setConfirmEndDate] = useState("");
|
||||||
|
const nv = (v: string) => v || "none";
|
||||||
|
const fromNv = (v: string) => v === "none" ? "" : v;
|
||||||
|
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
|
||||||
|
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
|
||||||
|
const [confirmWorker, setConfirmWorker] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 수정 모달
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [editOrder, setEditOrder] = useState<any>(null);
|
||||||
|
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
|
||||||
|
const [editStatus, setEditStatus] = useState("일반");
|
||||||
|
const [editStartDate, setEditStartDate] = useState("");
|
||||||
|
const [editEndDate, setEditEndDate] = useState("");
|
||||||
|
const [editEquipmentId, setEditEquipmentId] = useState("");
|
||||||
|
const [editWorkTeam, setEditWorkTeam] = useState("");
|
||||||
|
const [editWorker, setEditWorker] = useState("");
|
||||||
|
const [editRemark, setEditRemark] = useState("");
|
||||||
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
|
const [addQty, setAddQty] = useState("");
|
||||||
|
const [addEquipment, setAddEquipment] = useState("");
|
||||||
|
const [addWorkTeam, setAddWorkTeam] = useState("");
|
||||||
|
const [addWorker, setAddWorker] = useState("");
|
||||||
|
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
|
||||||
|
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
|
||||||
|
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
|
||||||
|
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||||
|
if (searchDateTo) params.dateTo = searchDateTo;
|
||||||
|
if (searchStatus !== "all") params.status = searchStatus;
|
||||||
|
if (searchProgress !== "all") params.progressStatus = searchProgress;
|
||||||
|
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
||||||
|
const r = await getWorkInstructionList(params);
|
||||||
|
if (r.success) setOrders(r.data || []);
|
||||||
|
} catch {} finally { setLoading(false); }
|
||||||
|
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||||
|
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
|
||||||
|
setSearchDateFrom(""); setSearchDateTo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 1단계 등록 ───
|
||||||
|
const openRegModal = () => {
|
||||||
|
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
|
||||||
|
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRegSource = useCallback(async (pageOverride?: number) => {
|
||||||
|
if (!regSourceType) return;
|
||||||
|
setRegSourceLoading(true);
|
||||||
|
try {
|
||||||
|
const p = pageOverride ?? regPage;
|
||||||
|
const params: any = { page: p, pageSize: regPageSize };
|
||||||
|
if (regKeyword.trim()) params.keyword = regKeyword.trim();
|
||||||
|
let r;
|
||||||
|
switch (regSourceType) {
|
||||||
|
case "production": r = await getWIProductionPlanSource(params); break;
|
||||||
|
case "order": r = await getWISalesOrderSource(params); break;
|
||||||
|
case "item": r = await getWIItemSource(params); break;
|
||||||
|
}
|
||||||
|
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
|
||||||
|
} catch {} finally { setRegSourceLoading(false); }
|
||||||
|
}, [regSourceType, regKeyword, regPage, regPageSize]);
|
||||||
|
|
||||||
|
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
|
||||||
|
|
||||||
|
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
|
||||||
|
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
|
||||||
|
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
|
||||||
|
|
||||||
|
const applyRegistration = () => {
|
||||||
|
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
|
||||||
|
const items: SelectedItem[] = [];
|
||||||
|
for (const item of regSourceData) {
|
||||||
|
if (!regCheckedIds.has(getRegId(item))) continue;
|
||||||
|
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
|
||||||
|
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
|
||||||
|
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동일품목 합산
|
||||||
|
if (regMergeSameItem) {
|
||||||
|
const merged = new Map<string, SelectedItem>();
|
||||||
|
for (const it of items) {
|
||||||
|
const key = it.itemCode;
|
||||||
|
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
|
||||||
|
else { merged.set(key, { ...it }); }
|
||||||
|
}
|
||||||
|
setConfirmItems(Array.from(merged.values()));
|
||||||
|
} else {
|
||||||
|
setConfirmItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfirmWiNo("불러오는 중...");
|
||||||
|
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
||||||
|
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
||||||
|
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||||
|
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 2단계 최종 적용 ───
|
||||||
|
const finalizeRegistration = async () => {
|
||||||
|
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||||
|
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||||
|
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);
|
||||||
|
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||||
|
else alert(r.message || "저장 실패");
|
||||||
|
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 수정 모달 ───
|
||||||
|
const openEditModal = (order: any) => {
|
||||||
|
const wiNo = order.work_instruction_no;
|
||||||
|
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
|
||||||
|
setEditOrder(order); setEditStatus(order.status || "일반");
|
||||||
|
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
||||||
|
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
||||||
|
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
||||||
|
setEditItems(relatedDetails.map((d: any) => ({
|
||||||
|
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
||||||
|
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
||||||
|
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
||||||
|
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||||
|
})));
|
||||||
|
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEditItem = () => {
|
||||||
|
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
||||||
|
setEditItems(prev => [...prev, {
|
||||||
|
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
|
||||||
|
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
|
||||||
|
}]);
|
||||||
|
setAddQty("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
|
||||||
|
setEditSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||||
|
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||||
|
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);
|
||||||
|
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||||
|
else alert(r.message || "저장 실패");
|
||||||
|
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (wiId: string) => {
|
||||||
|
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
|
||||||
|
const r = await deleteWorkInstructions([wiId]);
|
||||||
|
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgress = (o: any) => {
|
||||||
|
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
|
||||||
|
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
|
||||||
|
};
|
||||||
|
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
|
||||||
|
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
|
||||||
|
|
||||||
|
const getDisplayNo = (o: any) => {
|
||||||
|
const cnt = Number(o.detail_count || 1);
|
||||||
|
const seq = Number(o.detail_seq || 1);
|
||||||
|
if (cnt <= 1) return o.work_instruction_no || "-";
|
||||||
|
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWorkerName = (userId: string) => {
|
||||||
|
if (!userId) return "-";
|
||||||
|
const emp = employeeOptions.find(e => e.user_id === userId);
|
||||||
|
return emp ? emp.user_name : userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
|
||||||
|
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
|
||||||
|
className?: string; triggerClassName?: string;
|
||||||
|
}) => (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||||
|
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
|
||||||
|
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="이름 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-4 text-center">사원을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
|
||||||
|
선택 안 함
|
||||||
|
</CommandItem>
|
||||||
|
{employeeOptions.map(emp => (
|
||||||
|
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
|
||||||
|
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
|
||||||
|
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-4 p-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">작업기간</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
|
||||||
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">검색</Label>
|
||||||
|
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||||
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||||
|
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">진행현황</Label>
|
||||||
|
<Select value={searchProgress} onValueChange={setSearchProgress}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="대기">대기</SelectItem><SelectItem value="진행중">진행중</SelectItem><SelectItem value="완료">완료</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}><RotateCcw className="w-4 h-4 mr-1.5" /> 초기화</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메인 테이블 */}
|
||||||
|
<Card className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Wrench className="w-4 h-4" /> 작업지시 목록
|
||||||
|
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)</Badge>
|
||||||
|
</h3>
|
||||||
|
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> 작업지시 등록</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[150px]">작업지시번호</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">진행현황</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[100px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">수량</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>
|
||||||
|
<TableHead className="w-[100px] text-center">완료일</TableHead>
|
||||||
|
<TableHead className="w-[150px] text-center">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</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>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={12} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
||||||
|
) : orders.map((o, rowIdx) => {
|
||||||
|
const pct = getProgress(o);
|
||||||
|
const pLabel = getProgressLabel(o);
|
||||||
|
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||||
|
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
|
||||||
|
const isFirstOfGroup = Number(o.detail_seq) === 1;
|
||||||
|
return (
|
||||||
|
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
|
||||||
|
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{isFirstOfGroup ? (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||||
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</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-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</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>
|
||||||
|
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{isFirstOfGroup && (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> 수정</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 1단계: 등록 모달 ── */}
|
||||||
|
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] 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"><Plus className="w-4 h-4" /> 작업지시 등록</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
|
||||||
|
<Label className="text-sm font-semibold whitespace-nowrap">근거:</Label>
|
||||||
|
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
|
||||||
|
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="production">생산계획</SelectItem><SelectItem value="order">수주</SelectItem><SelectItem value="item">품목정보</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
|
{regSourceType && (<>
|
||||||
|
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
|
||||||
|
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
|
||||||
|
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
|
||||||
|
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5">조회</span>
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
|
||||||
|
<span className="text-sm font-semibold">동일품목 합산</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
{!regSourceType ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">근거를 선택하고 검색해주세요</div>
|
||||||
|
) : regSourceLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : regSourceData.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">데이터가 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||||
|
{regSourceType === "item" && <><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px]">규격</TableHead></>}
|
||||||
|
{regSourceType === "order" && <><TableHead className="w-[110px]">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
||||||
|
{regSourceType === "production" && <><TableHead className="w-[110px]">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{regSourceData.map((item, idx) => {
|
||||||
|
const id = getRegId(item);
|
||||||
|
const checked = regCheckedIds.has(id);
|
||||||
|
return (
|
||||||
|
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
|
||||||
|
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
||||||
|
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
|
||||||
|
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
|
||||||
|
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{regTotalCount > 0 && (
|
||||||
|
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">총 {regTotalCount}건 (선택: {regCheckedIds.size}건)</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
|
||||||
|
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
|
||||||
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||||
|
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> 작업지시 적용</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── 2단계: 확인 모달 ── */}
|
||||||
|
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] 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"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||||
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
|
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">상태</Label>
|
||||||
|
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">설비</Label>
|
||||||
|
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">작업조</Label>
|
||||||
|
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-5">
|
||||||
|
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
||||||
|
<div className="max-h-[300px] overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead></TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{confirmItems.map((item, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||||
|
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
||||||
|
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||||
|
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── 수정 모달 ── */}
|
||||||
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] 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"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||||
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
|
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-xs">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||||
|
<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 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 className="border rounded-lg p-4 bg-muted/20">
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">수량 <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
|
||||||
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">설비</Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
||||||
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업조</Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
||||||
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업자</Label>
|
||||||
|
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> 추가</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 테이블 */}
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
||||||
|
<span className="text-sm font-semibold">작업지시 항목</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[280px] overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{editItems.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||||
|
) : editItems.map((item, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||||
|
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{editItems.length > 0 && (
|
||||||
|
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold">총 수량</span>
|
||||||
|
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
||||||
|
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,9 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||||
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
|
"/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|
||||||
|
// 생산 관리 (커스텀 페이지)
|
||||||
|
"/production/work-instruction": dynamic(() => import("@/app/(main)/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|
||||||
// 물류 관리 (커스텀 페이지)
|
// 물류 관리 (커스텀 페이지)
|
||||||
"/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
"/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
export interface PaginatedResponse { success: boolean; data: any[]; totalCount: number; page: number; pageSize: number; }
|
||||||
|
|
||||||
|
export async function getWorkInstructionList(params?: Record<string, any>) {
|
||||||
|
const res = await apiClient.get("/work-instruction/list", { params });
|
||||||
|
return res.data as { success: boolean; data: any[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewWorkInstructionNo() {
|
||||||
|
const res = await apiClient.get("/work-instruction/preview-no");
|
||||||
|
return res.data as { success: boolean; instructionNo: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorkInstruction(data: any) {
|
||||||
|
const res = await apiClient.post("/work-instruction/save", data);
|
||||||
|
return res.data as { success: boolean; data?: any; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkInstructions(ids: string[]) {
|
||||||
|
const res = await apiClient.post("/work-instruction/delete", { ids });
|
||||||
|
return res.data as { success: boolean; deletedCount?: number; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWIItemSource(params?: Record<string, any>) {
|
||||||
|
const res = await apiClient.get("/work-instruction/source/item", { params });
|
||||||
|
return res.data as PaginatedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWISalesOrderSource(params?: Record<string, any>) {
|
||||||
|
const res = await apiClient.get("/work-instruction/source/sales-order", { params });
|
||||||
|
return res.data as PaginatedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWIProductionPlanSource(params?: Record<string, any>) {
|
||||||
|
const res = await apiClient.get("/work-instruction/source/production-plan", { params });
|
||||||
|
return res.data as PaginatedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEquipmentList() {
|
||||||
|
const res = await apiClient.get("/work-instruction/equipment");
|
||||||
|
return res.data as { success: boolean; data: { id: string; equipment_code: string; equipment_name: string }[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }[] };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue