+ {/* 원판 영역 */}
+
+
+ {/* 격자 (SVG) */}
+
+ {/* 시트 라벨 */}
+
+ {sheet.matW}×{sheet.matH}mm
+
+ {/* 자투리 그룹 — 같은 그룹은 한 도형으로 표시 (fill + polygon outline) */}
+ {remnantGroups.map((g) => {
+ const isSelected = selectedGroupId === g.groupId;
+ const outlineColor =
+ g.status === "keep" ? "rgba(37,99,235,0.85)" :
+ g.status === "discard" ? "rgba(245,158,11,0.8)" :
+ "rgba(139,92,246,0.85)"; // mixed: violet
+ return (
+
+ {/* 그룹 내 사각형 fill — 각 조각의 status별 색상 (개별 표시) */}
+ {g.rects.map((rm) => {
+ const rx = Math.round(rm.x * scale);
+ const ry = Math.round(rm.y * scale);
+ const rw = Math.max(2, Math.round(rm.w * scale));
+ const rh = Math.max(2, Math.round(rm.h * scale));
+ const rmKeep = rm.status === "keep";
+ const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep";
+ return (
+ {
+ e.stopPropagation();
+ setSelectedPI(null);
+ setSelectedGroupId(g.groupId);
+ // Shift+클릭 = 그 조각만 토글, 일반 클릭 = 그룹 전체 토글
+ if (e.shiftKey) {
+ setStatusOverrides((prev) => ({
+ ...prev,
+ [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rmKeep ? "discard" : "keep",
+ }));
+ } else {
+ setStatusOverrides((prev) => {
+ const next = { ...prev };
+ g.rects.forEach((r) => {
+ next[`${r.x}|${r.y}|${r.w}|${r.h}`] = groupNewStatus;
+ });
+ return next;
+ });
+ }
+ }}
+ title={`${rmKeep ? "[보관]" : "[폐기]"} ${rm.w}×${rm.h} — 클릭: 그룹 토글 / Shift+클릭: 이 조각만`}
+ className={cn(
+ "absolute cursor-pointer transition-colors hover:brightness-110",
+ rmKeep ? "bg-blue-300/40" : "bg-amber-300/25",
+ isSelected && "brightness-125"
+ )}
+ style={{
+ left: rx, top: ry, width: rw, height: rh,
+ border: g.rects.length > 1 ? "1px solid rgba(255,255,255,0.5)" : "none",
+ }}
+ />
+ );
+ })}
+ {/* 그룹 외곽선 polygon (SVG) */}
+
+ {/* 그룹 라벨 — 가장 큰 자투리 사각형 안 좌상단 (piece와 안 겹침) */}
+ {(() => {
+ const largest = g.rects.reduce((a, b) => a.w * a.h > b.w * b.h ? a : b);
+ return (
+
+
+ {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합 ${g.keepCount}/${g.rects.length}`}
+ {g.rects.length > 1 && ` · ${g.rects.length}조각`}
+
+
+ );
+ })()}
+
+ );
+ })}
+ {/* 피스 */}
+ {placements.map((p, pi) => {
+ const px = Math.round(p.x * scale);
+ const py = Math.round(p.y * scale);
+ const pw = Math.max(5, Math.round(p.w * scale));
+ const ph = Math.max(5, Math.round(p.h * scale));
+ const isSel = pi === selectedPI;
+ return (
+
handleMouseDown(e, pi)}
+ onDoubleClick={(e) => { e.stopPropagation(); setSelectedPI(pi); rotateSelected(); }}
+ className={cn(
+ "absolute flex items-center justify-center overflow-hidden cursor-grab select-none",
+ isSel && "ring-4 ring-blue-600 ring-offset-1 z-30"
+ )}
+ style={{
+ left: px, top: py, width: pw, height: ph,
+ background: p.color, opacity: 0.88,
+ border: p.rotated ? "2px solid rgba(255,255,255,0.9)" : "1px solid rgba(255,255,255,0.5)",
+ }}
+ >
+ {pw > 40 && ph > 22 && (
+
+
{p.name}
+
{p.w}×{p.h}mm
+
+ )}
+ {p.rotated && (
+
↻
+ )}
+
+ );
+ })}
+
+
+
+ {/* 사이드 패널 */}
+
+
+
📌 선택된 피스
+ {selected ? (
+
+
{selected.name}
+
+
+ { setInputX(e.target.value); applyInput("x", e.target.value); }}
+ className="h-7 w-[80px] text-xs" />
+ mm
+
+
+
+ { setInputY(e.target.value); applyInput("y", e.target.value); }}
+ className="h-7 w-[80px] text-xs" />
+ mm
+
+
+ 크기: {selected.w}×{selected.h}mm{selected.rotated ? " [회전됨]" : ""}
+
+
+ ) : (
+
피스를 클릭하여 선택하세요
+ )}
+
+
+
📏 원판 내 여백
+
+
좌: {selected ? `${gapL}mm` : "-"}
+
우: {selected ? `${gapR}mm` : "-"}
+
상: {selected ? `${gapT}mm` : "-"}
+
하: {selected ? `${gapB}mm` : "-"}
+
+
+ {/* 자투리 관리 패널 — 항상 표시 */}
+
+
+ ✂️ 자투리 그룹 {remnantGroups.length}개
+
+ {remnantGroups.length === 0 ? (
+
자투리가 없습니다
+ ) : (
+
+ {remnantGroups.map((g) => {
+ const isSelected = selectedGroupId === g.groupId;
+ const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep";
+ return (
+
{ setSelectedGroupId(g.groupId); setSelectedPI(null); }}
+ className={cn(
+ "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[10px]",
+ isSelected ? "bg-red-100 ring-1 ring-red-400" : "hover:bg-muted"
+ )}
+ >
+
+ {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합${g.keepCount}/${g.rects.length}`}
+
+ {g.rects.length}조각 · {g.totalArea.toLocaleString()}mm²
+
+
+ );
+ })}
+
+ )}
+
+ {/* 선택된 자투리 그룹 옵션 */}
+ {selectedGroupId !== null && (() => {
+ const g = remnantGroups.find((x) => x.groupId === selectedGroupId);
+ if (!g) return null;
+ const options = computeSplitOptions(g.rects);
+ const hasOverride = !!groupSplitOverrides[g.groupKey];
+ const setAllInGroup = (status: "keep" | "discard") => {
+ setStatusOverrides((prev) => {
+ const next = { ...prev };
+ g.rects.forEach((rm) => {
+ next[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = status;
+ });
+ return next;
+ });
+ };
+ const toggleSingle = (rm: RemnantItem) => {
+ setStatusOverrides((prev) => ({
+ ...prev,
+ [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rm.status === "keep" ? "discard" : "keep",
+ }));
+ };
+ return (
+
+
+ ✂️ 선택된 자투리 그룹
+
+
+
+ {g.rects.length}조각 · 합 {g.totalArea.toLocaleString()}mm² · BBox {g.bbox.w}×{g.bbox.h}
+ {g.status === "mixed" && · 보관 {g.keepCount}/{g.rects.length}}
+
+ {/* 그룹 일괄 토글 */}
+
+
+
+
+ {/* 조각별 개별 토글 */}
+
조각별 보관/폐기 ({g.rects.length}개)
+
+ {g.rects.map((rm) => (
+
+ ))}
+
+
분할 방식 적용
+
+
+
+
+
+ {hasOverride && (
+
+ )}
+
+ );
+ })()}
+
+
+ 📋 피스 목록 {placements.length}개
+
+
+ {placements.map((p, pi) => (
+
setSelectedPI(pi)}
+ className={cn(
+ "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[11px]",
+ pi === selectedPI ? "bg-primary/20 font-bold" : "hover:bg-muted"
+ )}
+ >
+
+
{p.name}
+
{p.w}×{p.h}
+ {p.rotated &&
↻}
+
+ ))}
+
+
+
+
+
+
+
+ 드래그: 이동 | 더블클릭: 회전 | X/Y 입력: 정밀 이동
+
+
+
+
+
+
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────
+// 길이형 배치 시각화
+// ─────────────────────────────────────────────────────────
+function LengthBatchView({ result }: { result: LengthResult }) {
+ const groups = result.pipeGroups || result.pipes.map((p, pi) => ({
+ count: 1, repIdx: pi, representative: p, indices: [pi],
+ }));
+ const totalPipes = result.pipes.length;
+ const hasGroups = groups.some((g) => g.count > 1);
+ const DW = 560;
+
+ if (!result.pipes.length) return null;
+
+ return (
+
+ {hasGroups && (
+
+ 📏 총 {totalPipes}개 필요
+ —
+ {groups.length}가지 절단 패턴
+
+ )}
+ {groups.map((g) => {
+ const pipe = g.representative;
+ const scale = DW / pipe.matLen;
+ const first = Math.min(...g.indices) + 1;
+ const last = Math.max(...g.indices) + 1;
+ const rem = Math.max(0, pipe.remaining);
+ return (
+
+
+ 파이프 #{g.count > 1 ? `${first}~${last}` : first}
+ ({pipe.matName})
+ {g.count > 1 && (
+ ×{g.count}개 동일
+ )}
+ 잔재: {rem}mm ({((rem/pipe.matLen)*100).toFixed(1)}%)
+
+
+ {pipe.segments.map((seg, si) => {
+ const sw = Math.max(2, Math.round(seg.len * scale));
+ return (
+
+ {sw > 40 ? `${seg.len}mm` : ""}
+
+ );
+ })}
+ {rem > 0 && (
+
+ {Math.max(2, Math.round(rem * scale)) > 30 ? `${rem}mm` : ""}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────
+// 자투리 관리 뷰
+// ─────────────────────────────────────────────────────────
+function RemnantView({
+ batchResult, cutType, minReuse, setMinReuse, getSheetRemnants, onToggleGroupStatus, onSetAllStatus,
+}: {
+ batchResult: AreaResult | LengthResult | null;
+ cutType: CutType;
+ minReuse: number;
+ setMinReuse: (n: number) => void;
+ getSheetRemnants: (sheet: Sheet) => RemnantItem[];
+ onToggleGroupStatus: (sheetId: number, remIds: string[], status: "keep" | "discard") => void;
+ onSetAllStatus: (status: "keep" | "discard") => void;
+}) {
+ const rows = useMemo(() => {
+ if (!batchResult) return [];
+ if (cutType === "area") {
+ const r = batchResult as AreaResult;
+ // sheet별로 자투리 그룹화 → 그룹 단위 row 생성
+ type Row = {
+ sheetId: number; sheetIdx: number; remIds: string[];
+ matName: string; spec: string; pieces: number;
+ totalArea: number; bboxW: number; bboxH: number;
+ canReuse: boolean; status: "keep" | "discard";
+ };
+ const list: Row[] = [];
+ r.sheets.forEach((sheet, si) => {
+ const rems = getSheetRemnants(sheet);
+ const groups = computeRemnantGroups(rems);
+ groups.forEach((g) => {
+ // 한 그룹이 keep/discard 혼합일 수 있으므로 status 별로 분리하여 row 생성
+ (["keep", "discard"] as const).forEach((st) => {
+ const rects = g.rects.filter((x) => x.status === st);
+ if (rects.length === 0) return;
+ const totalArea = rects.reduce((s, x) => s + x.w * x.h, 0);
+ const minX = Math.min(...rects.map((x) => x.x));
+ const minY = Math.min(...rects.map((x) => x.y));
+ const maxX = Math.max(...rects.map((x) => x.x + x.w));
+ const maxY = Math.max(...rects.map((x) => x.y + x.h));
+ const bboxW = maxX - minX;
+ const bboxH = maxY - minY;
+ const spec = rects.length === 1
+ ? `${rects[0].w}×${rects[0].h}`
+ : `${bboxW}×${bboxH} (${rects.length}조각 합 ${totalArea.toLocaleString()}mm²)`;
+ const minDim = rects.length === 1 ? Math.min(rects[0].w, rects[0].h) : Math.min(bboxW, bboxH);
+ const canReuse = minDim >= minReuse;
+ list.push({
+ sheetId: sheet.id, sheetIdx: si,
+ remIds: rects.map((x) => x.id),
+ matName: sheet.matName,
+ spec, pieces: rects.length,
+ totalArea, bboxW, bboxH,
+ canReuse,
+ status: st,
+ });
+ });
+ });
+ });
+ list.sort((a, b) => b.totalArea - a.totalArea);
+ return list.map((row, gi) => ({
+ no: gi + 1,
+ sheetId: row.sheetId,
+ remIds: row.remIds,
+ range: `Sheet #${row.sheetIdx + 1}`,
+ count: row.pieces,
+ matName: row.matName,
+ spec: row.spec,
+ remVal: row.totalArea.toLocaleString(),
+ totalRemVal: row.totalArea.toLocaleString(),
+ util: "-",
+ canReuse: row.canReuse,
+ status: row.status,
+ }));
+ } else {
+ const r = batchResult as LengthResult;
+ const groups = r.pipeGroups || r.pipes.map((p, pi) => ({ count: 1, repIdx: pi, representative: p, indices: [pi] }));
+ return groups.map((g, gi) => {
+ const pipe = g.representative;
+ const remLen = Math.max(0, pipe.remaining);
+ const usedLen = pipe.segments.reduce((s, seg) => s + seg.len, 0);
+ const util = pipe.matLen > 0 ? (usedLen / pipe.matLen) * 100 : 0;
+ const canReuse = remLen >= minReuse;
+ const first = Math.min(...g.indices) + 1;
+ const last = Math.max(...g.indices) + 1;
+ return {
+ no: gi + 1,
+ sheetId: 0,
+ remIds: [] as string[],
+ range: g.count > 1 ? `#${first}~#${last}` : `#${first}`,
+ count: g.count,
+ matName: pipe.matName,
+ spec: `${remLen}mm`,
+ remVal: remLen.toLocaleString(),
+ totalRemVal: (remLen * g.count).toLocaleString(),
+ util: util.toFixed(1),
+ canReuse,
+ status: "discard" as const,
+ };
+ });
+ }
+ }, [batchResult, cutType, minReuse, getSheetRemnants]);
+
+ const summary = useMemo(() => {
+ const keep = rows.filter((r) => r.status === "keep");
+ const discard = rows.filter((r) => r.status !== "keep");
+ return {
+ keepKinds: keep.length,
+ keepCount: keep.length,
+ discardKinds: discard.length,
+ discardCount: discard.length,
+ };
+ }, [rows]);
+
+ return (
+
+
+
+
+
+
자투리 목록
+
{rows.length}개
+
+ {cutType === "area" && rows.length > 0 && (
+ <>
+
+
+ 보관 {summary.keepCount}개
+
+
+ 폐기 {summary.discardCount}개
+
+ >
+ )}
+
+
+ {cutType === "area" && rows.length > 0 && (
+ <>
+
+
+
+ >
+ )}
+
+
setMinReuse(+e.target.value)} className="h-7 w-[70px] text-xs" />
+
mm 이상
+
+
+
+ {rows.length === 0 ? (
+
+
+
계산 실행 후 자투리 정보가 표시됩니다
+
+ ) : (
+
+
+
+ No
+ {cutType === "length" ? "파이프번호" : "원판"}
+ 원자재명
+ {cutType === "length" ? "잔재 길이" : "자투리 규격"}
+ {cutType === "length" ? "손실 길이" : "단위 면적"}
+ {cutType === "length" ? "이용률" : "총 면적"}
+ 재사용
+ {cutType === "area" && 처리}
+
+
+
+ {rows.map((r) => (
+
+ {r.no}
+
+ {r.range}
+ {cutType === "length" && r.count > 1 && ×{r.count}}
+
+ {r.matName}
+ {r.spec}
+
+ {r.remVal}
+
+
+ {cutType === "length" ? (
+ <>
+ {r.util}%
+
+ >
+ ) : (
+ {r.totalRemVal}
+ )}
+
+
+
+ {r.canReuse ? "가능" : "불가"}
+
+
+ {cutType === "area" && (
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx
index 110b2b4d..c5a2ecda 100644
--- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx
@@ -647,6 +647,7 @@ export default function WorkInstructionPage() {
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
+ { key: "batch_no", label: "배치번호", width: "w-[130px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v ?
{v} :
-) : "" },
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return null;
@@ -911,6 +912,9 @@ export default function WorkInstructionPage() {
순번
+ {editOrder?.batch_no ? (
+ 배치번호
+ ) : null}
품목코드
품목명
규격
@@ -928,10 +932,13 @@ export default function WorkInstructionPage() {
{editItems.length === 0 ? (
- 등록된 품목이 없어요
+ 등록된 품목이 없어요
) : editItems.map((item, idx) => (
{idx + 1}
+ {editOrder?.batch_no ? (
+ {editOrder.batch_no}
+ ) : null}
{item.itemCode}
{item.itemName || "-"}
{item.spec || "-"}
diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx
index b0ec2729..3662876a 100644
--- a/frontend/components/layout/AdminPageRenderer.tsx
+++ b/frontend/components/layout/AdminPageRenderer.tsx
@@ -340,6 +340,7 @@ const ADMIN_PAGE_REGISTRY: Record> = {
"/COMPANY_9/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }),
+ "/COMPANY_9/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
@@ -572,6 +573,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = {
"/COMPANY_9/production/work-instruction": () => import("@/app/(main)/COMPANY_9/production/work-instruction/page"),
"/COMPANY_9/production/plan-management": () => import("@/app/(main)/COMPANY_9/production/plan-management/page"),
"/COMPANY_9/production/bom": () => import("@/app/(main)/COMPANY_9/production/bom/page"),
+ "/COMPANY_9/production/cutting-plan": () => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"),
"/COMPANY_9/equipment/info": () => import("@/app/(main)/COMPANY_9/equipment/info/page"),
"/COMPANY_9/equipment/plc-settings": () => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"),
"/COMPANY_9/monitoring/production": () => import("@/app/(main)/COMPANY_9/monitoring/production/page"),