feat: add schedule preview functionality for production plans
- Implemented previewSchedule and previewSemiSchedule functions in the production controller to allow users to preview schedule changes without making actual database modifications. - Added corresponding routes for schedule preview in productionRoutes. - Enhanced productionPlanService with logic to generate schedule previews based on provided items and plan IDs. - Introduced SchedulePreviewDialog component to display the preview results in the frontend, including summary and detailed views of planned schedules. These updates improve the user experience by providing a way to visualize scheduling changes before applying them, ensuring better planning and decision-making. Made-with: Cursor
This commit is contained in:
parent
5cdbd2446b
commit
64c9f25f63
|
|
@ -95,6 +95,25 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 자동 스케줄 미리보기 (실제 INSERT 없이 예상 결과 반환) ───
|
||||
|
||||
export async function previewSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { items, options } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" });
|
||||
}
|
||||
|
||||
const data = await productionService.previewSchedule(companyCode, items, options || {});
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("자동 스케줄 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 자동 스케줄 생성 ───
|
||||
|
||||
export async function generateSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
@ -141,6 +160,29 @@ export async function mergeSchedules(req: AuthenticatedRequest, res: Response) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ───
|
||||
|
||||
export async function previewSemiSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { plan_ids, options } = req.body;
|
||||
|
||||
if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" });
|
||||
}
|
||||
|
||||
const data = await productionService.previewSemiSchedule(
|
||||
companyCode,
|
||||
plan_ids,
|
||||
options || {}
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("반제품 계획 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
|
||||
export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
|
|||
|
|
@ -21,12 +21,18 @@ router.get("/plan/:id", productionController.getPlanById);
|
|||
router.put("/plan/:id", productionController.updatePlan);
|
||||
router.delete("/plan/:id", productionController.deletePlan);
|
||||
|
||||
// 자동 스케줄 미리보기 (실제 변경 없이 예상 결과)
|
||||
router.post("/generate-schedule/preview", productionController.previewSchedule);
|
||||
|
||||
// 자동 스케줄 생성
|
||||
router.post("/generate-schedule", productionController.generateSchedule);
|
||||
|
||||
// 스케줄 병합
|
||||
router.post("/merge-schedules", productionController.mergeSchedules);
|
||||
|
||||
// 반제품 계획 미리보기
|
||||
router.post("/generate-semi-schedule/preview", productionController.previewSemiSchedule);
|
||||
|
||||
// 반제품 계획 자동 생성
|
||||
router.post("/generate-semi-schedule", productionController.generateSemiSchedule);
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,101 @@ interface GenerateScheduleOptions {
|
|||
product_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 스케줄 미리보기 (DB 변경 없이 예상 결과만 반환)
|
||||
*/
|
||||
export async function previewSchedule(
|
||||
companyCode: string,
|
||||
items: GenerateScheduleItem[],
|
||||
options: GenerateScheduleOptions
|
||||
) {
|
||||
const pool = getPool();
|
||||
const productType = options.product_type || "완제품";
|
||||
const safetyLeadTime = options.safety_lead_time || 1;
|
||||
|
||||
const previews: any[] = [];
|
||||
const deletedSchedules: any[] = [];
|
||||
const keptSchedules: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (options.recalculate_unstarted) {
|
||||
// 삭제 대상(planned) 상세 조회
|
||||
const deleteResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, item.item_code, productType]
|
||||
);
|
||||
deletedSchedules.push(...deleteResult.rows);
|
||||
|
||||
// 유지 대상(진행중 등) 상세 조회
|
||||
const keptResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
);
|
||||
keptSchedules.push(...keptResult.rows);
|
||||
}
|
||||
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
}
|
||||
|
||||
// 해당 품목의 수주 건수 확인
|
||||
const orderCountResult = await pool.query(
|
||||
`SELECT COUNT(*) AS cnt FROM sales_order_mng
|
||||
WHERE company_code = $1 AND part_code = $2 AND part_code IS NOT NULL`,
|
||||
[companyCode, item.item_code]
|
||||
);
|
||||
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
|
||||
|
||||
previews.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
required_qty: requiredQty,
|
||||
daily_capacity: dailyCapacity,
|
||||
hourly_capacity: item.hourly_capacity || 100,
|
||||
production_days: productionDays,
|
||||
start_date: startDate.toISOString().split("T")[0],
|
||||
end_date: endDate.toISOString().split("T")[0],
|
||||
due_date: item.earliest_due_date,
|
||||
order_count: orderCount,
|
||||
status: "planned",
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: previews.length + keptSchedules.length,
|
||||
new_count: previews.length,
|
||||
kept_count: keptSchedules.length,
|
||||
deleted_count: deletedSchedules.length,
|
||||
};
|
||||
|
||||
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
companyCode: string,
|
||||
items: GenerateScheduleItem[],
|
||||
|
|
@ -317,14 +412,16 @@ export async function generateSchedule(
|
|||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
}
|
||||
|
||||
// 계획번호 생성
|
||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-%`]
|
||||
);
|
||||
const nextNo = planNoResult.rows[0].next_no || 1;
|
||||
const planNo = `PP-${String(nextNo).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
|
|
@ -472,6 +569,123 @@ export async function mergeSchedules(
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 반제품 BOM 소요량 조회 (공통) ───
|
||||
|
||||
async function getBomChildItems(
|
||||
client: any,
|
||||
companyCode: string,
|
||||
itemCode: string
|
||||
) {
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
bd.child_item_id,
|
||||
ii.item_name AS child_item_name,
|
||||
ii.item_number AS child_item_code,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||
WHERE b.company_code = $1
|
||||
AND b.item_code = $2
|
||||
AND COALESCE(b.status, 'active') = 'active'
|
||||
`;
|
||||
const result = await client.query(bomQuery, [companyCode, itemCode]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 미리보기 (실제 DB 변경 없음) ───
|
||||
|
||||
export async function previewSemiSchedule(
|
||||
companyCode: string,
|
||||
planIds: number[],
|
||||
options: { considerStock?: boolean; excludeUsed?: boolean }
|
||||
) {
|
||||
const pool = getPool();
|
||||
|
||||
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
|
||||
const plansResult = await pool.query(
|
||||
`SELECT * FROM production_plan_mng
|
||||
WHERE company_code = $1 AND id IN (${placeholders})
|
||||
AND product_type = '완제품'`,
|
||||
[companyCode, ...planIds]
|
||||
);
|
||||
|
||||
const previews: any[] = [];
|
||||
const existingSemiPlans: any[] = [];
|
||||
|
||||
for (const plan of plansResult.rows) {
|
||||
// 이미 존재하는 반제품 계획 조회
|
||||
const existingResult = await pool.query(
|
||||
`SELECT * FROM production_plan_mng
|
||||
WHERE company_code = $1 AND parent_plan_id = $2 AND product_type = '반제품'`,
|
||||
[companyCode, plan.id]
|
||||
);
|
||||
existingSemiPlans.push(...existingResult.rows);
|
||||
|
||||
const bomItems = await getBomChildItems(pool, companyCode, plan.item_code);
|
||||
|
||||
for (const bomItem of bomItems) {
|
||||
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
|
||||
|
||||
if (options.considerStock) {
|
||||
const stockResult = await pool.query(
|
||||
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2`,
|
||||
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
|
||||
);
|
||||
const stock = parseFloat(stockResult.rows[0].stock) || 0;
|
||||
requiredQty = Math.max(requiredQty - stock, 0);
|
||||
}
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
|
||||
previews.push({
|
||||
parent_plan_id: plan.id,
|
||||
parent_plan_no: plan.plan_no,
|
||||
parent_item_name: plan.item_name,
|
||||
item_code: bomItem.child_item_code || bomItem.child_item_id,
|
||||
item_name: bomItem.child_item_name || bomItem.child_item_id,
|
||||
plan_qty: requiredQty,
|
||||
bom_qty: parseFloat(bomItem.bom_qty) || 1,
|
||||
start_date: semiStartDate.toISOString().split("T")[0],
|
||||
end_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
due_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
product_type: "반제품",
|
||||
status: "planned",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 반제품 중 삭제 대상 (status = planned)
|
||||
const deletedSchedules = existingSemiPlans.filter(
|
||||
(s) => s.status === "planned"
|
||||
);
|
||||
// 기존 반제품 중 유지 대상 (진행중 등)
|
||||
const keptSchedules = existingSemiPlans.filter(
|
||||
(s) => s.status !== "planned" && s.status !== "completed"
|
||||
);
|
||||
|
||||
const summary = {
|
||||
total: previews.length + keptSchedules.length,
|
||||
new_count: previews.length,
|
||||
deleted_count: deletedSchedules.length,
|
||||
kept_count: keptSchedules.length,
|
||||
parent_count: plansResult.rowCount,
|
||||
};
|
||||
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
|
||||
export async function generateSemiSchedule(
|
||||
|
|
@ -486,41 +700,36 @@ export async function generateSemiSchedule(
|
|||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 선택된 완제품 계획 조회
|
||||
const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", ");
|
||||
const plansResult = await client.query(
|
||||
`SELECT * FROM production_plan_mng
|
||||
WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||
WHERE company_code = $1 AND id IN (${placeholders})
|
||||
AND product_type = '완제품'`,
|
||||
[companyCode, ...planIds]
|
||||
);
|
||||
|
||||
// 기존 planned 상태 반제품 삭제
|
||||
for (const plan of plansResult.rows) {
|
||||
await client.query(
|
||||
`DELETE FROM production_plan_mng
|
||||
WHERE company_code = $1 AND parent_plan_id = $2
|
||||
AND product_type = '반제품' AND status = 'planned'`,
|
||||
[companyCode, plan.id]
|
||||
);
|
||||
}
|
||||
|
||||
const newSemiPlans: any[] = [];
|
||||
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
|
||||
for (const plan of plansResult.rows) {
|
||||
// BOM에서 해당 품목의 반제품 소요량 조회
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
bd.child_item_id,
|
||||
ii.item_name AS child_item_name,
|
||||
ii.item_code AS child_item_code,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||
WHERE b.company_code = $1
|
||||
AND b.item_code = $2
|
||||
AND COALESCE(b.status, 'active') = 'active'
|
||||
`;
|
||||
const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]);
|
||||
const bomItems = await getBomChildItems(client, companyCode, plan.item_code);
|
||||
|
||||
for (const bomItem of bomResult.rows) {
|
||||
for (const bomItem of bomItems) {
|
||||
let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1);
|
||||
|
||||
// 재고 고려
|
||||
if (options.considerStock) {
|
||||
const stockResult = await client.query(
|
||||
`SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock
|
||||
`SELECT COALESCE(SUM(CAST(current_qty AS numeric)), 0) AS stock
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2`,
|
||||
[companyCode, bomItem.child_item_code || bomItem.child_item_id]
|
||||
|
|
@ -531,18 +740,20 @@ export async function generateSemiSchedule(
|
|||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품 납기일 = 완제품 시작일
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1));
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
|
||||
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
||||
const planNoResult = await client.query(
|
||||
`SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no
|
||||
FROM production_plan_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
`SELECT COUNT(*) + 1 AS next_no
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||
[companyCode, `PP-${todayStr}-S%`]
|
||||
);
|
||||
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||
const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1;
|
||||
const planNo = `PP-${todayStr}-S${String(nextNo).padStart(3, "0")}`;
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO production_plan_mng (
|
||||
|
|
@ -560,8 +771,8 @@ export async function generateSemiSchedule(
|
|||
bomItem.child_item_name || bomItem.child_item_id,
|
||||
requiredQty,
|
||||
semiStartDate.toISOString().split("T")[0],
|
||||
typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0],
|
||||
typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0],
|
||||
typeof semiEndDate === "string" ? semiEndDate.split("T")[0] : new Date(semiEndDate).toISOString().split("T")[0],
|
||||
typeof semiDueDate === "string" ? semiDueDate.split("T")[0] : new Date(semiDueDate).toISOString().split("T")[0],
|
||||
plan.id,
|
||||
createdBy,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -548,11 +548,11 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
|||
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
|
||||
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
|
||||
- [x] staticFilters 지원 (커스텀 테이블 필터링)
|
||||
- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스)
|
||||
- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화)
|
||||
- [x] 설정 패널 구현
|
||||
- [x] API 연동
|
||||
- [x] 레지스트리 등록
|
||||
- [ ] 테스트 완료
|
||||
- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건)
|
||||
- [x] 문서화 (README.md)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -892,3 +892,79 @@ if (process.env.NODE_ENV === "development") {
|
|||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 생산기간(리드타임) 산출 - 현재 상태 및 개선 방안
|
||||
|
||||
> 작성일: 2026-03-16 | 상태: 검토 대기 (스키마 변경 전 상의 필요)
|
||||
|
||||
### 12.1 현재 구현 상태
|
||||
|
||||
**생산일수 계산 로직** (`productionPlanService.ts`):
|
||||
|
||||
```
|
||||
생산일수 = ceil(계획수량 / 일생산능력)
|
||||
종료일 = 납기일 - 안전리드타임
|
||||
시작일 = 종료일 - 생산일수
|
||||
```
|
||||
|
||||
**현재 기본값 (하드코딩):**
|
||||
|
||||
| 항목 | 현재값 | 위치 |
|
||||
|------|--------|------|
|
||||
| 일생산능력 (daily_capacity) | 800 EA/일 | `productionPlanService.ts` 기본값 |
|
||||
| 시간당 능력 (hourly_capacity) | 100 EA/시간 | `productionPlanService.ts` 기본값 |
|
||||
| 안전리드타임 (safety_lead_time) | 1일 | 옵션 기본값 |
|
||||
| 반제품 리드타임 (lead_time) | 1일 | `production_plan_mng` 기본값 |
|
||||
|
||||
**문제점:**
|
||||
- `item_info`에 생산 파라미터 컬럼이 없음
|
||||
- 모든 품목이 동일한 기본값(800EA/일)으로 계산됨
|
||||
- 업체별/품목별 생산능력 차이를 반영 불가
|
||||
|
||||
### 12.2 개선 방향 (상의 후 결정)
|
||||
|
||||
**1단계 (품목 마스터 기반) - 권장:**
|
||||
|
||||
`item_info` 테이블에 컬럼 추가:
|
||||
- `lead_time_days`: 리드타임 (일)
|
||||
- `daily_capacity`: 일생산능력
|
||||
- `min_lot_size`: 최소 생산 단위 (선택)
|
||||
- `setup_time`: 셋업시간 (선택)
|
||||
|
||||
자동 스케줄 생성 시 품목 마스터 조회 → 값 없으면 기본값 사용 (하위 호환)
|
||||
|
||||
**2단계 (설비별 능력) - 고객 요청 시:**
|
||||
|
||||
별도 테이블 `item_equipment_capacity`:
|
||||
- 품목 + 설비 조합별 생산능력 관리
|
||||
- 동일 품목이라도 설비에 따라 능력 다를 때
|
||||
|
||||
**3단계 (공정 라우팅) - 대기업 대응:**
|
||||
|
||||
공정 순서 + 공정별 소요시간 전체 관리
|
||||
- 현재 시점에서는 불필요
|
||||
|
||||
### 12.3 반제품 계획 생성 현황
|
||||
|
||||
**구현 완료 항목:**
|
||||
- API: `POST /production/generate-semi-schedule/preview` (미리보기)
|
||||
- API: `POST /production/generate-semi-schedule` (실제 생성)
|
||||
- BOM 기반 소요량 자동 계산
|
||||
- 타임라인 컴포넌트 내 "반제품 계획 생성" 버튼 (완제품 탭에서만 표시)
|
||||
- 반제품 탭: linkedFilter 제거, staticFilters만 사용 (전체 반제품 표시)
|
||||
|
||||
**반제품 생산기간 계산:**
|
||||
- 반제품 납기일 = 완제품 시작일
|
||||
- 반제품 시작일 = 완제품 시작일 - lead_time (기본 1일)
|
||||
- BOM 소요량 = 완제품 계획수량 x BOM 수량
|
||||
|
||||
**테스트 BOM 데이터:**
|
||||
|
||||
| 완제품 | 반제품 | BOM 수량 |
|
||||
|--------|--------|----------|
|
||||
| ITEM-001 (탑씰 Type A) | SEMI-001 (탑씰 필름 A) | 2 EA/개 |
|
||||
| ITEM-001 (탑씰 Type A) | SEMI-002 (탑씰 접착제) | 0.5 KG/개 |
|
||||
| ITEM-002 (탑씰 Type B) | SEMI-003 (탑씰 필름 B) | 3 EA/개 |
|
||||
| ITEM-002 (탑씰 Type B) | SEMI-004 (탑씰 코팅제) | 0.3 KG/개 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스
|
||||
|
||||
> **최종 업데이트**: 2026-03-13
|
||||
> **최종 업데이트**: 2026-03-16
|
||||
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
|
||||
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
|
||||
|
||||
|
|
@ -532,15 +532,20 @@ CREATE TABLE "{테이블명}" (
|
|||
|
||||
---
|
||||
|
||||
### 3.11 v2-timeline-scheduler (간트차트)
|
||||
### 3.11 v2-timeline-scheduler (간트차트/타임라인)
|
||||
|
||||
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집.
|
||||
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. 품목별 그룹 뷰, 자동 스케줄 생성, 반제품 계획 연동 지원.
|
||||
|
||||
**기본 설정**:
|
||||
|
||||
| 설정 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| selectedTable | string | - | 스케줄 데이터 테이블 |
|
||||
| customTableName | string | - | selectedTable 대신 사용 (useCustomTable=true 시) |
|
||||
| useCustomTable | boolean | `false` | customTableName 사용 여부 |
|
||||
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
|
||||
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
|
||||
| viewMode | string | - | 뷰 모드: `"itemGrouped"` (품목별 카드 그룹) / 미설정 시 리소스 기반 |
|
||||
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
|
||||
| editable | boolean | `true` | 편집 가능 |
|
||||
| draggable | boolean | `true` | 드래그 이동 허용 |
|
||||
|
|
@ -548,15 +553,16 @@ CREATE TABLE "{테이블명}" (
|
|||
| rowHeight | number | `50` | 행 높이(px) |
|
||||
| headerHeight | number | `60` | 헤더 높이(px) |
|
||||
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
|
||||
| cellWidth.day | number | `60` | 일 단위 셀 너비 |
|
||||
| cellWidth.week | number | `120` | 주 단위 셀 너비 |
|
||||
| cellWidth.month | number | `40` | 월 단위 셀 너비 |
|
||||
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
|
||||
| showProgress | boolean | `true` | 진행률 바 표시 |
|
||||
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
|
||||
| showToolbar | boolean | `true` | 상단 툴바 표시 |
|
||||
| showLegend | boolean | `true` | 범례(상태 색상 안내) 표시 |
|
||||
| showNavigation | boolean | `true` | 날짜 네비게이션 버튼 표시 |
|
||||
| showZoomControls | boolean | `true` | 줌 컨트롤 버튼 표시 |
|
||||
| showAddButton | boolean | `true` | 추가 버튼 |
|
||||
| height | number | `500` | 높이(px) |
|
||||
| maxHeight | number | - | 최대 높이(px) |
|
||||
|
||||
**fieldMapping (필수)**:
|
||||
|
||||
|
|
@ -583,10 +589,74 @@ CREATE TABLE "{테이블명}" (
|
|||
| 상태 | 기본 색상 |
|
||||
|------|----------|
|
||||
| planned | `"#3b82f6"` (파랑) |
|
||||
| in_progress | `"#f59e0b"` (주황) |
|
||||
| completed | `"#10b981"` (초록) |
|
||||
| in_progress | `"#10b981"` (초록) |
|
||||
| completed | `"#6b7280"` (회색) |
|
||||
| delayed | `"#ef4444"` (빨강) |
|
||||
| cancelled | `"#6b7280"` (회색) |
|
||||
| cancelled | `"#9ca3af"` (연회색) |
|
||||
|
||||
**staticFilters (정적 필터)** - DB 조회 시 항상 적용되는 WHERE 조건:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| product_type | string | `"완제품"` 또는 `"반제품"` 등 고정 필터 |
|
||||
| status | string | 상태값 필터 |
|
||||
| (임의 컬럼) | string | 해당 컬럼으로 필터링 |
|
||||
|
||||
```json
|
||||
"staticFilters": {
|
||||
"product_type": "완제품"
|
||||
}
|
||||
```
|
||||
|
||||
**linkedFilter (연결 필터)** - 다른 컴포넌트(주로 테이블)의 선택 이벤트와 연동:
|
||||
|
||||
| 설정 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| sourceField | string | 소스 컴포넌트(좌측 테이블)의 필터 기준 컬럼 |
|
||||
| targetField | string | 타임라인 스케줄 데이터에서 매칭할 컬럼 |
|
||||
| sourceTableName | string | 이벤트 발신 테이블명 (이벤트 필터용) |
|
||||
| sourceComponentId | string | 이벤트 발신 컴포넌트 ID (선택) |
|
||||
| emptyMessage | string | 선택 전 빈 상태 메시지 |
|
||||
| showEmptyWhenNoSelection | boolean | 선택 전 빈 상태 표시 여부 |
|
||||
|
||||
```json
|
||||
"linkedFilter": {
|
||||
"sourceField": "part_code",
|
||||
"targetField": "item_code",
|
||||
"sourceTableName": "sales_order_mng",
|
||||
"emptyMessage": "좌측 수주 목록에서 품목을 선택하세요",
|
||||
"showEmptyWhenNoSelection": true
|
||||
}
|
||||
```
|
||||
|
||||
> **linkedFilter 동작 원리**: v2EventBus의 `TABLE_SELECTION_CHANGE` 이벤트를 구독.
|
||||
> 좌측 테이블에서 행을 선택하면 해당 행의 `sourceField` 값을 수집하여,
|
||||
> 타임라인 데이터 중 `targetField`가 일치하는 스케줄만 클라이언트 측에서 필터링 표시.
|
||||
> `staticFilters`는 서버 측 조회, `linkedFilter`는 클라이언트 측 필터링.
|
||||
|
||||
**viewMode: "itemGrouped" (품목별 그룹 뷰)**:
|
||||
|
||||
리소스(설비) 기반 간트차트 대신, 품목(item_code)별로 카드를 그룹화하여 표시하는 모드.
|
||||
각 카드 안에 해당 품목의 스케줄 바가 미니 타임라인으로 표시됨.
|
||||
|
||||
설정 시 `viewMode: "itemGrouped"`만 추가하면 됨. 툴바에 자동으로:
|
||||
- 날짜 네비게이션 (이전/오늘/다음)
|
||||
- 줌 컨트롤
|
||||
- 새로고침 버튼
|
||||
- (완제품 탭일 때) **완제품 계획 생성** / **반제품 계획 생성** 버튼
|
||||
|
||||
**자동 스케줄 생성 (내장 기능)**:
|
||||
|
||||
`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 일 때 자동 활성화.
|
||||
|
||||
- **완제품 계획 생성**: linkedFilter로 선택된 수주 품목 기반, 미리보기 다이얼로그 → 확인 후 생성
|
||||
- API: `POST /production/generate-schedule/preview` → `POST /production/generate-schedule`
|
||||
- **반제품 계획 생성**: 현재 타임라인의 완제품 스케줄 기반, BOM 소요량으로 반제품 계획 미리보기 → 확인 후 생성
|
||||
- API: `POST /production/generate-semi-schedule/preview` → `POST /production/generate-semi-schedule`
|
||||
|
||||
> **중요**: 반제품 전용 타임라인에는 `linkedFilter`를 걸지 않는다.
|
||||
> 반제품 item_code가 수주 품목 코드와 다르므로 매칭 불가.
|
||||
> `staticFilters: { product_type: "반제품" }`만 설정하여 전체 반제품 계획을 표시.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -923,16 +993,32 @@ CREATE TABLE "{테이블명}" (
|
|||
## 4. 패턴 의사결정 트리
|
||||
|
||||
```
|
||||
Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler
|
||||
Q2. 다차원 피벗 분석? → v2-pivot-grid
|
||||
Q3. 그룹별 접기/펼치기? → v2-table-grouped
|
||||
Q4. 카드 형태 표시? → v2-card-display
|
||||
Q5. 마스터-디테일?
|
||||
Q1. 좌측 마스터 + 우측 탭(타임라인/테이블) 복합 구성?
|
||||
→ 패턴 F → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
|
||||
Q2. 시간축 기반 일정/간트차트?
|
||||
├ 품목별 카드 그룹 뷰? → 패턴 E-2 → v2-timeline-scheduler(viewMode:itemGrouped)
|
||||
└ 리소스(설비) 기반? → 패턴 E → v2-timeline-scheduler
|
||||
Q3. 다차원 피벗 분석? → v2-pivot-grid
|
||||
Q4. 그룹별 접기/펼치기? → v2-table-grouped
|
||||
Q5. 카드 형태 표시? → v2-card-display
|
||||
Q6. 마스터-디테일?
|
||||
├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs
|
||||
└ 단일 디테일? → v2-split-panel-layout
|
||||
Q6. 단일 테이블? → v2-table-search-widget + v2-table-list
|
||||
Q7. 단일 테이블? → v2-table-search-widget + v2-table-list
|
||||
```
|
||||
|
||||
### 패턴 요약표
|
||||
|
||||
| 패턴 | 대표 화면 | 핵심 컴포넌트 |
|
||||
|------|----------|-------------|
|
||||
| A | 거래처관리 | v2-table-search-widget + v2-table-list |
|
||||
| B | 수주관리 | v2-split-panel-layout |
|
||||
| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs |
|
||||
| D | 재고현황 | v2-table-grouped |
|
||||
| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) |
|
||||
| E-2 | 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) |
|
||||
| F | 생산계획 | split(custom) + tabs + timeline |
|
||||
|
||||
---
|
||||
|
||||
## 5. 관계(relation) 레퍼런스
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용)
|
||||
|
||||
> **최종 업데이트**: 2026-03-13
|
||||
> **최종 업데이트**: 2026-03-16
|
||||
> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드
|
||||
> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다
|
||||
|
||||
|
|
@ -533,7 +533,9 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now();
|
|||
}
|
||||
```
|
||||
|
||||
### 8.5 패턴 E: 타임라인/간트차트
|
||||
### 8.5 패턴 E: 타임라인/간트차트 (리소스 기반)
|
||||
|
||||
**사용 조건**: 설비/작업자 등 리소스 기준으로 스케줄을 시간축에 표시
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -575,6 +577,246 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now();
|
|||
}
|
||||
```
|
||||
|
||||
### 8.6 패턴 E-2: 타임라인 (품목 그룹 뷰 + 연결 필터)
|
||||
|
||||
**사용 조건**: 좌측 테이블에서 선택한 품목 기반으로 타임라인을 필터링 표시. 품목별 카드 그룹 뷰.
|
||||
|
||||
> 리소스(설비) 기반이 아닌, **품목(item_code)별로 카드 그룹** 형태로 스케줄을 표시한다.
|
||||
> 좌측 테이블에서 행을 선택하면 `linkedFilter`로 해당 품목의 스케줄만 필터링.
|
||||
> `staticFilters`로 완제품/반제품 등 데이터 유형을 고정 필터링.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "timeline_finished",
|
||||
"url": "@/lib/registry/components/v2-timeline-scheduler",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1920, "height": 800 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"label": "완제품 생산계획",
|
||||
"selectedTable": "{스케줄_테이블}",
|
||||
"viewMode": "itemGrouped",
|
||||
"fieldMapping": {
|
||||
"id": "id",
|
||||
"resourceId": "item_code",
|
||||
"title": "item_name",
|
||||
"startDate": "start_date",
|
||||
"endDate": "end_date",
|
||||
"status": "status"
|
||||
},
|
||||
"defaultZoomLevel": "day",
|
||||
"staticFilters": {
|
||||
"product_type": "완제품"
|
||||
},
|
||||
"linkedFilter": {
|
||||
"sourceField": "part_code",
|
||||
"targetField": "item_code",
|
||||
"sourceTableName": "{좌측_테이블명}",
|
||||
"emptyMessage": "좌측 목록에서 품목을 선택하세요",
|
||||
"showEmptyWhenNoSelection": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 설정 설명**:
|
||||
|
||||
| 설정 | 용도 |
|
||||
|------|------|
|
||||
| `viewMode: "itemGrouped"` | 리소스 행이 아닌, 품목별 카드 그룹으로 표시 |
|
||||
| `staticFilters` | DB 조회 시 항상 적용 (서버측 WHERE 조건) |
|
||||
| `linkedFilter` | 다른 컴포넌트 선택 이벤트로 클라이언트 측 필터링 |
|
||||
| `linkedFilter.sourceField` | 소스 테이블에서 가져올 값의 컬럼명 |
|
||||
| `linkedFilter.targetField` | 타임라인 데이터에서 매칭할 컬럼명 |
|
||||
|
||||
> **주의**: `linkedFilter`와 `staticFilters`의 차이
|
||||
> - `staticFilters`: DB SELECT 쿼리의 WHERE 절에 포함 → 서버에서 필터링
|
||||
> - `linkedFilter`: 전체 데이터를 불러온 후, 선택 이벤트에 따라 클라이언트에서 필터링
|
||||
|
||||
### 8.7 패턴 F: 복합 화면 (좌측 테이블 + 우측 탭 내 타임라인)
|
||||
|
||||
**사용 조건**: 생산계획처럼 좌측 마스터 테이블 + 우측에 탭으로 여러 타임라인/테이블을 표시하는 복합 화면.
|
||||
`v2-split-panel-layout`의 `rightPanel.displayMode: "custom"` + `v2-tabs-widget` + `v2-timeline-scheduler` 조합.
|
||||
|
||||
**구조 개요**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ v2-split-panel-layout │
|
||||
│ ┌──────────┬─────────────────────────────────┐ │
|
||||
│ │ leftPanel │ rightPanel (displayMode:custom)│ │
|
||||
│ │ │ ┌─────────────────────────────┐│ │
|
||||
│ │ v2-table- │ │ v2-tabs-widget ││ │
|
||||
│ │ grouped │ │ ┌───────┬───────┬─────────┐ ││ │
|
||||
│ │ (수주목록) │ │ │완제품 │반제품 │기타 탭 │ ││ │
|
||||
│ │ │ │ └───────┴───────┴─────────┘ ││ │
|
||||
│ │ │ │ ┌─────────────────────────┐ ││ │
|
||||
│ │ │ │ │ v2-timeline-scheduler │ ││ │
|
||||
│ │ │ │ │ (품목별 그룹 뷰) │ ││ │
|
||||
│ │ │ │ └─────────────────────────┘ ││ │
|
||||
│ │ │ └─────────────────────────────┘│ │
|
||||
│ └──────────┴─────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**실제 layout_data 예시** (생산계획 화면 참고):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "split_pp",
|
||||
"url": "@/lib/registry/components/v2-split-panel-layout",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1920, "height": 850 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"label": "생산계획",
|
||||
"splitRatio": 25,
|
||||
"resizable": true,
|
||||
"autoLoad": true,
|
||||
"syncSelection": true,
|
||||
"leftPanel": {
|
||||
"title": "수주 목록",
|
||||
"displayMode": "custom",
|
||||
"components": [
|
||||
{
|
||||
"id": "grouped_orders",
|
||||
"componentType": "v2-table-grouped",
|
||||
"label": "수주별 품목",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 600, "height": 800 },
|
||||
"componentConfig": {
|
||||
"selectedTable": "sales_order_mng",
|
||||
"groupConfig": {
|
||||
"groupByColumn": "order_number",
|
||||
"groupLabelFormat": "{value}",
|
||||
"defaultExpanded": true,
|
||||
"summary": { "showCount": true }
|
||||
},
|
||||
"columns": [
|
||||
{ "columnName": "part_code", "displayName": "품번", "visible": true, "width": 100 },
|
||||
{ "columnName": "part_name", "displayName": "품명", "visible": true, "width": 120 },
|
||||
{ "columnName": "order_qty", "displayName": "수량", "visible": true, "width": 60 },
|
||||
{ "columnName": "delivery_date", "displayName": "납기일", "visible": true, "width": 90 }
|
||||
],
|
||||
"showCheckbox": true,
|
||||
"checkboxMode": "multi"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"rightPanel": {
|
||||
"title": "생산 계획",
|
||||
"displayMode": "custom",
|
||||
"components": [
|
||||
{
|
||||
"id": "tabs_pp",
|
||||
"componentType": "v2-tabs-widget",
|
||||
"label": "생산계획 탭",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1400, "height": 800 },
|
||||
"componentConfig": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "tab_finished",
|
||||
"label": "완제품",
|
||||
"order": 1,
|
||||
"components": [
|
||||
{
|
||||
"id": "timeline_finished",
|
||||
"componentType": "v2-timeline-scheduler",
|
||||
"label": "완제품 타임라인",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1380, "height": 750 },
|
||||
"componentConfig": {
|
||||
"selectedTable": "production_plan_mng",
|
||||
"viewMode": "itemGrouped",
|
||||
"fieldMapping": {
|
||||
"id": "id",
|
||||
"resourceId": "item_code",
|
||||
"title": "item_name",
|
||||
"startDate": "start_date",
|
||||
"endDate": "end_date",
|
||||
"status": "status"
|
||||
},
|
||||
"defaultZoomLevel": "day",
|
||||
"staticFilters": {
|
||||
"product_type": "완제품"
|
||||
},
|
||||
"linkedFilter": {
|
||||
"sourceField": "part_code",
|
||||
"targetField": "item_code",
|
||||
"sourceTableName": "sales_order_mng",
|
||||
"emptyMessage": "좌측 수주 목록에서 품목을 선택하세요",
|
||||
"showEmptyWhenNoSelection": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tab_semi",
|
||||
"label": "반제품",
|
||||
"order": 2,
|
||||
"components": [
|
||||
{
|
||||
"id": "timeline_semi",
|
||||
"componentType": "v2-timeline-scheduler",
|
||||
"label": "반제품 타임라인",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 1380, "height": 750 },
|
||||
"componentConfig": {
|
||||
"selectedTable": "production_plan_mng",
|
||||
"viewMode": "itemGrouped",
|
||||
"fieldMapping": {
|
||||
"id": "id",
|
||||
"resourceId": "item_code",
|
||||
"title": "item_name",
|
||||
"startDate": "start_date",
|
||||
"endDate": "end_date",
|
||||
"status": "status"
|
||||
},
|
||||
"defaultZoomLevel": "day",
|
||||
"staticFilters": {
|
||||
"product_type": "반제품"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"defaultTab": "tab_finished"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"gridSettings": { "columns": 12, "gap": 16, "padding": 16 },
|
||||
"screenResolution": { "width": 1920, "height": 1080 }
|
||||
}
|
||||
```
|
||||
|
||||
**패턴 F 핵심 포인트**:
|
||||
|
||||
| 포인트 | 설명 |
|
||||
|--------|------|
|
||||
| `leftPanel.displayMode: "custom"` | 좌측에 v2-table-grouped 등 자유 배치 |
|
||||
| `rightPanel.displayMode: "custom"` | 우측에 v2-tabs-widget 등 자유 배치 |
|
||||
| `componentConfig` | custom 내부 컴포넌트는 overrides 대신 componentConfig 사용 |
|
||||
| `componentType` | custom 내부에서는 url 대신 componentType 사용 |
|
||||
| 완제품 탭에만 `linkedFilter` | 좌측 테이블과 연동 필터링 |
|
||||
| 반제품 탭에는 `linkedFilter` 없음 | 반제품 item_code가 수주 품목과 다르므로 전체 표시 |
|
||||
| 자동 스케줄 생성 버튼 | `staticFilters.product_type === "완제품"` 일 때 자동 표시 |
|
||||
|
||||
> **displayMode: "custom" 내부 컴포넌트 규칙**:
|
||||
> - `url` 대신 `componentType` 사용 (예: `"v2-timeline-scheduler"`, `"v2-table-grouped"`)
|
||||
> - `overrides` 대신 `componentConfig` 사용
|
||||
> - `position`, `size`는 동일하게 사용
|
||||
|
||||
---
|
||||
|
||||
## 9. Step 7: menu_info INSERT
|
||||
|
|
@ -696,29 +938,47 @@ VALUES
|
|||
사용자가 화면을 요청하면 이 트리로 패턴을 결정한다.
|
||||
|
||||
```
|
||||
Q1. 시간축 기반 일정/간트차트가 필요한가?
|
||||
├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler
|
||||
Q1. 좌측 마스터 + 우측에 탭으로 타임라인/테이블 등 복합 구성이 필요한가?
|
||||
├─ YES → 패턴 F (복합 화면) → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
|
||||
└─ NO ↓
|
||||
|
||||
Q2. 다차원 집계/피벗 분석이 필요한가?
|
||||
Q2. 시간축 기반 일정/간트차트가 필요한가?
|
||||
├─ YES → Q2-1. 품목별 카드 그룹 뷰인가?
|
||||
│ ├─ YES → 패턴 E-2 (품목 그룹 타임라인) → v2-timeline-scheduler(viewMode:itemGrouped)
|
||||
│ └─ NO → 패턴 E (리소스 기반 타임라인) → v2-timeline-scheduler
|
||||
└─ NO ↓
|
||||
|
||||
Q3. 다차원 집계/피벗 분석이 필요한가?
|
||||
├─ YES → 피벗 → v2-pivot-grid
|
||||
└─ NO ↓
|
||||
|
||||
Q3. 데이터를 그룹별로 접기/펼치기가 필요한가?
|
||||
Q4. 데이터를 그룹별로 접기/펼치기가 필요한가?
|
||||
├─ YES → 패턴 D (그룹화) → v2-table-grouped
|
||||
└─ NO ↓
|
||||
|
||||
Q4. 이미지+정보를 카드 형태로 표시하는가?
|
||||
Q5. 이미지+정보를 카드 형태로 표시하는가?
|
||||
├─ YES → 카드뷰 → v2-card-display
|
||||
└─ NO ↓
|
||||
|
||||
Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가?
|
||||
├─ YES → Q5-1. 디테일에 여러 탭이 필요한가?
|
||||
Q6. 마스터 테이블 선택 시 연관 디테일이 필요한가?
|
||||
├─ YES → Q6-1. 디테일에 여러 탭이 필요한가?
|
||||
│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs
|
||||
│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout
|
||||
└─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list
|
||||
```
|
||||
|
||||
### 패턴 선택 빠른 참조
|
||||
|
||||
| 패턴 | 대표 화면 | 핵심 컴포넌트 |
|
||||
|------|----------|-------------|
|
||||
| A | 거래처관리, 코드관리 | v2-table-search-widget + v2-table-list |
|
||||
| B | 수주관리, 발주관리 | v2-split-panel-layout |
|
||||
| C | 수주관리(멀티탭) | v2-split-panel-layout + additionalTabs |
|
||||
| D | 재고현황, 그룹별조회 | v2-table-grouped |
|
||||
| E | 설비 작업일정 | v2-timeline-scheduler (리소스 기반) |
|
||||
| E-2 | 단독 품목별 타임라인 | v2-timeline-scheduler (viewMode: itemGrouped) |
|
||||
| F | 생산계획, 작업지시 | v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler |
|
||||
|
||||
---
|
||||
|
||||
## 13. 화면 간 연결 관계 정의
|
||||
|
|
@ -1119,7 +1379,8 @@ VALUES (
|
|||
| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` |
|
||||
| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` |
|
||||
| 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` |
|
||||
| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` |
|
||||
| 간트차트 (리소스 기반) | v2-timeline-scheduler | `fieldMapping`, `resourceTable` |
|
||||
| 타임라인 (품목 그룹) | v2-timeline-scheduler | `viewMode:"itemGrouped"`, `staticFilters`, `linkedFilter` |
|
||||
| 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` |
|
||||
| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` |
|
||||
| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` |
|
||||
|
|
@ -1144,3 +1405,97 @@ VALUES (
|
|||
| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` |
|
||||
| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` |
|
||||
| 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` |
|
||||
|
||||
---
|
||||
|
||||
## 17. v2-timeline-scheduler 고급 설정 가이드
|
||||
|
||||
### 17.1 viewMode 선택 기준
|
||||
|
||||
| viewMode | 용도 | Y축 |
|
||||
|----------|------|-----|
|
||||
| (미설정) | 설비별 작업일정, 보전계획 | 설비/작업자 행 |
|
||||
| `"itemGrouped"` | 생산계획, 출하계획 | 품목별 카드 그룹 |
|
||||
|
||||
### 17.2 staticFilters vs linkedFilter 비교
|
||||
|
||||
| 구분 | staticFilters | linkedFilter |
|
||||
|------|--------------|-------------|
|
||||
| **적용 시점** | DB SELECT 쿼리 시 | 클라이언트 렌더링 시 |
|
||||
| **위치** | 서버 측 (WHERE 절) | 프론트 측 (JS 필터링) |
|
||||
| **변경 가능** | 고정 (layout에 하드코딩) | 동적 (이벤트 기반) |
|
||||
| **용도** | 완제품/반제품 구분 등 | 좌측 테이블 선택 연동 |
|
||||
|
||||
**조합 예시**:
|
||||
```
|
||||
staticFilters: { product_type: "완제품" } → DB에서 완제품만 조회
|
||||
linkedFilter: { sourceField: "part_code", targetField: "item_code" }
|
||||
→ 완제품 중 좌측에서 선택한 품목만 표시
|
||||
```
|
||||
|
||||
### 17.3 자동 스케줄 생성 (내장 기능)
|
||||
|
||||
`viewMode: "itemGrouped"` + `staticFilters.product_type === "완제품"` 조건 충족 시,
|
||||
타임라인 툴바에 **완제품 계획 생성** / **반제품 계획 생성** 버튼이 자동 표시됨.
|
||||
|
||||
**완제품 계획 생성 플로우**:
|
||||
```
|
||||
1. linkedFilter로 선택된 수주 품목 수집
|
||||
2. POST /production/generate-schedule/preview → 미리보기 다이얼로그
|
||||
3. 사용자 확인 → POST /production/generate-schedule → 실제 생성
|
||||
4. 타임라인 자동 새로고침
|
||||
```
|
||||
|
||||
**반제품 계획 생성 플로우**:
|
||||
```
|
||||
1. 현재 타임라인의 완제품 스케줄 ID 수집
|
||||
2. POST /production/generate-semi-schedule/preview → BOM 기반 소요량 계산
|
||||
3. 미리보기 다이얼로그 (기존 반제품 계획 삭제/유지 정보 포함)
|
||||
4. 사용자 확인 → POST /production/generate-semi-schedule → 실제 생성
|
||||
5. 반제품 탭으로 전환 시 새 데이터 표시
|
||||
```
|
||||
|
||||
### 17.4 반제품 탭 주의사항
|
||||
|
||||
반제품 전용 타임라인에는 `linkedFilter`를 **걸지 않는다**.
|
||||
|
||||
이유: 반제품의 `item_code`(예: `SEMI-001`)와 수주 품목의 `part_code`(예: `ITEM-001`)가
|
||||
서로 다른 값이므로 매칭이 불가능하다. `staticFilters: { product_type: "반제품" }`만 설정.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "timeline_semi",
|
||||
"componentType": "v2-timeline-scheduler",
|
||||
"componentConfig": {
|
||||
"selectedTable": "production_plan_mng",
|
||||
"viewMode": "itemGrouped",
|
||||
"staticFilters": { "product_type": "반제품" },
|
||||
"fieldMapping": { "..." : "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 17.5 이벤트 연동 (v2EventBus)
|
||||
|
||||
타임라인 컴포넌트는 `v2EventBus`를 통해 다른 컴포넌트와 통신한다.
|
||||
|
||||
| 이벤트 | 방향 | 설명 |
|
||||
|--------|------|------|
|
||||
| `TABLE_SELECTION_CHANGE` | 수신 | 좌측 테이블 행 선택 시 linkedFilter 적용 |
|
||||
| `TIMELINE_REFRESH` | 발신/수신 | 타임라인 데이터 새로고침 |
|
||||
|
||||
**연결 필터 이벤트 페이로드**:
|
||||
```typescript
|
||||
{
|
||||
eventType: "TABLE_SELECTION_CHANGE",
|
||||
source: "grouped_orders",
|
||||
tableName: "sales_order_mng",
|
||||
selectedRows: [
|
||||
{ id: "...", part_code: "ITEM-001", ... },
|
||||
{ id: "...", part_code: "ITEM-002", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
타임라인은 `selectedRows`에서 `linkedFilter.sourceField` 값을 추출하여,
|
||||
자신의 데이터 중 `linkedFilter.targetField`가 일치하는 항목만 표시.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,451 @@
|
|||
# 생산계획 화면 (TOPSEAL_PP_MAIN) 테스트 시나리오
|
||||
|
||||
> **화면 URL**: `http://localhost:9771/screens/3985`
|
||||
> **로그인 정보**: `topseal_admin` / `qlalfqjsgh11`
|
||||
> **작성일**: 2026-03-16
|
||||
|
||||
---
|
||||
|
||||
## 사전 조건
|
||||
|
||||
- 백엔드 서버 (포트 8080) 실행 중
|
||||
- 프론트엔드 서버 (포트 9771) 실행 중
|
||||
- `topseal_admin` 계정으로 로그인 완료
|
||||
- 사이드바 > 생산관리 > 생산계획 메뉴 클릭하여 화면 진입
|
||||
|
||||
### 현재 테스트 데이터 현황
|
||||
|
||||
| 구분 | 건수 | 상세 |
|
||||
|------|:----:|------|
|
||||
| 완제품 생산계획 | 7건 | planned(3), in_progress(3), completed(1) |
|
||||
| 반제품 생산계획 | 6건 | planned(2), in_progress(2), completed(1) |
|
||||
| 설비(리소스) | 10개 | CNC밀링#1~#2, 머시닝센터#1, 레이저절단기, 프레스기#1, 용접기#1, 도장설비#1, 조립라인#1, 검사대#1~#2 |
|
||||
| 수주 데이터 | 10건 | sales_order_mng |
|
||||
|
||||
---
|
||||
|
||||
## TC-01. 화면 레이아웃 확인
|
||||
|
||||
### 목적
|
||||
화면이 설계대로 좌/우 분할 패널로 렌더링되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 생산계획 화면 진입
|
||||
2. 좌측 패널에 "수주 데이터" 탭이 보이는지 확인
|
||||
3. 우측 패널에 "완제품" / "반제품" 탭이 보이는지 확인
|
||||
4. 분할 패널 비율이 약 45:55인지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 좌측: "수주데이터" 탭 + "안전재고 부족분" 탭
|
||||
- [ ] 우측: "완제품" 탭 + "반제품" 탭
|
||||
- [ ] 하단에 버튼들 (새로고침, 자동 스케줄, 병합, 반제품계획, 저장) 표시
|
||||
- [ ] 좌측 하단에 "선택 품목 불러오기" 버튼 표시
|
||||
|
||||
---
|
||||
|
||||
## TC-02. 좌측 패널 - 수주데이터 그룹 테이블
|
||||
|
||||
### 목적
|
||||
v2-table-grouped 컴포넌트의 그룹화 및 접기/펼치기 기능 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. "수주데이터" 탭 선택
|
||||
2. 데이터가 품목코드(part_code) 기준으로 그룹화되었는지 확인
|
||||
3. 그룹 헤더 행에 품명, 품목코드가 표시되는지 확인
|
||||
4. 그룹 헤더 클릭하여 접기/펼치기 토글
|
||||
5. "전체 펼치기" / "전체 접기" 버튼 동작 확인
|
||||
6. 그룹별 합계(수주량, 출고량, 잔량) 표시 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 데이터가 part_code 기준으로 그룹화되어 표시
|
||||
- [ ] 그룹 헤더에 `{품명} ({품목코드})` 형식으로 표시
|
||||
- [ ] 그룹 헤더 클릭 시 하위 행 접기/펼치기 동작
|
||||
- [ ] 전체 펼치기/접기 버튼 정상 동작
|
||||
- [ ] 그룹별 수주량/출고량/잔량 합계 표시
|
||||
|
||||
---
|
||||
|
||||
## TC-03. 좌측 패널 - 체크박스 선택
|
||||
|
||||
### 목적
|
||||
그룹 테이블에서 체크박스 선택이 정상 동작하는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 개별 행 체크박스 선택/해제
|
||||
2. 그룹 헤더 체크박스로 그룹 전체 선택/해제
|
||||
3. 다른 그룹의 행도 동시 선택 가능한지 확인
|
||||
4. 선택된 행이 하이라이트되는지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 개별 행 체크박스 선택/해제 정상
|
||||
- [ ] 그룹 체크박스로 하위 전체 선택/해제
|
||||
- [ ] 여러 그룹에서 동시 선택 가능
|
||||
- [ ] 선택된 행 시각적 구분 (하이라이트)
|
||||
|
||||
---
|
||||
|
||||
## TC-04. 우측 패널 - 완제품 타임라인 기본 표시
|
||||
|
||||
### 목적
|
||||
v2-timeline-scheduler의 기본 렌더링 및 데이터 표시 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. "완제품" 탭 선택 (기본 선택)
|
||||
2. 타임라인 헤더에 날짜가 표시되는지 확인
|
||||
3. 리소스(설비) 목록이 좌측에 표시되는지 확인
|
||||
4. 스케줄 바가 해당 설비/날짜에 표시되는지 확인
|
||||
5. 스케줄 바에 품명이 표시되는지 확인
|
||||
6. 오늘 날짜 라인(빨간 세로선)이 표시되는지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 타임라인 헤더에 날짜 표시 (월 그룹 + 일별)
|
||||
- [ ] 좌측 리소스 열에 설비명 표시 (프레스기#1, CNC밀링머신#1 등)
|
||||
- [ ] 7건의 완제품 스케줄 바가 올바른 위치에 표시
|
||||
- [ ] 스케줄 바에 item_name 표시
|
||||
- [ ] 오늘 날짜 (2026-03-16) 위치에 빨간 세로선 표시
|
||||
- [ ] "반제품" 데이터는 보이지 않음 (staticFilters 적용 확인)
|
||||
|
||||
---
|
||||
|
||||
## TC-05. 타임라인 - 상태별 색상 표시
|
||||
|
||||
### 목적
|
||||
스케줄 상태에 따른 색상 구분 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 완제품 탭에서 스케줄 바 색상 확인
|
||||
2. 각 상태별 색상이 다른지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] `planned` (계획): 파란색 (#3b82f6)
|
||||
- [ ] `in_progress` (진행): 초록색 (#10b981)
|
||||
- [ ] `completed` (완료): 회색 (#6b7280)
|
||||
- [ ] `delayed` (지연): 빨간색 (#ef4444) - 해당 데이터 있으면
|
||||
- [ ] 상태별 색상이 명확히 구분됨
|
||||
|
||||
---
|
||||
|
||||
## TC-06. 타임라인 - 진행률 표시
|
||||
|
||||
### 목적
|
||||
스케줄 바 내부에 진행률이 시각적으로 표시되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 진행률이 있는 스케줄 바 확인
|
||||
2. 바 내부에 진행률 비율만큼 채워진 영역 확인
|
||||
3. 진행률 퍼센트 텍스트 표시 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] `탑씰 Type A` (id:103): 40% 진행률 표시
|
||||
- [ ] `탑씰 Type B` (id:2): 25% 진행률 표시
|
||||
- [ ] `탑씰 Type C` (id:105): 25% 진행률 표시
|
||||
- [ ] `탑씰 Type A` (id:4): 100% 진행률 표시 (완료)
|
||||
- [ ] 바 내부에 진행 영역이 색이 다르게 채워짐
|
||||
|
||||
---
|
||||
|
||||
## TC-07. 타임라인 - 줌 레벨 전환
|
||||
|
||||
### 목적
|
||||
일/주/월 줌 레벨 전환이 정상 동작하는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 툴바에서 "주" (기본) 줌 레벨 확인
|
||||
2. "일" 줌 레벨로 전환 -> 날짜 간격 변화 확인
|
||||
3. "월" 줌 레벨로 전환 -> 날짜 간격 변화 확인
|
||||
4. 다시 "주" 줌 레벨로 복귀
|
||||
|
||||
### 예상 결과
|
||||
- [ ] "일" 모드: 날짜 셀이 넓어지고, 하루 단위로 상세 표시
|
||||
- [ ] "주" 모드: 기본 크기, 주 단위 표시
|
||||
- [ ] "월" 모드: 날짜 셀이 좁아지고, 월 단위로 축소 표시
|
||||
- [ ] 줌 레벨 전환 시 스케줄 바 위치/크기가 자동 조정
|
||||
|
||||
---
|
||||
|
||||
## TC-08. 타임라인 - 날짜 네비게이션
|
||||
|
||||
### 목적
|
||||
이전/다음/오늘 버튼으로 타임라인 이동이 정상 동작하는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 툴바에서 현재 표시 날짜 확인
|
||||
2. "다음" 버튼 클릭 -> 다음 주(또는 기간)로 이동
|
||||
3. "이전" 버튼 클릭 -> 이전 주로 이동
|
||||
4. "오늘" 버튼 클릭 -> 현재 날짜 영역으로 이동
|
||||
5. 2월 초 데이터가 있으므로 충분히 이전으로 이동하여 과거 데이터 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] "다음" 클릭 시 타임라인이 오른쪽(미래)으로 이동
|
||||
- [ ] "이전" 클릭 시 타임라인이 왼쪽(과거)으로 이동
|
||||
- [ ] "오늘" 클릭 시 2026-03-16 부근으로 이동
|
||||
- [ ] 날짜 헤더의 표시 날짜가 변경됨
|
||||
- [ ] 이동 후에도 스케줄 바가 올바른 위치에 표시
|
||||
|
||||
---
|
||||
|
||||
## TC-09. 타임라인 - 드래그 이동
|
||||
|
||||
### 목적
|
||||
스케줄 바를 드래그하여 날짜를 변경하는 기능 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 완제품 탭에서 `planned` 상태의 스케줄 바 선택 (예: 탑씰 Type A, id:106)
|
||||
2. 스케줄 바를 마우스로 클릭하고 좌/우로 드래그
|
||||
3. 드래그 중 바가 마우스를 따라 이동하는지 확인 (시각적 피드백)
|
||||
4. 마우스 놓기 후 결과 확인
|
||||
5. 성공 시 토스트 알림 확인
|
||||
6. DB에 start_date/end_date가 변경되었는지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 스케줄 바 드래그 시 시각적으로 이동 (opacity 변화)
|
||||
- [ ] 드래그 완료 후 "스케줄이 이동되었습니다" 토스트 표시
|
||||
- [ ] 날짜가 드래그 거리만큼 변경 (시작일/종료일 동일 간격 유지)
|
||||
- [ ] 실패 시 "스케줄 이동 실패" 에러 토스트 표시 후 원래 위치로 복귀
|
||||
|
||||
---
|
||||
|
||||
## TC-10. 타임라인 - 리사이즈 (기간 조정)
|
||||
|
||||
### 목적
|
||||
스케줄 바의 시작/종료 핸들을 드래그하여 기간을 변경하는 기능 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 완제품 탭에서 스케줄 바에 마우스 호버
|
||||
2. 바 좌측/우측에 리사이즈 핸들이 나타나는지 확인
|
||||
3. 우측 핸들을 오른쪽으로 드래그 -> 종료일 연장
|
||||
4. 좌측 핸들을 오른쪽으로 드래그 -> 시작일 변경
|
||||
5. 성공 시 토스트 알림 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 바 호버 시 좌/우측에 리사이즈 핸들(세로 바) 표시
|
||||
- [ ] 우측 핸들 드래그 시 종료일만 변경 (시작일 유지)
|
||||
- [ ] 좌측 핸들 드래그 시 시작일만 변경 (종료일 유지)
|
||||
- [ ] 리사이즈 완료 후 "스케줄 기간이 변경되었습니다" 토스트 표시
|
||||
- [ ] 바 크기가 변경된 기간에 맞게 조정
|
||||
|
||||
---
|
||||
|
||||
## TC-11. 타임라인 - 충돌 감지
|
||||
|
||||
### 목적
|
||||
같은 설비에 시간이 겹치는 스케줄이 있을 때 충돌 표시가 되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 충돌 데이터 확인:
|
||||
- 프레스기#1 (equipment_id=11): id:103 (03/10~03/17), id:4 (01/28~01/30) → 겹치지 않아서 충돌 없음
|
||||
- 조립라인#1 (equipment_id=14): id:5 (02/01~02/02), id:6 (02/01~02/02) → 기간 겹침! (반제품)
|
||||
2. 반제품 탭으로 이동하여 조립라인#1의 충돌 확인
|
||||
3. 또는 드래그로 충돌 상황을 만들어서 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 충돌 스케줄 바에 빨간 외곽선 (`ring-destructive`) 표시
|
||||
- [ ] 충돌 스케줄 바에 경고 아이콘 (AlertTriangle) 표시
|
||||
- [ ] 툴바에 "충돌 N건" 배지 표시 (빨간색)
|
||||
- [ ] 충돌이 없는 경우 배지 미표시
|
||||
|
||||
---
|
||||
|
||||
## TC-12. 타임라인 - 범례 (Legend)
|
||||
|
||||
### 목적
|
||||
하단 범례가 정상 표시되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 타임라인 하단에 범례 영역이 표시되는지 확인
|
||||
2. 상태별 색상 스와치가 표시되는지 확인
|
||||
3. 마일스톤 아이콘이 표시되는지 확인
|
||||
4. 충돌 표시 범례가 표시되는지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] "계획" (파란색), "진행" (초록색), "완료" (회색), "지연" (빨간색), "취소" (연회색) 표시
|
||||
- [ ] "마일스톤" 다이아몬드 아이콘 표시
|
||||
- [ ] "충돌" 빨간 테두리 아이콘 표시 (showConflicts 설정 시)
|
||||
- [ ] 범례가 타임라인 하단에 깔끔하게 배치
|
||||
|
||||
---
|
||||
|
||||
## TC-13. 반제품 탭 전환
|
||||
|
||||
### 목적
|
||||
반제품 탭으로 전환 시 반제품 데이터만 필터링되어 표시되는지 확인 (staticFilters)
|
||||
|
||||
### 테스트 단계
|
||||
1. 우측 패널에서 "반제품" 탭 클릭
|
||||
2. 표시되는 스케줄이 반제품만인지 확인
|
||||
3. 완제품 데이터가 보이지 않는지 확인
|
||||
4. 다시 "완제품" 탭 클릭하여 전환 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] "반제품" 탭 클릭 시 반제품 스케줄만 표시 (4건)
|
||||
- [ ] 반제품 리소스: 조립라인#1, 용접기#1, 레이저절단기
|
||||
- [ ] 완제품 데이터는 표시되지 않음
|
||||
- [ ] "완제품" 탭 복귀 시 완제품 데이터만 표시
|
||||
|
||||
---
|
||||
|
||||
## TC-14. 버튼 - 새로고침
|
||||
|
||||
### 목적
|
||||
"새로고침" 버튼 클릭 시 데이터가 다시 로드되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 우측 패널 하단의 "새로고침" 버튼 클릭
|
||||
2. 타임라인 데이터가 다시 로드되는지 확인
|
||||
3. 토스트 알림 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 클릭 시 API 호출 (GET /api/production/order-summary)
|
||||
- [ ] 성공 시 "데이터를 새로고침했습니다." 토스트 표시
|
||||
- [ ] 타임라인 데이터 갱신
|
||||
|
||||
---
|
||||
|
||||
## TC-15. 버튼 - 자동 스케줄
|
||||
|
||||
### 목적
|
||||
좌측 테이블에서 수주 데이터를 선택한 후 자동 스케줄 생성이 되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 좌측 패널에서 수주 데이터 행 1개 이상 체크박스 선택
|
||||
2. "자동 스케줄" 버튼 클릭
|
||||
3. 확인 다이얼로그 표시 확인 ("선택한 품목의 자동 스케줄을 생성하시겠습니까?")
|
||||
4. "확인" 클릭
|
||||
5. 결과 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 확인 다이얼로그 표시
|
||||
- [ ] 성공 시 "자동 스케줄이 생성되었습니다." 토스트 표시
|
||||
- [ ] 우측 타임라인에 새로운 스케줄 바 추가
|
||||
- [ ] 실패 시 에러 메시지 표시
|
||||
- [ ] 선택 없이 클릭 시 적절한 안내 메시지
|
||||
|
||||
---
|
||||
|
||||
## TC-16. 버튼 - 선택 품목 불러오기
|
||||
|
||||
### 목적
|
||||
좌측 수주 데이터에서 선택한 품목을 생산계획으로 불러오는 기능 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 좌측 수주데이터 탭에서 품목 선택 (체크박스)
|
||||
2. "선택 품목 불러오기" 버튼 클릭
|
||||
3. 확인 다이얼로그 ("선택한 품목의 생산계획을 생성하시겠습니까?")
|
||||
4. 결과 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 확인 다이얼로그 표시
|
||||
- [ ] 성공 시 "선택 품목이 불러와졌습니다." 토스트 표시
|
||||
- [ ] 타임라인 자동 새로고침
|
||||
|
||||
---
|
||||
|
||||
## TC-17. 버튼 - 저장
|
||||
|
||||
### 목적
|
||||
변경된 생산계획 데이터가 저장되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 타임라인에서 스케줄 바 드래그 또는 리사이즈로 데이터 변경
|
||||
2. "저장" 버튼 클릭
|
||||
3. 저장 결과 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 성공 시 "생산계획이 저장되었습니다." 토스트 표시
|
||||
- [ ] 변경 사항이 DB에 반영
|
||||
|
||||
---
|
||||
|
||||
## TC-18. 반응형 CSS 확인
|
||||
|
||||
### 목적
|
||||
공통 반응형 CSS가 올바르게 적용되었는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 브라우저 창 너비를 640px 이하로 줄이기 (모바일)
|
||||
2. 텍스트 크기, 버튼 크기, 패딩 변화 확인
|
||||
3. 브라우저 창 너비를 1280px 이상으로 늘리기 (데스크톱)
|
||||
4. 원래 크기로 복귀 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 모바일(~640px): 텍스트 `text-[10px]`, 작은 버튼, 좁은 패딩
|
||||
- [ ] 데스크톱(640px~): 텍스트 `text-sm`, 기본 버튼, 넓은 패딩
|
||||
- [ ] 줌 버튼, 네비게이션 버튼, 리소스명, 날짜 헤더 모두 반응형 적용
|
||||
- [ ] 스케줄 바 내부 텍스트도 반응형 (text-[10px] sm:text-xs)
|
||||
- [ ] 범례 텍스트도 반응형
|
||||
|
||||
---
|
||||
|
||||
## TC-19. 마일스톤 표시
|
||||
|
||||
### 목적
|
||||
시작일과 종료일이 같은 스케줄이 마일스톤(다이아몬드)으로 표시되는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. DB에 마일스톤 테스트 데이터 추가:
|
||||
```sql
|
||||
INSERT INTO production_plan_mng (id, item_name, product_type, status, start_date, end_date, equipment_id, progress_rate, company_code)
|
||||
VALUES (200, '마일스톤 테스트', '완제품', 'planned', '2026-03-20', '2026-03-20', 9, '0', 'COMPANY_7');
|
||||
```
|
||||
2. 새로고침 후 해당 날짜에 다이아몬드 마커가 표시되는지 확인
|
||||
3. 호버 시 정보 표시 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 시작일 = 종료일인 스케줄은 바 대신 다이아몬드 마커로 표시
|
||||
- [ ] 다이아몬드가 45도 회전된 정사각형으로 표시
|
||||
- [ ] 호버 시 효과 적용
|
||||
|
||||
---
|
||||
|
||||
## TC-20. 안전재고 부족분 탭
|
||||
|
||||
### 목적
|
||||
좌측 패널의 "안전재고 부족분" 탭이 정상 동작하는지 확인
|
||||
|
||||
### 테스트 단계
|
||||
1. 좌측 패널에서 "안전재고 부족분" 탭 클릭
|
||||
2. inventory_stock 테이블 데이터가 표시되는지 확인
|
||||
3. 빈 데이터인 경우 빈 상태 메시지 확인
|
||||
|
||||
### 예상 결과
|
||||
- [ ] 탭 전환 정상 동작
|
||||
- [ ] 데이터 있으면: 품목코드, 현재고, 안전재고, 창고, 최근입고일 표시
|
||||
- [ ] 데이터 없으면: "안전재고 부족분 데이터가 없습니다" 메시지
|
||||
|
||||
---
|
||||
|
||||
## 알려진 이슈 / 참고 사항
|
||||
|
||||
| 번호 | 내용 | 심각도 |
|
||||
|:----:|------|:------:|
|
||||
| 1 | "1 Issue" 배지가 화면 좌측 하단에 표시됨 (원인 미확인) | 낮음 |
|
||||
| 2 | 생산계획 화면 URL 직접 접근 시 회사정보 화면(138)이 먼저 보일 수 있음 → 사이드바 메뉴를 통해 접근 권장 | 중간 |
|
||||
| 3 | 설비(equipment_info)의 equipment_group이 null → 리소스 그룹핑 미표시 | 낮음 |
|
||||
| 4 | 가상 스크롤은 리소스(설비) 30개 이상일 때 자동 활성화 (현재 10개라 비활성) | 참고 |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 결과 요약
|
||||
|
||||
| TC | 항목 | 결과 | 비고 |
|
||||
|:--:|------|:----:|------|
|
||||
| 01 | 화면 레이아웃 | | |
|
||||
| 02 | 수주데이터 그룹 테이블 | | |
|
||||
| 03 | 체크박스 선택 | | |
|
||||
| 04 | 완제품 타임라인 기본 표시 | | |
|
||||
| 05 | 상태별 색상 | | |
|
||||
| 06 | 진행률 표시 | | |
|
||||
| 07 | 줌 레벨 전환 | | |
|
||||
| 08 | 날짜 네비게이션 | | |
|
||||
| 09 | 드래그 이동 | | |
|
||||
| 10 | 리사이즈 | | |
|
||||
| 11 | 충돌 감지 | | |
|
||||
| 12 | 범례 | | |
|
||||
| 13 | 반제품 탭 전환 | | |
|
||||
| 14 | 새로고침 버튼 | | |
|
||||
| 15 | 자동 스케줄 버튼 | | |
|
||||
| 16 | 선택 품목 불러오기 | | |
|
||||
| 17 | 저장 버튼 | | |
|
||||
| 18 | 반응형 CSS | | |
|
||||
| 19 | 마일스톤 표시 | | |
|
||||
| 20 | 안전재고 부족분 탭 | | |
|
||||
|
|
@ -153,15 +153,37 @@ export function useGroupedData(
|
|||
}
|
||||
);
|
||||
|
||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||
setRawData(Array.isArray(responseData) ? responseData : []);
|
||||
let responseData = response.data?.data?.data || response.data?.data || [];
|
||||
responseData = Array.isArray(responseData) ? responseData : [];
|
||||
|
||||
// dataFilter 적용 (클라이언트 사이드 필터링)
|
||||
if (config.dataFilter && config.dataFilter.length > 0) {
|
||||
responseData = responseData.filter((item: any) => {
|
||||
return config.dataFilter!.every((f) => {
|
||||
const val = item[f.column];
|
||||
switch (f.operator) {
|
||||
case "eq": return val === f.value;
|
||||
case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value;
|
||||
case "gt": return Number(val) > Number(f.value);
|
||||
case "lt": return Number(val) < Number(f.value);
|
||||
case "gte": return Number(val) >= Number(f.value);
|
||||
case "lte": return Number(val) <= Number(f.value);
|
||||
case "like": return String(val ?? "").includes(String(f.value));
|
||||
case "in": return Array.isArray(f.value) ? f.value.includes(val) : false;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setRawData(responseData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "데이터 로드 중 오류 발생");
|
||||
setRawData([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tableName, externalData, searchFilters]);
|
||||
}, [tableName, externalData, searchFilters, config.dataFilter]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
|
|
@ -9,18 +9,27 @@ import {
|
|||
Loader2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Package,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
TimelineSchedulerComponentProps,
|
||||
ScheduleItem,
|
||||
ZoomLevel,
|
||||
} from "./types";
|
||||
import { useTimelineData } from "./hooks/useTimelineData";
|
||||
import { TimelineHeader, ResourceRow, TimelineLegend } from "./components";
|
||||
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
||||
import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components";
|
||||
import { zoomLevelOptions, defaultTimelineSchedulerConfig, statusOptions } from "./config";
|
||||
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 가상 스크롤 활성화 임계값 (리소스 수)
|
||||
const VIRTUAL_THRESHOLD = 30;
|
||||
|
||||
/**
|
||||
* v2-timeline-scheduler 메인 컴포넌트
|
||||
|
|
@ -44,9 +53,59 @@ export function TimelineSchedulerComponent({
|
|||
}: TimelineSchedulerComponentProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ────────── 자동 스케줄 생성 상태 ──────────
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewApplying, setPreviewApplying] = useState(false);
|
||||
const [previewSummary, setPreviewSummary] = useState<any>(null);
|
||||
const [previewItems, setPreviewItems] = useState<any[]>([]);
|
||||
const [previewDeleted, setPreviewDeleted] = useState<any[]>([]);
|
||||
const [previewKept, setPreviewKept] = useState<any[]>([]);
|
||||
const linkedFilterValuesRef = useRef<any[]>([]);
|
||||
|
||||
// ────────── 반제품 계획 생성 상태 ──────────
|
||||
const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false);
|
||||
const [semiPreviewLoading, setSemiPreviewLoading] = useState(false);
|
||||
const [semiPreviewApplying, setSemiPreviewApplying] = useState(false);
|
||||
const [semiPreviewSummary, setSemiPreviewSummary] = useState<any>(null);
|
||||
const [semiPreviewItems, setSemiPreviewItems] = useState<any[]>([]);
|
||||
const [semiPreviewDeleted, setSemiPreviewDeleted] = useState<any[]>([]);
|
||||
const [semiPreviewKept, setSemiPreviewKept] = useState<any[]>([]);
|
||||
|
||||
// ────────── linkedFilter 상태 ──────────
|
||||
const linkedFilter = config.linkedFilter;
|
||||
const hasLinkedFilter = !!linkedFilter;
|
||||
const [linkedFilterValues, setLinkedFilterValues] = useState<string[]>([]);
|
||||
const [hasReceivedSelection, setHasReceivedSelection] = useState(false);
|
||||
|
||||
// linkedFilter 이벤트 수신
|
||||
useEffect(() => {
|
||||
if (!hasLinkedFilter) return;
|
||||
|
||||
const handler = (event: any) => {
|
||||
if (linkedFilter!.sourceTableName && event.tableName !== linkedFilter!.sourceTableName) return;
|
||||
if (linkedFilter!.sourceComponentId && event.componentId !== linkedFilter!.sourceComponentId) return;
|
||||
|
||||
const selectedRows: any[] = event.selectedRows || [];
|
||||
const sourceField = linkedFilter!.sourceField;
|
||||
|
||||
const values = selectedRows
|
||||
.map((row: any) => String(row[sourceField] ?? ""))
|
||||
.filter((v: string) => v !== "" && v !== "undefined" && v !== "null");
|
||||
|
||||
const uniqueValues = [...new Set(values)];
|
||||
setLinkedFilterValues(uniqueValues);
|
||||
setHasReceivedSelection(true);
|
||||
linkedFilterValuesRef.current = selectedRows;
|
||||
};
|
||||
|
||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, handler);
|
||||
return unsubscribe;
|
||||
}, [hasLinkedFilter, linkedFilter]);
|
||||
|
||||
// 타임라인 데이터 훅
|
||||
const {
|
||||
schedules,
|
||||
schedules: rawSchedules,
|
||||
resources,
|
||||
isLoading: hookLoading,
|
||||
error: hookError,
|
||||
|
|
@ -58,8 +117,21 @@ export function TimelineSchedulerComponent({
|
|||
goToNext,
|
||||
goToToday,
|
||||
updateSchedule,
|
||||
refresh: refreshTimeline,
|
||||
} = useTimelineData(config, externalSchedules, externalResources);
|
||||
|
||||
// linkedFilter 적용: 선택된 값으로 스케줄 필터링
|
||||
const schedules = useMemo(() => {
|
||||
if (!hasLinkedFilter) return rawSchedules;
|
||||
if (linkedFilterValues.length === 0) return [];
|
||||
|
||||
const targetField = linkedFilter!.targetField;
|
||||
return rawSchedules.filter((s) => {
|
||||
const val = String((s.data as any)?.[targetField] ?? (s as any)[targetField] ?? "");
|
||||
return linkedFilterValues.includes(val);
|
||||
});
|
||||
}, [rawSchedules, hasLinkedFilter, linkedFilterValues, linkedFilter]);
|
||||
|
||||
const isLoading = externalLoading ?? hookLoading;
|
||||
const error = externalError ?? hookError;
|
||||
|
||||
|
|
@ -267,11 +339,212 @@ export function TimelineSchedulerComponent({
|
|||
}
|
||||
}, [onAddSchedule, effectiveResources]);
|
||||
|
||||
// ────────── 자동 스케줄 생성: 미리보기 요청 ──────────
|
||||
const handleAutoSchedulePreview = useCallback(async () => {
|
||||
const selectedRows = linkedFilterValuesRef.current;
|
||||
if (!selectedRows || selectedRows.length === 0) {
|
||||
toast.warning("좌측에서 품목을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceField = config.linkedFilter?.sourceField || "part_code";
|
||||
const grouped = new Map<string, any[]>();
|
||||
selectedRows.forEach((row: any) => {
|
||||
const key = row[sourceField] || "";
|
||||
if (!key) return;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(row);
|
||||
});
|
||||
|
||||
const items = Array.from(grouped.entries()).map(([itemCode, rows]) => {
|
||||
const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0);
|
||||
const earliestDueDate = rows
|
||||
.map((r: any) => r.due_date)
|
||||
.filter(Boolean)
|
||||
.sort()[0] || new Date().toISOString().split("T")[0];
|
||||
const first = rows[0];
|
||||
|
||||
return {
|
||||
item_code: itemCode,
|
||||
item_name: first.part_name || first.item_name || itemCode,
|
||||
required_qty: totalBalanceQty,
|
||||
earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate,
|
||||
hourly_capacity: Number(first.hourly_capacity) || undefined,
|
||||
daily_capacity: Number(first.daily_capacity) || undefined,
|
||||
};
|
||||
}).filter((item) => item.required_qty > 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
toast.warning("선택된 품목의 잔량이 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPreviewDialog(true);
|
||||
setPreviewLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/production/generate-schedule/preview", {
|
||||
items,
|
||||
options: {
|
||||
product_type: config.staticFilters?.product_type || "완제품",
|
||||
safety_lead_time: 1,
|
||||
recalculate_unstarted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
setPreviewSummary(response.data.data.summary);
|
||||
setPreviewItems(response.data.data.previews);
|
||||
setPreviewDeleted(response.data.data.deletedSchedules || []);
|
||||
setPreviewKept(response.data.data.keptSchedules || []);
|
||||
} else {
|
||||
toast.error("미리보기 생성 실패");
|
||||
setShowPreviewDialog(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("미리보기 요청 실패", { description: err.message });
|
||||
setShowPreviewDialog(false);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [config.linkedFilter, config.staticFilters]);
|
||||
|
||||
// ────────── 자동 스케줄 생성: 확인 및 적용 ──────────
|
||||
const handleAutoScheduleApply = useCallback(async () => {
|
||||
if (!previewItems || previewItems.length === 0) return;
|
||||
|
||||
setPreviewApplying(true);
|
||||
|
||||
const items = previewItems.map((p: any) => ({
|
||||
item_code: p.item_code,
|
||||
item_name: p.item_name,
|
||||
required_qty: p.required_qty,
|
||||
earliest_due_date: p.due_date,
|
||||
hourly_capacity: p.hourly_capacity,
|
||||
daily_capacity: p.daily_capacity,
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/production/generate-schedule", {
|
||||
items,
|
||||
options: {
|
||||
product_type: config.staticFilters?.product_type || "완제품",
|
||||
safety_lead_time: 1,
|
||||
recalculate_unstarted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
const summary = response.data.data.summary;
|
||||
toast.success("생산계획 업데이트 완료", {
|
||||
description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}건`,
|
||||
});
|
||||
setShowPreviewDialog(false);
|
||||
refreshTimeline();
|
||||
} else {
|
||||
toast.error("생산계획 생성 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("생산계획 생성 실패", { description: err.message });
|
||||
} finally {
|
||||
setPreviewApplying(false);
|
||||
}
|
||||
}, [previewItems, config.staticFilters, refreshTimeline]);
|
||||
|
||||
// ────────── 반제품 계획 생성: 미리보기 요청 ──────────
|
||||
const handleSemiSchedulePreview = useCallback(async () => {
|
||||
// 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집
|
||||
const finishedSchedules = schedules.filter((s) => {
|
||||
const productType = (s.data as any)?.product_type || "";
|
||||
return productType === "완제품";
|
||||
});
|
||||
|
||||
if (finishedSchedules.length === 0) {
|
||||
toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
|
||||
if (planIds.length === 0) {
|
||||
toast.warning("유효한 완제품 계획 ID가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSemiPreviewDialog(true);
|
||||
setSemiPreviewLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
|
||||
plan_ids: planIds,
|
||||
options: { considerStock: true },
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
setSemiPreviewSummary(response.data.data.summary);
|
||||
setSemiPreviewItems(response.data.data.previews || []);
|
||||
setSemiPreviewDeleted(response.data.data.deletedSchedules || []);
|
||||
setSemiPreviewKept(response.data.data.keptSchedules || []);
|
||||
} else {
|
||||
toast.error("반제품 미리보기 실패", { description: response.data?.message });
|
||||
setShowSemiPreviewDialog(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("반제품 미리보기 요청 실패", { description: err.message });
|
||||
setShowSemiPreviewDialog(false);
|
||||
} finally {
|
||||
setSemiPreviewLoading(false);
|
||||
}
|
||||
}, [schedules]);
|
||||
|
||||
// ────────── 반제품 계획 생성: 확인 및 적용 ──────────
|
||||
const handleSemiScheduleApply = useCallback(async () => {
|
||||
const finishedSchedules = schedules.filter((s) => {
|
||||
const productType = (s.data as any)?.product_type || "";
|
||||
return productType === "완제품";
|
||||
});
|
||||
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
|
||||
|
||||
if (planIds.length === 0) return;
|
||||
|
||||
setSemiPreviewApplying(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/production/generate-semi-schedule", {
|
||||
plan_ids: planIds,
|
||||
options: { considerStock: true },
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
const data = response.data.data;
|
||||
toast.success("반제품 계획 생성 완료", {
|
||||
description: `${data.count}건의 반제품 계획이 생성되었습니다`,
|
||||
});
|
||||
setShowSemiPreviewDialog(false);
|
||||
refreshTimeline();
|
||||
} else {
|
||||
toast.error("반제품 계획 생성 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("반제품 계획 생성 실패", { description: err.message });
|
||||
} finally {
|
||||
setSemiPreviewApplying(false);
|
||||
}
|
||||
}, [schedules, refreshTimeline]);
|
||||
|
||||
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
|
||||
const showToolbar = config.showToolbar !== false;
|
||||
const showLegend = config.showLegend !== false;
|
||||
const toolbarHeight = showToolbar ? 36 : 0;
|
||||
const legendHeight = showLegend ? 28 : 0;
|
||||
|
||||
// ────────── 가상 스크롤 ──────────
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: effectiveResources.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// ────────── 디자인 모드 플레이스홀더 ──────────
|
||||
if (isDesignMode) {
|
||||
|
|
@ -320,8 +593,49 @@ export function TimelineSchedulerComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// ────────── 데이터 없음 ──────────
|
||||
if (schedules.length === 0) {
|
||||
// ────────── linkedFilter 빈 상태 (itemGrouped가 아닌 경우만 early return) ──────────
|
||||
// itemGrouped 모드에서는 툴바를 항상 보여주기 위해 여기서 return하지 않음
|
||||
if (config.viewMode !== "itemGrouped") {
|
||||
if (hasLinkedFilter && !hasReceivedSelection) {
|
||||
const emptyMsg = linkedFilter?.emptyMessage || "좌측 목록에서 품목 또는 수주를 선택하세요";
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-lg border bg-muted/10"
|
||||
style={{ height: config.height || 500 }}
|
||||
>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Package className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
||||
<p className="text-xs font-medium sm:text-sm">{emptyMsg}</p>
|
||||
<p className="mt-1.5 max-w-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
||||
선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLinkedFilter && hasReceivedSelection && schedules.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-lg border bg-muted/10"
|
||||
style={{ height: config.height || 500 }}
|
||||
>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
||||
<p className="text-xs font-medium sm:text-sm">
|
||||
선택한 항목에 대한 스케줄이 없습니다
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
||||
다른 품목을 선택하거나 스케줄을 생성해 주세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────── 데이터 없음 (linkedFilter 없고 itemGrouped가 아닌 경우) ──────────
|
||||
if (schedules.length === 0 && config.viewMode !== "itemGrouped") {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-lg border bg-muted/10"
|
||||
|
|
@ -342,7 +656,178 @@ export function TimelineSchedulerComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// ────────── 메인 렌더링 ──────────
|
||||
// ────────── 품목 그룹 모드 렌더링 ──────────
|
||||
if (config.viewMode === "itemGrouped") {
|
||||
const itemGroups = groupSchedulesByItem(schedules);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
|
||||
style={{
|
||||
height: config.height || 500,
|
||||
maxHeight: config.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* 툴바: 액션 버튼 + 네비게이션 */}
|
||||
{showToolbar && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b bg-muted/30 px-2 py-1.5 sm:gap-2 sm:px-3 sm:py-2">
|
||||
{/* 네비게이션 */}
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
{config.showNavigation !== false && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={goToPrevious} className="h-6 px-1.5 sm:h-7 sm:px-2">
|
||||
<ChevronLeft className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={goToToday} className="h-6 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs">
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={goToNext} className="h-6 px-1.5 sm:h-7 sm:px-2">
|
||||
<ChevronRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-xs">
|
||||
{viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 줌 + 액션 버튼 */}
|
||||
<div className="ml-auto flex items-center gap-1 sm:gap-1.5">
|
||||
{config.showZoomControls !== false && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} className="h-6 px-1.5 sm:h-7 sm:px-2">
|
||||
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-medium sm:text-xs">
|
||||
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} className="h-6 px-1.5 sm:h-7 sm:px-2">
|
||||
<ZoomIn className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
<div className="mx-0.5 h-4 w-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={refreshTimeline} className="h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs">
|
||||
<RefreshCw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
새로고침
|
||||
</Button>
|
||||
{config.staticFilters?.product_type === "완제품" && (
|
||||
<>
|
||||
<Button size="sm" onClick={handleAutoSchedulePreview} className="h-6 gap-1 bg-emerald-600 px-2 text-[10px] hover:bg-emerald-700 sm:h-7 sm:px-3 sm:text-xs">
|
||||
<Zap className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
완제품 계획 생성
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSemiSchedulePreview} className="h-6 gap-1 bg-blue-600 px-2 text-[10px] hover:bg-blue-700 sm:h-7 sm:px-3 sm:text-xs">
|
||||
<Package className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
반제품 계획 생성
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 범례 */}
|
||||
{showLegend && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b px-3 py-1.5 sm:gap-4 sm:px-4 sm:py-2">
|
||||
<span className="text-[10px] text-muted-foreground sm:text-xs">생산 상태:</span>
|
||||
{statusOptions.map((s) => (
|
||||
<div key={s.value} className="flex items-center gap-1">
|
||||
<div className="h-2.5 w-2.5 rounded-sm sm:h-3 sm:w-3" style={{ backgroundColor: s.color }} />
|
||||
<span className="text-[10px] sm:text-xs">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[10px] text-muted-foreground sm:text-xs">납기:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2.5 w-2.5 rounded-sm border-2 border-destructive sm:h-3 sm:w-3" />
|
||||
<span className="text-[10px] sm:text-xs">납기일</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목별 카드 목록 또는 빈 상태 */}
|
||||
{itemGroups.length > 0 ? (
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-3 sm:space-y-4 sm:p-4">
|
||||
{itemGroups.map((group) => (
|
||||
<ItemTimelineCard
|
||||
key={group.itemCode}
|
||||
group={group}
|
||||
viewStartDate={viewStartDate}
|
||||
viewEndDate={viewEndDate}
|
||||
zoomLevel={zoomLevel}
|
||||
cellWidth={cellWidth}
|
||||
config={config}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{hasLinkedFilter && !hasReceivedSelection ? (
|
||||
<>
|
||||
<Package className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
||||
<p className="text-xs font-medium sm:text-sm">
|
||||
{linkedFilter?.emptyMessage || "좌측 목록에서 품목을 선택하세요"}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
||||
선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다
|
||||
</p>
|
||||
</>
|
||||
) : hasLinkedFilter && hasReceivedSelection ? (
|
||||
<>
|
||||
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
||||
<p className="text-xs font-medium sm:text-sm">
|
||||
선택한 항목에 대한 스케줄이 없습니다
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-[260px] text-[10px] sm:mt-2 sm:text-xs">
|
||||
위 "자동 스케줄 생성" 버튼으로 생산계획을 생성하세요
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
|
||||
<p className="text-xs font-medium sm:text-sm">스케줄 데이터가 없습니다</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완제품 스케줄 생성 미리보기 다이얼로그 */}
|
||||
<SchedulePreviewDialog
|
||||
open={showPreviewDialog}
|
||||
onOpenChange={setShowPreviewDialog}
|
||||
isLoading={previewLoading}
|
||||
summary={previewSummary}
|
||||
previews={previewItems}
|
||||
deletedSchedules={previewDeleted}
|
||||
keptSchedules={previewKept}
|
||||
onConfirm={handleAutoScheduleApply}
|
||||
isApplying={previewApplying}
|
||||
/>
|
||||
|
||||
{/* 반제품 계획 생성 미리보기 다이얼로그 */}
|
||||
<SchedulePreviewDialog
|
||||
open={showSemiPreviewDialog}
|
||||
onOpenChange={setShowSemiPreviewDialog}
|
||||
isLoading={semiPreviewLoading}
|
||||
summary={semiPreviewSummary}
|
||||
previews={semiPreviewItems}
|
||||
deletedSchedules={semiPreviewDeleted}
|
||||
keptSchedules={semiPreviewKept}
|
||||
onConfirm={handleSemiScheduleApply}
|
||||
isApplying={semiPreviewApplying}
|
||||
title="반제품 계획 자동 생성"
|
||||
description="BOM 기반으로 완제품 계획에 필요한 반제품 생산계획을 생성합니다"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────── 메인 렌더링 (리소스 기반) ──────────
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -450,7 +935,7 @@ export function TimelineSchedulerComponent({
|
|||
)}
|
||||
|
||||
{/* 타임라인 본문 (스크롤 영역) */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="min-w-max">
|
||||
{/* 헤더 */}
|
||||
<TimelineHeader
|
||||
|
|
@ -463,28 +948,73 @@ export function TimelineSchedulerComponent({
|
|||
showTodayLine={config.showTodayLine}
|
||||
/>
|
||||
|
||||
{/* 리소스 행들 */}
|
||||
<div>
|
||||
{effectiveResources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
schedules={schedulesByResource.get(resource.id) || []}
|
||||
startDate={viewStartDate}
|
||||
endDate={viewEndDate}
|
||||
zoomLevel={zoomLevel}
|
||||
rowHeight={rowHeight}
|
||||
cellWidth={cellWidth}
|
||||
resourceColumnWidth={resourceColumnWidth}
|
||||
config={config}
|
||||
conflictIds={conflictIds}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onCellClick={handleCellClick}
|
||||
onDragComplete={handleDragComplete}
|
||||
onResizeComplete={handleResizeComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 리소스 행들 - 30개 이상이면 가상 스크롤 */}
|
||||
{useVirtual ? (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const resource = effectiveResources[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<ResourceRow
|
||||
resource={resource}
|
||||
schedules={
|
||||
schedulesByResource.get(resource.id) || []
|
||||
}
|
||||
startDate={viewStartDate}
|
||||
endDate={viewEndDate}
|
||||
zoomLevel={zoomLevel}
|
||||
rowHeight={rowHeight}
|
||||
cellWidth={cellWidth}
|
||||
resourceColumnWidth={resourceColumnWidth}
|
||||
config={config}
|
||||
conflictIds={conflictIds}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onCellClick={handleCellClick}
|
||||
onDragComplete={handleDragComplete}
|
||||
onResizeComplete={handleResizeComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{effectiveResources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
schedules={
|
||||
schedulesByResource.get(resource.id) || []
|
||||
}
|
||||
startDate={viewStartDate}
|
||||
endDate={viewEndDate}
|
||||
zoomLevel={zoomLevel}
|
||||
rowHeight={rowHeight}
|
||||
cellWidth={cellWidth}
|
||||
resourceColumnWidth={resourceColumnWidth}
|
||||
config={config}
|
||||
conflictIds={conflictIds}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onCellClick={handleCellClick}
|
||||
onDragComplete={handleDragComplete}
|
||||
onResizeComplete={handleResizeComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flame } from "lucide-react";
|
||||
import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types";
|
||||
import { statusOptions, dayLabels } from "../config";
|
||||
|
||||
interface ItemScheduleGroup {
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
hourlyCapacity: number;
|
||||
dailyCapacity: number;
|
||||
schedules: ScheduleItem[];
|
||||
totalPlanQty: number;
|
||||
totalCompletedQty: number;
|
||||
remainingQty: number;
|
||||
dueDates: { date: string; isUrgent: boolean }[];
|
||||
}
|
||||
|
||||
interface ItemTimelineCardProps {
|
||||
group: ItemScheduleGroup;
|
||||
viewStartDate: Date;
|
||||
viewEndDate: Date;
|
||||
zoomLevel: ZoomLevel;
|
||||
cellWidth: number;
|
||||
config: TimelineSchedulerConfig;
|
||||
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||
}
|
||||
|
||||
const toDateString = (d: Date) => d.toISOString().split("T")[0];
|
||||
|
||||
const addDays = (d: Date, n: number) => {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
};
|
||||
|
||||
const diffDays = (a: Date, b: Date) =>
|
||||
Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
function generateDateCells(start: Date, end: Date) {
|
||||
const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = [];
|
||||
const today = toDateString(new Date());
|
||||
let cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const d = new Date(cur);
|
||||
const dow = d.getDay();
|
||||
cells.push({
|
||||
date: d,
|
||||
label: String(d.getDate()),
|
||||
dayLabel: dayLabels[dow],
|
||||
isWeekend: dow === 0 || dow === 6,
|
||||
isToday: toDateString(d) === today,
|
||||
dateStr: toDateString(d),
|
||||
});
|
||||
cur = addDays(cur, 1);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function ItemTimelineCard({
|
||||
group,
|
||||
viewStartDate,
|
||||
viewEndDate,
|
||||
zoomLevel,
|
||||
cellWidth,
|
||||
config,
|
||||
onScheduleClick,
|
||||
}: ItemTimelineCardProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dateCells = useMemo(
|
||||
() => generateDateCells(viewStartDate, viewEndDate),
|
||||
[viewStartDate, viewEndDate]
|
||||
);
|
||||
|
||||
const totalWidth = dateCells.length * cellWidth;
|
||||
|
||||
const dueDateSet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
group.dueDates.forEach((d) => set.add(d.date));
|
||||
return set;
|
||||
}, [group.dueDates]);
|
||||
|
||||
const urgentDateSet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date));
|
||||
return set;
|
||||
}, [group.dueDates]);
|
||||
|
||||
const statusColor = (status: string) =>
|
||||
config.statusColors?.[status as keyof typeof config.statusColors] ||
|
||||
statusOptions.find((s) => s.value === status)?.color ||
|
||||
"#3b82f6";
|
||||
|
||||
const isUrgentItem = group.dueDates.some((d) => d.isUrgent);
|
||||
const hasRemaining = group.remainingQty > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background">
|
||||
{/* 품목 헤더 */}
|
||||
<div className="flex items-start justify-between border-b px-3 py-2 sm:px-4 sm:py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<input type="checkbox" className="mt-1 h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground sm:text-xs">{group.itemCode}</p>
|
||||
<p className="text-xs font-semibold sm:text-sm">{group.itemName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-muted-foreground sm:text-xs">
|
||||
<p>
|
||||
시간당: <span className="font-semibold text-foreground">{group.hourlyCapacity.toLocaleString()}</span> EA
|
||||
</p>
|
||||
<p>
|
||||
일일: <span className="font-semibold text-foreground">{group.dailyCapacity.toLocaleString()}</span> EA
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타임라인 영역 */}
|
||||
<div ref={scrollRef} className="overflow-x-auto">
|
||||
<div style={{ width: totalWidth, minWidth: "100%" }}>
|
||||
{/* 날짜 헤더 */}
|
||||
<div className="flex border-b">
|
||||
{dateCells.map((cell) => {
|
||||
const isDueDate = dueDateSet.has(cell.dateStr);
|
||||
const isUrgentDate = urgentDateSet.has(cell.dateStr);
|
||||
return (
|
||||
<div
|
||||
key={cell.dateStr}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center justify-center border-r py-1",
|
||||
cell.isWeekend && "bg-muted/30",
|
||||
cell.isToday && "bg-primary/5",
|
||||
isDueDate && "ring-2 ring-inset ring-destructive",
|
||||
isUrgentDate && "bg-destructive/5"
|
||||
)}
|
||||
style={{ width: cellWidth }}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-[10px] font-medium sm:text-xs",
|
||||
cell.isToday && "text-primary",
|
||||
cell.isWeekend && "text-destructive/70"
|
||||
)}>
|
||||
{cell.label}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[8px] sm:text-[10px]",
|
||||
cell.isToday && "text-primary",
|
||||
cell.isWeekend && "text-destructive/50",
|
||||
!cell.isToday && !cell.isWeekend && "text-muted-foreground"
|
||||
)}>
|
||||
{cell.dayLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 스케줄 바 영역 */}
|
||||
<div className="relative" style={{ height: 48 }}>
|
||||
{group.schedules.map((schedule) => {
|
||||
const schedStart = new Date(schedule.startDate);
|
||||
const schedEnd = new Date(schedule.endDate);
|
||||
|
||||
const startOffset = diffDays(schedStart, viewStartDate);
|
||||
const endOffset = diffDays(schedEnd, viewStartDate);
|
||||
|
||||
const left = Math.max(0, startOffset * cellWidth);
|
||||
const right = Math.min(totalWidth, (endOffset + 1) * cellWidth);
|
||||
const width = Math.max(cellWidth * 0.5, right - left);
|
||||
|
||||
if (right < 0 || left > totalWidth) return null;
|
||||
|
||||
const qty = Number(schedule.data?.plan_qty) || 0;
|
||||
const color = statusColor(schedule.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className="absolute cursor-pointer rounded-md shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{
|
||||
left,
|
||||
top: 8,
|
||||
width,
|
||||
height: 32,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
onClick={() => onScheduleClick?.(schedule)}
|
||||
title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center truncate px-1 text-[10px] font-medium text-white sm:text-xs">
|
||||
{qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 납기일 마커 */}
|
||||
{group.dueDates.map((dueDate, idx) => {
|
||||
const d = new Date(dueDate.date);
|
||||
const offset = diffDays(d, viewStartDate);
|
||||
if (offset < 0 || offset > dateCells.length) return null;
|
||||
const left = offset * cellWidth + cellWidth / 2;
|
||||
return (
|
||||
<div
|
||||
key={`due-${idx}`}
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left, width: 0 }}
|
||||
>
|
||||
<div className={cn(
|
||||
"absolute top-0 h-full w-px",
|
||||
dueDate.isUrgent ? "bg-destructive" : "bg-destructive/40"
|
||||
)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 잔량 영역 */}
|
||||
<div className="flex items-center gap-2 border-t px-3 py-1.5 sm:px-4 sm:py-2">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
|
||||
{hasRemaining && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold sm:text-xs",
|
||||
isUrgentItem
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-warning/10 text-warning"
|
||||
)}>
|
||||
{isUrgentItem && <Flame className="h-3 w-3 sm:h-3.5 sm:w-3.5" />}
|
||||
{group.remainingQty.toLocaleString()} EA
|
||||
</div>
|
||||
)}
|
||||
{/* 스크롤 인디케이터 */}
|
||||
<div className="ml-auto flex-1">
|
||||
<div className="h-1 w-16 rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 데이터를 품목별로 그룹화
|
||||
*/
|
||||
export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] {
|
||||
const grouped = new Map<string, ScheduleItem[]>();
|
||||
|
||||
schedules.forEach((s) => {
|
||||
const key = s.data?.item_code || "unknown";
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(s);
|
||||
});
|
||||
|
||||
const result: ItemScheduleGroup[] = [];
|
||||
|
||||
grouped.forEach((items, itemCode) => {
|
||||
const first = items[0];
|
||||
const hourlyCapacity = Number(first.data?.hourly_capacity) || 0;
|
||||
const dailyCapacity = Number(first.data?.daily_capacity) || 0;
|
||||
const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0);
|
||||
const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0);
|
||||
|
||||
const dueDates: { date: string; isUrgent: boolean }[] = [];
|
||||
const seenDueDates = new Set<string>();
|
||||
items.forEach((s) => {
|
||||
const dd = s.data?.due_date;
|
||||
if (dd) {
|
||||
const dateStr = typeof dd === "string" ? dd.split("T")[0] : "";
|
||||
if (dateStr && !seenDueDates.has(dateStr)) {
|
||||
seenDueDates.add(dateStr);
|
||||
const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high";
|
||||
dueDates.push({ date: dateStr, isUrgent });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
itemCode,
|
||||
itemName: first.data?.item_name || first.title || itemCode,
|
||||
hourlyCapacity,
|
||||
dailyCapacity,
|
||||
schedules: items.sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
),
|
||||
totalPlanQty,
|
||||
totalCompletedQty,
|
||||
remainingQty: totalPlanQty - totalCompletedQty,
|
||||
dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
});
|
||||
});
|
||||
|
||||
return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode));
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, AlertTriangle, Check, X, Trash2, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { statusOptions } from "../config";
|
||||
|
||||
interface PreviewItem {
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
required_qty: number;
|
||||
daily_capacity: number;
|
||||
hourly_capacity: number;
|
||||
production_days: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
due_date: string;
|
||||
order_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ExistingSchedule {
|
||||
id: string;
|
||||
plan_no: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
plan_qty: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
completed_qty?: string;
|
||||
}
|
||||
|
||||
interface PreviewSummary {
|
||||
total: number;
|
||||
new_count: number;
|
||||
kept_count: number;
|
||||
deleted_count: number;
|
||||
}
|
||||
|
||||
interface SchedulePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
summary: PreviewSummary | null;
|
||||
previews: PreviewItem[];
|
||||
deletedSchedules: ExistingSchedule[];
|
||||
keptSchedules: ExistingSchedule[];
|
||||
onConfirm: () => void;
|
||||
isApplying: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{ key: "total", label: "총 계획", color: "bg-primary/10 text-primary" },
|
||||
{ key: "new_count", label: "신규 입력", color: "bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400" },
|
||||
{ key: "deleted_count", label: "삭제될", color: "bg-destructive/10 text-destructive" },
|
||||
{ key: "kept_count", label: "유지(진행중)", color: "bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400" },
|
||||
];
|
||||
|
||||
function formatDate(d: string | null | undefined): string {
|
||||
if (!d) return "-";
|
||||
const s = typeof d === "string" ? d : String(d);
|
||||
return s.split("T")[0];
|
||||
}
|
||||
|
||||
export function SchedulePreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isLoading,
|
||||
summary,
|
||||
previews,
|
||||
deletedSchedules,
|
||||
keptSchedules,
|
||||
onConfirm,
|
||||
isApplying,
|
||||
title,
|
||||
description,
|
||||
}: SchedulePreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{title || "생산계획 변경사항 확인"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{description || "변경사항을 확인해주세요"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">미리보기 생성 중...</span>
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
|
||||
{/* 경고 배너 */}
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium">변경사항을 확인해주세요</p>
|
||||
<p className="mt-0.5 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
|
||||
아래 변경사항을 검토하신 후 확인 버튼을 눌러주시면 생산계획이 업데이트됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{summaryCards.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className={cn("rounded-lg px-3 py-3 text-center", card.color)}
|
||||
>
|
||||
<p className="text-lg font-bold sm:text-xl">
|
||||
{(summary as any)[card.key] ?? 0}
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-xs">{card.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 신규 생성 목록 */}
|
||||
{previews.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-emerald-600 sm:text-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
신규 생성되는 계획 ({previews.length}건)
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{previews.map((item, idx) => {
|
||||
const statusInfo = statusOptions.find((s) => s.value === item.status);
|
||||
return (
|
||||
<div key={idx} className="rounded-md border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold sm:text-sm">
|
||||
{item.item_code} - {item.item_name}
|
||||
</p>
|
||||
<span
|
||||
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
|
||||
style={{ backgroundColor: statusInfo?.color || "#3b82f6" }}
|
||||
>
|
||||
{statusInfo?.label || item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-primary sm:text-sm">
|
||||
수량: <span className="font-semibold">{(item.required_qty || (item as any).plan_qty || 0).toLocaleString()}</span> EA
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[10px] text-muted-foreground sm:text-xs">
|
||||
<span>시작일: {formatDate(item.start_date)}</span>
|
||||
<span>종료일: {formatDate(item.end_date)}</span>
|
||||
</div>
|
||||
{item.order_count ? (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
|
||||
{item.order_count}건 수주 통합 (총 {item.required_qty.toLocaleString()} EA)
|
||||
</p>
|
||||
) : (item as any).parent_item_name ? (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
|
||||
상위: {(item as any).parent_plan_no} ({(item as any).parent_item_name}) | BOM 수량: {(item as any).bom_qty || 1}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제될 목록 */}
|
||||
{deletedSchedules.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-destructive sm:text-sm">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
삭제될 기존 계획 ({deletedSchedules.length}건)
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{deletedSchedules.map((item, idx) => (
|
||||
<div key={idx} className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold sm:text-sm">
|
||||
{item.item_code} - {item.item_name}
|
||||
</p>
|
||||
<span className="rounded-md bg-destructive/20 px-2 py-0.5 text-[10px] font-medium text-destructive sm:text-xs">
|
||||
삭제 예정
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
|
||||
{item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA
|
||||
</p>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
|
||||
<span>시작일: {formatDate(item.start_date)}</span>
|
||||
<span>종료일: {formatDate(item.end_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 유지될 목록 (진행중) */}
|
||||
{keptSchedules.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-600 sm:text-sm">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
유지되는 진행중 계획 ({keptSchedules.length}건)
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{keptSchedules.map((item, idx) => {
|
||||
const statusInfo = statusOptions.find((s) => s.value === item.status);
|
||||
return (
|
||||
<div key={idx} className="rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold sm:text-sm">
|
||||
{item.item_code} - {item.item_name}
|
||||
</p>
|
||||
<span
|
||||
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
|
||||
style={{ backgroundColor: statusInfo?.color || "#f59e0b" }}
|
||||
>
|
||||
{statusInfo?.label || item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
|
||||
{item.plan_no} | 수량: {Number(item.plan_qty || 0).toLocaleString()} EA
|
||||
{item.completed_qty ? ` (완료: ${Number(item.completed_qty).toLocaleString()} EA)` : ""}
|
||||
</p>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
|
||||
<span>시작일: {formatDate(item.start_date)}</span>
|
||||
<span>종료일: {formatDate(item.end_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
미리보기 데이터를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isApplying}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
<X className="mr-1 h-3.5 w-3.5" />
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading || isApplying || !summary || previews.length === 0}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isApplying ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
확인 및 적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,3 +2,5 @@ export { TimelineHeader } from "./TimelineHeader";
|
|||
export { ScheduleBar } from "./ScheduleBar";
|
||||
export { ResourceRow } from "./ResourceRow";
|
||||
export { TimelineLegend } from "./TimelineLegend";
|
||||
export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard";
|
||||
export { SchedulePreviewDialog } from "./SchedulePreviewDialog";
|
||||
|
|
|
|||
|
|
@ -225,6 +225,35 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
|
|||
|
||||
/** 최대 높이 */
|
||||
maxHeight?: number | string;
|
||||
|
||||
/**
|
||||
* 표시 모드
|
||||
* - "resource": 기존 설비(리소스) 기반 간트 차트 (기본값)
|
||||
* - "itemGrouped": 품목별 카드형 타임라인 (참고 이미지 스타일)
|
||||
*/
|
||||
viewMode?: "resource" | "itemGrouped";
|
||||
|
||||
/** 범례 표시 여부 */
|
||||
showLegend?: boolean;
|
||||
|
||||
/**
|
||||
* 연결 필터 설정: 다른 컴포넌트의 선택에 따라 데이터를 필터링
|
||||
* 설정 시 초기 상태는 빈 화면, 선택 이벤트 수신 시 필터링된 데이터 표시
|
||||
*/
|
||||
linkedFilter?: {
|
||||
/** 소스 컴포넌트 ID (선택 이벤트를 발생시키는 컴포넌트) */
|
||||
sourceComponentId?: string;
|
||||
/** 소스 테이블명 (이벤트의 tableName과 매칭) */
|
||||
sourceTableName?: string;
|
||||
/** 소스 필드 (선택된 행에서 추출할 필드) */
|
||||
sourceField: string;
|
||||
/** 타겟 필드 (타임라인 데이터에서 필터링할 필드) */
|
||||
targetField: string;
|
||||
/** 선택 없을 때 빈 상태 표시 여부 (기본: true) */
|
||||
showEmptyWhenNoSelection?: boolean;
|
||||
/** 빈 상태 메시지 */
|
||||
emptyMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-placeholder": "^2.27.1",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
|
|
@ -3756,6 +3757,23 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.22",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.22.tgz",
|
||||
"integrity": "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
|
|
@ -3769,6 +3787,16 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.22",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz",
|
||||
"integrity": "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-placeholder": "^2.27.1",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue