feat: add work instruction management features

- Introduced new routes and controllers for managing work instructions, including functionalities for listing, saving, and previewing work instruction numbers.
- Implemented company code filtering for multi-tenancy support, ensuring that users can only access their respective work instructions.
- Added a new Work Instruction page in the frontend for comprehensive management, including search and registration functionalities.

These changes aim to enhance the management of work instructions, facilitating better tracking and organization within the application.
This commit is contained in:
kjs 2026-03-19 17:52:17 +09:00
parent c81594b137
commit 460757e3a0
6 changed files with 1028 additions and 0 deletions

View File

@ -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); // 설계 모듈

View File

@ -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 }); }
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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 }),

View File

@ -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 }[] };
}