Merge branch 'gbpark-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
# 절단계획 관리 작업 진행상황
|
||||
|
||||
> 마지막 업데이트: 2026-04-21
|
||||
> 작업자: gbpark (COMPANY_30 / 중앙안전유)
|
||||
|
||||
## 작업 개요
|
||||
유리 절단 최적화를 위한 절단계획 관리 페이지 구축. Guillotine 절단 + 자투리 관리 + 배치번호 추적.
|
||||
|
||||
## 핵심 파일
|
||||
- **알고리즘**: `frontend/lib/cutting/packing.ts`
|
||||
- **메인 페이지**: `frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx`
|
||||
- **백엔드 서비스**: `backend-node/src/services/cuttingPlanService.ts`
|
||||
- **컨트롤러**: `backend-node/src/controllers/cuttingPlanController.ts`
|
||||
- **라우트**: `backend-node/src/routes/cuttingPlanRoutes.ts`
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 1. 알고리즘
|
||||
- [x] 혼합 최적 / 동일 품목 우선 알고리즘 분리 (구조 자체가 다름)
|
||||
- [x] MaxRects → 2-stage Guillotine FFDH 전환 (직선 절단만 가능하도록)
|
||||
- [x] `packAreaHomogeneous`: 실제 maxCap 측정 후 전용 sheet + 잔여물 혼합 방식
|
||||
|
||||
### 2. 자투리(Remnant) 관리
|
||||
- [x] Plane sweep 기반 빈 영역 검출 (`computeRemnants`)
|
||||
- [x] kerf 너비 false positive 필터링 (`w > minDim && h > minDim`)
|
||||
- [x] Union-Find으로 인접 자투리 그룹화
|
||||
- [x] 그룹 폴리곤 외곽선 계산 (`computeGroupOutline`)
|
||||
- [x] 그룹 분할: 가로/세로/큰 사각형 우선 (`decomposeUnion`)
|
||||
- [x] 자투리 개별 상태 토글 (보관/폐기), shift+click 지원
|
||||
- [x] 수동 편집 화면 + 확대(Zoom) 화면 모두에서 토글 가능
|
||||
|
||||
### 3. 드래그 동작
|
||||
- [x] kerf 고려한 nearest-limit 스냅 (수동편집 + 확대편집)
|
||||
|
||||
### 4. 배치번호(Batch No) 추적
|
||||
- [x] DB: cutting_plan_item.src_no → 수주 LEFT JOIN
|
||||
- [x] `getOrders` SQL 수정: `cpm.id AS batch_id, cpm.plan_no AS batch_no`
|
||||
- [x] `savePlan`: src_orders 배열로 처리, 첫 행만 qty 보유
|
||||
- [x] excludeInPlan 수정: production_plan + shipment_plan만 제외 (cutting_plan은 중복 허용)
|
||||
- [x] 배치번호 클릭 → 전체 계획 로드 (`loadPlan`)
|
||||
|
||||
## 미해결 이슈 (다음 세션 시작점)
|
||||
|
||||
### **자투리 "보관" 상태 영속화** (2026-04-21 1차 처리 완료, UI 검증 대기)
|
||||
|
||||
**수정 내용**:
|
||||
- ✅ `cutting_plan_sheet`에 `remnants JSONB` 컬럼 추가 (ALTER 실행 완료, vexplor_dev)
|
||||
- ✅ `backend-node/src/services/cuttingPlanService.ts` — `savePlan` INSERT에 `remnants` 파라미터 14번째 추가 (`JSON.stringify`)
|
||||
- ✅ `getPlanDetail`은 `SELECT *`이므로 자동 포함 (pg가 JSONB → JS 배열 자동 파싱)
|
||||
- ✅ `frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx`
|
||||
- `savePlan` payload area sheets에 `remnants: sh.remnants ?? null` 추가
|
||||
- `loadPlan` area Sheet 복원 시 `remnants: Array.isArray(sh.remnants) ? ... : undefined` 추가
|
||||
|
||||
**UI 검증 (사용자 확인 완료)**:
|
||||
- DB 저장/복원 동작. 단, 배치번호 클릭 한 번으로는 안 보이고 두 번째 클릭에서야 복원되는 증상.
|
||||
|
||||
**추가 원인 & 조치 (2026-04-21 2차)**:
|
||||
- page.tsx line ~364 useEffect(`[packMode, cutType, kerf, margin, mat1.code, mat2.code]`)가
|
||||
loadPlan의 `setCutType/setKerf/...`에 반응해 `calculate()`를 호출 → 방금 복원한 remnants를 덮어씀.
|
||||
- 2번째 클릭엔 state가 같아 useEffect 안 돌고 그대로 유지.
|
||||
- **수정**: `skipAutoRecalcRef` (useRef) 도입. loadPlan 시작 시 true 세팅, 직후 useEffect에서 1회 소비 후 false.
|
||||
- 관련 위치: page.tsx (`const skipAutoRecalcRef = useRef(false)` 선언부, 재계산 useEffect 상단 가드, loadPlan 초입 세팅).
|
||||
|
||||
**인프라 이슈 (참고)**:
|
||||
- 사무실 우분투 `pms-backend-mac` 컨테이너는 Syncthing이 파일 교체 순간 cwd가 사라지며 nodemon이 `uv_cwd` ENOENT로 죽을 수 있음. Hot reload 이상 시 `docker restart pms-backend-mac`.
|
||||
|
||||
**자투리 관리 탭 집계 수정 (2026-04-21 3차)**:
|
||||
- 증상: 배치 계획 탭에서 "혼합 1/2"로 표시된 그룹이 자투리 관리 탭에서는 "보관 0개"로 잘못 집계.
|
||||
- 원인 1 (표시): `RemnantView`가 그룹 단위 row 생성 + `allKeep = every("keep")` 기준 → 혼합 그룹이 전부 discard로 분류.
|
||||
- **수정**: `groups.forEach` 내부에서 rects를 `status` 기준으로 split하여 keep/discard 각각 row 생성.
|
||||
- 원인 2 (저장): `sheetGroups`의 대표 sheet에만 `remnants`가 있고 비대표 5장은 null → 로드 시 비대표는 기본 "discard"로 집계.
|
||||
- **수정**: `savePlan` payload 빌드 시 `sheetGroups.representative.remnants`를 같은 그룹 indices에 복제. 개별 sheet가 자체 remnants를 갖고 있으면 그대로 유지.
|
||||
|
||||
**남은 잠재 이슈 (확대편집 모달 관련)**:
|
||||
1. `ZoomEditorModal`의 `statusOverrides`는 lazy init이라 sheet.remnants 변경 시 stale
|
||||
2. piece drag 핸들러에서 `remnants: undefined` 설정 → 수동 결정 손실
|
||||
3. 비대표 sheet 개별 편집 시 그룹 전파 기본값이 그 sheet를 덮지는 않지만, 대표 편집이 이후에 오면 비대표 개별 편집이 덮어질 여지 (현재는 대표에서만 편집 UI 제공이라 문제 없음)
|
||||
|
||||
## 보류 작업 (사용자가 나중에 진행)
|
||||
- [ ] 중앙 품목 페이지 헤더의 필터/정렬 기능을 절단계획 수주 탭에 적용
|
||||
- [ ] Step 3: 마우스로 절단선 직접 그리기 (interactive cutting)
|
||||
- [ ] Step 4: 드래그로 새 자투리 생성 (manual remnant drawing)
|
||||
|
||||
## DB 검증 정보 (vexplor_dev)
|
||||
- 서버: `211.115.91.141:11134/vexplor_dev`
|
||||
- 검증된 데이터: `CP-2026-0001` (5 수주), `CP-2026-0002` (4 수주)
|
||||
- 구조: cutting_plan_mng → cutting_plan_item(src_no=수주FK) → cutting_plan_sheet → cutting_plan_placement
|
||||
- `cutting_plan_sheet.remnants JSONB` (2026-04-21 추가): `[{id,x,y,w,h,status:"keep"|"discard"}]`
|
||||
|
||||
## 핵심 코드 스니펫
|
||||
|
||||
**ZoomEditorModal statusOverrides 초기화** (page.tsx):
|
||||
```typescript
|
||||
const [statusOverrides, setStatusOverrides] = useState<Record<string, "keep" | "discard">>(() => {
|
||||
const init: Record<string, "keep" | "discard"> = {};
|
||||
if (sheet.remnants) {
|
||||
sheet.remnants.forEach((rm) => {
|
||||
init[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = rm.status;
|
||||
});
|
||||
}
|
||||
return init;
|
||||
});
|
||||
```
|
||||
|
||||
**자투리 필터 (packing.ts)**:
|
||||
```typescript
|
||||
const minDim = Math.max(1, kerf);
|
||||
return finalRects.filter((r) => r.w > minDim && r.h > minDim);
|
||||
```
|
||||
@@ -155,6 +155,7 @@ import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
|
||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||
import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀)
|
||||
@@ -381,6 +382,7 @@ app.use("/api/mold", moldRoutes); // 금형 관리
|
||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
|
||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||
app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 절단계획 컨트롤러
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as svc from "../services/cuttingPlanService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function wrap(res: Response, fn: () => Promise<any>, errMsg: string) {
|
||||
return fn().then(
|
||||
(data) => res.json({ success: true, data }),
|
||||
(e: any) => {
|
||||
logger.error(errMsg, { error: e?.message });
|
||||
return res.status(500).json({ success: false, message: e?.message || errMsg });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getMaterials(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const cutType = (req.query.cutType as string) || "area";
|
||||
return wrap(res, () => svc.getMaterials(companyCode, cutType), "원자재 조회 실패");
|
||||
}
|
||||
|
||||
export function searchItems(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const keyword = req.query.keyword as string | undefined;
|
||||
return wrap(res, () => svc.searchItems(companyCode, keyword), "품목 검색 실패");
|
||||
}
|
||||
|
||||
export function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const from = req.query.from as string | undefined;
|
||||
const to = req.query.to as string | undefined;
|
||||
const keyword = req.query.keyword as string | undefined;
|
||||
const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const excludeInPlan = req.query.excludeInPlan === "true";
|
||||
return wrap(
|
||||
res,
|
||||
() => svc.getOrders(companyCode, { from, to, keyword, page, limit, excludeInPlan }),
|
||||
"수주 조회 실패"
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlans(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { from, to, planNo, status } = req.query;
|
||||
return wrap(res, () => svc.getPlans(companyCode, {
|
||||
from: from as string, to: to as string,
|
||||
planNo: planNo as string, status: status as string,
|
||||
}), "계획 목록 조회 실패");
|
||||
}
|
||||
|
||||
export async function getPlanDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const data = await svc.getPlanDetail(companyCode, id);
|
||||
if (!data) return res.status(404).json({ success: false, message: "계획을 찾을 수 없습니다" });
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
logger.error("계획 상세 조회 실패", { error: e?.message });
|
||||
return res.status(500).json({ success: false, message: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function nextPlanNo(req: AuthenticatedRequest, res: Response) {
|
||||
const companyCode = req.user!.companyCode;
|
||||
return wrap(res, () => svc.nextPlanNo(companyCode), "계획번호 생성 실패");
|
||||
}
|
||||
|
||||
export async function savePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId || "system";
|
||||
const data = await svc.savePlan(companyCode, userId, req.body);
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
logger.error("계획 저장 실패", { error: e?.message });
|
||||
return res.status(500).json({ success: false, message: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const ok = await svc.deletePlan(companyCode, id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: "삭제 대상을 찾을 수 없습니다" });
|
||||
return res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
logger.error("계획 삭제 실패", { error: e?.message });
|
||||
return res.status(500).json({ success: false, message: e?.message });
|
||||
}
|
||||
}
|
||||
@@ -96,13 +96,13 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
const countRes = await pool.query(countSql, params);
|
||||
const totalCount = countRes.rows[0]?.cnt ?? 0;
|
||||
|
||||
// 2) 현재 페이지 WI id 목록
|
||||
// 2) 현재 페이지 WI id 목록 (최신 생성순, 동일 created_date일 때 번호 내림차순)
|
||||
const offset = (pageNum! - 1) * sizeNum!;
|
||||
const pageSql = `
|
||||
SELECT wi.id
|
||||
FROM work_instruction wi
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, wi.id DESC
|
||||
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC
|
||||
LIMIT ${sizeNum} OFFSET ${offset}
|
||||
`;
|
||||
const pageRes = await pool.query(pageSql, params);
|
||||
@@ -128,6 +128,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
wi.worker,
|
||||
wi.remark AS wi_remark,
|
||||
wi.created_date,
|
||||
wi.batch_no,
|
||||
wi.cutting_plan_id,
|
||||
d.id AS detail_id,
|
||||
d.item_number,
|
||||
d.qty AS detail_qty,
|
||||
@@ -148,7 +150,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
@@ -160,7 +162,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
LEFT JOIN item_routing_version rv
|
||||
ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
WHERE wi.id = ANY($1::varchar[])
|
||||
ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC
|
||||
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
||||
`;
|
||||
const dataRes = await pool.query(dataSql, [wiIds]);
|
||||
|
||||
@@ -189,6 +191,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
wi.worker,
|
||||
wi.remark AS wi_remark,
|
||||
wi.created_date,
|
||||
wi.batch_no,
|
||||
wi.cutting_plan_id,
|
||||
d.id AS detail_id,
|
||||
d.item_number,
|
||||
d.qty AS detail_qty,
|
||||
@@ -209,7 +213,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
||||
wi.routing AS routing_version_id,
|
||||
COALESCE(rv.version_name, '') AS routing_name,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
|
||||
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
||||
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
||||
FROM work_instruction wi
|
||||
INNER JOIN work_instruction_detail d
|
||||
@@ -219,7 +223,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
||||
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
||||
${whereClause}
|
||||
ORDER BY wi.created_date DESC, d.created_date ASC
|
||||
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
@@ -262,7 +266,7 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
await ensureDetailRoutingColumn();
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId } = req.body;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
@@ -281,8 +285,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
wiId = editId;
|
||||
wiNo = check.rows[0].work_instruction_no;
|
||||
await client.query(
|
||||
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
|
||||
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]);
|
||||
} else {
|
||||
@@ -296,8 +300,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
||||
}
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
|
||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
|
||||
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,batch_no,cutting_plan_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW(),$15) RETURNING id`,
|
||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId]
|
||||
);
|
||||
wiId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 절단계획 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/cuttingPlanController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 조회 소스
|
||||
router.get("/materials", ctrl.getMaterials); // 원자재 목록
|
||||
router.get("/items", ctrl.searchItems); // 품목 검색
|
||||
router.get("/orders", ctrl.getOrders); // 수주 목록
|
||||
|
||||
// 계획
|
||||
router.get("/next-plan-no", ctrl.nextPlanNo);
|
||||
router.get("/plans", ctrl.getPlans);
|
||||
router.get("/plans/:id", ctrl.getPlanDetail);
|
||||
router.post("/plans", ctrl.savePlan);
|
||||
router.put("/plans/:id", ctrl.savePlan); // id는 body에 담겨오거나 경로로
|
||||
router.delete("/plans/:id", ctrl.deletePlan);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 절단계획 서비스
|
||||
* - 원자재 / 수주 / 품목 조회
|
||||
* - 절단계획 CRUD (헤더 + 품목 + 원판 + 피스 좌표)
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 조회: 원자재 (item_info.division = 'CAT_DIV_RAW_MAT')
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function getMaterials(companyCode: string, cutType: string) {
|
||||
const pool = getPool();
|
||||
const q = `
|
||||
SELECT
|
||||
ii.id, ii.item_number, ii.item_name,
|
||||
COALESCE(ii.width::numeric, 0) AS width,
|
||||
COALESCE(ii.height::numeric, 0) AS height,
|
||||
COALESCE(ii.thickness::numeric, 0) AS thickness,
|
||||
ii.size, ii.material,
|
||||
COALESCE(inv.stock, 0) AS stock,
|
||||
COALESCE(inv.safety, 0) AS safety_stock
|
||||
FROM item_info ii
|
||||
LEFT JOIN (
|
||||
SELECT item_code,
|
||||
SUM(COALESCE(NULLIF(current_qty,'')::numeric, 0)) AS stock,
|
||||
SUM(COALESCE(NULLIF(safety_qty,'')::numeric, 0)) AS safety
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1
|
||||
GROUP BY item_code
|
||||
) inv ON inv.item_code = ii.item_number
|
||||
WHERE ii.company_code = $1
|
||||
AND ii.division = 'CAT_DIV_RAW_MAT'
|
||||
AND COALESCE(ii.status,'active') <> 'deleted'
|
||||
ORDER BY ii.item_name
|
||||
`;
|
||||
const r = await pool.query(q, [companyCode]);
|
||||
return r.rows.map((row: any) => ({
|
||||
...row,
|
||||
stock: Number(row.stock) || 0,
|
||||
safety_stock: Number(row.safety_stock) || 0,
|
||||
length: cutType === "length" ? Number(row.width || 0) : 0,
|
||||
unit: cutType === "length" ? "개" : "장",
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 조회: 품목 검색 (완제품)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function searchItems(companyCode: string, keyword?: string) {
|
||||
const pool = getPool();
|
||||
const params: any[] = [companyCode];
|
||||
let where = `company_code = $1 AND division <> 'CAT_DIV_RAW_MAT'`;
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`);
|
||||
where += ` AND (item_name ILIKE $2 OR item_number ILIKE $2)`;
|
||||
}
|
||||
const q = `
|
||||
SELECT id, item_number, item_name, size,
|
||||
COALESCE(width::numeric,0) AS width,
|
||||
COALESCE(height::numeric,0) AS height,
|
||||
COALESCE(thickness::numeric,0) AS thickness
|
||||
FROM item_info
|
||||
WHERE ${where}
|
||||
ORDER BY item_name
|
||||
LIMIT 200
|
||||
`;
|
||||
const r = await pool.query(q, params);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 조회: 수주 소스 — 페이지네이션 + 계획/출하로 넘어간 건 제외 옵션
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function getOrders(
|
||||
companyCode: string,
|
||||
opts?: { from?: string; to?: string; page?: number; limit?: number; excludeInPlan?: boolean; keyword?: string }
|
||||
) {
|
||||
const pool = getPool();
|
||||
const params: any[] = [companyCode];
|
||||
let where = `so.company_code = $1 AND COALESCE(so.part_name,'') <> ''`;
|
||||
if (opts?.from) { params.push(opts.from); where += ` AND so.order_date >= $${params.length}`; }
|
||||
if (opts?.to) { params.push(opts.to); where += ` AND so.order_date <= $${params.length}`; }
|
||||
if (opts?.keyword) {
|
||||
params.push(`%${opts.keyword}%`);
|
||||
where += ` AND (so.order_no ILIKE $${params.length} OR so.part_name ILIKE $${params.length} OR so.part_code ILIKE $${params.length})`;
|
||||
}
|
||||
|
||||
// 생산계획/출하계획으로 넘어간 수주만 제외 (절단계획은 본 화면에서 관리하므로 보여줌 → 배치번호로 식별)
|
||||
if (opts?.excludeInPlan) {
|
||||
where += `
|
||||
AND NOT EXISTS (SELECT 1 FROM production_plan_mng pp WHERE pp.order_no = so.order_no AND pp.company_code = so.company_code)
|
||||
AND NOT EXISTS (SELECT 1 FROM shipment_plan sp WHERE sp.sales_order_id = so.id AND sp.company_code = so.company_code)
|
||||
`;
|
||||
}
|
||||
|
||||
// 총 건수
|
||||
const cntQ = `SELECT COUNT(*)::int AS total FROM sales_order_mng so WHERE ${where}`;
|
||||
const cntR = await pool.query(cntQ, params);
|
||||
const total = cntR.rows[0]?.total || 0;
|
||||
|
||||
// 페이지네이션
|
||||
const page = Math.max(1, opts?.page || 1);
|
||||
const limit = Math.min(1000, Math.max(1, opts?.limit || 100));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const q = `
|
||||
SELECT so.order_no, so.order_date, so.due_date, so.partner_id,
|
||||
so.part_code, so.part_name, so.spec, so.material,
|
||||
COALESCE(so.order_qty::numeric,0) AS order_qty,
|
||||
COALESCE(so.balance_qty::numeric,0) AS balance_qty,
|
||||
so.status,
|
||||
ii.id AS item_id,
|
||||
ii.item_name AS item_name,
|
||||
cpm.id AS batch_id,
|
||||
cpm.plan_no AS batch_no
|
||||
FROM sales_order_mng so
|
||||
LEFT JOIN item_info ii
|
||||
ON ii.item_number = so.part_code
|
||||
AND ii.company_code = so.company_code
|
||||
LEFT JOIN cutting_plan_item cpi
|
||||
ON cpi.src_no = so.order_no
|
||||
LEFT JOIN cutting_plan_mng cpm
|
||||
ON cpm.id = cpi.plan_id
|
||||
AND cpm.company_code = so.company_code
|
||||
WHERE ${where}
|
||||
ORDER BY cpm.plan_no DESC NULLS LAST, so.order_date DESC NULLS LAST, so.order_no DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
const r = await pool.query(q, params);
|
||||
return { rows: r.rows, total, page, limit };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 조회: 계획 목록
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function getPlans(
|
||||
companyCode: string,
|
||||
filter: { from?: string; to?: string; planNo?: string; status?: string }
|
||||
) {
|
||||
const pool = getPool();
|
||||
const params: any[] = [companyCode];
|
||||
let where = `company_code = $1`;
|
||||
if (filter.from) { params.push(filter.from); where += ` AND plan_date_from >= $${params.length}`; }
|
||||
if (filter.to) { params.push(filter.to); where += ` AND plan_date_to <= $${params.length}`; }
|
||||
if (filter.planNo) { params.push(`%${filter.planNo}%`); where += ` AND plan_no ILIKE $${params.length}`; }
|
||||
if (filter.status) { params.push(filter.status); where += ` AND status = $${params.length}`; }
|
||||
const q = `
|
||||
SELECT id, plan_no, plan_date_from, plan_date_to, cut_type, pack_mode, calc_mode,
|
||||
status, partner_id, total_sheets, util_rate, created_date
|
||||
FROM cutting_plan_mng
|
||||
WHERE ${where}
|
||||
ORDER BY id DESC
|
||||
LIMIT 200
|
||||
`;
|
||||
const r = await pool.query(q, params);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 조회: 계획 상세 (헤더 + 품목 + 원판 + 피스)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function getPlanDetail(companyCode: string, planId: number) {
|
||||
const pool = getPool();
|
||||
const hdr = await pool.query(
|
||||
`SELECT * FROM cutting_plan_mng WHERE id = $1 AND company_code = $2`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
if (!hdr.rows.length) return null;
|
||||
|
||||
// item_info JOIN으로 item_number/item_name 복원. item_id가 null인 옛 데이터는 src_no → sales_order_mng.part_code로 fallback.
|
||||
const items = await pool.query(
|
||||
`SELECT cpi.*,
|
||||
COALESCE(ii_direct.item_number, ii_order.item_number) AS item_number,
|
||||
COALESCE(ii_direct.item_name, ii_order.item_name, cpi.item_name) AS item_name_resolved
|
||||
FROM cutting_plan_item cpi
|
||||
LEFT JOIN item_info ii_direct ON ii_direct.id::text = cpi.item_id
|
||||
LEFT JOIN sales_order_mng so ON so.order_no = cpi.src_no AND so.company_code = $2
|
||||
LEFT JOIN item_info ii_order ON ii_order.item_number = so.part_code AND ii_order.company_code = $2
|
||||
WHERE cpi.plan_id = $1
|
||||
ORDER BY cpi.seq, cpi.id`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
const sheets = await pool.query(`SELECT * FROM cutting_plan_sheet WHERE plan_id = $1 ORDER BY sheet_no`, [planId]);
|
||||
const sheetIds = sheets.rows.map((s: any) => s.id);
|
||||
let placements: any[] = [];
|
||||
if (sheetIds.length) {
|
||||
const p = await pool.query(
|
||||
`SELECT * FROM cutting_plan_placement WHERE sheet_id = ANY($1::int[]) ORDER BY sheet_id, placement_order, id`,
|
||||
[sheetIds]
|
||||
);
|
||||
placements = p.rows;
|
||||
}
|
||||
return { header: hdr.rows[0], items: items.rows, sheets: sheets.rows, placements };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 다음 계획번호 생성: CP-YYYY-NNNN
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function nextPlanNo(companyCode: string): Promise<string> {
|
||||
const pool = getPool();
|
||||
const y = new Date().getFullYear();
|
||||
const prefix = `CP-${y}-`;
|
||||
const r = await pool.query(
|
||||
`SELECT plan_no FROM cutting_plan_mng
|
||||
WHERE company_code=$1 AND plan_no LIKE $2
|
||||
ORDER BY plan_no DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
let next = 1;
|
||||
if (r.rows.length) {
|
||||
const last = r.rows[0].plan_no as string;
|
||||
const tail = parseInt(last.substring(prefix.length), 10);
|
||||
if (!isNaN(tail)) next = tail + 1;
|
||||
}
|
||||
return `${prefix}${String(next).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 계획 저장 (신규 또는 업데이트) — 헤더+자식 전체 대체
|
||||
// payload: { header, items, sheets, placements }
|
||||
// sheets[i].placements[j] 구조도 허용 (중첩형)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function savePlan(companyCode: string, userId: string, payload: any) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const h = payload.header || {};
|
||||
let planId: number;
|
||||
|
||||
if (h.id) {
|
||||
// UPDATE 헤더
|
||||
const up = await client.query(
|
||||
`UPDATE cutting_plan_mng SET
|
||||
plan_no=$1, plan_date_from=$2, plan_date_to=$3,
|
||||
cut_type=$4, calc_mode=$5, pack_mode=$6,
|
||||
mat_item_id=$7, mat_item_id_2=$8,
|
||||
kerf=$9, margin=$10, min_remnant=$11, min_reuse=$12,
|
||||
partner_id=$13, status=$14,
|
||||
total_sheets=$15, total_pieces=$16, util_rate=$17, total_loss=$18, rotated_count=$19,
|
||||
remarks=$20, updated_date=NOW(), updated_by=$21
|
||||
WHERE id=$22 AND company_code=$23
|
||||
RETURNING id`,
|
||||
[
|
||||
h.plan_no, h.plan_date_from || null, h.plan_date_to || null,
|
||||
h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed",
|
||||
h.mat_item_id || null, h.mat_item_id_2 || null,
|
||||
h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100,
|
||||
h.partner_id || null, h.status || "draft",
|
||||
h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null,
|
||||
h.remarks || null, userId,
|
||||
h.id, companyCode,
|
||||
]
|
||||
);
|
||||
if (!up.rows.length) throw new Error("계획을 찾을 수 없거나 권한이 없습니다");
|
||||
planId = up.rows[0].id;
|
||||
|
||||
// 자식 전체 삭제 후 재등록
|
||||
await client.query(`DELETE FROM cutting_plan_placement WHERE sheet_id IN (SELECT id FROM cutting_plan_sheet WHERE plan_id=$1)`, [planId]);
|
||||
await client.query(`DELETE FROM cutting_plan_sheet WHERE plan_id=$1`, [planId]);
|
||||
await client.query(`DELETE FROM cutting_plan_item WHERE plan_id=$1`, [planId]);
|
||||
} else {
|
||||
// INSERT 헤더
|
||||
const plan_no = h.plan_no || (await nextPlanNo(companyCode));
|
||||
const ins = await client.query(
|
||||
`INSERT INTO cutting_plan_mng
|
||||
(company_code, plan_no, plan_date_from, plan_date_to,
|
||||
cut_type, calc_mode, pack_mode,
|
||||
mat_item_id, mat_item_id_2,
|
||||
kerf, margin, min_remnant, min_reuse,
|
||||
partner_id, status,
|
||||
total_sheets, total_pieces, util_rate, total_loss, rotated_count,
|
||||
remarks, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode, plan_no, h.plan_date_from || null, h.plan_date_to || null,
|
||||
h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed",
|
||||
h.mat_item_id || null, h.mat_item_id_2 || null,
|
||||
h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100,
|
||||
h.partner_id || null, h.status || "draft",
|
||||
h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null,
|
||||
h.remarks || null, userId,
|
||||
]
|
||||
);
|
||||
planId = ins.rows[0].id;
|
||||
}
|
||||
|
||||
// 품목 저장 — 원래 index (프론트 itemIdx) 를 id 매핑용으로 추적
|
||||
// 한 PlanItem이 여러 수주를 합산했을 때(src_orders.length > 1)는 수주별 row 분리 저장
|
||||
// (각 수주에 batch_no 연결되도록). 첫 row에만 piece 정보 + 나머지는 같은 메타 + qty 0.
|
||||
const itemIdMap = new Map<number, number>(); // 프론트 itemIdx → DB id (첫 row)
|
||||
const items = payload.items || [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const srcList: (string | null)[] =
|
||||
Array.isArray(it.src_orders) && it.src_orders.length > 0
|
||||
? it.src_orders
|
||||
: [it.src_no || null];
|
||||
let firstId: number | null = null;
|
||||
for (let k = 0; k < srcList.length; k++) {
|
||||
const orderNo = srcList[k];
|
||||
const r = await client.query(
|
||||
`INSERT INTO cutting_plan_item
|
||||
(plan_id, seq, src_type, src_no, item_id, item_name, item_spec,
|
||||
width, height, length, qty, dir, color, placed_qty)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING id`,
|
||||
[
|
||||
planId, it.seq ?? i + 1,
|
||||
it.src_type || (orderNo ? "order" : "manual"), orderNo,
|
||||
it.item_id || null, it.item_name || "", it.item_spec || null,
|
||||
it.width ?? null, it.height ?? null, it.length ?? null,
|
||||
k === 0 ? (it.qty ?? 0) : 0, // 첫 row에만 qty (배치/통계용)
|
||||
it.dir || "무관", it.color || null,
|
||||
k === 0 ? (it.placed_qty ?? 0) : 0,
|
||||
]
|
||||
);
|
||||
if (firstId === null) firstId = r.rows[0].id;
|
||||
}
|
||||
if (firstId !== null) itemIdMap.set(i, firstId);
|
||||
}
|
||||
|
||||
// 원판(시트) + 배치 저장 — payload.sheets[i].placements[] 중첩형 지원
|
||||
const sheets = payload.sheets || [];
|
||||
for (let s = 0; s < sheets.length; s++) {
|
||||
const sh = sheets[s];
|
||||
const sr = await client.query(
|
||||
`INSERT INTO cutting_plan_sheet
|
||||
(plan_id, sheet_no, mat_item_id, mat_name,
|
||||
mat_width, mat_height, mat_length,
|
||||
util_rate, used_area, remnant_area, used_length, remnant_length, group_key, remnants)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING id`,
|
||||
[
|
||||
planId, sh.sheet_no ?? s + 1, sh.mat_item_id || null, sh.mat_name || null,
|
||||
sh.mat_width ?? null, sh.mat_height ?? null, sh.mat_length ?? null,
|
||||
sh.util_rate ?? null, sh.used_area ?? null, sh.remnant_area ?? null,
|
||||
sh.used_length ?? null, sh.remnant_length ?? null, sh.group_key || null,
|
||||
Array.isArray(sh.remnants) ? JSON.stringify(sh.remnants) : null,
|
||||
]
|
||||
);
|
||||
const sheetId = sr.rows[0].id;
|
||||
|
||||
const placements = sh.placements || [];
|
||||
for (let p = 0; p < placements.length; p++) {
|
||||
const pl = placements[p];
|
||||
const planItemId = pl.plan_item_id ?? (pl.itemIdx != null ? itemIdMap.get(pl.itemIdx) : null);
|
||||
await client.query(
|
||||
`INSERT INTO cutting_plan_placement
|
||||
(sheet_id, plan_item_id, x, y, w, h, start_x, seg_length, rotated, placement_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[
|
||||
sheetId, planItemId || null,
|
||||
pl.x ?? null, pl.y ?? null, pl.w ?? null, pl.h ?? null,
|
||||
pl.start_x ?? null, pl.seg_length ?? null,
|
||||
!!pl.rotated, pl.placement_order ?? p,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { id: planId, plan_no: payload.header?.plan_no };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 삭제
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export async function deletePlan(companyCode: string, planId: number) {
|
||||
const pool = getPool();
|
||||
const r = await pool.query(
|
||||
`DELETE FROM cutting_plan_mng WHERE id=$1 AND company_code=$2 RETURNING id`,
|
||||
[planId, companyCode]
|
||||
);
|
||||
return r.rows.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 절단계획 → 작업지시 적용 모달
|
||||
* jskim-node의 작업지시 모달 구조와 호환 (품목별 일정/설비/작업조/작업자 지정).
|
||||
* 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CheckCircle2, ChevronsUpDown, Loader2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover, PopoverContent, PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Table, TableHeader, TableRow, TableHead, TableBody, TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
previewWorkInstructionNo, saveWorkInstruction,
|
||||
getEquipmentList, getEmployeeList,
|
||||
} from "@/lib/api/workInstruction";
|
||||
|
||||
// ─── 공용 다중선택 Popover (설비/작업조/작업자) ────────────────────
|
||||
interface MultiSelectOption { value: string; label: string; sub?: string; }
|
||||
interface MultiSelectPopoverProps {
|
||||
options: MultiSelectOption[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
triggerClassName?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
function MultiSelectPopover({
|
||||
options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요",
|
||||
}: MultiSelectPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||
const toggle = (val: string) => {
|
||||
if (selectedSet.has(val)) onChange(value.filter((v) => v !== val));
|
||||
else onChange([...value, val]);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchable || !keyword.trim()) return options;
|
||||
const k = keyword.trim().toLowerCase();
|
||||
return options.filter((o) => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k));
|
||||
}, [options, keyword, searchable]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (value.length === 0) return placeholder;
|
||||
if (value.length === 1) return options.find((o) => o.value === value[0])?.label || value[0];
|
||||
if (value.length === 2) {
|
||||
return value.map((v) => options.find((o) => o.value === v)?.label || v).join(", ");
|
||||
}
|
||||
return `${value.length}개 선택`;
|
||||
}, [value, options, placeholder]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
|
||||
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
|
||||
{searchable && (
|
||||
<div className="p-2 border-b">
|
||||
<Input placeholder="검색..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="h-7 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
|
||||
) : filtered.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
|
||||
onClick={(e) => { e.preventDefault(); toggle(opt.value); }}
|
||||
>
|
||||
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
|
||||
<span className="flex-1 truncate">
|
||||
{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="p-1.5 border-t flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{value.length}개 선택됨</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}>초기화</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 모달 인터페이스 ────────────────────────────────────────────────
|
||||
export interface WorkInstructionApplyItem {
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec?: string;
|
||||
qty: number;
|
||||
remark?: string;
|
||||
sourceTable?: string;
|
||||
sourceId?: string;
|
||||
// 품목별 일정/설비/작업조/작업자
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
equipmentIds?: string[];
|
||||
workTeams?: string[];
|
||||
workers?: string[];
|
||||
}
|
||||
|
||||
export interface WorkInstructionApplyModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
initialItems: WorkInstructionApplyItem[];
|
||||
batchNo?: string | null;
|
||||
cuttingPlanId?: number | null;
|
||||
onSaved?: (result: { id: string; workInstructionNo: string }) => void;
|
||||
}
|
||||
|
||||
export default function WorkInstructionApplyModal({
|
||||
open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved,
|
||||
}: WorkInstructionApplyModalProps) {
|
||||
const [wiNo, setWiNo] = useState("");
|
||||
const [status, setStatus] = useState("일반");
|
||||
const [remark, setRemark] = useState("");
|
||||
const [items, setItems] = useState<WorkInstructionApplyItem[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
|
||||
const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]);
|
||||
|
||||
// 모달 오픈 시 초기화 + 옵션 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
setItems(initialItems.map((x) => ({
|
||||
...x,
|
||||
startDate: x.startDate || today,
|
||||
endDate: x.endDate || "",
|
||||
equipmentIds: x.equipmentIds || [],
|
||||
workTeams: x.workTeams || [],
|
||||
workers: x.workers || [],
|
||||
})));
|
||||
setStatus("일반");
|
||||
setRemark("");
|
||||
|
||||
previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {});
|
||||
getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {});
|
||||
getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {});
|
||||
}, [open, initialItems]);
|
||||
|
||||
const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
// 하위호환: 마스터에는 첫 품목의 대표값을 실음 (jskim 스타일)
|
||||
const first = items[0];
|
||||
const payload = {
|
||||
status,
|
||||
startDate: first?.startDate || "",
|
||||
endDate: first?.endDate || "",
|
||||
equipmentId: first?.equipmentIds?.[0] || "",
|
||||
workTeam: first?.workTeams?.[0] || "",
|
||||
worker: first?.workers?.[0] || "",
|
||||
remark,
|
||||
routing: null,
|
||||
batchNo: batchNo || null,
|
||||
cuttingPlanId: cuttingPlanId ?? null,
|
||||
items: items.map((i) => ({
|
||||
itemNumber: i.itemCode, itemCode: i.itemCode, partCode: i.itemCode,
|
||||
qty: String(i.qty), remark: i.remark || "",
|
||||
sourceTable: i.sourceTable || "cutting_plan",
|
||||
sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""),
|
||||
routing: null,
|
||||
startDate: i.startDate || "",
|
||||
endDate: i.endDate || "",
|
||||
equipmentIds: (i.equipmentIds || []).join(","),
|
||||
workTeams: (i.workTeams || []).join(","),
|
||||
workers: (i.workers || []).join(","),
|
||||
})),
|
||||
};
|
||||
const r = await saveWorkInstruction(payload);
|
||||
if (!r.success) { toast.error(r.message || "저장 실패"); return; }
|
||||
toast.success(`작업지시 ${r.data?.workInstructionNo || wiNo} 등록 완료`);
|
||||
onOpenChange(false);
|
||||
onSaved?.(r.data);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const equipmentSelectOptions = useMemo(
|
||||
() => equipmentOptions.map((eq) => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code })),
|
||||
[equipmentOptions]
|
||||
);
|
||||
const workerSelectOptions = useMemo(
|
||||
() => workerOptions.map((w) => ({ value: w.user_id, label: w.user_name, sub: w.dept_name || undefined })),
|
||||
[workerOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>
|
||||
기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.
|
||||
{batchNo ? <span className="ml-2 text-primary font-medium">배치번호 {batchNo}</span> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label>
|
||||
<Input value={wiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="일반">일반</SelectItem>
|
||||
<SelectItem value="긴급">긴급</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input className="h-9" placeholder="비고를 입력해주세요" value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table className="min-w-[1700px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">배치번호</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-primary">{batchNo || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode || "-"}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>
|
||||
{item.itemName || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Input type="number" className="h-7 text-[13px] w-20"
|
||||
value={item.qty}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]"
|
||||
value={item.startDate || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]"
|
||||
value={item.endDate || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentSelectOptions}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={workerSelectOptions}
|
||||
value={item.workers || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-[13px]" placeholder="비고"
|
||||
value={item.remark || ""}
|
||||
onChange={(e) => setItems((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={() => setItems((prev) => prev.filter((_, i) => i !== idx))}>
|
||||
<X className="w-3 h-3 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="text-center text-muted-foreground text-[12px] py-6">
|
||||
품목이 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave || 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -657,6 +657,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 ? <span className="font-mono text-primary">{v}</span> : <span className="text-muted-foreground">-</span>) : "" },
|
||||
{ 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;
|
||||
@@ -801,7 +802,7 @@ export default function WorkInstructionPage() {
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table className="min-w-[1500px]">
|
||||
<Table className="min-w-[1600px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
@@ -815,7 +816,7 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -923,10 +924,13 @@ export default function WorkInstructionPage() {
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<Table className="min-w-[1500px]">
|
||||
<Table className="min-w-[1700px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
{editOrder?.batch_no ? (
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">배치번호</TableHead>
|
||||
) : null}
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
@@ -938,16 +942,19 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={editOrder?.batch_no ? 15 : 14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
{editOrder?.batch_no ? (
|
||||
<TableCell className="text-[13px] font-mono text-primary">{editOrder.batch_no}</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -388,6 +389,141 @@ function renderCellValue(row: Record<string, any>, col: ReportColumnDef): React.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헤더 필터 - 라벨 컬럼 (고유값 체크박스)
|
||||
// ============================================
|
||||
function LabelFilterContent({
|
||||
values,
|
||||
selected,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
values: string[];
|
||||
selected: Set<string> | null;
|
||||
onChange: (next: Set<string> | null) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = useMemo(
|
||||
() => values.filter((v) => v.toLowerCase().includes(search.toLowerCase())),
|
||||
[values, search]
|
||||
);
|
||||
const effective = selected ?? new Set(values);
|
||||
const toggle = (v: string) => {
|
||||
const next = new Set(effective);
|
||||
if (next.has(v)) next.delete(v); else next.add(v);
|
||||
onChange(next);
|
||||
};
|
||||
const selectAll = () => onChange(new Set(values));
|
||||
const deselectAll = () => onChange(new Set());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="값 검색..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="flex gap-1 text-[11px]">
|
||||
<button type="button" onClick={selectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted">전체</button>
|
||||
<button type="button" onClick={deselectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted">해제</button>
|
||||
<button type="button" onClick={onClear} className="flex-1 rounded border px-1 py-1 hover:bg-muted">초기화</button>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto rounded border">
|
||||
{filtered.map((v) => {
|
||||
const checked = effective.has(v);
|
||||
return (
|
||||
<label
|
||||
key={v}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(v)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate text-xs">{v}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">결과 없음</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{selected === null ? "전체 표시" : `${effective.size} / ${values.length} 선택`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헤더 필터 - 메트릭(숫자) 컬럼 (min/max 범위)
|
||||
// ============================================
|
||||
function MetricFilterContent({
|
||||
range,
|
||||
onApply,
|
||||
onClear,
|
||||
}: {
|
||||
range?: { min?: number; max?: number };
|
||||
onApply: (r: { min?: number; max?: number }) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [minInput, setMinInput] = useState(range?.min !== undefined ? String(range.min) : "");
|
||||
const [maxInput, setMaxInput] = useState(range?.max !== undefined ? String(range.max) : "");
|
||||
const apply = () => {
|
||||
const minRaw = minInput.trim();
|
||||
const maxRaw = maxInput.trim();
|
||||
const minNum = minRaw === "" ? undefined : Number(minRaw);
|
||||
const maxNum = maxRaw === "" ? undefined : Number(maxRaw);
|
||||
onApply({
|
||||
min: minNum !== undefined && !isNaN(minNum) ? minNum : undefined,
|
||||
max: maxNum !== undefined && !isNaN(maxNum) ? maxNum : undefined,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">최소값 (이상)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={minInput}
|
||||
onChange={(e) => setMinInput(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">최대값 (이하)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxInput}
|
||||
onChange={(e) => setMaxInput(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="예: 10000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={apply}
|
||||
className="flex-1 rounded bg-primary py-1 text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMinInput(""); setMaxInput(""); onClear(); }}
|
||||
className="flex-1 rounded border py-1 hover:bg-muted"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ReportEngine 컴포넌트
|
||||
// ============================================
|
||||
@@ -419,6 +555,9 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
const [tableSearchQuery, setTableSearchQuery] = useState("");
|
||||
const [tableSortColumn, setTableSortColumn] = useState<string | null>(null);
|
||||
const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc");
|
||||
// 집계 테이블 헤더 필터: 라벨(그룹) 컬럼은 값 체크박스, 메트릭 컬럼은 숫자 범위
|
||||
const [labelColumnFilter, setLabelColumnFilter] = useState<Set<string> | null>(null);
|
||||
const [metricColumnFilters, setMetricColumnFilters] = useState<Record<number, { min?: number; max?: number }>>({});
|
||||
// 집계 테이블 그룹핑 (1차 그룹으로 묶기)
|
||||
const [tableGrouped, setTableGrouped] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
@@ -878,13 +1017,44 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
return { series: seriesList, labels, chartData };
|
||||
}, [rawData, conditions, groupBy, extraGroupBys, filterFields, config.metrics]);
|
||||
|
||||
// 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 적용) — 미리 계산된 values 사용
|
||||
// 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 + 헤더 필터 적용) — 미리 계산된 values 사용
|
||||
const displayLabels = useMemo(() => {
|
||||
let list = analysisResult.labels;
|
||||
if (tableSearchQuery) {
|
||||
const q = tableSearchQuery.toLowerCase();
|
||||
list = list.filter((l) => l.toLowerCase().includes(q));
|
||||
}
|
||||
// 라벨 컬럼 필터 (체크박스 선택값만 통과)
|
||||
if (labelColumnFilter) {
|
||||
const sel = labelColumnFilter;
|
||||
list = list.filter((l) => {
|
||||
if (tableGrouped) {
|
||||
const parts = l.split(" / ");
|
||||
if (parts.length >= 2) {
|
||||
const primary = parts[primaryGroupIndex] ?? parts[0] ?? l;
|
||||
return sel.has(primary);
|
||||
}
|
||||
}
|
||||
return sel.has(l);
|
||||
});
|
||||
}
|
||||
// 메트릭 컬럼 필터 (AND 조건, min/max 범위)
|
||||
const activeMetricKeys = Object.keys(metricColumnFilters);
|
||||
if (activeMetricKeys.length > 0) {
|
||||
list = list.filter((l) => {
|
||||
for (const key of activeMetricKeys) {
|
||||
const range = metricColumnFilters[Number(key)];
|
||||
if (!range) continue;
|
||||
if (range.min === undefined && range.max === undefined) continue;
|
||||
const s = analysisResult.series[Number(key)];
|
||||
if (!s) continue;
|
||||
const v = s.values[l] || 0;
|
||||
if (range.min !== undefined && v < range.min) return false;
|
||||
if (range.max !== undefined && v > range.max) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (tableSortColumn !== null) {
|
||||
list = [...list].sort((a, b) => {
|
||||
if (tableSortColumn === "__label__") {
|
||||
@@ -900,7 +1070,39 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [analysisResult, tableSearchQuery, tableSortColumn, tableSortDirection]);
|
||||
}, [
|
||||
analysisResult,
|
||||
tableSearchQuery,
|
||||
tableSortColumn,
|
||||
tableSortDirection,
|
||||
labelColumnFilter,
|
||||
metricColumnFilters,
|
||||
tableGrouped,
|
||||
primaryGroupIndex,
|
||||
]);
|
||||
|
||||
// 라벨 필터 고유값 (그룹핑 상태에 따라 primary part 또는 전체 라벨 기준)
|
||||
const labelFilterUniqueValues = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const l of analysisResult.labels) {
|
||||
if (tableGrouped) {
|
||||
const parts = l.split(" / ");
|
||||
if (parts.length >= 2) {
|
||||
set.add(parts[primaryGroupIndex] ?? parts[0] ?? l);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set.add(l);
|
||||
}
|
||||
return Array.from(set).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })
|
||||
);
|
||||
}, [analysisResult.labels, tableGrouped, primaryGroupIndex]);
|
||||
|
||||
// 그룹 기준 변경 시 기존 라벨 필터는 리셋 (값 기준이 달라지므로)
|
||||
useEffect(() => {
|
||||
setLabelColumnFilter(null);
|
||||
}, [tableGrouped, primaryGroupIndex, groupBy, extraGroupBys]);
|
||||
|
||||
const toggleTableSort = (col: string) => {
|
||||
if (tableSortColumn === col) {
|
||||
@@ -1738,42 +1940,109 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th
|
||||
className="p-2 text-left text-xs font-medium text-muted-foreground cursor-pointer hover:bg-muted/50 select-none"
|
||||
onClick={() => toggleTableSort("__label__")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex]
|
||||
? `${currentGroupParts[primaryGroupIndex]}별`
|
||||
: (currentGroupParts.length > 0
|
||||
? currentGroupParts.map((p) => `${p}별`).join(" × ")
|
||||
: config.groupByOptions.find((o) => o.id === groupBy)?.name)}
|
||||
{tableSortColumn === "__label__" && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</span>
|
||||
<th className="p-2 text-left text-xs font-medium text-muted-foreground select-none">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => toggleTableSort("__label__")}
|
||||
>
|
||||
{tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex]
|
||||
? `${currentGroupParts[primaryGroupIndex]}별`
|
||||
: (currentGroupParts.length > 0
|
||||
? currentGroupParts.map((p) => `${p}별`).join(" × ")
|
||||
: config.groupByOptions.find((o) => o.id === groupBy)?.name)}
|
||||
{tableSortColumn === "__label__" && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
|
||||
labelColumnFilter && "bg-primary/10 text-primary"
|
||||
)}
|
||||
title="값 필터"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
<LabelFilterContent
|
||||
values={labelFilterUniqueValues}
|
||||
selected={labelColumnFilter}
|
||||
onChange={setLabelColumnFilter}
|
||||
onClear={() => setLabelColumnFilter(null)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
{analysisResult.series.map((s, si) => {
|
||||
const colKey = String(si);
|
||||
const hasFilter = !!metricColumnFilters[si];
|
||||
return (
|
||||
<th
|
||||
key={si}
|
||||
className="p-2 text-right text-xs font-medium cursor-pointer hover:bg-muted/50 select-none"
|
||||
className="p-2 text-right text-xs font-medium select-none"
|
||||
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
|
||||
onClick={() => toggleTableSort(colKey)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end w-full">
|
||||
<span>
|
||||
{s.condName}
|
||||
<br />
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{s.metricName}({aggLabel(s.aggMethod)})
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 justify-end text-right hover:text-foreground"
|
||||
onClick={() => toggleTableSort(colKey)}
|
||||
>
|
||||
<span>
|
||||
{s.condName}
|
||||
<br />
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{s.metricName}({aggLabel(s.aggMethod)})
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{tableSortColumn === colKey && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</span>
|
||||
{tableSortColumn === colKey && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
|
||||
hasFilter && "bg-primary/10 text-primary"
|
||||
)}
|
||||
title="범위 필터"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-60 p-2" align="end">
|
||||
<MetricFilterContent
|
||||
range={metricColumnFilters[si]}
|
||||
onApply={(r) =>
|
||||
setMetricColumnFilters((prev) => {
|
||||
if (r.min === undefined && r.max === undefined) {
|
||||
const next = { ...prev };
|
||||
delete next[si];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [si]: r };
|
||||
})
|
||||
}
|
||||
onClear={() =>
|
||||
setMetricColumnFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[si];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -387,6 +387,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_30/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_30/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_30/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/bom": dynamic(() => import("@/app/(main)/COMPANY_30/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_30/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_30/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -614,6 +615,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
|
||||
"/COMPANY_30/production/work-instruction": () => import("@/app/(main)/COMPANY_30/production/work-instruction/page"),
|
||||
"/COMPANY_30/production/plan-management": () => import("@/app/(main)/COMPANY_30/production/plan-management/page"),
|
||||
"/COMPANY_30/production/bom": () => import("@/app/(main)/COMPANY_30/production/bom/page"),
|
||||
"/COMPANY_30/production/cutting-plan": () => import("@/app/(main)/COMPANY_30/production/cutting-plan/page"),
|
||||
"/COMPANY_30/equipment/info": () => import("@/app/(main)/COMPANY_30/equipment/info/page"),
|
||||
"/COMPANY_30/equipment/plc-settings": () => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"),
|
||||
"/COMPANY_30/monitoring/production": () => import("@/app/(main)/COMPANY_30/monitoring/production/page"),
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
/**
|
||||
* 절단 배치(Bin Packing) 알고리즘
|
||||
* - 면적형 2D: 2-stage Guillotine (FFDH 변형) — 직선 절단 보장 (유리/판재 절단)
|
||||
* - 면적형 동일품목 우선 배치 — 각 품목별로 독립된 원판 (다른 품목과 안 섞임)
|
||||
* - 길이형 1D: FFD (packLength)
|
||||
*/
|
||||
|
||||
export type CutType = "area" | "length";
|
||||
export type PackMode = "mixed" | "homo";
|
||||
export type Dir = "무관" | "가로" | "세로";
|
||||
|
||||
export interface Material {
|
||||
code: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
length?: number;
|
||||
stock?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface PlanItem {
|
||||
name: string; // 품목명 (item_info.item_name)
|
||||
code?: string; // 품목코드 (item_info.item_number)
|
||||
item_id?: string; // item_info.id (cutting_plan_item.item_id 저장용)
|
||||
width: number;
|
||||
height: number;
|
||||
length?: number;
|
||||
qty: number;
|
||||
dir: Dir;
|
||||
color: string;
|
||||
placed?: number;
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
color: string;
|
||||
name: string;
|
||||
itemIdx: number;
|
||||
rotated: boolean;
|
||||
}
|
||||
|
||||
export interface Shelf {
|
||||
y: number;
|
||||
h: number;
|
||||
currentX: number;
|
||||
}
|
||||
|
||||
export interface RemnantItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
status: "keep" | "discard";
|
||||
}
|
||||
|
||||
export interface Sheet {
|
||||
id: number;
|
||||
matW: number;
|
||||
matH: number;
|
||||
matCode: string;
|
||||
matName: string;
|
||||
shelves: Shelf[];
|
||||
placements: Placement[];
|
||||
// 사용자가 편집한 자투리. undefined이면 placements로부터 자동 추출 (extractInitialRemnants).
|
||||
remnants?: RemnantItem[];
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
len: number;
|
||||
color: string;
|
||||
name: string;
|
||||
itemIdx: number;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
export interface Pipe {
|
||||
id: number;
|
||||
matLen: number;
|
||||
matCode: string;
|
||||
matName: string;
|
||||
remaining: number;
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
export interface SheetGroup {
|
||||
count: number;
|
||||
repIdx: number;
|
||||
representative: Sheet;
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
export interface PipeGroup {
|
||||
count: number;
|
||||
repIdx: number;
|
||||
representative: Pipe;
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
export interface AreaResult {
|
||||
sheets: Sheet[];
|
||||
sheetGroups?: SheetGroup[];
|
||||
}
|
||||
|
||||
export interface LengthResult {
|
||||
pipes: Pipe[];
|
||||
pipeGroups?: PipeGroup[];
|
||||
}
|
||||
|
||||
export const COLORS = [
|
||||
"#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", "#ef4444",
|
||||
"#06b6d4", "#ec4899", "#84cc16", "#f97316", "#0ea5e9",
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 면적형 2D 배치 — 2-stage Guillotine (FFDH 기반)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 모든 절단선이 원판 가장자리에서 가장자리까지 직선으로 이어지도록 보장.
|
||||
// 1단계: 원판을 가로 shelf로 분할 (shelf 간 = 가로 직선 절단)
|
||||
// 2단계: 각 shelf 안에서 piece들을 좌→우 배치 (shelf 내 = 세로 직선 절단)
|
||||
|
||||
type PieceInput = {
|
||||
w: number; h: number; canRot: boolean;
|
||||
color: string; name: string; itemIdx: number;
|
||||
};
|
||||
|
||||
type FitResult = { w: number; h: number; rotated: boolean };
|
||||
|
||||
// shelf 시작 piece용 — 둘 다 fit이면 height 큰 쪽 (shelf height 최대화)
|
||||
function fitForShelfStart(p: PieceInput, maxH: number, maxW: number): FitResult | null {
|
||||
const a = p.w <= maxW && p.h <= maxH ? { w: p.w, h: p.h, rotated: false } : null;
|
||||
const b = p.canRot && p.h <= maxW && p.w <= maxH ? { w: p.h, h: p.w, rotated: true } : null;
|
||||
if (a && b) return a.h >= b.h ? a : b;
|
||||
return a || b;
|
||||
}
|
||||
|
||||
// shelf 내부 piece용 — 둘 다 fit이면 width 작은 쪽 (남은 width 보존)
|
||||
function fitInShelf(p: PieceInput, shelfH: number, availW: number): FitResult | null {
|
||||
const a = p.w <= availW && p.h <= shelfH ? { w: p.w, h: p.h, rotated: false } : null;
|
||||
const b = p.canRot && p.h <= availW && p.w <= shelfH ? { w: p.h, h: p.w, rotated: true } : null;
|
||||
if (a && b) return a.w <= b.w ? a : b;
|
||||
return a || b;
|
||||
}
|
||||
|
||||
function packGuillotineSingle(
|
||||
primary: Material, pieces: PieceInput[], kerf: number,
|
||||
sortFn: (a: PieceInput, b: PieceInput) => number, startId: number
|
||||
): Sheet[] {
|
||||
const matW = primary.width || 0;
|
||||
const matH = primary.height || 0;
|
||||
if (!matW || !matH || !pieces.length) return [];
|
||||
|
||||
const remaining = [...pieces].sort(sortFn);
|
||||
const sheets: Sheet[] = [];
|
||||
let sheetId = startId;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const sheet: Sheet = {
|
||||
id: sheetId++, matW, matH,
|
||||
matCode: primary.code, matName: primary.name,
|
||||
shelves: [], placements: [],
|
||||
};
|
||||
let currentY = 0;
|
||||
let placedAny = false;
|
||||
|
||||
while (currentY < matH) {
|
||||
const remH = matH - currentY;
|
||||
let firstIdx = -1;
|
||||
let firstFit: FitResult | null = null;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const fit = fitForShelfStart(remaining[i], remH, matW);
|
||||
if (fit) { firstIdx = i; firstFit = fit; break; }
|
||||
}
|
||||
if (firstIdx === -1 || !firstFit) break;
|
||||
|
||||
const first = remaining[firstIdx];
|
||||
const shelfH = firstFit.h;
|
||||
sheet.placements.push({
|
||||
x: 0, y: currentY, w: firstFit.w, h: firstFit.h,
|
||||
color: first.color, name: first.name, itemIdx: first.itemIdx,
|
||||
rotated: firstFit.rotated,
|
||||
});
|
||||
let currentX = firstFit.w + kerf;
|
||||
remaining.splice(firstIdx, 1);
|
||||
placedAny = true;
|
||||
|
||||
let i = 0;
|
||||
while (i < remaining.length) {
|
||||
const availW = matW - currentX;
|
||||
if (availW <= 0) break;
|
||||
const fit = fitInShelf(remaining[i], shelfH, availW);
|
||||
if (!fit) { i++; continue; }
|
||||
const p = remaining[i];
|
||||
sheet.placements.push({
|
||||
x: currentX, y: currentY, w: fit.w, h: fit.h,
|
||||
color: p.color, name: p.name, itemIdx: p.itemIdx,
|
||||
rotated: fit.rotated,
|
||||
});
|
||||
currentX += fit.w + kerf;
|
||||
remaining.splice(i, 1);
|
||||
}
|
||||
|
||||
currentY += shelfH + kerf;
|
||||
}
|
||||
|
||||
if (!placedAny) break;
|
||||
sheets.push(sheet);
|
||||
}
|
||||
|
||||
return sheets;
|
||||
}
|
||||
|
||||
// 4가지 정렬 전략 비교 후 best 선택 (원판 수 최소 → 이용률 최대)
|
||||
function runGuillotine(primary: Material, pieces: PieceInput[], kerf: number, startId: number = 1): Sheet[] {
|
||||
const matW = primary.width || 0;
|
||||
const matH = primary.height || 0;
|
||||
if (!matW || !matH || !pieces.length) return [];
|
||||
const totalArea = matW * matH;
|
||||
|
||||
const sorts = [
|
||||
(a: PieceInput, b: PieceInput) => Math.max(b.w, b.h) - Math.max(a.w, a.h), // 긴 변 큰 순
|
||||
(a: PieceInput, b: PieceInput) => b.h - a.h || b.w - a.w, // 높이 큰 순
|
||||
(a: PieceInput, b: PieceInput) => b.w - a.w || b.h - a.h, // 폭 큰 순
|
||||
(a: PieceInput, b: PieceInput) => b.w * b.h - a.w * a.h, // 면적 큰 순
|
||||
];
|
||||
|
||||
let best: Sheet[] = [];
|
||||
let bestScore: [number, number] = [Infinity, -Infinity];
|
||||
|
||||
for (const sortFn of sorts) {
|
||||
const sheets = packGuillotineSingle(primary, pieces, kerf, sortFn, startId);
|
||||
if (!sheets.length) continue;
|
||||
const count = sheets.length;
|
||||
const usedArea = sheets.reduce((s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0);
|
||||
const util = usedArea / (count * totalArea);
|
||||
const score: [number, number] = [count, -util];
|
||||
if (
|
||||
score[0] < bestScore[0] ||
|
||||
(score[0] === bestScore[0] && score[1] < bestScore[1])
|
||||
) {
|
||||
best = sheets;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function expandPieces(items: PlanItem[]): PieceInput[] {
|
||||
const out: PieceInput[] = [];
|
||||
items.forEach((item, itemIdx) => {
|
||||
const qty = parseInt(String(item.qty)) || 0;
|
||||
for (let i = 0; i < qty; i++) {
|
||||
out.push({
|
||||
w: item.width, h: item.height,
|
||||
canRot: item.dir === "무관",
|
||||
color: item.color, name: item.name, itemIdx,
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// packArea — 혼합 최적 (모든 품목 합쳐서 guillotine packing)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
export function packArea(mats: Material[], items: PlanItem[], kerf: number): AreaResult {
|
||||
if (!mats.length || !items.length) return { sheets: [] };
|
||||
const primary = mats[0];
|
||||
const pieces = expandPieces(items);
|
||||
return { sheets: runGuillotine(primary, pieces, kerf, 1) };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// packAreaHomogeneous — 동일 품목 우선 + 잔여는 혼합 최적
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 1) 각 품목별로 한 판 가득 채울 수 있는 만큼 단독 판 생성 (floor(qty / maxCap))
|
||||
// 2) 가득 못 채우는 잔여 수량은 모든 품목 모아서 혼합 packing (효율 추구)
|
||||
export function packAreaHomogeneous(mats: Material[], items: PlanItem[], kerf: number): AreaResult {
|
||||
if (!mats.length || !items.length) return { sheets: [] };
|
||||
const primary = mats[0];
|
||||
const matW = primary.width || 0;
|
||||
const matH = primary.height || 0;
|
||||
if (!matW || !matH) return { sheets: [] };
|
||||
|
||||
const allSheets: Sheet[] = [];
|
||||
let sheetId = 1;
|
||||
const remQty = items.map((i) => parseInt(String(i.qty)) || 0);
|
||||
|
||||
// 1) 각 품목 가득 찬 단독 판 생성
|
||||
items.forEach((item, idx) => {
|
||||
const qty = remQty[idx];
|
||||
if (!qty || !item.width || !item.height) return;
|
||||
|
||||
// 실제 maxCap 측정 — runGuillotine 8전략으로 한 판 최대 수용량
|
||||
const itemArea = item.width * item.height;
|
||||
const upper = Math.max(1, Math.ceil((matW * matH) / itemArea) + 5);
|
||||
const testPieces: PieceInput[] = [];
|
||||
const trialN = Math.min(qty, upper);
|
||||
for (let i = 0; i < trialN; i++) {
|
||||
testPieces.push({
|
||||
w: item.width, h: item.height,
|
||||
canRot: item.dir === "무관",
|
||||
color: item.color, name: item.name, itemIdx: idx,
|
||||
});
|
||||
}
|
||||
const trialSheets = runGuillotine(primary, testPieces, kerf, 0);
|
||||
const maxCap = trialSheets[0]?.placements.length || 0;
|
||||
if (maxCap === 0) return;
|
||||
|
||||
const fullSheets = Math.floor(qty / maxCap);
|
||||
if (fullSheets === 0) return; // 한 판도 못 채움 → 전부 잔여로
|
||||
|
||||
const usedQty = fullSheets * maxCap;
|
||||
const fullPieces: PieceInput[] = [];
|
||||
for (let i = 0; i < usedQty; i++) {
|
||||
fullPieces.push({
|
||||
w: item.width, h: item.height,
|
||||
canRot: item.dir === "무관",
|
||||
color: item.color, name: item.name, itemIdx: idx,
|
||||
});
|
||||
}
|
||||
const itemSheets = runGuillotine(primary, fullPieces, kerf, sheetId);
|
||||
sheetId += itemSheets.length;
|
||||
allSheets.push(...itemSheets);
|
||||
remQty[idx] = qty - usedQty;
|
||||
});
|
||||
|
||||
// 2) 잔여 수량을 모아서 혼합 packing (효율 추구)
|
||||
const remPieces: PieceInput[] = [];
|
||||
items.forEach((item, idx) => {
|
||||
const q = remQty[idx];
|
||||
if (!q || !item.width || !item.height) return;
|
||||
for (let i = 0; i < q; i++) {
|
||||
remPieces.push({
|
||||
w: item.width, h: item.height,
|
||||
canRot: item.dir === "무관",
|
||||
color: item.color, name: item.name, itemIdx: idx,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (remPieces.length) {
|
||||
const mixSheets = runGuillotine(primary, remPieces, kerf, sheetId);
|
||||
allSheets.push(...mixSheets);
|
||||
}
|
||||
|
||||
return { sheets: allSheets };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 자투리 형태 추출 (직사각형 단위)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 원판에서 빈 공간을 직사각형 자투리들로 분할.
|
||||
// guillotine 배치 결과를 가정하고 shelf 우측 / shelf 내부 piece 위 / 마지막 shelf 아래 자투리 추출.
|
||||
|
||||
export interface Remnant {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export function computeRemnants(sheet: Sheet, kerf: number = 0): Remnant[] {
|
||||
const matW = sheet.matW;
|
||||
const matH = sheet.matH;
|
||||
if (!sheet.placements.length) {
|
||||
return [{ x: 0, y: 0, w: matW, h: matH }];
|
||||
}
|
||||
|
||||
// piece 점유 영역을 kerf 만큼 확장 (칼날 폭은 자투리 아님)
|
||||
// 가장자리(0, matW, matH)는 자르지 않으므로 그쪽으로는 확장하지 않음
|
||||
const expanded = sheet.placements.map((p) => ({
|
||||
x: Math.max(0, p.x - kerf),
|
||||
y: Math.max(0, p.y - kerf),
|
||||
x2: Math.min(matW, p.x + p.w + kerf),
|
||||
y2: Math.min(matH, p.y + p.h + kerf),
|
||||
}));
|
||||
|
||||
// 1) 모든 piece의 y 경계로 horizontal strip 분할 (확장된 좌표)
|
||||
const ySet = new Set<number>([0, matH]);
|
||||
for (const p of expanded) {
|
||||
if (p.y > 0 && p.y < matH) ySet.add(p.y);
|
||||
if (p.y2 > 0 && p.y2 < matH) ySet.add(p.y2);
|
||||
}
|
||||
const ys = [...ySet].sort((a, b) => a - b);
|
||||
|
||||
// 2) 각 strip 안에서 piece가 차지하지 않는 x 구간 추출
|
||||
const stripRects: Remnant[] = [];
|
||||
for (let i = 0; i < ys.length - 1; i++) {
|
||||
const y1 = ys[i];
|
||||
const y2 = ys[i + 1];
|
||||
const sh = y2 - y1;
|
||||
if (sh < 1) continue;
|
||||
|
||||
// 이 strip에 걸치는 piece들의 x 범위 (확장된 좌표)
|
||||
const occupied: { x1: number; x2: number }[] = [];
|
||||
for (const p of expanded) {
|
||||
if (p.y < y2 && p.y2 > y1) {
|
||||
occupied.push({ x1: p.x, x2: p.x2 });
|
||||
}
|
||||
}
|
||||
occupied.sort((a, b) => a.x1 - b.x1);
|
||||
|
||||
// 빈 x 구간 찾기 (occupied 사이의 gap + 좌우 끝)
|
||||
let cursor = 0;
|
||||
for (const o of occupied) {
|
||||
if (o.x1 > cursor + 0.5) {
|
||||
stripRects.push({ x: cursor, y: y1, w: o.x1 - cursor, h: sh });
|
||||
}
|
||||
cursor = Math.max(cursor, o.x2);
|
||||
}
|
||||
if (cursor < matW - 0.5) {
|
||||
stripRects.push({ x: cursor, y: y1, w: matW - cursor, h: sh });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 세로 인접 + 같은 x 범위 자투리 합치기 (반복 적용)
|
||||
const verticallyMerged = mergeAdjacent(stripRects, "vertical");
|
||||
// 4) 가로 인접 + 같은 y 범위 자투리 합치기 (위에서 합쳐진 결과 위에)
|
||||
const finalRects = mergeAdjacent(verticallyMerged, "horizontal");
|
||||
|
||||
// 너무 좁은 조각 제거 (kerf 이하는 절단 시 사라지는 영역, 미세 오차)
|
||||
const minDim = Math.max(1, kerf);
|
||||
return finalRects.filter((r) => r.w > minDim && r.h > minDim);
|
||||
}
|
||||
|
||||
// 인접 사각형 합치기 — direction별로 같은 축 정렬되고 인접하면 병합
|
||||
function mergeAdjacent(rects: Remnant[], direction: "vertical" | "horizontal"): Remnant[] {
|
||||
const out: Remnant[] = rects.map((r) => ({ ...r }));
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let i = 0; i < out.length && !changed; i++) {
|
||||
for (let j = i + 1; j < out.length; j++) {
|
||||
const a = out[i];
|
||||
const b = out[j];
|
||||
if (direction === "vertical") {
|
||||
// 같은 x 범위 + 세로 인접 (a 아래에 b 또는 b 아래에 a)
|
||||
if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) {
|
||||
if (Math.abs(a.y + a.h - b.y) < 0.5) {
|
||||
out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (Math.abs(b.y + b.h - a.y) < 0.5) {
|
||||
out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 같은 y 범위 + 가로 인접
|
||||
if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) {
|
||||
if (Math.abs(a.x + a.w - b.x) < 0.5) {
|
||||
out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (Math.abs(b.x + b.w - a.x) < 0.5) {
|
||||
out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// computeRemnants 결과에 ID + 기본 status를 부여해서 RemnantItem[]로 변환.
|
||||
// 사용자가 자투리를 편집하지 않은 sheet에서 초기값 생성용.
|
||||
export function extractInitialRemnants(sheet: Sheet, idPrefix: string = "", kerf: number = 0): RemnantItem[] {
|
||||
return computeRemnants(sheet, kerf).map((rm, i) => ({
|
||||
id: `${idPrefix}r${i}`,
|
||||
x: rm.x,
|
||||
y: rm.y,
|
||||
w: rm.w,
|
||||
h: rm.h,
|
||||
status: "discard",
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 자투리 그룹화 — 변 공유하는 자투리들을 한 덩어리로 묶음 (Union-Find)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface RemnantGroup {
|
||||
groupId: number;
|
||||
rects: RemnantItem[];
|
||||
}
|
||||
|
||||
function sharesEdgeBox(
|
||||
a: { x: number; y: number; w: number; h: number },
|
||||
b: { x: number; y: number; w: number; h: number }
|
||||
): boolean {
|
||||
const eps = 0.5;
|
||||
// 가로 인접 (수직 변 공유)
|
||||
const horizAdj = Math.abs(a.x + a.w - b.x) < eps || Math.abs(b.x + b.w - a.x) < eps;
|
||||
const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
||||
if (horizAdj && yOverlap > eps) return true;
|
||||
// 세로 인접 (수평 변 공유)
|
||||
const vertAdj = Math.abs(a.y + a.h - b.y) < eps || Math.abs(b.y + b.h - a.y) < eps;
|
||||
const xOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
||||
if (vertAdj && xOverlap > eps) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeRemnantGroups(rects: RemnantItem[]): RemnantGroup[] {
|
||||
const n = rects.length;
|
||||
if (n === 0) return [];
|
||||
const parent = Array.from({ length: n }, (_, i) => i);
|
||||
const find = (i: number): number => {
|
||||
while (parent[i] !== i) {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
return i;
|
||||
};
|
||||
const union = (i: number, j: number) => {
|
||||
const ri = find(i), rj = find(j);
|
||||
if (ri !== rj) parent[ri] = rj;
|
||||
};
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (sharesEdgeBox(rects[i], rects[j])) union(i, j);
|
||||
}
|
||||
}
|
||||
const groupMap = new Map<number, RemnantItem[]>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const root = find(i);
|
||||
const arr = groupMap.get(root) || [];
|
||||
arr.push(rects[i]);
|
||||
groupMap.set(root, arr);
|
||||
}
|
||||
return [...groupMap.values()].map((arr, gi) => ({ groupId: gi, rects: arr }));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 그룹 외곽선 (polygon) 계산 — 사각형들의 union outline
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 알고리즘: 모든 사각형의 4변을 방향성(시계방향) segment로 추출 →
|
||||
// 격자점 기준 unit segment로 분해 → 같은 위치 반대 방향 segment 쌍 제거 →
|
||||
// 남은 segment를 head-tail 매칭으로 polygon path 구성 (구멍 포함 가능).
|
||||
|
||||
export interface OutlinePath {
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
export function computeGroupOutline(rects: { x: number; y: number; w: number; h: number }[]): OutlinePath[] {
|
||||
if (rects.length === 0) return [];
|
||||
|
||||
// 1) x, y 격자 좌표 모음
|
||||
const xSet = new Set<number>();
|
||||
const ySet = new Set<number>();
|
||||
rects.forEach((r) => {
|
||||
xSet.add(r.x); xSet.add(r.x + r.w);
|
||||
ySet.add(r.y); ySet.add(r.y + r.h);
|
||||
});
|
||||
const xs = [...xSet].sort((a, b) => a - b);
|
||||
const ys = [...ySet].sort((a, b) => a - b);
|
||||
const xi = (x: number) => xs.indexOf(x);
|
||||
const yi = (y: number) => ys.indexOf(y);
|
||||
|
||||
// 2) 격자 셀 occupancy (셀 [i, j] 가 채워져 있는지)
|
||||
const occ: boolean[][] = [];
|
||||
for (let j = 0; j < ys.length - 1; j++) {
|
||||
const row: boolean[] = [];
|
||||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||||
const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h);
|
||||
row.push(o);
|
||||
}
|
||||
occ.push(row);
|
||||
}
|
||||
|
||||
// 3) 각 셀의 4변 중 외부 경계인 것만 directed edge로 등록 (시계방향)
|
||||
// 격자점은 (xs[i], ys[j]). 셀 [i, j]는 (xs[i], ys[j])-(xs[i+1], ys[j+1]).
|
||||
// 셀이 채워져 있고 인접 셀이 비어있으면 그 변은 외곽.
|
||||
type EdgeKey = string;
|
||||
const edges = new Map<EdgeKey, { from: [number, number]; to: [number, number] }>();
|
||||
const addEdge = (fi: number, fj: number, ti: number, tj: number) => {
|
||||
const key = `${fi},${fj}-${ti},${tj}`;
|
||||
edges.set(key, { from: [fi, fj], to: [ti, tj] });
|
||||
};
|
||||
const cellOcc = (i: number, j: number) =>
|
||||
j >= 0 && j < occ.length && i >= 0 && i < occ[0].length ? occ[j][i] : false;
|
||||
|
||||
for (let j = 0; j < occ.length; j++) {
|
||||
for (let i = 0; i < occ[0].length; i++) {
|
||||
if (!occ[j][i]) continue;
|
||||
// top edge (셀 위쪽이 비어있음 → 시계방향: left→right at y=ys[j])
|
||||
if (!cellOcc(i, j - 1)) addEdge(i, j, i + 1, j);
|
||||
// right edge (셀 오른쪽이 비어있음 → 시계방향: top→bottom at x=xs[i+1])
|
||||
if (!cellOcc(i + 1, j)) addEdge(i + 1, j, i + 1, j + 1);
|
||||
// bottom edge (셀 아래가 비어있음 → 시계방향: right→left at y=ys[j+1])
|
||||
if (!cellOcc(i, j + 1)) addEdge(i + 1, j + 1, i, j + 1);
|
||||
// left edge (셀 왼쪽이 비어있음 → 시계방향: bottom→top at x=xs[i])
|
||||
if (!cellOcc(i - 1, j)) addEdge(i, j + 1, i, j);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 같은 from 격자점에서 시작하는 edge 인덱스
|
||||
const fromMap = new Map<string, { from: [number, number]; to: [number, number] }[]>();
|
||||
edges.forEach((e) => {
|
||||
const k = `${e.from[0]},${e.from[1]}`;
|
||||
const arr = fromMap.get(k) || [];
|
||||
arr.push(e);
|
||||
fromMap.set(k, arr);
|
||||
});
|
||||
|
||||
// 5) edge들을 head-tail로 연결해서 닫힌 polygon들 추출
|
||||
const paths: OutlinePath[] = [];
|
||||
const used = new Set<string>();
|
||||
edges.forEach((startEdge, startKey) => {
|
||||
if (used.has(startKey)) return;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
let cur = startEdge;
|
||||
let curKey = startKey;
|
||||
while (cur && !used.has(curKey)) {
|
||||
used.add(curKey);
|
||||
points.push({ x: xs[cur.from[0]], y: ys[cur.from[1]] });
|
||||
const nextKeyPrefix = `${cur.to[0]},${cur.to[1]}`;
|
||||
const candidates = fromMap.get(nextKeyPrefix) || [];
|
||||
const next = candidates.find((e) => !used.has(`${e.from[0]},${e.from[1]}-${e.to[0]},${e.to[1]}`));
|
||||
if (!next) break;
|
||||
cur = next;
|
||||
curKey = `${next.from[0]},${next.from[1]}-${next.to[0]},${next.to[1]}`;
|
||||
}
|
||||
if (points.length >= 3) paths.push({ points: simplifyCollinear(points) });
|
||||
});
|
||||
return paths;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 자투리 그룹 재분할 — 그룹의 union 영역을 다른 전략으로 직사각형 분해
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// strategy:
|
||||
// "h" — 가로 strip 우선 (기본 자동 추출과 같음, 세로 인접 합침)
|
||||
// "v" — 세로 strip 우선 (가로 인접 합침)
|
||||
// "max" — Maximum Empty Rectangle 반복 (가장 큰 사각형부터 추출)
|
||||
|
||||
export function decomposeUnion(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
strategy: "h" | "v" | "max"
|
||||
): { x: number; y: number; w: number; h: number }[] {
|
||||
if (rects.length === 0) return [];
|
||||
|
||||
// 격자 좌표
|
||||
const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b);
|
||||
const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b);
|
||||
|
||||
// 격자 occupancy
|
||||
const cols = xs.length - 1;
|
||||
const rows = ys.length - 1;
|
||||
const occ: boolean[][] = [];
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const row: boolean[] = [];
|
||||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||||
const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h);
|
||||
row.push(o);
|
||||
}
|
||||
occ.push(row);
|
||||
}
|
||||
|
||||
if (strategy === "h") {
|
||||
// 각 row strip에서 occupied 셀들을 가로 인접으로 합침
|
||||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||||
for (let j = 0; j < rows; j++) {
|
||||
let i = 0;
|
||||
while (i < cols) {
|
||||
if (!occ[j][i]) { i++; continue; }
|
||||
let i2 = i;
|
||||
while (i2 < cols && occ[j][i2]) i2++;
|
||||
out.push({ x: xs[i], y: ys[j], w: xs[i2] - xs[i], h: ys[j + 1] - ys[j] });
|
||||
i = i2;
|
||||
}
|
||||
}
|
||||
return mergeAdjacentLocal(out, "vertical");
|
||||
}
|
||||
|
||||
if (strategy === "v") {
|
||||
// 각 column strip에서 occupied 셀들을 세로 인접으로 합침
|
||||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||||
for (let i = 0; i < cols; i++) {
|
||||
let j = 0;
|
||||
while (j < rows) {
|
||||
if (!occ[j][i]) { j++; continue; }
|
||||
let j2 = j;
|
||||
while (j2 < rows && occ[j2][i]) j2++;
|
||||
out.push({ x: xs[i], y: ys[j], w: xs[i + 1] - xs[i], h: ys[j2] - ys[j] });
|
||||
j = j2;
|
||||
}
|
||||
}
|
||||
return mergeAdjacentLocal(out, "horizontal");
|
||||
}
|
||||
|
||||
// "max" — Maximum Empty Rectangle 반복
|
||||
const out: { x: number; y: number; w: number; h: number }[] = [];
|
||||
const occCopy = occ.map((row) => [...row]);
|
||||
while (true) {
|
||||
const best = findLargestRectangleInGrid(occCopy, xs, ys);
|
||||
if (!best || best.w * best.h < 1) break;
|
||||
out.push(best);
|
||||
// 사용한 영역 점유 해제
|
||||
for (let j = 0; j < rows; j++) {
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const cx = (xs[i] + xs[i + 1]) / 2;
|
||||
const cy = (ys[j] + ys[j + 1]) / 2;
|
||||
if (cx >= best.x && cx <= best.x + best.w && cy >= best.y && cy <= best.y + best.h) {
|
||||
occCopy[j][i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 격자에서 가장 큰 직사각형 (모든 셀 occupied) 찾기 — Histogram 기반 O(rows × cols)
|
||||
function findLargestRectangleInGrid(
|
||||
occ: boolean[][],
|
||||
xs: number[],
|
||||
ys: number[]
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
const rows = occ.length;
|
||||
if (rows === 0) return null;
|
||||
const cols = occ[0].length;
|
||||
if (cols === 0) return null;
|
||||
|
||||
// 각 셀 위쪽 연속 occupied 셀 수 (히스토그램)
|
||||
const heights: number[][] = [];
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const row: number[] = [];
|
||||
for (let i = 0; i < cols; i++) {
|
||||
row.push(occ[j][i] ? (j > 0 ? heights[j - 1][i] + 1 : 1) : 0);
|
||||
}
|
||||
heights.push(row);
|
||||
}
|
||||
|
||||
let bestArea = 0;
|
||||
let bestRect: { x: number; y: number; w: number; h: number } | null = null;
|
||||
|
||||
for (let j = 0; j < rows; j++) {
|
||||
// 각 행마다 히스토그램에서 가장 큰 직사각형 (실제 mm 면적 기준)
|
||||
const stack: number[] = [];
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cur = i === cols ? 0 : heights[j][i];
|
||||
while (stack.length && (i === cols || heights[j][stack[stack.length - 1]] > cur)) {
|
||||
const top = stack.pop()!;
|
||||
const left = stack.length ? stack[stack.length - 1] + 1 : 0;
|
||||
const right = i - 1;
|
||||
const cellH = heights[j][top];
|
||||
// 실제 좌표
|
||||
const x = xs[left];
|
||||
const w = xs[right + 1] - xs[left];
|
||||
const y = ys[j - cellH + 1];
|
||||
const h = ys[j + 1] - ys[j - cellH + 1];
|
||||
const area = w * h;
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
bestRect = { x, y, w, h };
|
||||
}
|
||||
}
|
||||
stack.push(i);
|
||||
}
|
||||
}
|
||||
return bestRect;
|
||||
}
|
||||
|
||||
// 인접 합치기 (decomposeUnion 전용 로컬 버전 — coord 비교만)
|
||||
function mergeAdjacentLocal(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
direction: "vertical" | "horizontal"
|
||||
): { x: number; y: number; w: number; h: number }[] {
|
||||
const out = rects.map((r) => ({ ...r }));
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let i = 0; i < out.length && !changed; i++) {
|
||||
for (let j = i + 1; j < out.length; j++) {
|
||||
const a = out[i];
|
||||
const b = out[j];
|
||||
if (direction === "vertical") {
|
||||
if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) {
|
||||
if (Math.abs(a.y + a.h - b.y) < 0.5) {
|
||||
out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (Math.abs(b.y + b.h - a.y) < 0.5) {
|
||||
out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) {
|
||||
if (Math.abs(a.x + a.w - b.x) < 0.5) {
|
||||
out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (Math.abs(b.x + b.w - a.x) < 0.5) {
|
||||
out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h };
|
||||
out.splice(j, 1);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 같은 직선 위 연속점 제거 (꼭짓점만 남김)
|
||||
function simplifyCollinear(points: { x: number; y: number }[]): { x: number; y: number }[] {
|
||||
if (points.length < 3) return points;
|
||||
const out: { x: number; y: number }[] = [];
|
||||
const n = points.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = points[(i - 1 + n) % n];
|
||||
const cur = points[i];
|
||||
const next = points[(i + 1) % n];
|
||||
const collinearH = prev.y === cur.y && cur.y === next.y;
|
||||
const collinearV = prev.x === cur.x && cur.x === next.x;
|
||||
if (!collinearH && !collinearV) out.push(cur);
|
||||
}
|
||||
return out.length >= 3 ? out : points;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 길이형 1D 배치 (FFD)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export function packLength(mats: Material[], items: PlanItem[], kerf: number): LengthResult {
|
||||
const pieces: { len: number; name: string; color: string; itemIdx: number }[] = [];
|
||||
items.forEach((item, idx) => {
|
||||
const q = parseInt(String(item.qty)) || 0;
|
||||
const len = item.length || 0;
|
||||
for (let i = 0; i < q; i++) pieces.push({ len, name: item.name, color: item.color, itemIdx: idx });
|
||||
});
|
||||
pieces.sort((a, b) => b.len - a.len);
|
||||
|
||||
const pipes: Pipe[] = [];
|
||||
for (const p of pieces) {
|
||||
let placed = false;
|
||||
for (const pipe of pipes) {
|
||||
if (pipe.remaining >= p.len) {
|
||||
pipe.segments.push({
|
||||
len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx,
|
||||
startX: pipe.matLen - pipe.remaining,
|
||||
});
|
||||
pipe.remaining -= p.len + kerf;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
const mat = mats.find((m) => m && (m.length || 0) >= p.len);
|
||||
if (!mat) continue;
|
||||
pipes.push({
|
||||
id: pipes.length + 1,
|
||||
matLen: mat.length || 0,
|
||||
matCode: mat.code,
|
||||
matName: mat.name,
|
||||
remaining: (mat.length || 0) - p.len - kerf,
|
||||
segments: [{ len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx, startX: 0 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return { pipes };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 동일 배치 그룹화
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export function computeSheetGroups(sheets: Sheet[]): SheetGroup[] {
|
||||
const groups: SheetGroup[] = [];
|
||||
const used = new Set<number>();
|
||||
sheets.forEach((sheet, si) => {
|
||||
if (used.has(si)) return;
|
||||
const indices = [si];
|
||||
sheets.forEach((other, oi) => {
|
||||
if (oi <= si || used.has(oi)) return;
|
||||
if (isSameSheetLayout(sheet, other)) { indices.push(oi); used.add(oi); }
|
||||
});
|
||||
used.add(si);
|
||||
groups.push({ count: indices.length, repIdx: si, representative: sheet, indices });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isSameSheetLayout(a: Sheet, b: Sheet): boolean {
|
||||
if (a.matCode !== b.matCode || a.placements.length !== b.placements.length) return false;
|
||||
return a.placements.every((pa, i) => {
|
||||
const pb = b.placements[i];
|
||||
return (
|
||||
pa.itemIdx === pb.itemIdx && pa.w === pb.w && pa.h === pb.h &&
|
||||
Math.abs(pa.x - pb.x) < 0.5 && Math.abs(pa.y - pb.y) < 0.5
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function computePipeGroups(pipes: Pipe[]): PipeGroup[] {
|
||||
const groups: PipeGroup[] = [];
|
||||
const used = new Set<number>();
|
||||
pipes.forEach((pipe, pi) => {
|
||||
if (used.has(pi)) return;
|
||||
const indices = [pi];
|
||||
pipes.forEach((other, oi) => {
|
||||
if (oi <= pi || used.has(oi)) return;
|
||||
if (isSamePipeLayout(pipe, other)) { indices.push(oi); used.add(oi); }
|
||||
});
|
||||
used.add(pi);
|
||||
groups.push({ count: indices.length, repIdx: pi, representative: pipe, indices });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isSamePipeLayout(a: Pipe, b: Pipe): boolean {
|
||||
if (a.matCode !== b.matCode || a.segments.length !== b.segments.length) return false;
|
||||
return a.segments.every((sa, i) => {
|
||||
const sb = b.segments[i];
|
||||
return sa.itemIdx === sb.itemIdx && sa.len === sb.len;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 통계
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export function computeAreaStats(result: AreaResult) {
|
||||
const sheets = result.sheets;
|
||||
const count = sheets.length;
|
||||
const totalPieces = sheets.reduce((s, sh) => s + sh.placements.length, 0);
|
||||
const rotated = sheets.reduce(
|
||||
(s, sh) => s + sh.placements.filter((p) => p.rotated).length, 0
|
||||
);
|
||||
const totalMat = sheets.reduce((s, sh) => s + sh.matW * sh.matH, 0);
|
||||
const usedArea = sheets.reduce(
|
||||
(s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0
|
||||
);
|
||||
const util = totalMat > 0 ? (usedArea / totalMat) * 100 : 0;
|
||||
const loss = totalMat - usedArea;
|
||||
return { count, totalPieces, rotated, util, loss, usedArea, totalMat };
|
||||
}
|
||||
|
||||
export function computeLengthStats(result: LengthResult) {
|
||||
const pipes = result.pipes;
|
||||
const count = pipes.length;
|
||||
const totalPieces = pipes.reduce((s, p) => s + p.segments.length, 0);
|
||||
const totalLen = pipes.reduce((s, p) => s + p.matLen, 0);
|
||||
const usedLen = pipes.reduce(
|
||||
(s, p) => s + p.segments.reduce((ss, seg) => ss + seg.len, 0), 0
|
||||
);
|
||||
const util = totalLen > 0 ? (usedLen / totalLen) * 100 : 0;
|
||||
const loss = totalLen - usedLen;
|
||||
return { count, totalPieces, util, loss, usedLen, totalLen };
|
||||
}
|
||||
Generated
+7
@@ -74,6 +74,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"maxrects-packer": "^2.7.3",
|
||||
"modern-screenshot": "^4.6.8",
|
||||
"next": "^15.4.8",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -12347,6 +12348,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/maxrects-packer": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/maxrects-packer/-/maxrects-packer-2.7.3.tgz",
|
||||
"integrity": "sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"maxrects-packer": "^2.7.3",
|
||||
"modern-screenshot": "^4.6.8",
|
||||
"next": "^15.4.8",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
Reference in New Issue
Block a user