Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
Made-with: Cursor ; Conflicts: ; backend-node/src/services/numberingRuleService.ts
This commit is contained in:
commit
7bb74ec449
|
|
@ -153,6 +153,7 @@ backend-node/uploads/
|
||||||
uploads/
|
uploads/
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
|
*.png
|
||||||
*.gif
|
*.gif
|
||||||
*.pdf
|
*.pdf
|
||||||
*.doc
|
*.doc
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,318 @@
|
||||||
|
/**
|
||||||
|
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
|
||||||
|
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
|
||||||
|
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
|
||||||
|
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
// ── 배포 DB 연결 ──
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
|
||||||
|
});
|
||||||
|
|
||||||
|
const COMPANY_CODE = "COMPANY_7";
|
||||||
|
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
|
||||||
|
|
||||||
|
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
|
||||||
|
const actionIconMap: Record<string, string> = {
|
||||||
|
save: "Check",
|
||||||
|
delete: "Trash2",
|
||||||
|
edit: "Pencil",
|
||||||
|
navigate: "ArrowRight",
|
||||||
|
modal: "Maximize2",
|
||||||
|
transferData: "SendHorizontal",
|
||||||
|
excel_download: "Download",
|
||||||
|
excel_upload: "Upload",
|
||||||
|
quickInsert: "Zap",
|
||||||
|
control: "Settings",
|
||||||
|
barcode_scan: "ScanLine",
|
||||||
|
operation_control: "Truck",
|
||||||
|
event: "Send",
|
||||||
|
copy: "Copy",
|
||||||
|
};
|
||||||
|
const FALLBACK_ICON = "SquareMousePointer";
|
||||||
|
|
||||||
|
function getIconForAction(actionType?: string): string {
|
||||||
|
if (actionType && actionIconMap[actionType]) {
|
||||||
|
return actionIconMap[actionType];
|
||||||
|
}
|
||||||
|
return FALLBACK_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
|
||||||
|
function isTopLevelButton(comp: any): boolean {
|
||||||
|
return (
|
||||||
|
comp.url?.includes("v2-button-primary") ||
|
||||||
|
comp.overrides?.type === "v2-button-primary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTabChildButton(comp: any): boolean {
|
||||||
|
return comp.componentType === "v2-button-primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isButtonComponent(comp: any): boolean {
|
||||||
|
return isTopLevelButton(comp) || isTabChildButton(comp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 탭 위젯인지 판별 ──
|
||||||
|
function isTabsWidget(comp: any): boolean {
|
||||||
|
return (
|
||||||
|
comp.url?.includes("v2-tabs-widget") ||
|
||||||
|
comp.overrides?.type === "v2-tabs-widget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
|
||||||
|
function applyButtonStyle(config: any, actionType: string | undefined) {
|
||||||
|
const iconName = getIconForAction(actionType);
|
||||||
|
|
||||||
|
config.displayMode = "icon-text";
|
||||||
|
|
||||||
|
config.icon = {
|
||||||
|
name: iconName,
|
||||||
|
type: "lucide",
|
||||||
|
size: "보통",
|
||||||
|
...(config.icon?.color ? { color: config.icon.color } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.iconTextPosition = "right";
|
||||||
|
config.iconGap = 6;
|
||||||
|
|
||||||
|
if (!config.style) config.style = {};
|
||||||
|
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
|
||||||
|
config.style.borderRadius = "8px";
|
||||||
|
config.style.labelColor = "#FFFFFF";
|
||||||
|
config.style.fontSize = "12px";
|
||||||
|
config.style.fontWeight = "normal";
|
||||||
|
config.style.labelTextAlign = "left";
|
||||||
|
|
||||||
|
if (actionType === "delete") {
|
||||||
|
config.style.backgroundColor = "#F04544";
|
||||||
|
} else if (actionType === "excel_upload" || actionType === "excel_download") {
|
||||||
|
config.style.backgroundColor = "#212121";
|
||||||
|
} else {
|
||||||
|
config.style.backgroundColor = "#3B83F6";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonStyle(comp: any): boolean {
|
||||||
|
if (isTopLevelButton(comp)) {
|
||||||
|
const overrides = comp.overrides || {};
|
||||||
|
const actionType = overrides.action?.type;
|
||||||
|
|
||||||
|
if (!comp.size) comp.size = {};
|
||||||
|
comp.size.height = 40;
|
||||||
|
|
||||||
|
applyButtonStyle(overrides, actionType);
|
||||||
|
comp.overrides = overrides;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTabChildButton(comp)) {
|
||||||
|
const config = comp.componentConfig || {};
|
||||||
|
const actionType = config.action?.type;
|
||||||
|
|
||||||
|
if (!comp.size) comp.size = {};
|
||||||
|
comp.size.height = 40;
|
||||||
|
|
||||||
|
applyButtonStyle(config, actionType);
|
||||||
|
comp.componentConfig = config;
|
||||||
|
|
||||||
|
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
|
||||||
|
if (!comp.style) comp.style = {};
|
||||||
|
comp.style.borderRadius = "8px";
|
||||||
|
comp.style.labelColor = "#FFFFFF";
|
||||||
|
comp.style.fontSize = "12px";
|
||||||
|
comp.style.fontWeight = "normal";
|
||||||
|
comp.style.labelTextAlign = "left";
|
||||||
|
comp.style.backgroundColor = config.style.backgroundColor;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 백업 테이블 생성 ──
|
||||||
|
async function createBackup() {
|
||||||
|
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
|
||||||
|
|
||||||
|
const exists = await pool.query(
|
||||||
|
`SELECT to_regclass($1) AS tbl`,
|
||||||
|
[BACKUP_TABLE],
|
||||||
|
);
|
||||||
|
if (exists.rows[0].tbl) {
|
||||||
|
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
|
||||||
|
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||||
|
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`CREATE TABLE ${BACKUP_TABLE} AS
|
||||||
|
SELECT * FROM screen_layouts_v2
|
||||||
|
WHERE company_code = $1`,
|
||||||
|
[COMPANY_CODE],
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
||||||
|
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 백업에서 원복 ──
|
||||||
|
async function restoreFromBackup() {
|
||||||
|
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE screen_layouts_v2 AS target
|
||||||
|
SET layout_data = backup.layout_data,
|
||||||
|
updated_at = backup.updated_at
|
||||||
|
FROM ${BACKUP_TABLE} AS backup
|
||||||
|
WHERE target.screen_id = backup.screen_id
|
||||||
|
AND target.company_code = backup.company_code
|
||||||
|
AND target.layer_id = backup.layer_id`,
|
||||||
|
);
|
||||||
|
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메인: 버튼 일괄 변경 ──
|
||||||
|
async function updateButtons(testMode: boolean) {
|
||||||
|
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
|
||||||
|
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
|
||||||
|
|
||||||
|
// company_7 레코드 조회
|
||||||
|
const rows = await pool.query(
|
||||||
|
`SELECT screen_id, layer_id, company_code, layout_data
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE company_code = $1
|
||||||
|
ORDER BY screen_id, layer_id`,
|
||||||
|
[COMPANY_CODE],
|
||||||
|
);
|
||||||
|
console.log(`대상 레코드 수: ${rows.rowCount}`);
|
||||||
|
|
||||||
|
if (!rows.rowCount) {
|
||||||
|
console.log("변경할 레코드가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
let totalUpdated = 0;
|
||||||
|
let totalButtons = 0;
|
||||||
|
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
|
||||||
|
|
||||||
|
for (const row of targetRows) {
|
||||||
|
const layoutData = row.layout_data;
|
||||||
|
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttonsInRow = 0;
|
||||||
|
for (const comp of layoutData.components) {
|
||||||
|
// 최상위 버튼 처리
|
||||||
|
if (updateButtonStyle(comp)) {
|
||||||
|
buttonsInRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 위젯 내부 버튼 처리
|
||||||
|
if (isTabsWidget(comp)) {
|
||||||
|
const tabs = comp.overrides?.tabs || [];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const tabComps = tab.components || [];
|
||||||
|
for (const tabComp of tabComps) {
|
||||||
|
if (updateButtonStyle(tabComp)) {
|
||||||
|
buttonsInRow++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttonsInRow > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_layouts_v2
|
||||||
|
SET layout_data = $1, updated_at = NOW()
|
||||||
|
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
|
||||||
|
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
|
||||||
|
);
|
||||||
|
totalUpdated++;
|
||||||
|
totalButtons += buttonsInRow;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
|
||||||
|
if (testMode) {
|
||||||
|
const sampleBtn = layoutData.components.find(isButtonComponent);
|
||||||
|
if (sampleBtn) {
|
||||||
|
console.log("\n--- 변경 후 샘플 버튼 ---");
|
||||||
|
console.log(JSON.stringify(sampleBtn, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n--- 결과 ---`);
|
||||||
|
console.log(`변경된 레코드: ${totalUpdated}개`);
|
||||||
|
console.log(`변경된 버튼: ${totalButtons}개`);
|
||||||
|
|
||||||
|
if (testMode) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
|
||||||
|
} else {
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log("\nCOMMIT 완료.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("\n에러 발생. ROLLBACK 완료.", err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI 진입점 ──
|
||||||
|
async function main() {
|
||||||
|
const arg = process.argv[2];
|
||||||
|
|
||||||
|
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
|
||||||
|
console.log("사용법:");
|
||||||
|
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
|
||||||
|
console.log(" --run : 전체 실행 (COMMIT)");
|
||||||
|
console.log(" --backup : 백업 테이블 생성");
|
||||||
|
console.log(" --restore : 백업에서 원복");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (arg === "--backup") {
|
||||||
|
await createBackup();
|
||||||
|
} else if (arg === "--restore") {
|
||||||
|
await restoreFromBackup();
|
||||||
|
} else if (arg === "--test") {
|
||||||
|
await createBackup();
|
||||||
|
await updateButtons(true);
|
||||||
|
} else if (arg === "--run") {
|
||||||
|
await createBackup();
|
||||||
|
await updateButtons(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("스크립트 실행 실패:", err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||||
|
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
|
|
@ -124,6 +125,7 @@ import entitySearchRoutes, {
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||||
|
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
|
|
@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||||
app.use("/api/screen-management", screenManagementRoutes);
|
app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||||
|
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
|
@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||||
|
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||||
|
|
|
||||||
|
|
@ -314,13 +314,14 @@ router.post(
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
const { formData, manualInputValue } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const previewCode = await numberingRuleService.previewCode(
|
const previewCode = await numberingRuleService.previewCode(
|
||||||
ruleId,
|
ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
formData
|
formData,
|
||||||
|
manualInputValue
|
||||||
);
|
);
|
||||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-BE1: 작업지시 공정 일괄 생성
|
||||||
|
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
|
||||||
|
*/
|
||||||
|
export const createWorkProcesses = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
if (!work_instruction_id || !routing_version_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"work_instruction_id와 routing_version_id는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[pop/production] create-work-processes 요청", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
work_instruction_id,
|
||||||
|
item_code,
|
||||||
|
routing_version_id,
|
||||||
|
plan_qty,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
|
||||||
|
const existCheck = await client.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM work_order_process
|
||||||
|
WHERE wo_id = $1 AND company_code = $2`,
|
||||||
|
[work_instruction_id, companyCode]
|
||||||
|
);
|
||||||
|
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 공정이 생성된 작업지시입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
|
||||||
|
const routingDetails = await client.query(
|
||||||
|
`SELECT rd.id, rd.seq_no, rd.process_code,
|
||||||
|
COALESCE(pm.process_name, rd.process_code) as process_name,
|
||||||
|
rd.is_required, rd.is_fixed_order, rd.standard_time
|
||||||
|
FROM item_routing_detail rd
|
||||||
|
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
|
||||||
|
AND pm.company_code = rd.company_code
|
||||||
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||||
|
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
|
||||||
|
[routing_version_id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routingDetails.rows.length === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "라우팅 버전에 등록된 공정이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes: Array<{
|
||||||
|
id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_name: string;
|
||||||
|
checklist_count: number;
|
||||||
|
}> = [];
|
||||||
|
let totalChecklists = 0;
|
||||||
|
|
||||||
|
for (const rd of routingDetails.rows) {
|
||||||
|
// 2. work_order_process INSERT
|
||||||
|
const wopResult = await client.query(
|
||||||
|
`INSERT INTO work_order_process (
|
||||||
|
company_code, wo_id, seq_no, process_code, process_name,
|
||||||
|
is_required, is_fixed_order, standard_time, plan_qty,
|
||||||
|
status, routing_detail_id, writer
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
work_instruction_id,
|
||||||
|
rd.seq_no,
|
||||||
|
rd.process_code,
|
||||||
|
rd.process_name,
|
||||||
|
rd.is_required,
|
||||||
|
rd.is_fixed_order,
|
||||||
|
rd.standard_time,
|
||||||
|
plan_qty || null,
|
||||||
|
"waiting",
|
||||||
|
rd.id,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const wopId = wopResult.rows[0].id;
|
||||||
|
|
||||||
|
// 3. process_work_result INSERT (스냅샷 복사)
|
||||||
|
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
|
||||||
|
const snapshotResult = await client.query(
|
||||||
|
`INSERT INTO process_work_result (
|
||||||
|
company_code, work_order_process_id,
|
||||||
|
source_work_item_id, source_detail_id,
|
||||||
|
work_phase, item_title, item_sort_order,
|
||||||
|
detail_content, detail_type, detail_sort_order, is_required,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
input_type, lookup_target, display_fields, duration_minutes,
|
||||||
|
status, writer
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
pwi.company_code, $1,
|
||||||
|
pwi.id, pwd.id,
|
||||||
|
pwi.work_phase, pwi.title, pwi.sort_order::text,
|
||||||
|
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
|
||||||
|
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
|
||||||
|
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
|
||||||
|
'pending', $2
|
||||||
|
FROM process_work_item pwi
|
||||||
|
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
|
||||||
|
AND pwd.company_code = pwi.company_code
|
||||||
|
WHERE pwi.routing_detail_id = $3
|
||||||
|
AND pwi.company_code = $4
|
||||||
|
ORDER BY pwi.sort_order, pwd.sort_order`,
|
||||||
|
[wopId, userId, rd.id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const checklistCount = snapshotResult.rowCount ?? 0;
|
||||||
|
totalChecklists += checklistCount;
|
||||||
|
|
||||||
|
processes.push({
|
||||||
|
id: wopId,
|
||||||
|
seq_no: rd.seq_no,
|
||||||
|
process_name: rd.process_name,
|
||||||
|
checklist_count: checklistCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("[pop/production] 공정 생성 완료", {
|
||||||
|
wopId,
|
||||||
|
processName: rd.process_name,
|
||||||
|
checklistCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("[pop/production] create-work-processes 완료", {
|
||||||
|
companyCode,
|
||||||
|
work_instruction_id,
|
||||||
|
total_processes: processes.length,
|
||||||
|
total_checklists: totalChecklists,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
processes,
|
||||||
|
total_processes: processes.length,
|
||||||
|
total_checklists: totalChecklists,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("[pop/production] create-work-processes 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "공정 생성 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-BE2: 타이머 API (시작/일시정지/재시작)
|
||||||
|
*/
|
||||||
|
export const controlTimer = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const { work_order_process_id, action } = req.body;
|
||||||
|
|
||||||
|
if (!work_order_process_id || !action) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "work_order_process_id와 action은 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["start", "pause", "resume"].includes(action)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "action은 start, pause, resume 중 하나여야 합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[pop/production] timer 요청", {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
work_order_process_id,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "start":
|
||||||
|
// 최초 1회만 설정, 이미 있으면 무시
|
||||||
|
result = await pool.query(
|
||||||
|
`UPDATE work_order_process
|
||||||
|
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
|
||||||
|
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING id, started_at, status`,
|
||||||
|
[work_order_process_id, companyCode]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pause":
|
||||||
|
result = await pool.query(
|
||||||
|
`UPDATE work_order_process
|
||||||
|
SET paused_at = NOW()::text,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
|
||||||
|
RETURNING id, paused_at`,
|
||||||
|
[work_order_process_id, companyCode]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "resume":
|
||||||
|
// 일시정지 시간 누적 후 paused_at 초기화
|
||||||
|
result = await pool.query(
|
||||||
|
`UPDATE work_order_process
|
||||||
|
SET total_paused_time = (
|
||||||
|
COALESCE(total_paused_time::int, 0)
|
||||||
|
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
|
||||||
|
)::text,
|
||||||
|
paused_at = NULL,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
|
||||||
|
RETURNING id, total_paused_time`,
|
||||||
|
[work_order_process_id, companyCode]
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[pop/production] timer 완료", {
|
||||||
|
action,
|
||||||
|
work_order_process_id,
|
||||||
|
result: result.rows[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("[pop/production] timer 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* 생산계획 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import * as productionService from "../services/productionPlanService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||||
|
|
||||||
|
export async function getOrderSummary(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { excludePlanned, itemCode, itemName } = req.query;
|
||||||
|
|
||||||
|
const data = await productionService.getOrderSummary(companyCode, {
|
||||||
|
excludePlanned: excludePlanned === "true",
|
||||||
|
itemCode: itemCode as string,
|
||||||
|
itemName: itemName as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 getStockShortage(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const data = await productionService.getStockShortage(companyCode);
|
||||||
|
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 getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const planId = parseInt(req.params.id, 10);
|
||||||
|
const data = await productionService.getPlanById(companyCode, planId);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
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 updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const planId = parseInt(req.params.id, 10);
|
||||||
|
const updatedBy = req.user!.userId;
|
||||||
|
|
||||||
|
const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("생산계획 수정 실패", { error: error.message });
|
||||||
|
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 생산계획 삭제 ───
|
||||||
|
|
||||||
|
export async function deletePlan(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const planId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
|
await productionService.deletePlan(companyCode, planId);
|
||||||
|
return res.json({ success: true, message: "삭제되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("생산계획 삭제 실패", { error: error.message });
|
||||||
|
return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 자동 스케줄 미리보기 (실제 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) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const createdBy = req.user!.userId;
|
||||||
|
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.generateSchedule(companyCode, items, options || {}, createdBy);
|
||||||
|
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 mergeSchedules(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const mergedBy = req.user!.userId;
|
||||||
|
const { schedule_ids, product_type } = req.body;
|
||||||
|
|
||||||
|
if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) {
|
||||||
|
return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await productionService.mergeSchedules(
|
||||||
|
companyCode,
|
||||||
|
schedule_ids,
|
||||||
|
product_type || "완제품",
|
||||||
|
mergedBy
|
||||||
|
);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("스케줄 병합 실패", { error: error.message });
|
||||||
|
const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500;
|
||||||
|
return res.status(status).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 반제품 계획 미리보기 (실제 변경 없이 예상 결과) ───
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const createdBy = req.user!.userId;
|
||||||
|
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.generateSemiSchedule(
|
||||||
|
companyCode,
|
||||||
|
plan_ids,
|
||||||
|
options || {},
|
||||||
|
createdBy
|
||||||
|
);
|
||||||
|
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 splitSchedule(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const splitBy = req.user!.userId;
|
||||||
|
const planId = parseInt(req.params.id, 10);
|
||||||
|
const { split_qty } = req.body;
|
||||||
|
|
||||||
|
if (!split_qty || split_qty <= 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("스케줄 분할 실패", { error: error.message });
|
||||||
|
return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,11 @@ interface TaskBody {
|
||||||
manualItemField?: string;
|
manualItemField?: string;
|
||||||
manualPkColumn?: string;
|
manualPkColumn?: string;
|
||||||
cartScreenId?: string;
|
cartScreenId?: string;
|
||||||
|
preCondition?: {
|
||||||
|
column: string;
|
||||||
|
expectedValue: string;
|
||||||
|
failMessage?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatusValue(
|
function resolveStatusValue(
|
||||||
|
|
@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
const item = items[i] ?? {};
|
const item = items[i] ?? {};
|
||||||
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||||
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||||
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
const condParams: unknown[] = [resolved, companyCode, lookupValues[i]];
|
||||||
[resolved, companyCode, lookupValues[i]],
|
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||||
|
if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||||
|
condWhere += ` AND "${task.preCondition.column}" = $4`;
|
||||||
|
condParams.push(task.preCondition.expectedValue);
|
||||||
|
}
|
||||||
|
const condResult = await client.query(
|
||||||
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`,
|
||||||
|
condParams,
|
||||||
);
|
);
|
||||||
|
if (task.preCondition && condResult.rowCount === 0) {
|
||||||
|
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||||
|
(err as any).isPreConditionFail = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
}
|
||||||
} else if (opType === "db-conditional") {
|
} else if (opType === "db-conditional") {
|
||||||
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
if (task.preCondition) {
|
||||||
|
logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", {
|
||||||
|
taskId: task.id, preCondition: task.preCondition,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||||
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||||
|
|
||||||
|
|
@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
|
||||||
await client.query(
|
let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`;
|
||||||
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
const queryParams: unknown[] = [value, companyCode, lookupValues[i]];
|
||||||
[value, companyCode, lookupValues[i]],
|
if (task.preCondition?.column && task.preCondition?.expectedValue) {
|
||||||
|
if (!isSafeIdentifier(task.preCondition.column)) {
|
||||||
|
throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`);
|
||||||
|
}
|
||||||
|
whereSql += ` AND "${task.preCondition.column}" = $4`;
|
||||||
|
queryParams.push(task.preCondition.expectedValue);
|
||||||
|
}
|
||||||
|
const updateResult = await client.query(
|
||||||
|
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`,
|
||||||
|
queryParams,
|
||||||
);
|
);
|
||||||
|
if (task.preCondition && updateResult.rowCount === 0) {
|
||||||
|
const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다.");
|
||||||
|
(err as any).isPreConditionFail = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
|
|
||||||
|
if (error.isPreConditionFail) {
|
||||||
|
logger.warn("[pop/execute-action] preCondition 실패", { message: error.message });
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
errorCode: "PRE_CONDITION_FAIL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.error("[pop/execute-action] 오류:", error);
|
logger.error("[pop/execute-action] 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import {
|
||||||
|
createWorkProcesses,
|
||||||
|
controlTimer,
|
||||||
|
} from "../controllers/popProductionController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.post("/create-work-processes", createWorkProcesses);
|
||||||
|
router.post("/timer", controlTimer);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* 생산계획 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as productionController from "../controllers/productionController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 수주 데이터 조회 (품목별 그룹핑)
|
||||||
|
router.get("/order-summary", productionController.getOrderSummary);
|
||||||
|
|
||||||
|
// 안전재고 부족분 조회
|
||||||
|
router.get("/stock-shortage", productionController.getStockShortage);
|
||||||
|
|
||||||
|
// 생산계획 CRUD
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 스케줄 분할
|
||||||
|
router.post("/plan/:id/split", productionController.splitSchedule);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -39,7 +39,9 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
|
||||||
result += val;
|
result += val;
|
||||||
if (idx < partValues.length - 1) {
|
if (idx < partValues.length - 1) {
|
||||||
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||||
result += sep;
|
if (val || !result.endsWith(sep)) {
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -74,16 +76,22 @@ class NumberingRuleService {
|
||||||
*/
|
*/
|
||||||
private async buildPrefixKey(
|
private async buildPrefixKey(
|
||||||
rule: NumberingRuleConfig,
|
rule: NumberingRuleConfig,
|
||||||
formData?: Record<string, any>
|
formData?: Record<string, any>,
|
||||||
|
manualValues?: string[]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||||
const prefixParts: string[] = [];
|
const prefixParts: string[] = [];
|
||||||
|
let manualIndex = 0;
|
||||||
|
|
||||||
for (const part of sortedParts) {
|
for (const part of sortedParts) {
|
||||||
if (part.partType === "sequence") continue;
|
if (part.partType === "sequence") continue;
|
||||||
|
|
||||||
if (part.generationMethod === "manual") {
|
if (part.generationMethod === "manual") {
|
||||||
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
|
const manualValue = manualValues?.[manualIndex] || "";
|
||||||
|
manualIndex++;
|
||||||
|
if (manualValue) {
|
||||||
|
prefixParts.push(manualValue);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1384,19 +1392,28 @@ class NumberingRuleService {
|
||||||
* @param ruleId 채번 규칙 ID
|
* @param ruleId 채번 규칙 ID
|
||||||
* @param companyCode 회사 코드
|
* @param companyCode 회사 코드
|
||||||
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||||
|
* @param manualInputValue 수동 입력 값 (접두어별 순번 조회용)
|
||||||
*/
|
*/
|
||||||
async previewCode(
|
async previewCode(
|
||||||
ruleId: string,
|
ruleId: string,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
formData?: Record<string, any>
|
formData?: Record<string, any>,
|
||||||
|
manualInputValue?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const rule = await this.getRuleById(ruleId, companyCode);
|
const rule = await this.getRuleById(ruleId, companyCode);
|
||||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||||
|
|
||||||
|
// 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
|
||||||
|
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||||
|
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||||
|
|
||||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||||
|
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
const currentSeq = skipSequenceLookup
|
||||||
|
? 0
|
||||||
|
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||||
|
|
||||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||||
let baseSeq = currentSeq;
|
let baseSeq = currentSeq;
|
||||||
|
|
@ -1427,7 +1444,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("미리보기: 순번 조회 완료", {
|
logger.info("미리보기: 순번 조회 완료", {
|
||||||
ruleId, prefixKey, currentSeq, baseSeq,
|
ruleId, prefixKey, currentSeq, baseSeq, skipSequenceLookup,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parts = await Promise.all(rule.parts
|
const parts = await Promise.all(rule.parts
|
||||||
|
|
@ -1442,7 +1459,8 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
const nextSequence = baseSeq + 1;
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
|
const nextSequence = baseSeq + startFrom;
|
||||||
return String(nextSequence).padStart(length, "0");
|
return String(nextSequence).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1484,110 +1502,8 @@ class NumberingRuleService {
|
||||||
return autoConfig.textValue || "TEXT";
|
return autoConfig.textValue || "TEXT";
|
||||||
}
|
}
|
||||||
|
|
||||||
case "category": {
|
case "category":
|
||||||
// 카테고리 기반 코드 생성
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
|
|
||||||
const categoryMappings = autoConfig.categoryMappings || [];
|
|
||||||
|
|
||||||
if (!categoryKey || !formData) {
|
|
||||||
logger.warn("카테고리 키 또는 폼 데이터 없음", {
|
|
||||||
categoryKey,
|
|
||||||
hasFormData: !!formData,
|
|
||||||
});
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
|
|
||||||
const columnName = categoryKey.includes(".")
|
|
||||||
? categoryKey.split(".")[1]
|
|
||||||
: categoryKey;
|
|
||||||
|
|
||||||
// 폼 데이터에서 해당 컬럼의 값 가져오기
|
|
||||||
const selectedValue = formData[columnName];
|
|
||||||
|
|
||||||
logger.info("카테고리 파트 처리", {
|
|
||||||
categoryKey,
|
|
||||||
columnName,
|
|
||||||
selectedValue,
|
|
||||||
formDataKeys: Object.keys(formData),
|
|
||||||
mappingsCount: categoryMappings.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selectedValue) {
|
|
||||||
logger.warn("카테고리 값이 선택되지 않음", {
|
|
||||||
columnName,
|
|
||||||
formDataKeys: Object.keys(formData),
|
|
||||||
});
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
|
||||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
|
||||||
const selectedValueStr = String(selectedValue);
|
|
||||||
let mapping = categoryMappings.find((m: any) => {
|
|
||||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
|
||||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
|
||||||
return true;
|
|
||||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
|
||||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
|
||||||
return true;
|
|
||||||
// 라벨로 매칭 (폴백)
|
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
|
||||||
if (!mapping) {
|
|
||||||
try {
|
|
||||||
const pool = getPool();
|
|
||||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
|
||||||
? categoryKey.split(".")
|
|
||||||
: [categoryKey, categoryKey];
|
|
||||||
const cvResult = await pool.query(
|
|
||||||
`SELECT value_id, value_code, value_label FROM category_values
|
|
||||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
|
||||||
[catTableName, catColumnName, selectedValueStr]
|
|
||||||
);
|
|
||||||
if (cvResult.rows.length > 0) {
|
|
||||||
const resolvedId = cvResult.rows[0].value_id;
|
|
||||||
const resolvedLabel = cvResult.rows[0].value_label;
|
|
||||||
mapping = categoryMappings.find((m: any) => {
|
|
||||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
|
||||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (mapping) {
|
|
||||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
|
||||||
valueCode: selectedValueStr,
|
|
||||||
resolvedId,
|
|
||||||
resolvedLabel,
|
|
||||||
format: mapping.format,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (lookupError: any) {
|
|
||||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mapping) {
|
|
||||||
logger.info("카테고리 매핑 적용", {
|
|
||||||
selectedValue,
|
|
||||||
format: mapping.format,
|
|
||||||
categoryValueLabel: mapping.categoryValueLabel,
|
|
||||||
});
|
|
||||||
return mapping.format || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("카테고리 매핑을 찾을 수 없음", {
|
|
||||||
selectedValue,
|
|
||||||
availableMappings: categoryMappings.map((m: any) => ({
|
|
||||||
id: m.categoryValueId,
|
|
||||||
label: m.categoryValueLabel,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
case "reference": {
|
case "reference": {
|
||||||
const refColumn = autoConfig.referenceColumnName;
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
|
|
@ -1636,11 +1552,29 @@ class NumberingRuleService {
|
||||||
const rule = await this.getRuleById(ruleId, companyCode);
|
const rule = await this.getRuleById(ruleId, companyCode);
|
||||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||||
|
|
||||||
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성
|
// 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
|
||||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
const manualParts = rule.parts.filter(
|
||||||
|
(p: any) => p.generationMethod === "manual"
|
||||||
|
);
|
||||||
|
let extractedManualValues: string[] = [];
|
||||||
|
|
||||||
|
if (manualParts.length > 0 && userInputCode) {
|
||||||
|
extractedManualValues = await this.extractManualValuesFromInput(
|
||||||
|
rule, userInputCode, formData
|
||||||
|
);
|
||||||
|
|
||||||
|
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
|
||||||
|
if (extractedManualValues.length === 0 && manualParts.length === 1) {
|
||||||
|
extractedManualValues = [userInputCode];
|
||||||
|
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: prefix_key 빌드 (수동 값 포함)
|
||||||
|
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
|
||||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||||
|
|
||||||
// 순번이 있으면 테이블 내 최대값과 카운터를 비교하여 다음 순번 결정
|
// 3단계: 순번이 있으면 prefix_key 기반 UPSERT + 테이블 내 최대값 비교하여 다음 순번 결정
|
||||||
let allocatedSequence = 0;
|
let allocatedSequence = 0;
|
||||||
if (hasSequence) {
|
if (hasSequence) {
|
||||||
allocatedSequence = await this.resolveNextSequence(
|
allocatedSequence = await this.resolveNextSequence(
|
||||||
|
|
@ -1648,137 +1582,16 @@ class NumberingRuleService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("allocateCode: 테이블 기반 순번 할당", {
|
logger.info("allocateCode: prefix_key + 테이블 기반 순번 할당", {
|
||||||
ruleId, prefixKey, allocatedSequence,
|
ruleId, prefixKey, allocatedSequence, extractedManualValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
|
|
||||||
const manualParts = rule.parts.filter(
|
|
||||||
(p: any) => p.generationMethod === "manual"
|
|
||||||
);
|
|
||||||
let extractedManualValues: string[] = [];
|
|
||||||
|
|
||||||
if (manualParts.length > 0 && userInputCode) {
|
|
||||||
const previewParts = await Promise.all(rule.parts
|
|
||||||
.sort((a: any, b: any) => a.order - b.order)
|
|
||||||
.map(async (part: any) => {
|
|
||||||
if (part.generationMethod === "manual") {
|
|
||||||
return "____";
|
|
||||||
}
|
|
||||||
const autoConfig = part.autoConfig || {};
|
|
||||||
switch (part.partType) {
|
|
||||||
case "sequence": {
|
|
||||||
const length = autoConfig.sequenceLength || 3;
|
|
||||||
return "X".repeat(length);
|
|
||||||
}
|
|
||||||
case "text":
|
|
||||||
return autoConfig.textValue || "";
|
|
||||||
case "date":
|
|
||||||
return "DATEPART";
|
|
||||||
case "category": {
|
|
||||||
const catKey2 = autoConfig.categoryKey;
|
|
||||||
const catMappings2 = autoConfig.categoryMappings || [];
|
|
||||||
|
|
||||||
if (!catKey2 || !formData) {
|
|
||||||
return "CATEGORY";
|
|
||||||
}
|
|
||||||
|
|
||||||
const colName2 = catKey2.includes(".")
|
|
||||||
? catKey2.split(".")[1]
|
|
||||||
: catKey2;
|
|
||||||
const selVal2 = formData[colName2];
|
|
||||||
|
|
||||||
if (!selVal2) {
|
|
||||||
return "CATEGORY";
|
|
||||||
}
|
|
||||||
|
|
||||||
const selValStr2 = String(selVal2);
|
|
||||||
let catMapping2 = catMappings2.find((m: any) => {
|
|
||||||
if (m.categoryValueId?.toString() === selValStr2) return true;
|
|
||||||
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
|
|
||||||
if (m.categoryValueLabel === selValStr2) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!catMapping2) {
|
|
||||||
try {
|
|
||||||
const pool2 = getPool();
|
|
||||||
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
|
|
||||||
const cvr2 = await pool2.query(
|
|
||||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
|
||||||
[ct2, cc2, selValStr2]
|
|
||||||
);
|
|
||||||
if (cvr2.rows.length > 0) {
|
|
||||||
const rid2 = cvr2.rows[0].value_id;
|
|
||||||
const rlabel2 = cvr2.rows[0].value_label;
|
|
||||||
catMapping2 = catMappings2.find((m: any) => {
|
|
||||||
if (m.categoryValueId?.toString() === String(rid2)) return true;
|
|
||||||
if (m.categoryValueLabel === rlabel2) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return catMapping2?.format || "CATEGORY";
|
|
||||||
}
|
|
||||||
case "reference": {
|
|
||||||
const refCol2 = autoConfig.referenceColumnName;
|
|
||||||
if (refCol2 && formData && formData[refCol2]) {
|
|
||||||
return String(formData[refCol2]);
|
|
||||||
}
|
|
||||||
return "REF";
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
|
||||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
|
||||||
|
|
||||||
const templateParts = previewTemplate.split("____");
|
|
||||||
if (templateParts.length > 1) {
|
|
||||||
let remainingCode = userInputCode;
|
|
||||||
for (let i = 0; i < templateParts.length - 1; i++) {
|
|
||||||
const prefix = templateParts[i];
|
|
||||||
const suffix = templateParts[i + 1];
|
|
||||||
|
|
||||||
if (prefix && remainingCode.startsWith(prefix)) {
|
|
||||||
remainingCode = remainingCode.slice(prefix.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suffix) {
|
|
||||||
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
|
||||||
const manualEndIndex = suffixStart
|
|
||||||
? remainingCode.indexOf(suffixStart)
|
|
||||||
: remainingCode.length;
|
|
||||||
if (manualEndIndex > 0) {
|
|
||||||
extractedManualValues.push(
|
|
||||||
remainingCode.slice(0, manualEndIndex)
|
|
||||||
);
|
|
||||||
remainingCode = remainingCode.slice(manualEndIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extractedManualValues.push(remainingCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let manualPartIndex = 0;
|
let manualPartIndex = 0;
|
||||||
const parts = await Promise.all(rule.parts
|
const parts = await Promise.all(rule.parts
|
||||||
.sort((a: any, b: any) => a.order - b.order)
|
.sort((a: any, b: any) => a.order - b.order)
|
||||||
.map(async (part: any) => {
|
.map(async (part: any) => {
|
||||||
if (part.generationMethod === "manual") {
|
if (part.generationMethod === "manual") {
|
||||||
const manualValue =
|
const manualValue = extractedManualValues[manualPartIndex] || "";
|
||||||
extractedManualValues[manualPartIndex] ||
|
|
||||||
part.manualConfig?.value ||
|
|
||||||
"";
|
|
||||||
manualPartIndex++;
|
manualPartIndex++;
|
||||||
return manualValue;
|
return manualValue;
|
||||||
}
|
}
|
||||||
|
|
@ -1788,7 +1601,9 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(allocatedSequence).padStart(length, "0");
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
|
const actualSequence = allocatedSequence + startFrom - 1;
|
||||||
|
return String(actualSequence).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
|
|
@ -1825,65 +1640,14 @@ class NumberingRuleService {
|
||||||
return autoConfig.textValue || "TEXT";
|
return autoConfig.textValue || "TEXT";
|
||||||
}
|
}
|
||||||
|
|
||||||
case "category": {
|
case "category":
|
||||||
const categoryKey = autoConfig.categoryKey;
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
const categoryMappings = autoConfig.categoryMappings || [];
|
|
||||||
|
|
||||||
if (!categoryKey || !formData) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnName = categoryKey.includes(".")
|
|
||||||
? categoryKey.split(".")[1]
|
|
||||||
: categoryKey;
|
|
||||||
|
|
||||||
const selectedValue = formData[columnName];
|
|
||||||
|
|
||||||
if (!selectedValue) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedValueStr = String(selectedValue);
|
|
||||||
let allocMapping = categoryMappings.find((m: any) => {
|
|
||||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
|
||||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
|
||||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!allocMapping) {
|
|
||||||
try {
|
|
||||||
const pool3 = getPool();
|
|
||||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
|
||||||
const cvr3 = await pool3.query(
|
|
||||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
|
||||||
[ct3, cc3, selectedValueStr]
|
|
||||||
);
|
|
||||||
if (cvr3.rows.length > 0) {
|
|
||||||
const rid3 = cvr3.rows[0].value_id;
|
|
||||||
const rlabel3 = cvr3.rows[0].value_label;
|
|
||||||
allocMapping = categoryMappings.find((m: any) => {
|
|
||||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
|
||||||
if (m.categoryValueLabel === rlabel3) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allocMapping) {
|
|
||||||
return allocMapping.format || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
case "reference": {
|
case "reference": {
|
||||||
const refColumn = autoConfig.referenceColumnName;
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
if (refColumn && formData && formData[refColumn]) {
|
if (refColumn && formData && formData[refColumn]) {
|
||||||
return String(formData[refColumn]);
|
return String(formData[refColumn]);
|
||||||
}
|
}
|
||||||
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1922,6 +1686,139 @@ class NumberingRuleService {
|
||||||
return this.allocateCode(ruleId, companyCode);
|
return this.allocateCode(ruleId, companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 입력 코드에서 수동 파트 값을 추출
|
||||||
|
* 템플릿 기반 파싱으로 수동 입력 위치("____")에 해당하는 값을 분리
|
||||||
|
*/
|
||||||
|
private async extractManualValuesFromInput(
|
||||||
|
rule: NumberingRuleConfig,
|
||||||
|
userInputCode: string,
|
||||||
|
formData?: Record<string, any>
|
||||||
|
): Promise<string[]> {
|
||||||
|
const extractedValues: string[] = [];
|
||||||
|
|
||||||
|
const previewParts = await Promise.all(rule.parts
|
||||||
|
.sort((a: any, b: any) => a.order - b.order)
|
||||||
|
.map(async (part: any) => {
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
return "____";
|
||||||
|
}
|
||||||
|
const autoConfig = part.autoConfig || {};
|
||||||
|
switch (part.partType) {
|
||||||
|
case "sequence": {
|
||||||
|
const length = autoConfig.sequenceLength || 3;
|
||||||
|
return "X".repeat(length);
|
||||||
|
}
|
||||||
|
case "text":
|
||||||
|
return autoConfig.textValue || "";
|
||||||
|
case "date":
|
||||||
|
return "DATEPART";
|
||||||
|
case "category":
|
||||||
|
return this.resolveCategoryFormat(autoConfig, formData);
|
||||||
|
case "reference": {
|
||||||
|
const refColumn = autoConfig.referenceColumnName;
|
||||||
|
if (refColumn && formData && formData[refColumn]) {
|
||||||
|
return String(formData[refColumn]);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||||
|
|
||||||
|
const templateParts = previewTemplate.split("____");
|
||||||
|
if (templateParts.length > 1) {
|
||||||
|
let remainingCode = userInputCode;
|
||||||
|
for (let i = 0; i < templateParts.length - 1; i++) {
|
||||||
|
const prefix = templateParts[i];
|
||||||
|
const suffix = templateParts[i + 1];
|
||||||
|
|
||||||
|
if (prefix && remainingCode.startsWith(prefix)) {
|
||||||
|
remainingCode = remainingCode.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix) {
|
||||||
|
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
|
||||||
|
const manualEndIndex = suffixStart
|
||||||
|
? remainingCode.indexOf(suffixStart)
|
||||||
|
: remainingCode.length;
|
||||||
|
if (manualEndIndex > 0) {
|
||||||
|
extractedValues.push(
|
||||||
|
remainingCode.slice(0, manualEndIndex)
|
||||||
|
);
|
||||||
|
remainingCode = remainingCode.slice(manualEndIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extractedValues.push(remainingCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return extractedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 매핑에서 format 값을 해석
|
||||||
|
* categoryKey + formData로 선택된 값을 찾고, 매핑 테이블에서 format 반환
|
||||||
|
*/
|
||||||
|
private async resolveCategoryFormat(
|
||||||
|
autoConfig: Record<string, any>,
|
||||||
|
formData?: Record<string, any>
|
||||||
|
): Promise<string> {
|
||||||
|
const categoryKey = autoConfig.categoryKey;
|
||||||
|
const categoryMappings = autoConfig.categoryMappings || [];
|
||||||
|
|
||||||
|
if (!categoryKey || !formData) return "";
|
||||||
|
|
||||||
|
const columnName = categoryKey.includes(".")
|
||||||
|
? categoryKey.split(".")[1]
|
||||||
|
: categoryKey;
|
||||||
|
const selectedValue = formData[columnName];
|
||||||
|
|
||||||
|
if (!selectedValue) return "";
|
||||||
|
|
||||||
|
const selectedValueStr = String(selectedValue);
|
||||||
|
let mapping = categoryMappings.find((m: any) => {
|
||||||
|
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||||
|
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||||
|
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
|
||||||
|
if (!mapping) {
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
const [tableName, colName] = categoryKey.includes(".")
|
||||||
|
? categoryKey.split(".")
|
||||||
|
: [categoryKey, categoryKey];
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||||
|
[tableName, colName, selectedValueStr]
|
||||||
|
);
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const resolvedId = result.rows[0].value_id;
|
||||||
|
const resolvedLabel = result.rows[0].value_label;
|
||||||
|
mapping = categoryMappings.find((m: any) => {
|
||||||
|
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||||
|
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping?.format || "";
|
||||||
|
}
|
||||||
|
|
||||||
private formatDate(date: Date, format: string): string {
|
private formatDate(date: Date, format: string): string {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,879 @@
|
||||||
|
/**
|
||||||
|
* 생산계획 서비스
|
||||||
|
* - 수주 데이터 조회 (품목별 그룹핑)
|
||||||
|
* - 안전재고 부족분 조회
|
||||||
|
* - 자동 스케줄 생성
|
||||||
|
* - 스케줄 병합
|
||||||
|
* - 반제품 계획 자동 생성
|
||||||
|
* - 스케줄 분할
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||||
|
|
||||||
|
export async function getOrderSummary(
|
||||||
|
companyCode: string,
|
||||||
|
options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string }
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
const conditions: string[] = ["so.company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIdx = 2;
|
||||||
|
|
||||||
|
if (options?.itemCode) {
|
||||||
|
conditions.push(`so.part_code ILIKE $${paramIdx}`);
|
||||||
|
params.push(`%${options.itemCode}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
if (options?.itemName) {
|
||||||
|
conditions.push(`so.part_name ILIKE $${paramIdx}`);
|
||||||
|
params.push(`%${options.itemName}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
WITH order_summary AS (
|
||||||
|
SELECT
|
||||||
|
so.part_code AS item_code,
|
||||||
|
COALESCE(so.part_name, so.part_code) AS item_name,
|
||||||
|
SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty,
|
||||||
|
SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty,
|
||||||
|
SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty,
|
||||||
|
COUNT(*) AS order_count,
|
||||||
|
MIN(so.due_date) AS earliest_due_date
|
||||||
|
FROM sales_order_mng so
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY so.part_code, so.part_name
|
||||||
|
),
|
||||||
|
stock_info AS (
|
||||||
|
SELECT
|
||||||
|
item_code,
|
||||||
|
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
|
||||||
|
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
|
||||||
|
FROM inventory_stock
|
||||||
|
WHERE company_code = $1
|
||||||
|
GROUP BY item_code
|
||||||
|
),
|
||||||
|
plan_info AS (
|
||||||
|
SELECT
|
||||||
|
item_code,
|
||||||
|
SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty,
|
||||||
|
SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty
|
||||||
|
FROM production_plan_mng
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND COALESCE(product_type, '완제품') = '완제품'
|
||||||
|
AND status NOT IN ('completed', 'cancelled')
|
||||||
|
GROUP BY item_code
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
os.item_code,
|
||||||
|
os.item_name,
|
||||||
|
os.total_order_qty,
|
||||||
|
os.total_ship_qty,
|
||||||
|
os.total_balance_qty,
|
||||||
|
os.order_count,
|
||||||
|
os.earliest_due_date,
|
||||||
|
COALESCE(si.current_stock, 0) AS current_stock,
|
||||||
|
COALESCE(si.safety_stock, 0) AS safety_stock,
|
||||||
|
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
|
||||||
|
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
|
||||||
|
GREATEST(
|
||||||
|
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||||
|
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||||
|
0
|
||||||
|
) AS required_plan_qty
|
||||||
|
FROM order_summary os
|
||||||
|
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||||
|
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||||
|
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||||
|
ORDER BY os.item_code;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
// 그룹별 상세 수주 데이터도 함께 조회
|
||||||
|
const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND ");
|
||||||
|
const detailQuery = `
|
||||||
|
SELECT
|
||||||
|
id, order_no, part_code, part_name,
|
||||||
|
COALESCE(order_qty::numeric, 0) AS order_qty,
|
||||||
|
COALESCE(ship_qty::numeric, 0) AS ship_qty,
|
||||||
|
COALESCE(balance_qty::numeric, 0) AS balance_qty,
|
||||||
|
due_date, status, partner_id, manager_name
|
||||||
|
FROM sales_order_mng
|
||||||
|
WHERE ${detailWhere}
|
||||||
|
ORDER BY part_code, due_date;
|
||||||
|
`;
|
||||||
|
const detailResult = await pool.query(detailQuery, params);
|
||||||
|
|
||||||
|
// 그룹별로 상세 데이터 매핑
|
||||||
|
const ordersByItem: Record<string, any[]> = {};
|
||||||
|
for (const row of detailResult.rows) {
|
||||||
|
const key = row.part_code || "__null__";
|
||||||
|
if (!ordersByItem[key]) ordersByItem[key] = [];
|
||||||
|
ordersByItem[key].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.rows.map((group: any) => ({
|
||||||
|
...group,
|
||||||
|
orders: ordersByItem[group.item_code || "__null__"] || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info("수주 데이터 조회", { companyCode, groupCount: data.length });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 안전재고 부족분 조회 ───
|
||||||
|
|
||||||
|
export async function getStockShortage(companyCode: string) {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
ist.item_code,
|
||||||
|
ii.item_name,
|
||||||
|
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
|
||||||
|
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
|
||||||
|
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
|
||||||
|
GREATEST(
|
||||||
|
COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0
|
||||||
|
) AS recommended_qty,
|
||||||
|
ist.last_in_date
|
||||||
|
FROM inventory_stock ist
|
||||||
|
LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
|
||||||
|
WHERE ist.company_code = $1
|
||||||
|
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
|
||||||
|
ORDER BY shortage_qty ASC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [companyCode]);
|
||||||
|
logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount });
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 생산계획 CRUD ───
|
||||||
|
|
||||||
|
export async function getPlanById(companyCode: string, planId: number) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
|
||||||
|
[planId, companyCode]
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlan(
|
||||||
|
companyCode: string,
|
||||||
|
planId: number,
|
||||||
|
data: Record<string, any>,
|
||||||
|
updatedBy: string
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const allowedFields = [
|
||||||
|
"plan_qty", "start_date", "end_date", "due_date",
|
||||||
|
"equipment_id", "equipment_code", "equipment_name",
|
||||||
|
"manager_name", "work_shift", "priority", "remarks", "status",
|
||||||
|
"item_code", "item_name", "product_type", "order_no",
|
||||||
|
];
|
||||||
|
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (data[field] !== undefined) {
|
||||||
|
setClauses.push(`${field} = $${paramIdx}`);
|
||||||
|
params.push(data[field]);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
throw new Error("수정할 필드가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
setClauses.push(`updated_date = NOW()`);
|
||||||
|
setClauses.push(`updated_by = $${paramIdx}`);
|
||||||
|
params.push(updatedBy);
|
||||||
|
paramIdx++;
|
||||||
|
|
||||||
|
params.push(planId);
|
||||||
|
params.push(companyCode);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE production_plan_mng
|
||||||
|
SET ${setClauses.join(", ")}
|
||||||
|
WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
|
||||||
|
}
|
||||||
|
logger.info("생산계획 수정", { companyCode, planId });
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlan(companyCode: string, planId: number) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||||
|
[planId, companyCode]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다");
|
||||||
|
}
|
||||||
|
logger.info("생산계획 삭제", { companyCode, planId });
|
||||||
|
return { id: planId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 자동 스케줄 생성 ───
|
||||||
|
|
||||||
|
interface GenerateScheduleItem {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
required_qty: number;
|
||||||
|
earliest_due_date: string;
|
||||||
|
hourly_capacity?: number;
|
||||||
|
daily_capacity?: number;
|
||||||
|
lead_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateScheduleOptions {
|
||||||
|
safety_lead_time?: number;
|
||||||
|
recalculate_unstarted?: boolean;
|
||||||
|
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[],
|
||||||
|
options: GenerateScheduleOptions,
|
||||||
|
createdBy: string
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
const productType = options.product_type || "완제품";
|
||||||
|
const safetyLeadTime = options.safety_lead_time || 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
let keptCount = 0;
|
||||||
|
const newSchedules: any[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// 기존 미진행(planned) 스케줄 처리
|
||||||
|
if (options.recalculate_unstarted) {
|
||||||
|
const deleteResult = await client.query(
|
||||||
|
`DELETE FROM production_plan_mng
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND item_code = $2
|
||||||
|
AND COALESCE(product_type, '완제품') = $3
|
||||||
|
AND status = 'planned'
|
||||||
|
RETURNING id`,
|
||||||
|
[companyCode, item.item_code, productType]
|
||||||
|
);
|
||||||
|
deletedCount += deleteResult.rowCount || 0;
|
||||||
|
|
||||||
|
const keptResult = await client.query(
|
||||||
|
`SELECT COUNT(*) AS cnt 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]
|
||||||
|
);
|
||||||
|
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생산일수 계산
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||||
|
const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||||
|
const planNoResult = await client.query(
|
||||||
|
`SELECT COUNT(*) + 1 AS next_no
|
||||||
|
FROM production_plan_mng
|
||||||
|
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||||
|
[companyCode, `PP-${todayStr}-%`]
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
company_code, plan_no, plan_date, item_code, item_name,
|
||||||
|
product_type, plan_qty, start_date, end_date, due_date,
|
||||||
|
status, priority, hourly_capacity, daily_capacity, lead_time,
|
||||||
|
created_by, created_date, updated_date
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, CURRENT_DATE, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9,
|
||||||
|
'planned', 'normal', $10, $11, $12,
|
||||||
|
$13, NOW(), NOW()
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode, planNo, item.item_code, item.item_name,
|
||||||
|
productType, requiredQty,
|
||||||
|
startDate.toISOString().split("T")[0],
|
||||||
|
endDate.toISOString().split("T")[0],
|
||||||
|
item.earliest_due_date,
|
||||||
|
item.hourly_capacity || 100,
|
||||||
|
dailyCapacity,
|
||||||
|
item.lead_time || 1,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
newSchedules.push(insertResult.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: newSchedules.length + keptCount,
|
||||||
|
new_count: newSchedules.length,
|
||||||
|
kept_count: keptCount,
|
||||||
|
deleted_count: deletedCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
|
||||||
|
return { summary, schedules: newSchedules };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("자동 스케줄 생성 실패", { companyCode, error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 스케줄 병합 ───
|
||||||
|
|
||||||
|
export async function mergeSchedules(
|
||||||
|
companyCode: string,
|
||||||
|
scheduleIds: number[],
|
||||||
|
productType: string,
|
||||||
|
mergedBy: string
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 대상 스케줄 조회
|
||||||
|
const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", ");
|
||||||
|
const targetResult = await client.query(
|
||||||
|
`SELECT * FROM production_plan_mng
|
||||||
|
WHERE company_code = $1 AND id IN (${placeholders})
|
||||||
|
ORDER BY start_date`,
|
||||||
|
[companyCode, ...scheduleIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetResult.rowCount !== scheduleIds.length) {
|
||||||
|
throw new Error("일부 스케줄을 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = targetResult.rows;
|
||||||
|
|
||||||
|
// 동일 품목 검증
|
||||||
|
const itemCodes = [...new Set(rows.map((r: any) => r.item_code))];
|
||||||
|
if (itemCodes.length > 1) {
|
||||||
|
throw new Error("동일 품목의 스케줄만 병합할 수 있습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 병합 값 계산
|
||||||
|
const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0);
|
||||||
|
const earliestStart = rows.reduce(
|
||||||
|
(min: string, r: any) => (!min || r.start_date < min ? r.start_date : min),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const latestEnd = rows.reduce(
|
||||||
|
(max: string, r: any) => (!max || r.end_date > max ? r.end_date : max),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const earliestDue = rows.reduce(
|
||||||
|
(min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", ");
|
||||||
|
|
||||||
|
// 기존 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||||
|
[companyCode, ...scheduleIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 병합된 스케줄 생성
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||||
|
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO production_plan_mng (
|
||||||
|
company_code, plan_no, plan_date, item_code, item_name,
|
||||||
|
product_type, plan_qty, start_date, end_date, due_date,
|
||||||
|
status, order_no, created_by, created_date, updated_date
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, CURRENT_DATE, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9,
|
||||||
|
'planned', $10, $11, NOW(), NOW()
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode, planNo, rows[0].item_code, rows[0].item_name,
|
||||||
|
productType, totalQty,
|
||||||
|
earliestStart, latestEnd, earliestDue || null,
|
||||||
|
orderNos || null, mergedBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("스케줄 병합 완료", {
|
||||||
|
companyCode,
|
||||||
|
mergedFrom: scheduleIds,
|
||||||
|
mergedTo: insertResult.rows[0].id,
|
||||||
|
});
|
||||||
|
return insertResult.rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("스케줄 병합 실패", { companyCode, error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 반제품 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(
|
||||||
|
companyCode: string,
|
||||||
|
planIds: number[],
|
||||||
|
options: { considerStock?: boolean; excludeUsed?: boolean },
|
||||||
|
createdBy: string
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
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})
|
||||||
|
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) {
|
||||||
|
const bomItems = await getBomChildItems(client, 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 client.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 semiEndDate = plan.start_date;
|
||||||
|
const semiStartDate = new Date(plan.start_date);
|
||||||
|
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||||
|
|
||||||
|
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
||||||
|
const planNoResult = await client.query(
|
||||||
|
`SELECT COUNT(*) + 1 AS next_no
|
||||||
|
FROM production_plan_mng
|
||||||
|
WHERE company_code = $1 AND plan_no LIKE $2`,
|
||||||
|
[companyCode, `PP-${todayStr}-S%`]
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
company_code, plan_no, plan_date, item_code, item_name,
|
||||||
|
product_type, plan_qty, start_date, end_date, due_date,
|
||||||
|
status, parent_plan_id, created_by, created_date, updated_date
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, CURRENT_DATE, $3, $4,
|
||||||
|
'반제품', $5, $6, $7, $8,
|
||||||
|
'planned', $9, $10, NOW(), NOW()
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode, planNo,
|
||||||
|
bomItem.child_item_code || bomItem.child_item_id,
|
||||||
|
bomItem.child_item_name || bomItem.child_item_id,
|
||||||
|
requiredQty,
|
||||||
|
semiStartDate.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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
newSemiPlans.push(insertResult.rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("반제품 계획 생성 완료", {
|
||||||
|
companyCode,
|
||||||
|
parentPlanIds: planIds,
|
||||||
|
semiPlanCount: newSemiPlans.length,
|
||||||
|
});
|
||||||
|
return { count: newSemiPlans.length, schedules: newSemiPlans };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("반제품 계획 생성 실패", { companyCode, error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 스케줄 분할 ───
|
||||||
|
|
||||||
|
export async function splitSchedule(
|
||||||
|
companyCode: string,
|
||||||
|
planId: number,
|
||||||
|
splitQty: number,
|
||||||
|
splitBy: string
|
||||||
|
) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const planResult = await client.query(
|
||||||
|
`SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`,
|
||||||
|
[planId, companyCode]
|
||||||
|
);
|
||||||
|
if (planResult.rowCount === 0) {
|
||||||
|
throw new Error("생산계획을 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = planResult.rows[0];
|
||||||
|
const originalQty = parseFloat(plan.plan_qty) || 0;
|
||||||
|
|
||||||
|
if (splitQty >= originalQty || splitQty <= 0) {
|
||||||
|
throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원본 수량 감소
|
||||||
|
await client.query(
|
||||||
|
`UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2
|
||||||
|
WHERE id = $3 AND company_code = $4`,
|
||||||
|
[originalQty - splitQty, splitBy, planId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 분할된 새 계획 생성
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`;
|
||||||
|
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO production_plan_mng (
|
||||||
|
company_code, plan_no, plan_date, item_code, item_name,
|
||||||
|
product_type, plan_qty, start_date, end_date, due_date,
|
||||||
|
status, priority, equipment_id, equipment_code, equipment_name,
|
||||||
|
order_no, parent_plan_id, created_by, created_date, updated_date
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, CURRENT_DATE, $3, $4,
|
||||||
|
$5, $6, $7, $8, $9,
|
||||||
|
$10, $11, $12, $13, $14,
|
||||||
|
$15, $16, $17, NOW(), NOW()
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode, planNo, plan.item_code, plan.item_name,
|
||||||
|
plan.product_type, splitQty,
|
||||||
|
plan.start_date, plan.end_date, plan.due_date,
|
||||||
|
plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name,
|
||||||
|
plan.order_no, plan.parent_plan_id,
|
||||||
|
splitBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("스케줄 분할 완료", { companyCode, planId, splitQty });
|
||||||
|
return {
|
||||||
|
original: { id: planId, plan_qty: originalQty - splitQty },
|
||||||
|
split: insertResult.rows[0],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("스케줄 분할 실패", { companyCode, error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
||||||
- [x] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
- [x] 문서화 (README.md)
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
|
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
|
||||||
|
|
||||||
- [x] 타입 정의 완료
|
- [x] 타입 정의 완료
|
||||||
- [x] 기본 구조 생성
|
- [x] 기본 구조 생성
|
||||||
|
|
@ -539,16 +539,20 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
||||||
- [x] TimelineGrid (배경)
|
- [x] TimelineGrid (배경)
|
||||||
- [x] ResourceColumn (리소스)
|
- [x] ResourceColumn (리소스)
|
||||||
- [x] ScheduleBar 기본 렌더링
|
- [x] ScheduleBar 기본 렌더링
|
||||||
- [x] 드래그 이동 (기본)
|
- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
|
||||||
- [x] 리사이즈 (기본)
|
- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
|
||||||
- [x] 줌 레벨 전환
|
- [x] 줌 레벨 전환
|
||||||
- [x] 날짜 네비게이션
|
- [x] 날짜 네비게이션
|
||||||
- [ ] 충돌 감지 (향후)
|
- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
|
||||||
- [ ] 가상 스크롤 (향후)
|
- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
|
||||||
|
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
|
||||||
|
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
|
||||||
|
- [x] staticFilters 지원 (커스텀 테이블 필터링)
|
||||||
|
- [x] 가상 스크롤 (@tanstack/react-virtual, 30개 이상 리소스 시 자동 활성화)
|
||||||
- [x] 설정 패널 구현
|
- [x] 설정 패널 구현
|
||||||
- [x] API 연동
|
- [x] API 연동
|
||||||
- [x] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
- [ ] 테스트 완료
|
- [x] 테스트 완료 (20개 테스트 전체 통과 - 충돌감지 11건 + 날짜계산 9건)
|
||||||
- [x] 문서화 (README.md)
|
- [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 스키마 & 컴포넌트 설정 전체 레퍼런스
|
# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스
|
||||||
|
|
||||||
> **최종 업데이트**: 2026-03-13
|
> **최종 업데이트**: 2026-03-16
|
||||||
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
|
> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전
|
||||||
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
|
> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시)
|
||||||
|
|
||||||
|
|
@ -532,15 +532,20 @@ CREATE TABLE "{테이블명}" (
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.11 v2-timeline-scheduler (간트차트)
|
### 3.11 v2-timeline-scheduler (간트차트/타임라인)
|
||||||
|
|
||||||
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집.
|
**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. 품목별 그룹 뷰, 자동 스케줄 생성, 반제품 계획 연동 지원.
|
||||||
|
|
||||||
|
**기본 설정**:
|
||||||
|
|
||||||
| 설정 | 타입 | 기본값 | 설명 |
|
| 설정 | 타입 | 기본값 | 설명 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| selectedTable | string | - | 스케줄 데이터 테이블 |
|
| selectedTable | string | - | 스케줄 데이터 테이블 |
|
||||||
|
| customTableName | string | - | selectedTable 대신 사용 (useCustomTable=true 시) |
|
||||||
|
| useCustomTable | boolean | `false` | customTableName 사용 여부 |
|
||||||
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
|
| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 |
|
||||||
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
|
| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` |
|
||||||
|
| viewMode | string | - | 뷰 모드: `"itemGrouped"` (품목별 카드 그룹) / 미설정 시 리소스 기반 |
|
||||||
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
|
| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` |
|
||||||
| editable | boolean | `true` | 편집 가능 |
|
| editable | boolean | `true` | 편집 가능 |
|
||||||
| draggable | boolean | `true` | 드래그 이동 허용 |
|
| draggable | boolean | `true` | 드래그 이동 허용 |
|
||||||
|
|
@ -548,15 +553,16 @@ CREATE TABLE "{테이블명}" (
|
||||||
| rowHeight | number | `50` | 행 높이(px) |
|
| rowHeight | number | `50` | 행 높이(px) |
|
||||||
| headerHeight | number | `60` | 헤더 높이(px) |
|
| headerHeight | number | `60` | 헤더 높이(px) |
|
||||||
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
|
| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) |
|
||||||
| cellWidth.day | number | `60` | 일 단위 셀 너비 |
|
|
||||||
| cellWidth.week | number | `120` | 주 단위 셀 너비 |
|
|
||||||
| cellWidth.month | number | `40` | 월 단위 셀 너비 |
|
|
||||||
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
|
| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 |
|
||||||
| showProgress | boolean | `true` | 진행률 바 표시 |
|
| showProgress | boolean | `true` | 진행률 바 표시 |
|
||||||
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
|
| showTodayLine | boolean | `true` | 오늘 날짜 표시선 |
|
||||||
| showToolbar | boolean | `true` | 상단 툴바 표시 |
|
| showToolbar | boolean | `true` | 상단 툴바 표시 |
|
||||||
|
| showLegend | boolean | `true` | 범례(상태 색상 안내) 표시 |
|
||||||
|
| showNavigation | boolean | `true` | 날짜 네비게이션 버튼 표시 |
|
||||||
|
| showZoomControls | boolean | `true` | 줌 컨트롤 버튼 표시 |
|
||||||
| showAddButton | boolean | `true` | 추가 버튼 |
|
| showAddButton | boolean | `true` | 추가 버튼 |
|
||||||
| height | number | `500` | 높이(px) |
|
| height | number | `500` | 높이(px) |
|
||||||
|
| maxHeight | number | - | 최대 높이(px) |
|
||||||
|
|
||||||
**fieldMapping (필수)**:
|
**fieldMapping (필수)**:
|
||||||
|
|
||||||
|
|
@ -583,10 +589,74 @@ CREATE TABLE "{테이블명}" (
|
||||||
| 상태 | 기본 색상 |
|
| 상태 | 기본 색상 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| planned | `"#3b82f6"` (파랑) |
|
| planned | `"#3b82f6"` (파랑) |
|
||||||
| in_progress | `"#f59e0b"` (주황) |
|
| in_progress | `"#10b981"` (초록) |
|
||||||
| completed | `"#10b981"` (초록) |
|
| completed | `"#6b7280"` (회색) |
|
||||||
| delayed | `"#ef4444"` (빨강) |
|
| 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. 패턴 의사결정 트리
|
## 4. 패턴 의사결정 트리
|
||||||
|
|
||||||
```
|
```
|
||||||
Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler
|
Q1. 좌측 마스터 + 우측 탭(타임라인/테이블) 복합 구성?
|
||||||
Q2. 다차원 피벗 분석? → v2-pivot-grid
|
→ 패턴 F → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
|
||||||
Q3. 그룹별 접기/펼치기? → v2-table-grouped
|
Q2. 시간축 기반 일정/간트차트?
|
||||||
Q4. 카드 형태 표시? → v2-card-display
|
├ 품목별 카드 그룹 뷰? → 패턴 E-2 → v2-timeline-scheduler(viewMode:itemGrouped)
|
||||||
Q5. 마스터-디테일?
|
└ 리소스(설비) 기반? → 패턴 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 + additionalTabs
|
||||||
└ 단일 디테일? → v2-split-panel-layout
|
└ 단일 디테일? → 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) 레퍼런스
|
## 5. 관계(relation) 레퍼런스
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용)
|
# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용)
|
||||||
|
|
||||||
> **최종 업데이트**: 2026-03-13
|
> **최종 업데이트**: 2026-03-16
|
||||||
> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드
|
> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드
|
||||||
> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다
|
> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다
|
||||||
|
|
||||||
|
|
@ -533,7 +533,9 @@ DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.5 패턴 E: 타임라인/간트차트
|
### 8.5 패턴 E: 타임라인/간트차트 (리소스 기반)
|
||||||
|
|
||||||
|
**사용 조건**: 설비/작업자 등 리소스 기준으로 스케줄을 시간축에 표시
|
||||||
|
|
||||||
```json
|
```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
|
## 9. Step 7: menu_info INSERT
|
||||||
|
|
@ -696,29 +938,47 @@ VALUES
|
||||||
사용자가 화면을 요청하면 이 트리로 패턴을 결정한다.
|
사용자가 화면을 요청하면 이 트리로 패턴을 결정한다.
|
||||||
|
|
||||||
```
|
```
|
||||||
Q1. 시간축 기반 일정/간트차트가 필요한가?
|
Q1. 좌측 마스터 + 우측에 탭으로 타임라인/테이블 등 복합 구성이 필요한가?
|
||||||
├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler
|
├─ YES → 패턴 F (복합 화면) → v2-split-panel-layout(custom) + v2-tabs-widget + v2-timeline-scheduler
|
||||||
└─ NO ↓
|
└─ 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
|
├─ YES → 피벗 → v2-pivot-grid
|
||||||
└─ NO ↓
|
└─ NO ↓
|
||||||
|
|
||||||
Q3. 데이터를 그룹별로 접기/펼치기가 필요한가?
|
Q4. 데이터를 그룹별로 접기/펼치기가 필요한가?
|
||||||
├─ YES → 패턴 D (그룹화) → v2-table-grouped
|
├─ YES → 패턴 D (그룹화) → v2-table-grouped
|
||||||
└─ NO ↓
|
└─ NO ↓
|
||||||
|
|
||||||
Q4. 이미지+정보를 카드 형태로 표시하는가?
|
Q5. 이미지+정보를 카드 형태로 표시하는가?
|
||||||
├─ YES → 카드뷰 → v2-card-display
|
├─ YES → 카드뷰 → v2-card-display
|
||||||
└─ NO ↓
|
└─ NO ↓
|
||||||
|
|
||||||
Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가?
|
Q6. 마스터 테이블 선택 시 연관 디테일이 필요한가?
|
||||||
├─ YES → Q5-1. 디테일에 여러 탭이 필요한가?
|
├─ YES → Q6-1. 디테일에 여러 탭이 필요한가?
|
||||||
│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs
|
│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs
|
||||||
│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout
|
│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout
|
||||||
└─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list
|
└─ 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. 화면 간 연결 관계 정의
|
## 13. 화면 간 연결 관계 정의
|
||||||
|
|
@ -1119,7 +1379,8 @@ VALUES (
|
||||||
| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` |
|
| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` |
|
||||||
| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` |
|
| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` |
|
||||||
| 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` |
|
| 그룹화 테이블 | 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-pivot-grid | `fields(area, summaryType)` |
|
||||||
| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` |
|
| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` |
|
||||||
| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` |
|
| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` |
|
||||||
|
|
@ -1144,3 +1405,97 @@ VALUES (
|
||||||
| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` |
|
| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` |
|
||||||
| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` |
|
| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` |
|
||||||
| 품목 라우팅 | v2-item-routing | `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,856 @@
|
||||||
|
# 생산계획관리 화면 구현 설계서
|
||||||
|
|
||||||
|
> **Screen Code**: `TOPSEAL_PP_MAIN` (screen_id: 3985)
|
||||||
|
> **메뉴 경로**: 생산관리 > 생산계획관리
|
||||||
|
> **HTML 예시**: `00_화면개발_html/Cursor 폴더/화면개발/PC브라우저/생산/생산계획관리.html`
|
||||||
|
> **작성일**: 2026-03-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 화면 전체 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
+---------------------------------------------------------------------+
|
||||||
|
| 검색 섹션 (상단) |
|
||||||
|
| [품목코드] [품명] [계획기간(daterange)] [상태] |
|
||||||
|
| [사용자옵션] [엑셀업로드] [엑셀다운로드] |
|
||||||
|
+----------------------------------+--+-------------------------------+
|
||||||
|
| 좌측 패널 (50%, 리사이즈) | | 우측 패널 (50%) |
|
||||||
|
| +------------------------------+ |리| +---------------------------+ |
|
||||||
|
| | [수주데이터] [안전재고 부족분] | |사| | [완제품] [반제품] | |
|
||||||
|
| +------------------------------+ |이| +---------------------------+ |
|
||||||
|
| | 수주 목록 헤더 | |즈| | 완제품 생산 타임라인 헤더 | |
|
||||||
|
| | [계획에없는품목만] [불러오기] | |핸| | [새로고침] [자동스케줄] | |
|
||||||
|
| | +---------------------------+| |들| | [병합] [반제품계획] [저장] | |
|
||||||
|
| | | 품목 그룹 테이블 || | | | +------------------------+| |
|
||||||
|
| | | - 품목별 그룹 행 (13컬럼) || | | | | 옵션 패널 || |
|
||||||
|
| | | -> 수주 상세 행 (7컬럼) || | | | | [리드타임] [기간] [재계산]|| |
|
||||||
|
| | | - 접기/펼치기 토글 || | | | +------------------------+| |
|
||||||
|
| | | - 체크박스 (그룹/개별) || | | | | 범례 || |
|
||||||
|
| | +---------------------------+| | | | +------------------------+| |
|
||||||
|
| +------------------------------+ | | | | 타임라인 스케줄러 || |
|
||||||
|
| | | | | (간트차트 형태) || |
|
||||||
|
| -- 안전재고 부족분 탭 -- | | | +------------------------+| |
|
||||||
|
| | 부족 품목 테이블 (8컬럼) | | | +---------------------------+ |
|
||||||
|
| | - 체크박스, 품목코드, 품명 | | | |
|
||||||
|
| | - 현재고, 안전재고, 부족수량 | | | -- 반제품 탭 -- |
|
||||||
|
| | - 권장생산량, 최종입고일 | | | | 옵션 + 안내 패널 | |
|
||||||
|
| +------------------------------+ | | | 반제품 타임라인 스케줄러 | |
|
||||||
|
+----------------------------------+--+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 사용 테이블 및 컬럼 매핑
|
||||||
|
|
||||||
|
### 2.1 메인 테이블
|
||||||
|
|
||||||
|
| 테이블명 | 용도 | PK |
|
||||||
|
|----------|------|-----|
|
||||||
|
| `production_plan_mng` | 생산계획 마스터 | `id` (serial) |
|
||||||
|
| `sales_order_mng` | 수주 데이터 (좌측 패널 조회용) | `id` (serial) |
|
||||||
|
| `item_info` | 품목 마스터 (참조) | `id` (uuid text) |
|
||||||
|
| `inventory_stock` | 재고 현황 (안전재고 부족분 탭) | `id` (uuid text) |
|
||||||
|
| `equipment_info` | 설비 정보 (타임라인 리소스) | `id` (serial) |
|
||||||
|
| `bom` / `bom_detail` | BOM 정보 (반제품 계획 생성) | `id` (uuid text) |
|
||||||
|
| `work_instruction` | 작업지시 (타임라인 연동) | 별도 확인 필요 |
|
||||||
|
|
||||||
|
### 2.2 핵심 컬럼 매핑 - production_plan_mng
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | 용도 | HTML 매핑 |
|
||||||
|
|--------|------|------|-----------|
|
||||||
|
| `id` | serial PK | 고유 ID | `schedule.id` |
|
||||||
|
| `company_code` | varchar | 멀티테넌시 | - |
|
||||||
|
| `plan_no` | varchar NOT NULL | 계획번호 | `SCH-{timestamp}` |
|
||||||
|
| `plan_date` | date | 계획 등록일 | 자동 |
|
||||||
|
| `item_code` | varchar NOT NULL | 품목코드 | `schedule.itemCode` |
|
||||||
|
| `item_name` | varchar | 품목명 | `schedule.itemName` |
|
||||||
|
| `product_type` | varchar | 완제품/반제품 | `'완제품'` or `'반제품'` |
|
||||||
|
| `plan_qty` | numeric NOT NULL | 계획 수량 | `schedule.quantity` |
|
||||||
|
| `completed_qty` | numeric | 완료 수량 | `schedule.completedQty` |
|
||||||
|
| `progress_rate` | numeric | 진행률(%) | `schedule.progressRate` |
|
||||||
|
| `start_date` | date NOT NULL | 시작일 | `schedule.startDate` |
|
||||||
|
| `end_date` | date NOT NULL | 종료일 | `schedule.endDate` |
|
||||||
|
| `due_date` | date | 납기일 | `schedule.dueDate` |
|
||||||
|
| `equipment_id` | integer | 설비 ID | `schedule.equipmentId` |
|
||||||
|
| `equipment_code` | varchar | 설비 코드 | - |
|
||||||
|
| `equipment_name` | varchar | 설비명 | `schedule.productionLine` |
|
||||||
|
| `status` | varchar | 상태 | `planned/in_progress/completed/work-order` |
|
||||||
|
| `priority` | varchar | 우선순위 | `normal/high/urgent` |
|
||||||
|
| `hourly_capacity` | numeric | 시간당 생산능력 | `schedule.hourlyCapacity` |
|
||||||
|
| `daily_capacity` | numeric | 일일 생산능력 | `schedule.dailyCapacity` |
|
||||||
|
| `lead_time` | integer | 리드타임(일) | `schedule.leadTime` |
|
||||||
|
| `work_shift` | varchar | 작업조 | `DAY/NIGHT/BOTH` |
|
||||||
|
| `work_order_no` | varchar | 작업지시번호 | `schedule.workOrderNo` |
|
||||||
|
| `manager_name` | varchar | 담당자 | `schedule.manager` |
|
||||||
|
| `order_no` | varchar | 연관 수주번호 | `schedule.orderInfo[].orderNo` |
|
||||||
|
| `parent_plan_id` | integer | 모 계획 ID (반제품용) | `schedule.parentPlanId` |
|
||||||
|
| `remarks` | text | 비고 | `schedule.remarks` |
|
||||||
|
|
||||||
|
### 2.3 수주 데이터 조회용 - sales_order_mng
|
||||||
|
|
||||||
|
| 컬럼명 | 용도 | 좌측 테이블 컬럼 매핑 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `order_no` | 수주번호 | 수주 상세 행 - 수주번호 |
|
||||||
|
| `part_code` | 품목코드 | 그룹 행 - 품목코드 (그룹 기준) |
|
||||||
|
| `part_name` | 품명 | 그룹 행 - 품목명 |
|
||||||
|
| `order_qty` | 수주량 | 총수주량 (SUM) |
|
||||||
|
| `ship_qty` | 출고량 | 출고량 (SUM) |
|
||||||
|
| `balance_qty` | 잔량 | 잔량 (SUM) |
|
||||||
|
| `due_date` | 납기일 | 수주 상세 행 - 납기일 |
|
||||||
|
| `partner_id` | 거래처 | 수주 상세 행 - 거래처 |
|
||||||
|
| `status` | 상태 | 상태 배지 (일반/긴급) |
|
||||||
|
|
||||||
|
### 2.4 안전재고 부족분 조회용 - inventory_stock + item_info
|
||||||
|
|
||||||
|
| 컬럼명 | 출처 | 좌측 테이블 컬럼 매핑 |
|
||||||
|
|--------|------|----------------------|
|
||||||
|
| `item_code` | inventory_stock | 품목코드 |
|
||||||
|
| `item_name` | item_info (JOIN) | 품목명 |
|
||||||
|
| `current_qty` | inventory_stock | 현재고 |
|
||||||
|
| `safety_qty` | inventory_stock | 안전재고 |
|
||||||
|
| `부족수량` | 계산값 (`safety_qty - current_qty`) | 부족수량 (음수면 부족) |
|
||||||
|
| `권장생산량` | 계산값 (`safety_qty * 2 - current_qty`) | 권장생산량 |
|
||||||
|
| `last_in_date` | inventory_stock | 최종입고일 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. V2 컴포넌트 구현 가능/불가능 분석
|
||||||
|
|
||||||
|
### 3.1 구현 가능 (기존 V2 컴포넌트)
|
||||||
|
|
||||||
|
| 기능 | V2 컴포넌트 | 현재 상태 |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| 좌우 분할 레이아웃 | `v2-split-panel-layout` (`displayMode: "custom"`) | layout_data에 이미 존재 |
|
||||||
|
| 검색 필터 | `v2-table-search-widget` | layout_data에 이미 존재 |
|
||||||
|
| 좌측/우측 탭 전환 | `v2-tabs-widget` | layout_data에 이미 존재 |
|
||||||
|
| 체크박스 선택 | `v2-table-grouped` (`showCheckbox: true`) | layout_data에 이미 존재 |
|
||||||
|
| 단순 그룹핑 테이블 | `v2-table-grouped` (`groupByColumn`) | layout_data에 이미 존재 |
|
||||||
|
| 타임라인 스케줄러 | `v2-timeline-scheduler` | layout_data에 이미 존재 |
|
||||||
|
| 버튼 액션 | `v2-button-primary` | layout_data에 이미 존재 |
|
||||||
|
| 안전재고 부족분 테이블 | `v2-table-list` 또는 `v2-table-grouped` | 미구성 (탭2에 컴포넌트 없음) |
|
||||||
|
|
||||||
|
### 3.2 부분 구현 가능 (개선/확장 필요)
|
||||||
|
|
||||||
|
| 기능 | 문제점 | 필요 작업 |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| 수주 그룹 테이블 (2레벨) | `v2-table-grouped`는 **동일 컬럼 기준 그룹핑**만 지원. HTML은 그룹 행(13컬럼)과 상세 행(7컬럼)이 완전히 다른 구조 | 컴포넌트 확장 or 백엔드에서 집계 데이터를 별도 API로 제공 |
|
||||||
|
| 스케줄러 옵션 패널 | HTML의 안전리드타임/표시기간/재계산 옵션을 위한 전용 UI 없음 | `v2-input` + `v2-select` 조합으로 구성 가능 |
|
||||||
|
| 범례 UI | `v2-timeline-scheduler`에 statusColors 설정은 있지만 범례 UI 자체는 없음 | `v2-text-display` 또는 커스텀 구성 |
|
||||||
|
| 부족수량 빨간색 강조 | 조건부 서식(conditional formatting) 미지원 | 컴포넌트 확장 필요 |
|
||||||
|
| "계획에 없는 품목만" 필터 | 단순 테이블 필터가 아닌 교차 테이블 비교 필터 | 백엔드 API 필요 |
|
||||||
|
|
||||||
|
### 3.3 신규 개발 필요 (현재 V2 컴포넌트로 불가능)
|
||||||
|
|
||||||
|
| 기능 | 설명 | 구현 방안 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| **자동 스케줄 생성 API** | 선택 품목의 필요생산계획량, 납기일, 설비 생산능력 기반으로 타임라인 자동 배치 | 백엔드 전용 API |
|
||||||
|
| **선택 계획 병합 API** | 동일 품목 복수 스케줄을 하나로 합산 | 백엔드 전용 API |
|
||||||
|
| **반제품 계획 자동 생성 API** | BOM 기반으로 완제품 계획에서 필요 반제품 소요량 계산 | 백엔드 전용 API (BOM + 재고 연계) |
|
||||||
|
| **수주 잔량/현재고 연산 조회 API** | 여러 테이블 JOIN + 집계 연산으로 좌측 패널 데이터 제공 | 백엔드 전용 API |
|
||||||
|
| **스케줄 상세 모달** | 기본정보, 근거정보, 생산정보, 계획기간, 계획분할, 설비할당 | 모달 화면 (`TOPSEAL_PP_MODAL` screen_id: 3986) 보강 |
|
||||||
|
| **설비 선택 모달** | 설비별 수량 할당 및 일정 등록 | 신규 모달 화면 필요 |
|
||||||
|
| **변경사항 확인 모달** | 자동 스케줄 생성 전후 비교 (신규/유지/삭제 건수 요약) | 신규 모달 또는 확인 다이얼로그 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 API 설계
|
||||||
|
|
||||||
|
### 4.1 수주 데이터 조회 API (좌측 패널 - 수주데이터 탭)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/production/order-summary
|
||||||
|
```
|
||||||
|
|
||||||
|
**목적**: 수주 데이터를 **품목별로 그룹핑**하여 반환. 그룹 헤더에 집계값(총수주량, 출고량, 잔량, 현재고, 안전재고, 기생산계획량 등) 포함.
|
||||||
|
|
||||||
|
**응답 구조**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"item_code": "ITEM-001",
|
||||||
|
"item_name": "탑씰 Type A",
|
||||||
|
"hourly_capacity": 100,
|
||||||
|
"daily_capacity": 800,
|
||||||
|
"lead_time": 1,
|
||||||
|
"total_order_qty": 1000,
|
||||||
|
"total_ship_qty": 300,
|
||||||
|
"total_balance_qty": 700,
|
||||||
|
"current_stock": 100,
|
||||||
|
"safety_stock": 150,
|
||||||
|
"plan_ship_qty": 0,
|
||||||
|
"existing_plan_qty": 0,
|
||||||
|
"in_progress_qty": 0,
|
||||||
|
"required_plan_qty": 750,
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"order_no": "SO-2025-101",
|
||||||
|
"partner_name": "ABC 상사",
|
||||||
|
"order_qty": 500,
|
||||||
|
"ship_qty": 200,
|
||||||
|
"balance_qty": 300,
|
||||||
|
"due_date": "2025-11-05",
|
||||||
|
"is_urgent": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order_no": "SO-2025-102",
|
||||||
|
"partner_name": "XYZ 무역",
|
||||||
|
"order_qty": 500,
|
||||||
|
"ship_qty": 100,
|
||||||
|
"balance_qty": 400,
|
||||||
|
"due_date": "2025-11-10",
|
||||||
|
"is_urgent": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL 로직 (핵심)**:
|
||||||
|
```sql
|
||||||
|
WITH order_summary AS (
|
||||||
|
SELECT
|
||||||
|
so.part_code AS item_code,
|
||||||
|
so.part_name AS item_name,
|
||||||
|
SUM(COALESCE(so.order_qty, 0)) AS total_order_qty,
|
||||||
|
SUM(COALESCE(so.ship_qty, 0)) AS total_ship_qty,
|
||||||
|
SUM(COALESCE(so.balance_qty, 0)) AS total_balance_qty
|
||||||
|
FROM sales_order_mng so
|
||||||
|
WHERE so.company_code = $1
|
||||||
|
AND so.status NOT IN ('cancelled', 'completed')
|
||||||
|
AND so.balance_qty > 0
|
||||||
|
GROUP BY so.part_code, so.part_name
|
||||||
|
),
|
||||||
|
stock_info AS (
|
||||||
|
SELECT
|
||||||
|
item_code,
|
||||||
|
SUM(COALESCE(current_qty::numeric, 0)) AS current_stock,
|
||||||
|
MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock
|
||||||
|
FROM inventory_stock
|
||||||
|
WHERE company_code = $1
|
||||||
|
GROUP BY item_code
|
||||||
|
),
|
||||||
|
plan_info AS (
|
||||||
|
SELECT
|
||||||
|
item_code,
|
||||||
|
SUM(CASE WHEN status = 'planned' THEN plan_qty ELSE 0 END) AS existing_plan_qty,
|
||||||
|
SUM(CASE WHEN status = 'in_progress' THEN plan_qty ELSE 0 END) AS in_progress_qty
|
||||||
|
FROM production_plan_mng
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND product_type = '완제품'
|
||||||
|
AND status NOT IN ('completed', 'cancelled')
|
||||||
|
GROUP BY item_code
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
os.*,
|
||||||
|
COALESCE(si.current_stock, 0) AS current_stock,
|
||||||
|
COALESCE(si.safety_stock, 0) AS safety_stock,
|
||||||
|
COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty,
|
||||||
|
COALESCE(pi.in_progress_qty, 0) AS in_progress_qty,
|
||||||
|
GREATEST(
|
||||||
|
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||||
|
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||||
|
0
|
||||||
|
) AS required_plan_qty
|
||||||
|
FROM order_summary os
|
||||||
|
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||||
|
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||||
|
ORDER BY os.item_code;
|
||||||
|
```
|
||||||
|
|
||||||
|
**파라미터**:
|
||||||
|
- `company_code`: req.user.companyCode (자동)
|
||||||
|
- `exclude_planned` (optional): `true`이면 기존 계획이 있는 품목 제외
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 안전재고 부족분 조회 API (좌측 패널 - 안전재고 탭)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/production/stock-shortage
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 구조**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"item_code": "ITEM-001",
|
||||||
|
"item_name": "탑씰 Type A",
|
||||||
|
"current_qty": 50,
|
||||||
|
"safety_qty": 200,
|
||||||
|
"shortage_qty": -150,
|
||||||
|
"recommended_qty": 300,
|
||||||
|
"last_in_date": "2025-10-15"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL 로직**:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
ist.item_code,
|
||||||
|
ii.item_name,
|
||||||
|
COALESCE(ist.current_qty::numeric, 0) AS current_qty,
|
||||||
|
COALESCE(ist.safety_qty::numeric, 0) AS safety_qty,
|
||||||
|
(COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty,
|
||||||
|
GREATEST(COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0) AS recommended_qty,
|
||||||
|
ist.last_in_date
|
||||||
|
FROM inventory_stock ist
|
||||||
|
JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code
|
||||||
|
WHERE ist.company_code = $1
|
||||||
|
AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0)
|
||||||
|
ORDER BY shortage_qty ASC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 자동 스케줄 생성 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/production/generate-schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
**요청 body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_code": "ITEM-001",
|
||||||
|
"item_name": "탑씰 Type A",
|
||||||
|
"required_qty": 750,
|
||||||
|
"earliest_due_date": "2025-11-05",
|
||||||
|
"hourly_capacity": 100,
|
||||||
|
"daily_capacity": 800,
|
||||||
|
"lead_time": 1,
|
||||||
|
"orders": [
|
||||||
|
{ "order_no": "SO-2025-101", "balance_qty": 300, "due_date": "2025-11-05" },
|
||||||
|
{ "order_no": "SO-2025-102", "balance_qty": 400, "due_date": "2025-11-10" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"safety_lead_time": 1,
|
||||||
|
"recalculate_unstarted": true,
|
||||||
|
"product_type": "완제품"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**비즈니스 로직**:
|
||||||
|
1. 각 품목의 필요생산계획량, 납기일, 일일생산능력을 기반으로 생산일수 계산
|
||||||
|
2. `생산일수 = ceil(필요생산계획량 / 일일생산능력)`
|
||||||
|
3. `시작일 = 납기일 - 생산일수 - 안전리드타임`
|
||||||
|
4. 시작일이 오늘 이전이면 오늘로 조정
|
||||||
|
5. `recalculate_unstarted = true`면 기존 진행중/작업지시/완료 스케줄은 유지, 미진행(planned)만 제거 후 재계산
|
||||||
|
6. 결과를 `production_plan_mng`에 INSERT
|
||||||
|
7. 변경사항 요약(신규/유지/삭제 건수) 반환
|
||||||
|
|
||||||
|
**응답 구조**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"summary": {
|
||||||
|
"total": 3,
|
||||||
|
"new_count": 2,
|
||||||
|
"kept_count": 1,
|
||||||
|
"deleted_count": 1
|
||||||
|
},
|
||||||
|
"schedules": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"plan_no": "PP-2025-0001",
|
||||||
|
"item_code": "ITEM-001",
|
||||||
|
"item_name": "탑씰 Type A",
|
||||||
|
"plan_qty": 750,
|
||||||
|
"start_date": "2025-10-30",
|
||||||
|
"end_date": "2025-11-03",
|
||||||
|
"due_date": "2025-11-05",
|
||||||
|
"status": "planned"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 스케줄 병합 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/production/merge-schedules
|
||||||
|
```
|
||||||
|
|
||||||
|
**요청 body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schedule_ids": [101, 102, 103],
|
||||||
|
"product_type": "완제품"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**비즈니스 로직**:
|
||||||
|
1. 선택된 스케줄이 모두 동일 품목인지 검증
|
||||||
|
2. 완제품/반제품이 섞여있지 않은지 검증
|
||||||
|
3. 수량 합산, 가장 빠른 시작일/납기일, 가장 늦은 종료일 적용
|
||||||
|
4. 원본 스케줄 DELETE, 병합된 스케줄 INSERT
|
||||||
|
5. 수주 정보(order_no)는 병합 (중복 제거)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 반제품 계획 자동 생성 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/production/generate-semi-schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
**요청 body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan_ids": [101, 102],
|
||||||
|
"options": {
|
||||||
|
"consider_stock": true,
|
||||||
|
"keep_in_progress": false,
|
||||||
|
"exclude_used": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**비즈니스 로직**:
|
||||||
|
1. 선택된 완제품 계획의 품목코드로 BOM 조회
|
||||||
|
2. `bom` 테이블에서 해당 품목의 `item_id` → `bom_detail`에서 하위 반제품(`child_item_id`) 조회
|
||||||
|
3. 각 반제품의 필요 수량 = `완제품 계획수량 x BOM 소요량(quantity)`
|
||||||
|
4. `consider_stock = true`면 현재고/안전재고 감안하여 순 필요량 계산
|
||||||
|
5. `exclude_used = true`면 이미 투입된 반제품 수량 차감
|
||||||
|
6. 모품목 생산 시작일 고려하여 반제품 납기일 설정 (시작일 - 반제품 리드타임)
|
||||||
|
7. `production_plan_mng`에 `product_type = '반제품'`, `parent_plan_id` 설정하여 INSERT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.6 스케줄 상세 저장/수정 API
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/production/plan/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**요청 body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan_qty": 750,
|
||||||
|
"start_date": "2025-10-30",
|
||||||
|
"end_date": "2025-11-03",
|
||||||
|
"equipment_id": 1,
|
||||||
|
"equipment_code": "LINE-01",
|
||||||
|
"equipment_name": "1호기",
|
||||||
|
"manager_name": "홍길동",
|
||||||
|
"work_shift": "DAY",
|
||||||
|
"priority": "high",
|
||||||
|
"remarks": "긴급 생산"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.7 스케줄 분할 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/production/split-schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
**요청 body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan_id": 101,
|
||||||
|
"splits": [
|
||||||
|
{ "qty": 500, "start_date": "2025-10-30", "end_date": "2025-11-01" },
|
||||||
|
{ "qty": 250, "start_date": "2025-11-02", "end_date": "2025-11-03" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**비즈니스 로직**:
|
||||||
|
1. 분할 수량 합산이 원본 수량과 일치하는지 검증
|
||||||
|
2. 원본 스케줄 DELETE
|
||||||
|
3. 분할된 각 조각을 신규 INSERT (동일 `order_no`, `item_code` 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 모달 화면 설계
|
||||||
|
|
||||||
|
### 5.1 스케줄 상세 모달 (screen_id: 3986 보강)
|
||||||
|
|
||||||
|
**섹션 구성**:
|
||||||
|
|
||||||
|
| 섹션 | 필드 | 타입 | 비고 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **기본 정보** | 품목코드, 품목명 | text (readonly) | 자동 채움 |
|
||||||
|
| **근거 정보** | 수주번호/거래처/납기일 목록 | text (readonly) | 연관 수주 정보 표시 |
|
||||||
|
| **생산 정보** | 총 생산수량 | number | 수정 가능 |
|
||||||
|
| | 납기일 (수주 기준) | date (readonly) | 가장 빠른 납기일 |
|
||||||
|
| **계획 기간** | 계획 시작일, 종료일 | date | 수정 가능 |
|
||||||
|
| | 생산 기간 | text (readonly) | 자동 계산 표시 |
|
||||||
|
| **계획 분할** | 분할 개수, 분할 수량 입력 | select, number | 분할하기 기능 |
|
||||||
|
| **설비 할당** | 설비 선택 버튼 | button → 모달 | 설비 선택 모달 오픈 |
|
||||||
|
| **생산 상태** | 상태 | select (disabled) | `planned/work-order/in_progress/completed` |
|
||||||
|
| **추가 정보** | 담당자, 작업지시번호, 비고 | text | 수정 가능 |
|
||||||
|
| **하단 버튼** | 삭제, 취소, 저장 | buttons | - |
|
||||||
|
|
||||||
|
### 5.2 수주 불러오기 모달
|
||||||
|
|
||||||
|
**구성**:
|
||||||
|
- 선택된 품목 목록 표시
|
||||||
|
- 주의사항 안내
|
||||||
|
- 라디오 버튼: "기존 계획에 추가" / "별도 계획으로 생성"
|
||||||
|
- 취소/불러오기 버튼
|
||||||
|
|
||||||
|
### 5.3 안전재고 불러오기 모달
|
||||||
|
|
||||||
|
**구성**: 수주 불러오기 모달과 동일한 패턴
|
||||||
|
|
||||||
|
### 5.4 설비 선택 모달
|
||||||
|
|
||||||
|
**구성**:
|
||||||
|
- 총 수량 / 할당 수량 / 미할당 수량 요약
|
||||||
|
- 설비 카드 그리드 (설비명, 생산능력, 할당 수량 입력, 시작일/종료일)
|
||||||
|
- 취소/저장 버튼
|
||||||
|
|
||||||
|
### 5.5 변경사항 확인 모달
|
||||||
|
|
||||||
|
**구성**:
|
||||||
|
- 경고 메시지
|
||||||
|
- 변경사항 요약 카드 (총 계획, 신규 생성, 유지됨, 삭제됨)
|
||||||
|
- 변경사항 상세 목록 (품목별 변경 전/후 비교)
|
||||||
|
- 취소/확인 및 적용 버튼
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 현재 layout_data 수정 필요 사항
|
||||||
|
|
||||||
|
### 6.1 현재 layout_data 구조 (screen_id: 3985, layout_id: 9192)
|
||||||
|
|
||||||
|
```
|
||||||
|
comp_search (v2-table-search-widget) - 검색 필터
|
||||||
|
comp_split_panel (v2-split-panel-layout)
|
||||||
|
├── leftPanel (custom mode)
|
||||||
|
│ ├── left_tabs (v2-tabs-widget) - [수주데이터, 안전재고 부족분]
|
||||||
|
│ ├── order_table (v2-table-grouped) - 수주 테이블
|
||||||
|
│ └── btn_import (v2-button-primary) - 선택 품목 불러오기
|
||||||
|
├── rightPanel (custom mode)
|
||||||
|
│ ├── right_tabs (v2-tabs-widget) - [완제품, 반제품]
|
||||||
|
│ │ └── finished_tab.components
|
||||||
|
│ │ ├── v2-timeline-scheduler - 타임라인
|
||||||
|
│ │ └── v2-button-primary - 스케줄 생성
|
||||||
|
│ ├── btn_save (v2-button-primary) - 자동 스케줄 생성
|
||||||
|
│ └── btn_clear (v2-button-primary) - 초기화
|
||||||
|
comp_q0iqzkpx (v2-button-primary) - 하단 저장 버튼 (무의미)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 수정 필요 사항
|
||||||
|
|
||||||
|
| 항목 | 현재 상태 | 필요 상태 |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| **좌측 - 안전재고 탭** | 컴포넌트 없음 (`"컴포넌트가 없습니다"` 표시) | `v2-table-list` 또는 별도 조회 API 연결된 테이블 추가 |
|
||||||
|
| **좌측 - order_table** | `selectedTable: "sales_order_mng"` (범용 API) | 전용 API (`/api/production/order-summary`)로 변경 필요 |
|
||||||
|
| **좌측 - 체크박스 필터** | 없음 | "계획에 없는 품목만" 체크박스 UI 추가 |
|
||||||
|
| **우측 - 반제품 탭** | 컴포넌트 없음 | 반제품 타임라인 + 옵션 패널 추가 |
|
||||||
|
| **우측 - 타임라인** | `selectedTable: "work_instruction"` | `selectedTable: "production_plan_mng"` + 필터 `product_type='완제품'` |
|
||||||
|
| **우측 - 옵션 패널** | 없음 | 안전리드타임, 표시기간, 재계산 체크박스 → `v2-input` 조합 |
|
||||||
|
| **우측 - 범례** | 없음 | `v2-text-display` 또는 커스텀 범례 컴포넌트 |
|
||||||
|
| **우측 - 버튼들** | 일부만 존재 | 병합, 반제품계획, 저장, 초기화 추가 |
|
||||||
|
| **하단 저장 버튼** | 존재 (무의미) | 제거 |
|
||||||
|
| **우측 패널 렌더링 버그** | 타임라인 미렌더링 | SplitPanelLayout custom 모드 디버깅 필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 단계별 계획
|
||||||
|
|
||||||
|
### Phase 1: 기존 버그 수정 + 기본 구조 안정화
|
||||||
|
|
||||||
|
**목표**: 현재 layout_data로 화면이 최소한 정상 렌더링되게 만들기
|
||||||
|
|
||||||
|
| 작업 | 상세 | 예상 난이도 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 1-1. 좌측 z-index 겹침 수정 | SplitPanelLayout의 custom 모드에서 내부 컴포넌트가 비대화형 div에 가려지는 이슈 | 중 |
|
||||||
|
| 1-2. 우측 타임라인 렌더링 수정 | tabs-widget 내부 timeline-scheduler가 렌더링되지 않는 이슈 | 중 |
|
||||||
|
| 1-3. 하단 저장 버튼 제거 | layout_data에서 `comp_q0iqzkpx` 제거 | 하 |
|
||||||
|
| 1-4. 타임라인 데이터 소스 수정 | `work_instruction` → `production_plan_mng`으로 변경 | 하 |
|
||||||
|
|
||||||
|
### Phase 2: 백엔드 API 개발
|
||||||
|
|
||||||
|
**목표**: 화면에 필요한 데이터를 제공하는 전용 API 구축
|
||||||
|
|
||||||
|
| 작업 | 상세 | 예상 난이도 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 2-1. 수주 데이터 조회 API | `GET /api/production/order-summary` (4.1 참조) | 중 |
|
||||||
|
| 2-2. 안전재고 부족분 API | `GET /api/production/stock-shortage` (4.2 참조) | 하 |
|
||||||
|
| 2-3. 자동 스케줄 생성 API | `POST /api/production/generate-schedule` (4.3 참조) | 상 |
|
||||||
|
| 2-4. 스케줄 CRUD API | `PUT/DELETE /api/production/plan/:id` (4.6 참조) | 중 |
|
||||||
|
| 2-5. 스케줄 병합 API | `POST /api/production/merge-schedules` (4.4 참조) | 중 |
|
||||||
|
| 2-6. 반제품 계획 자동 생성 API | `POST /api/production/generate-semi-schedule` (4.5 참조) | 상 |
|
||||||
|
| 2-7. 스케줄 분할 API | `POST /api/production/split-schedule` (4.7 참조) | 중 |
|
||||||
|
|
||||||
|
### Phase 3: layout_data 보강 + 모달 화면
|
||||||
|
|
||||||
|
**목표**: 안전재고 탭, 반제품 탭, 모달들 구성
|
||||||
|
|
||||||
|
| 작업 | 상세 | 예상 난이도 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 3-1. 안전재고 부족분 탭 구성 | `stock_tab`에 테이블 컴포넌트 + "선택 품목 불러오기" 버튼 추가 | 중 |
|
||||||
|
| 3-2. 반제품 탭 구성 | `semi_tab`에 타임라인 + 옵션 + 버튼 추가 | 중 |
|
||||||
|
| 3-3. 옵션 패널 구성 | v2-input 조합으로 안전리드타임, 표시기간, 체크박스 | 중 |
|
||||||
|
| 3-4. 버튼 액션 연결 | 자동 스케줄, 병합, 반제품계획, 저장, 초기화 → API 연결 | 중 |
|
||||||
|
| 3-5. 스케줄 상세 모달 보강 | screen_id: 3986 layout_data 수정 | 중 |
|
||||||
|
| 3-6. 수주/안전재고 불러오기 모달 | 신규 모달 screen 생성 | 중 |
|
||||||
|
| 3-7. 설비 선택 모달 | 신규 모달 screen 생성 | 중 |
|
||||||
|
|
||||||
|
### Phase 4: v2-table-grouped 확장 (2레벨 트리 지원)
|
||||||
|
|
||||||
|
**목표**: HTML 예시의 "품목 그룹 → 수주 상세" 2레벨 트리 테이블 구현
|
||||||
|
|
||||||
|
| 작업 | 상세 | 예상 난이도 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| 4-1. 컴포넌트 확장 설계 | 그룹 행과 상세 행이 다른 컬럼 구조를 가질 수 있도록 설계 | 상 |
|
||||||
|
| 4-2. expandedRowRenderer 구현 | 그룹 행 펼침 시 별도 컬럼/데이터로 하위 행 렌더링 | 상 |
|
||||||
|
| 4-3. 그룹 행 집계 컬럼 설정 | 그룹 헤더에 SUM, 계산 필드 표시 (현재고, 안전재고, 필요생산계획 등) | 중 |
|
||||||
|
| 4-4. 조건부 서식 지원 | 부족수량 빨간색, 양수 초록색 등 | 중 |
|
||||||
|
|
||||||
|
**대안**: Phase 4가 너무 복잡하면, 좌측 수주데이터를 2개 연동 테이블로 분리 (상단: 품목별 집계 테이블, 하단: 선택 품목의 수주 상세 테이블) 하는 방식도 검토 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 파일 생성/수정 목록
|
||||||
|
|
||||||
|
### 8.1 백엔드
|
||||||
|
|
||||||
|
| 파일 | 작업 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend-node/src/routes/productionRoutes.ts` | 라우터 등록 | 신규 or 기존 확장 |
|
||||||
|
| `backend-node/src/controllers/productionController.ts` | API 핸들러 | 신규 or 기존 확장 |
|
||||||
|
| `backend-node/src/services/productionPlanService.ts` | 비즈니스 로직 서비스 | 신규 |
|
||||||
|
|
||||||
|
### 8.2 DB (layout_data 수정)
|
||||||
|
|
||||||
|
| 대상 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `screen_layouts_v2` (screen_id: 3985) | layout_data JSON 수정 |
|
||||||
|
| `screen_layouts_v2` (screen_id: 3986) | 모달 layout_data 보강 |
|
||||||
|
| `screen_definitions` + `screen_layouts_v2` | 설비 선택 모달 신규 등록 |
|
||||||
|
| `screen_definitions` + `screen_layouts_v2` | 불러오기 모달 신규 등록 |
|
||||||
|
|
||||||
|
### 8.3 프론트엔드 (API 클라이언트)
|
||||||
|
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/lib/api/production.ts` | 생산계획 전용 API 클라이언트 함수 추가 |
|
||||||
|
|
||||||
|
### 8.4 프론트엔드 (V2 컴포넌트 확장, Phase 4)
|
||||||
|
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/lib/registry/components/v2-table-grouped/` | 2레벨 트리 지원 확장 |
|
||||||
|
| `frontend/lib/registry/components/v2-timeline-scheduler/` | 옵션 패널/범례 확장 (필요시) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 이벤트 흐름 (주요 시나리오)
|
||||||
|
|
||||||
|
### 9.1 자동 스케줄 생성 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
|
||||||
|
2. 우측 "자동 스케줄 생성" 버튼 클릭
|
||||||
|
3. (옵션 확인) 안전리드타임, 재계산 모드 체크
|
||||||
|
4. POST /api/production/generate-schedule 호출
|
||||||
|
5. (응답) 변경사항 확인 모달 표시 (신규/유지/삭제 건수)
|
||||||
|
6. 사용자 "확인 및 적용" 클릭
|
||||||
|
7. 타임라인 스케줄러 새로고침
|
||||||
|
8. 좌측 수주 목록의 "기생산계획량" 컬럼 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 수주 불러오기 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택
|
||||||
|
2. "선택 품목 불러오기" 버튼 클릭
|
||||||
|
3. 불러오기 모달 표시 (선택 품목 목록 + 추가방식 선택)
|
||||||
|
4. "기존 계획에 추가" or "별도 계획으로 생성" 선택
|
||||||
|
5. "불러오기" 버튼 클릭
|
||||||
|
6. POST /api/production/generate-schedule 호출 (단건)
|
||||||
|
7. 타임라인 새로고침
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 타임라인 스케줄 클릭 → 상세 모달
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자가 타임라인의 스케줄 바 클릭
|
||||||
|
2. 스케줄 상세 모달 오픈 (TOPSEAL_PP_MODAL)
|
||||||
|
3. 기본정보(readonly), 근거정보(readonly), 생산정보(수정가능) 표시
|
||||||
|
4. 계획기간 수정, 설비할당, 분할 등 작업
|
||||||
|
5. "저장" → PUT /api/production/plan/:id
|
||||||
|
6. "삭제" → DELETE /api/production/plan/:id
|
||||||
|
7. 모달 닫기 → 타임라인 새로고침
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 반제품 계획 생성 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 우측 완제품 탭에서 스케줄 체크박스 선택
|
||||||
|
2. "선택 품목 → 반제품 계획" 버튼 클릭
|
||||||
|
3. POST /api/production/generate-semi-schedule 호출
|
||||||
|
- BOM 조회 → 필요 반제품 목록 + 소요량 계산
|
||||||
|
- 재고 감안 → 순 필요량 계산
|
||||||
|
- 반제품 계획 INSERT (product_type='반제품', parent_plan_id 설정)
|
||||||
|
4. 반제품 탭으로 자동 전환
|
||||||
|
5. 반제품 타임라인 새로고침
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 검색 필드 설정
|
||||||
|
|
||||||
|
| 필드명 | 타입 | 라벨 | 대상 컬럼 |
|
||||||
|
|--------|------|------|-----------|
|
||||||
|
| `item_code` | text | 품목코드 | `part_code` (수주) / `item_code` (계획) |
|
||||||
|
| `item_name` | text | 품명 | `part_name` / `item_name` |
|
||||||
|
| `plan_date` | daterange | 계획기간 | `start_date` ~ `end_date` |
|
||||||
|
| `status` | select | 상태 | 전체 / 계획 / 진행 / 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 권한 및 멀티테넌시
|
||||||
|
|
||||||
|
### 11.1 모든 API에 적용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
if (companyCode === '*') {
|
||||||
|
// 최고관리자: 모든 회사 데이터 조회 가능
|
||||||
|
} else {
|
||||||
|
// 일반 회사: WHERE company_code = $1 필수
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 데이터 격리
|
||||||
|
|
||||||
|
- `production_plan_mng.company_code` 필터 필수
|
||||||
|
- `sales_order_mng.company_code` 필터 필수
|
||||||
|
- `inventory_stock.company_code` 필터 필수
|
||||||
|
- JOIN 시 양쪽 테이블 모두 `company_code` 조건 포함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 우선순위 정리
|
||||||
|
|
||||||
|
| 우선순위 | 작업 | 이유 |
|
||||||
|
|----------|------|------|
|
||||||
|
| **1 (긴급)** | Phase 1: 기존 렌더링 버그 수정 | 현재 화면 자체가 정상 동작하지 않음 |
|
||||||
|
| **2 (높음)** | Phase 2-1, 2-2: 수주/재고 조회 API | 좌측 패널의 핵심 데이터 |
|
||||||
|
| **3 (높음)** | Phase 2-3: 자동 스케줄 생성 API | 우측 패널의 핵심 기능 |
|
||||||
|
| **4 (중간)** | Phase 3: layout_data 보강 | 안전재고 탭, 반제품 탭, 모달 |
|
||||||
|
| **5 (중간)** | Phase 2-4~2-7: 나머지 API | 병합, 분할, 반제품 계획 |
|
||||||
|
| **6 (낮음)** | Phase 4: 2레벨 트리 테이블 확장 | 현재 단순 그룹핑으로도 기본 동작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 A: HTML 예시의 모달 목록
|
||||||
|
|
||||||
|
| 모달명 | HTML ID | 용도 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 스케줄 상세 모달 | `scheduleModal` | 스케줄 기본정보/근거정보/생산정보/계획기간/분할/설비할당/상태/추가정보 |
|
||||||
|
| 수주 불러오기 모달 | `orderImportModal` | 선택 품목 목록 + 추가방식 선택 (기존추가/별도생성) |
|
||||||
|
| 안전재고 불러오기 모달 | `stockImportModal` | 부족 품목 목록 + 추가방식 선택 |
|
||||||
|
| 설비 선택 모달 | `equipmentSelectModal` | 설비 카드 + 수량할당 + 일정등록 |
|
||||||
|
| 변경사항 확인 모달 | `changeConfirmModal` | 자동스케줄 생성 결과 요약 + 상세 비교 |
|
||||||
|
|
||||||
|
## 부록 B: HTML 예시의 JS 핵심 함수 목록
|
||||||
|
|
||||||
|
| 함수명 | 기능 | 매핑 API |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `generateSchedule()` | 자동 스케줄 생성 (품목별 합산) | POST /api/production/generate-schedule |
|
||||||
|
| `saveSchedule()` | 스케줄 저장 (localStorage → DB) | POST /api/production/plan (bulk) |
|
||||||
|
| `mergeSelectedSchedules()` | 선택 계획 병합 | POST /api/production/merge-schedules |
|
||||||
|
| `generateSemiFromSelected()` | 반제품 계획 자동 생성 | POST /api/production/generate-semi-schedule |
|
||||||
|
| `saveScheduleFromModal()` | 모달에서 스케줄 저장 | PUT /api/production/plan/:id |
|
||||||
|
| `deleteScheduleFromModal()` | 모달에서 스케줄 삭제 | DELETE /api/production/plan/:id |
|
||||||
|
| `openOrderImportModal()` | 수주 불러오기 모달 열기 | - (프론트엔드 UI) |
|
||||||
|
| `importOrderItems()` | 수주 품목 불러오기 실행 | POST /api/production/generate-schedule |
|
||||||
|
| `openStockImportModal()` | 안전재고 불러오기 모달 열기 | - (프론트엔드 UI) |
|
||||||
|
| `importStockItems()` | 안전재고 품목 불러오기 실행 | POST /api/production/generate-schedule |
|
||||||
|
| `refreshOrderList()` | 수주 목록 새로고침 | GET /api/production/order-summary |
|
||||||
|
| `refreshStockList()` | 재고 부족 목록 새로고침 | GET /api/production/stock-shortage |
|
||||||
|
| `switchTab(tabName)` | 좌측 탭 전환 | - (프론트엔드 UI) |
|
||||||
|
| `switchTimelineTab(tabName)` | 우측 탭 전환 | - (프론트엔드 UI) |
|
||||||
|
| `toggleOrderDetails(itemGroup)` | 품목 그룹 펼치기/접기 | - (프론트엔드 UI) |
|
||||||
|
| `renderTimeline()` | 완제품 타임라인 렌더링 | - (프론트엔드 UI) |
|
||||||
|
| `renderSemiTimeline()` | 반제품 타임라인 렌더링 | - (프론트엔드 UI) |
|
||||||
|
| `executeSplit()` | 계획 분할 실행 | POST /api/production/split-schedule |
|
||||||
|
| `openEquipmentSelectModal()` | 설비 선택 모달 열기 | GET /api/equipment (기존) |
|
||||||
|
| `saveEquipmentSelection()` | 설비 할당 저장 | PUT /api/production/plan/:id |
|
||||||
|
| `applyScheduleChanges()` | 변경사항 확인 후 적용 | - (프론트엔드 상태 관리) |
|
||||||
|
|
||||||
|
## 부록 C: 수주 데이터 테이블 컬럼 상세
|
||||||
|
|
||||||
|
### 그룹 행 (품목별 집계)
|
||||||
|
|
||||||
|
| # | 컬럼 | 데이터 소스 | 정렬 |
|
||||||
|
|---|------|-------------|------|
|
||||||
|
| 1 | 체크박스 | - | center |
|
||||||
|
| 2 | 토글 (펼치기/접기) | - | center |
|
||||||
|
| 3 | 품목코드 | `sales_order_mng.part_code` (GROUP BY) | left |
|
||||||
|
| 4 | 품목명 | `sales_order_mng.part_name` | left |
|
||||||
|
| 5 | 총수주량 | `SUM(order_qty)` | right |
|
||||||
|
| 6 | 출고량 | `SUM(ship_qty)` | right |
|
||||||
|
| 7 | 잔량 | `SUM(balance_qty)` | right |
|
||||||
|
| 8 | 현재고 | `inventory_stock.current_qty` (JOIN) | right |
|
||||||
|
| 9 | 안전재고 | `inventory_stock.safety_qty` (JOIN) | right |
|
||||||
|
| 10 | 출하계획량 | `SUM(plan_ship_qty)` | right |
|
||||||
|
| 11 | 기생산계획량 | `production_plan_mng` 조회 (JOIN) | right |
|
||||||
|
| 12 | 생산진행 | `production_plan_mng` (status='in_progress') 조회 | right |
|
||||||
|
| 13 | 필요생산계획 | 계산값 (잔량+안전재고-현재고-기생산계획량-생산진행) | right, 빨간색 강조 |
|
||||||
|
|
||||||
|
### 상세 행 (개별 수주)
|
||||||
|
|
||||||
|
| # | 컬럼 | 데이터 소스 |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | (빈 칸) | - |
|
||||||
|
| 2 | (빈 칸) | - |
|
||||||
|
| 3-4 | 수주번호, 거래처, 상태배지 | `order_no`, `partner_id` → partner_name, `status` |
|
||||||
|
| 5 | 수주량 | `order_qty` |
|
||||||
|
| 6 | 출고량 | `ship_qty` |
|
||||||
|
| 7 | 잔량 | `balance_qty` |
|
||||||
|
| 8-13 | 납기일 (colspan) | `due_date` |
|
||||||
|
|
||||||
|
## 부록 D: 타임라인 스케줄러 필드 매핑
|
||||||
|
|
||||||
|
### 완제품 타임라인
|
||||||
|
|
||||||
|
| 타임라인 필드 | production_plan_mng 컬럼 | 비고 |
|
||||||
|
|--------------|--------------------------|------|
|
||||||
|
| `id` | `id` | PK |
|
||||||
|
| `resourceId` | `item_code` | 품목 기준 리소스 (설비 기준이 아님) |
|
||||||
|
| `title` | `item_name` + `plan_qty` | 표시 텍스트 |
|
||||||
|
| `startDate` | `start_date` | 시작일 |
|
||||||
|
| `endDate` | `end_date` | 종료일 |
|
||||||
|
| `status` | `status` | planned/in_progress/completed/work-order |
|
||||||
|
| `progress` | `progress_rate` | 진행률(%) |
|
||||||
|
|
||||||
|
### 반제품 타임라인
|
||||||
|
|
||||||
|
동일 구조, 단 `product_type = '반제품'` 필터 적용
|
||||||
|
|
||||||
|
### statusColors 매핑
|
||||||
|
|
||||||
|
| 상태 | 색상 | 의미 |
|
||||||
|
|------|------|------|
|
||||||
|
| `planned` | `#3b82f6` (파란색) | 계획됨 |
|
||||||
|
| `work-order` | `#f59e0b` (노란색) | 작업지시 |
|
||||||
|
| `in_progress` | `#10b981` (초록색) | 진행중 |
|
||||||
|
| `completed` | `#6b7280` (회색, 반투명) | 완료 |
|
||||||
|
| `delayed` | `#ef4444` (빨간색) | 지연 |
|
||||||
|
|
@ -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 | 안전재고 부족분 탭 | | |
|
||||||
|
|
@ -323,7 +323,7 @@ interface ButtonComponentConfig {
|
||||||
|
|
||||||
| 파일 | 내용 |
|
| 파일 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
|
| `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -338,3 +338,52 @@ interface ButtonComponentConfig {
|
||||||
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
|
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
|
||||||
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
|
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
|
||||||
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
|
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
|
||||||
|
- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨
|
||||||
|
- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [미구현] 커스텀 아이콘 전역 관리
|
||||||
|
|
||||||
|
### 현재 문제
|
||||||
|
|
||||||
|
- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임
|
||||||
|
- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임
|
||||||
|
- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함
|
||||||
|
|
||||||
|
### 변경 후 동작
|
||||||
|
|
||||||
|
- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리
|
||||||
|
- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시
|
||||||
|
- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출
|
||||||
|
|
||||||
|
### DB 테이블 (신규)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE custom_icon_registry (
|
||||||
|
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
company_code VARCHAR(500) NOT NULL,
|
||||||
|
icon_name VARCHAR(500) NOT NULL,
|
||||||
|
icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg'
|
||||||
|
svg_data TEXT, -- SVG일 경우 원본 데이터
|
||||||
|
created_date TIMESTAMP DEFAULT now(),
|
||||||
|
updated_date TIMESTAMP DEFAULT now(),
|
||||||
|
writer VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 API (신규)
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) |
|
||||||
|
| POST | `/api/custom-icons` | 커스텀 아이콘 추가 |
|
||||||
|
| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 |
|
||||||
|
|
||||||
|
### 프론트엔드 변경
|
||||||
|
|
||||||
|
- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경
|
||||||
|
- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션)
|
||||||
|
- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,24 @@
|
||||||
|
|
||||||
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
|
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
|
||||||
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
|
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
|
||||||
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
|
- **구현**: `lucide-react`의 `icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링
|
||||||
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
|
- **주의**: `allLucideIcons`는 `button-icon-map.tsx`에서 re-export하여 import를 중앙화
|
||||||
|
|
||||||
|
### 18. 커스텀 아이콘 전역 관리 (미구현)
|
||||||
|
|
||||||
|
- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경
|
||||||
|
- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음
|
||||||
|
- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** — 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합
|
||||||
|
- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장
|
||||||
|
- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션
|
||||||
|
|
||||||
|
### 19. 동적 아이콘 로딩 (getLucideIcon fallback)
|
||||||
|
|
||||||
|
- **결정**: `getLucideIcon(name)`이 `iconMap`에 없는 아이콘을 `lucide-react`의 `icons` 전체 객체에서 동적으로 조회 후 캐싱
|
||||||
|
- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링**
|
||||||
|
- **구현**: `button-icon-map.tsx`에 `import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱
|
||||||
|
- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요
|
||||||
|
- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -159,7 +175,7 @@
|
||||||
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
|
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
|
||||||
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
|
||||||
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
|
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
|
||||||
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
|
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback |
|
||||||
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
|
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -169,17 +185,21 @@
|
||||||
### lucide-react 아이콘 동적 렌더링
|
### lucide-react 아이콘 동적 렌더링
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// button-icon-map.ts
|
// button-icon-map.tsx
|
||||||
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
|
import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
// 추천 아이콘은 명시적 import, 나머지는 동적 조회
|
||||||
Check, Save, Trash2, Pencil, ...
|
const iconMap: Record<string, LucideIcon> = { Check, Save, ... };
|
||||||
};
|
|
||||||
|
|
||||||
export function renderButtonIcon(name: string, size: string | number) {
|
export function getLucideIcon(name: string): LucideIcon | undefined {
|
||||||
const IconComponent = iconMap[name];
|
if (iconMap[name]) return iconMap[name];
|
||||||
if (!IconComponent) return null;
|
// iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱
|
||||||
return <IconComponent style={getIconSizeStyle(size)} />;
|
const found = allLucideIcons[name as keyof typeof allLucideIcons];
|
||||||
|
if (found) {
|
||||||
|
iconMap[name] = found;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,30 @@
|
||||||
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
|
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
|
||||||
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
|
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
|
||||||
|
|
||||||
### 6단계: 정리
|
### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정)
|
||||||
|
|
||||||
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
|
- [x] `button-icon-map.tsx`에 `icons as allLucideIcons` import 추가
|
||||||
|
- [x] `getLucideIcon()` — `iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱
|
||||||
|
- [x] `allLucideIcons`를 `button-icon-map.tsx`에서 re-export (import 중앙화)
|
||||||
|
- [x] `ButtonConfigPanel.tsx` — `lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합
|
||||||
|
- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인
|
||||||
|
|
||||||
|
### 7단계: 정리
|
||||||
|
|
||||||
|
- [x] TypeScript 컴파일 에러 없음 확인
|
||||||
- [x] 불필요한 import 없음 확인
|
- [x] 불필요한 import 없음 확인
|
||||||
|
- [x] 문서 3개 최신화 (동적 로딩 반영)
|
||||||
- [x] 이 체크리스트 완료 표시 업데이트
|
- [x] 이 체크리스트 완료 표시 업데이트
|
||||||
|
|
||||||
|
### 8단계: 커스텀 아이콘 전역 관리 (미구현)
|
||||||
|
|
||||||
|
- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭)
|
||||||
|
- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`)
|
||||||
|
- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`)
|
||||||
|
- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경
|
||||||
|
- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리
|
||||||
|
- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
@ -156,3 +174,6 @@
|
||||||
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
|
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
|
||||||
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
|
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
|
||||||
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
|
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
|
||||||
|
| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 |
|
||||||
|
| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) |
|
||||||
|
| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# BTN - 버튼 UI 스타일 기준정보
|
||||||
|
|
||||||
|
## 1. 스타일 기준
|
||||||
|
|
||||||
|
### 공통 스타일
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 높이 | 40px |
|
||||||
|
| 표시모드 | 아이콘 + 텍스트 (icon-text) |
|
||||||
|
| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) |
|
||||||
|
| 아이콘 크기 비율 | 보통 |
|
||||||
|
| 아이콘-텍스트 간격 | 6px |
|
||||||
|
| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) |
|
||||||
|
| 테두리 모서리 | 8px |
|
||||||
|
| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) |
|
||||||
|
| 텍스트 색상 | #FFFFFF (흰색) |
|
||||||
|
| 텍스트 크기 | 12px |
|
||||||
|
| 텍스트 굵기 | normal (보통) |
|
||||||
|
| 텍스트 정렬 | 왼쪽 |
|
||||||
|
|
||||||
|
### 배경색 (액션별)
|
||||||
|
|
||||||
|
| 액션 타입 | 배경색 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `delete` | `#F04544` | 빨간색 |
|
||||||
|
| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 |
|
||||||
|
| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) |
|
||||||
|
|
||||||
|
배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다.
|
||||||
|
|
||||||
|
### 너비 (텍스트 글자수별)
|
||||||
|
|
||||||
|
| 글자수 | 너비 |
|
||||||
|
|---|---|
|
||||||
|
| 6글자 이하 | 140px |
|
||||||
|
| 7글자 이상 | 160px |
|
||||||
|
|
||||||
|
### 액션별 기본 아이콘
|
||||||
|
|
||||||
|
디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다.
|
||||||
|
|
||||||
|
소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap`
|
||||||
|
|
||||||
|
| action.type | 기본 아이콘 |
|
||||||
|
|---|---|
|
||||||
|
| `save` | Check |
|
||||||
|
| `delete` | Trash2 |
|
||||||
|
| `edit` | Pencil |
|
||||||
|
| `navigate` | ArrowRight |
|
||||||
|
| `modal` | Maximize2 |
|
||||||
|
| `transferData` | SendHorizontal |
|
||||||
|
| `excel_download` | Download |
|
||||||
|
| `excel_upload` | Upload |
|
||||||
|
| `quickInsert` | Zap |
|
||||||
|
| `control` | Settings |
|
||||||
|
| `barcode_scan` | ScanLine |
|
||||||
|
| `operation_control` | Truck |
|
||||||
|
| `event` | Send |
|
||||||
|
| `copy` | Copy |
|
||||||
|
| (그 외/없음) | SquareMousePointer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 코드 반영 현황
|
||||||
|
|
||||||
|
### 컴포넌트 기본값 (신규 버튼 생성 시 적용)
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) |
|
||||||
|
| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig |
|
||||||
|
|
||||||
|
### 액션 변경 시 배경색 자동 변경
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 |
|
||||||
|
|
||||||
|
### 렌더링 배경색 우선순위
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 |
|
||||||
|
|
||||||
|
배경색 결정 순서:
|
||||||
|
1. `webTypeConfig.backgroundColor`
|
||||||
|
2. `componentConfig.backgroundColor`
|
||||||
|
3. `component.style.backgroundColor`
|
||||||
|
4. `componentConfig.style.backgroundColor`
|
||||||
|
5. `component.style.labelColor` (레거시 호환)
|
||||||
|
6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`)
|
||||||
|
|
||||||
|
### 미반영 (추후 작업)
|
||||||
|
|
||||||
|
- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DB 데이터 매핑 (layout_data JSON)
|
||||||
|
|
||||||
|
버튼은 `layout_data.components[]` 배열 안에 `url`이 `v2-button-primary`인 컴포넌트로 저장된다.
|
||||||
|
|
||||||
|
| 항목 | JSON 위치 | 값 |
|
||||||
|
|---|---|---|
|
||||||
|
| 높이 | `size.height` | `40` |
|
||||||
|
| 너비 | `size.width` | `140` 또는 `160` |
|
||||||
|
| 표시모드 | `overrides.displayMode` | `"icon-text"` |
|
||||||
|
| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 |
|
||||||
|
| 아이콘 타입 | `overrides.icon.type` | `"lucide"` |
|
||||||
|
| 아이콘 크기 | `overrides.icon.size` | `"보통"` |
|
||||||
|
| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` |
|
||||||
|
| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` |
|
||||||
|
| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` |
|
||||||
|
| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` |
|
||||||
|
| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` |
|
||||||
|
| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` |
|
||||||
|
| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` |
|
||||||
|
| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 |
|
||||||
|
|
||||||
|
버튼이 위치하는 구조별 경로:
|
||||||
|
- 일반 버튼: `layout_data.components[]`
|
||||||
|
- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]`
|
||||||
|
- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록
|
||||||
|
|
||||||
|
### 대상
|
||||||
|
- **회사**: 탑씰 (company_code = 'COMPANY_7')
|
||||||
|
- **테이블**: screen_layouts_v2 (배포서버)
|
||||||
|
- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts`
|
||||||
|
- **백업 테이블**: `screen_layouts_v2_backup_company7`
|
||||||
|
|
||||||
|
### 작업 이력
|
||||||
|
|
||||||
|
| 날짜 | 작업 내용 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-03-13 | 백업 테이블 생성 | |
|
||||||
|
| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 |
|
||||||
|
| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 |
|
||||||
|
| 2026-03-13 | fontWeight "400" → "normal" 보정 | |
|
||||||
|
| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 |
|
||||||
|
| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | |
|
||||||
|
| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | |
|
||||||
|
| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | |
|
||||||
|
| 2026-03-13 | 전체 버튼 너비 140px 통일 | |
|
||||||
|
| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | |
|
||||||
|
| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 |
|
||||||
|
|
||||||
|
### 스킵 항목
|
||||||
|
- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976)
|
||||||
|
|
||||||
|
### 알려진 이슈
|
||||||
|
- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨.
|
||||||
|
|
||||||
|
### 원복 (필요 시)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE screen_layouts_v2 AS target
|
||||||
|
SET layout_data = backup.layout_data
|
||||||
|
FROM screen_layouts_v2_backup_company7 AS backup
|
||||||
|
WHERE target.layout_id = backup.layout_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백업 테이블 정리
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE screen_layouts_v2_backup_company7;
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
|
||||||
|
|
||||||
|
> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다.
|
||||||
|
|
||||||
|
1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐
|
||||||
|
2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함
|
||||||
|
3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 동작
|
||||||
|
|
||||||
|
### 채번 규칙 구성 (옵션설정 > 코드설정)
|
||||||
|
|
||||||
|
```
|
||||||
|
규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시)
|
||||||
|
|
||||||
|
1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128)
|
||||||
|
2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"`
|
||||||
|
3. 저장 클릭 → `buttonActions.ts`가 `_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출
|
||||||
|
4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치)
|
||||||
|
5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨**
|
||||||
|
6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용
|
||||||
|
7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터)
|
||||||
|
|
||||||
|
### 문제 1: 순번 공유 (buildPrefixKey)
|
||||||
|
|
||||||
|
**위치**: `numberingRuleService.ts` L85-88
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
|
||||||
|
continue; // ← 접두어별 순번 분리를 막는 원인
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이 `continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다.
|
||||||
|
"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다.
|
||||||
|
|
||||||
|
### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백)
|
||||||
|
|
||||||
|
**발생 흐름**:
|
||||||
|
|
||||||
|
1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출
|
||||||
|
2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442)
|
||||||
|
3. 템플릿 기반 수동 값 추출 시도 (L1411-1436):
|
||||||
|
```
|
||||||
|
템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번)
|
||||||
|
사용자 입력: "ㅁㅁㅁ"
|
||||||
|
```
|
||||||
|
4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패** → `extractedManualValues = []`
|
||||||
|
5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작:
|
||||||
|
```typescript
|
||||||
|
const manualValue =
|
||||||
|
extractedManualValues[0] || // undefined (추출 실패)
|
||||||
|
part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐
|
||||||
|
"";
|
||||||
|
```
|
||||||
|
6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨)
|
||||||
|
|
||||||
|
**DB 숨은 값 원인**:
|
||||||
|
- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨
|
||||||
|
- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음**
|
||||||
|
- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨
|
||||||
|
|
||||||
|
### 문제 3: 연속 구분자(--) 문제
|
||||||
|
|
||||||
|
**발생 흐름**:
|
||||||
|
|
||||||
|
1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열)
|
||||||
|
2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생
|
||||||
|
3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`)
|
||||||
|
4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용)
|
||||||
|
5. 입력 `-제발-015`이 `CATEGORY-`로 시작하지 않음 → 추출 실패
|
||||||
|
6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨
|
||||||
|
7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003`
|
||||||
|
|
||||||
|
### 정상 동작 확인된 부분
|
||||||
|
|
||||||
|
| 항목 | 상태 | 근거 |
|
||||||
|
|------|------|------|
|
||||||
|
| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 |
|
||||||
|
| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) |
|
||||||
|
| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 |
|
||||||
|
|
||||||
|
### 비정상 확인된 부분
|
||||||
|
|
||||||
|
| 항목 | 상태 | 근거 |
|
||||||
|
|------|------|------|
|
||||||
|
| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 |
|
||||||
|
| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 |
|
||||||
|
| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 후 동작
|
||||||
|
|
||||||
|
### prefix_key에 수동 파트 값 포함
|
||||||
|
|
||||||
|
```
|
||||||
|
현재: prefix_key = 카테고리값만 (수동 파트 무시)
|
||||||
|
변경: prefix_key = 카테고리값 + "|" + 수동입력값
|
||||||
|
```
|
||||||
|
|
||||||
|
### allocateCode 실행 순서 변경
|
||||||
|
|
||||||
|
```
|
||||||
|
현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합
|
||||||
|
변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
|
||||||
|
```
|
||||||
|
|
||||||
|
### 순번 동작
|
||||||
|
|
||||||
|
```
|
||||||
|
"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001
|
||||||
|
"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002
|
||||||
|
"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001
|
||||||
|
"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003
|
||||||
|
```
|
||||||
|
|
||||||
|
### BULK1 폴백 제거 (코드 + DB 이중 조치)
|
||||||
|
|
||||||
|
```
|
||||||
|
코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용
|
||||||
|
DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 연속 구분자 방지 + 템플릿 정합성 복원
|
||||||
|
|
||||||
|
```
|
||||||
|
joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
|
||||||
|
extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신)
|
||||||
|
→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시각적 예시
|
||||||
|
|
||||||
|
| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 |
|
||||||
|
|------------|----------|------|-------------|
|
||||||
|
| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` |
|
||||||
|
| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` |
|
||||||
|
| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` |
|
||||||
|
| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) |
|
||||||
|
| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 사용자
|
||||||
|
participant BA as buttonActions.ts
|
||||||
|
participant API as allocateNumberingCode API
|
||||||
|
participant NRS as numberingRuleService
|
||||||
|
participant DB as numbering_rule_sequences
|
||||||
|
|
||||||
|
User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ")
|
||||||
|
BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData)
|
||||||
|
API->>NRS: allocateCode()
|
||||||
|
|
||||||
|
Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행)
|
||||||
|
NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ")
|
||||||
|
Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용
|
||||||
|
NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"]
|
||||||
|
|
||||||
|
Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함)
|
||||||
|
NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"])
|
||||||
|
Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ"
|
||||||
|
|
||||||
|
Note over NRS: 3단계: 시퀀스 할당
|
||||||
|
NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ")
|
||||||
|
DB-->>NRS: current_sequence = 1
|
||||||
|
|
||||||
|
Note over NRS: 4단계: 코드 조합
|
||||||
|
NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001"
|
||||||
|
NRS-->>API: "카테고리값-ㅁㅁㅁ-001"
|
||||||
|
API-->>BA: generatedCode
|
||||||
|
BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 대상 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 | 규모 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey`에 `manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode`에 `manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
|
||||||
|
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
|
||||||
|
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode`에 `manualInputValue` 파라미터 추가 | ~3줄 |
|
||||||
|
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
|
||||||
|
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
|
||||||
|
|
||||||
|
### buildPrefixKey 호출부 영향 분석
|
||||||
|
|
||||||
|
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|
||||||
|
|--------|------|---------------------|------|
|
||||||
|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
|
||||||
|
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
|
||||||
|
|
||||||
|
### 멀티테넌시 체크
|
||||||
|
|
||||||
|
| 항목 | 상태 | 근거 |
|
||||||
|
|------|------|------|
|
||||||
|
| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 |
|
||||||
|
| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 |
|
||||||
|
| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 |
|
||||||
|
| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 코드 설계
|
||||||
|
|
||||||
|
### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지
|
||||||
|
|
||||||
|
**위치**: L36-48
|
||||||
|
**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
|
||||||
|
let result = "";
|
||||||
|
partValues.forEach((val, idx) => {
|
||||||
|
result += val;
|
||||||
|
if (idx < partValues.length - 1) {
|
||||||
|
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||||
|
if (val || !result.endsWith(sep)) {
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함
|
||||||
|
|
||||||
|
**위치**: L75-88
|
||||||
|
**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async buildPrefixKey(
|
||||||
|
rule: NumberingRuleConfig,
|
||||||
|
formData?: Record<string, any>,
|
||||||
|
manualValues?: string[]
|
||||||
|
): Promise<string> {
|
||||||
|
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const prefixParts: string[] = [];
|
||||||
|
let manualIndex = 0;
|
||||||
|
|
||||||
|
for (const part of sortedParts) {
|
||||||
|
if (part.partType === "sequence") continue;
|
||||||
|
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
const manualValue = manualValues?.[manualIndex] || "";
|
||||||
|
manualIndex++;
|
||||||
|
if (manualValue) {
|
||||||
|
prefixParts.push(manualValue);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ...
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixParts.join("|");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음.
|
||||||
|
|
||||||
|
### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리
|
||||||
|
|
||||||
|
**위치**: L1290-1584
|
||||||
|
**핵심 변경 2가지**:
|
||||||
|
|
||||||
|
(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경.
|
||||||
|
|
||||||
|
(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async allocateCode(ruleId, companyCode, formData?, userInputCode?) {
|
||||||
|
// ... 규칙 조회 ...
|
||||||
|
|
||||||
|
// 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행)
|
||||||
|
const manualParts = rule.parts.filter(p => p.generationMethod === "manual");
|
||||||
|
let extractedManualValues: string[] = [];
|
||||||
|
|
||||||
|
if (manualParts.length > 0 && userInputCode) {
|
||||||
|
extractedManualValues = await this.extractManualValuesFromInput(
|
||||||
|
rule, userInputCode, formData
|
||||||
|
);
|
||||||
|
|
||||||
|
// 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용
|
||||||
|
if (extractedManualValues.length === 0 && manualParts.length === 1) {
|
||||||
|
extractedManualValues = [userInputCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: 수동 값을 포함하여 prefix_key 빌드
|
||||||
|
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
|
||||||
|
|
||||||
|
// 3단계: 시퀀스 할당 (기존 로직 그대로)
|
||||||
|
|
||||||
|
// 4단계: 코드 조합 (manualConfig.value 폴백 제거)
|
||||||
|
// 기존: extractedManualValues[i] || part.manualConfig?.value || ""
|
||||||
|
// 변경: extractedManualValues[i] || ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원
|
||||||
|
|
||||||
|
기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출.
|
||||||
|
로직 자체는 변경 없음, 위치만 이동.
|
||||||
|
카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async extractManualValuesFromInput(
|
||||||
|
rule: NumberingRuleConfig,
|
||||||
|
userInputCode: string,
|
||||||
|
formData?: Record<string, any>
|
||||||
|
): Promise<string[]> {
|
||||||
|
// 기존 L1332-1442의 로직을 그대로 이동
|
||||||
|
// 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환
|
||||||
|
// → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. DB 마이그레이션 - BULK1 유령 기본값 제거
|
||||||
|
|
||||||
|
**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql`
|
||||||
|
|
||||||
|
`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리)
|
||||||
|
UPDATE numbering_rule_parts
|
||||||
|
SET manual_config = manual_config - 'value'
|
||||||
|
WHERE generation_method = 'manual'
|
||||||
|
AND manual_config ? 'value'
|
||||||
|
AND manual_config->>'value' = 'BULK1';
|
||||||
|
```
|
||||||
|
|
||||||
|
> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거.
|
||||||
|
> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨.
|
||||||
|
> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설계 원칙
|
||||||
|
|
||||||
|
- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건
|
||||||
|
- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리
|
||||||
|
- `buildPrefixKey`의 `manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음
|
||||||
|
- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님
|
||||||
|
- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음
|
||||||
|
- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지
|
||||||
|
- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름)
|
||||||
|
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
|
||||||
|
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
|
||||||
|
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실시간 순번 미리보기 (추가 기능)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
|
||||||
|
품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.
|
||||||
|
|
||||||
|
### 목표 동작
|
||||||
|
|
||||||
|
```
|
||||||
|
모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번)
|
||||||
|
"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건)
|
||||||
|
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as 사용자
|
||||||
|
participant V2 as V2Input
|
||||||
|
participant API as previewNumberingCode
|
||||||
|
participant BE as numberingRuleService.previewCode
|
||||||
|
participant DB as numbering_rule_sequences
|
||||||
|
|
||||||
|
User->>V2: 수동 입력 "ㅇㅇ"
|
||||||
|
Note over V2: 디바운스 300ms
|
||||||
|
V2->>API: preview(ruleId, formData, "ㅇㅇ")
|
||||||
|
API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
|
||||||
|
BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
|
||||||
|
Note over BE: prefix_key = "카테고리|ㅇㅇ"
|
||||||
|
BE->>DB: getSequenceForPrefix(prefix_key)
|
||||||
|
DB-->>BE: currentSeq = 0
|
||||||
|
Note over BE: nextSequence = 0 + startFrom(5) = 5
|
||||||
|
BE-->>API: "-____-005"
|
||||||
|
API-->>V2: generatedCode
|
||||||
|
V2->>V2: suffix = "-005" 갱신
|
||||||
|
Note over V2: 화면 표시: -[ㅇㅇ]-005
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변경 내용
|
||||||
|
|
||||||
|
1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
|
||||||
|
2. **백엔드 서비스**: `previewCode`가 `manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
|
||||||
|
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
|
||||||
|
4. **프론트엔드 API**: `previewNumberingCode`에 `manualInputValue` 파라미터 추가
|
||||||
|
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
|
||||||
|
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
|
||||||
|
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
|
||||||
|
|
||||||
|
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 왜 이 작업을 하는가
|
||||||
|
|
||||||
|
- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨
|
||||||
|
- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함
|
||||||
|
- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함
|
||||||
|
- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 결정 사항과 근거
|
||||||
|
|
||||||
|
### 1. 수동 값 추출을 buildPrefixKey 전으로 이동
|
||||||
|
|
||||||
|
- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경
|
||||||
|
- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함
|
||||||
|
- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향)
|
||||||
|
|
||||||
|
### 2. buildPrefixKey에 수동 파트 값 포함
|
||||||
|
|
||||||
|
- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함
|
||||||
|
- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함
|
||||||
|
- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음
|
||||||
|
|
||||||
|
### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용
|
||||||
|
|
||||||
|
- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용
|
||||||
|
- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임
|
||||||
|
- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음)
|
||||||
|
|
||||||
|
### 4. 코드 조합에서 manualConfig.value 폴백 제거
|
||||||
|
|
||||||
|
- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""` → `extractedManualValues[i] || ""`
|
||||||
|
- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx`에 `value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨
|
||||||
|
- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리
|
||||||
|
|
||||||
|
### 5. DB 마이그레이션은 BULK1만 타겟팅
|
||||||
|
|
||||||
|
- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정
|
||||||
|
- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지
|
||||||
|
- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요)
|
||||||
|
|
||||||
|
### 6. extractManualValuesFromInput 헬퍼 분리
|
||||||
|
|
||||||
|
- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출
|
||||||
|
- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움
|
||||||
|
- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리)
|
||||||
|
|
||||||
|
### 7. 프론트엔드 변경 불필요
|
||||||
|
|
||||||
|
- **결정**: 프론트엔드 코드 수정 없음
|
||||||
|
- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음
|
||||||
|
|
||||||
|
### 8. joinPartsWithSeparators 연속 구분자 방지
|
||||||
|
|
||||||
|
- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음
|
||||||
|
- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지
|
||||||
|
- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵
|
||||||
|
|
||||||
|
### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경
|
||||||
|
|
||||||
|
- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경
|
||||||
|
- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]`
|
||||||
|
- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일 위치
|
||||||
|
|
||||||
|
| 구분 | 파일 경로 | 설명 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) |
|
||||||
|
| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 |
|
||||||
|
| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 |
|
||||||
|
| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 |
|
||||||
|
| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 |
|
||||||
|
| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기술 참고
|
||||||
|
|
||||||
|
### allocateCode 실행 순서 (변경 전 → 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합
|
||||||
|
변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
|
||||||
|
```
|
||||||
|
|
||||||
|
### prefix_key 구성 (변경 전 → 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키)
|
||||||
|
변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 폴백 체인 (변경 전 → 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 전: extractedManualValues[i] || manualConfig.value || ""
|
||||||
|
변경 후: extractedManualValues[i] || ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ"
|
||||||
|
변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 템플릿 정합성 (변경 전 → 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패
|
||||||
|
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. 실시간 순번 미리보기 구현 방식
|
||||||
|
|
||||||
|
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
|
||||||
|
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
|
||||||
|
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
|
||||||
|
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
|
||||||
|
|
||||||
|
### 11. previewCode에 manualInputValue 전달
|
||||||
|
|
||||||
|
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey`에 `[manualInputValue]`로 전달
|
||||||
|
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
|
||||||
|
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
|
||||||
|
|
||||||
|
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
|
||||||
|
|
||||||
|
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
|
||||||
|
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
|
||||||
|
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5` → `-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
|
||||||
|
|
||||||
|
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
|
||||||
|
|
||||||
|
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
|
||||||
|
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
|
||||||
|
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
|
||||||
|
|
||||||
|
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
|
||||||
|
|
||||||
|
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
|
||||||
|
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
|
||||||
|
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
|
||||||
|
|
||||||
|
### BULK1이 DB에 남아있는 이유
|
||||||
|
|
||||||
|
```
|
||||||
|
ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음)
|
||||||
|
플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue }
|
||||||
|
→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨
|
||||||
|
→ UI에서 제거 불가능한 유령 값
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
|
||||||
|
|
||||||
|
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공정 상태
|
||||||
|
|
||||||
|
- 전체 진행률: **100%** (전체 완료)
|
||||||
|
- 현재 단계: 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
### 1단계: 구조적 변경 (행위 변경 없음)
|
||||||
|
|
||||||
|
- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리
|
||||||
|
- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체
|
||||||
|
- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음)
|
||||||
|
|
||||||
|
### 2단계: buildPrefixKey 수정
|
||||||
|
|
||||||
|
- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가
|
||||||
|
- [x] 수동 파트 처리 로직 변경: `continue` → `manualValues`에서 값 꺼내 `prefixParts`에 추가
|
||||||
|
- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터)
|
||||||
|
|
||||||
|
### 3단계: allocateCode 순서 변경 + 폴백 정리
|
||||||
|
|
||||||
|
- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동
|
||||||
|
- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가
|
||||||
|
- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달
|
||||||
|
- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거
|
||||||
|
|
||||||
|
### 4단계: DB 마이그레이션
|
||||||
|
|
||||||
|
- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성
|
||||||
|
- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거
|
||||||
|
- [x] 마이그레이션 실행 (9건 정리 완료)
|
||||||
|
|
||||||
|
### 5단계: 연속 구분자(--) 방지
|
||||||
|
|
||||||
|
- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가
|
||||||
|
- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성)
|
||||||
|
|
||||||
|
### 6단계: 검증
|
||||||
|
|
||||||
|
- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인
|
||||||
|
- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님)
|
||||||
|
- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인
|
||||||
|
- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인
|
||||||
|
- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인
|
||||||
|
- [x] previewCode (미리보기) 동작 영향 없음 확인
|
||||||
|
- [x] BULK1이 더 이상 생성되지 않음 확인
|
||||||
|
|
||||||
|
### 7단계: 실시간 순번 미리보기
|
||||||
|
|
||||||
|
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
|
||||||
|
- [x] 백엔드 서비스: `previewCode`에 `manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
|
||||||
|
- [x] 프론트엔드 API: `previewNumberingCode`에 `manualInputValue` 파라미터 추가
|
||||||
|
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
|
||||||
|
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
|
||||||
|
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
|
||||||
|
- [x] 린트 에러 없음 확인
|
||||||
|
|
||||||
|
### 8단계: 코드 정리
|
||||||
|
|
||||||
|
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
|
||||||
|
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
|
||||||
|
- [x] 린트 에러 없음 확인
|
||||||
|
|
||||||
|
### 9단계: 정리
|
||||||
|
|
||||||
|
- [x] 계획서/맥락노트/체크리스트 최신화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 알려진 이슈 (보류)
|
||||||
|
|
||||||
|
| 이슈 | 설명 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 |
|
||||||
|
| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
|
||||||
|
| 2026-03-11 | 1-4단계 구현 완료 |
|
||||||
|
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
|
||||||
|
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
|
||||||
|
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
|
||||||
|
| 2026-03-12 | 계맥체 최신화 완료 |
|
||||||
|
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
|
||||||
|
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
|
||||||
|
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
|
||||||
|
| 2026-03-12 | 6단계 검증 완료. 전체 완료 |
|
||||||
|
|
@ -17,14 +17,17 @@ import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV5,
|
PopLayoutData,
|
||||||
GridMode,
|
GridMode,
|
||||||
isV5Layout,
|
isPopLayout,
|
||||||
createEmptyPopLayoutV5,
|
createEmptyLayout,
|
||||||
GAP_PRESETS,
|
GAP_PRESETS,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
|
BLOCK_GAP,
|
||||||
|
BLOCK_PADDING,
|
||||||
detectGridMode,
|
detectGridMode,
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import { loadLegacyLayout } from "@/components/pop/designer/utils/legacyLoader";
|
||||||
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
||||||
import "@/lib/registry/pop-components";
|
import "@/lib/registry/pop-components";
|
||||||
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
||||||
|
|
@ -79,7 +82,7 @@ function PopScreenViewPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -116,22 +119,22 @@ function PopScreenViewPage() {
|
||||||
try {
|
try {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
if (popLayout && isV5Layout(popLayout)) {
|
if (popLayout && isPopLayout(popLayout)) {
|
||||||
// v5 레이아웃 로드
|
const v6Layout = loadLegacyLayout(popLayout);
|
||||||
setLayout(popLayout);
|
setLayout(v6Layout);
|
||||||
const componentCount = Object.keys(popLayout.components).length;
|
const componentCount = Object.keys(popLayout.components).length;
|
||||||
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||||
} else if (popLayout) {
|
} else if (popLayout) {
|
||||||
// 다른 버전 레이아웃은 빈 v5로 처리
|
// 다른 버전 레이아웃은 빈 v5로 처리
|
||||||
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
|
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
|
||||||
setLayout(createEmptyPopLayoutV5());
|
setLayout(createEmptyLayout());
|
||||||
} else {
|
} else {
|
||||||
console.log("[POP] 레이아웃 없음");
|
console.log("[POP] 레이아웃 없음");
|
||||||
setLayout(createEmptyPopLayoutV5());
|
setLayout(createEmptyLayout());
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||||
setLayout(createEmptyPopLayoutV5());
|
setLayout(createEmptyLayout());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[POP] 화면 로드 실패:", error);
|
console.error("[POP] 화면 로드 실패:", error);
|
||||||
|
|
@ -318,12 +321,8 @@ function PopScreenViewPage() {
|
||||||
style={{ maxWidth: 1366 }}
|
style={{ maxWidth: 1366 }}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Gap 프리셋 계산
|
const adjustedGap = BLOCK_GAP;
|
||||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
const adjustedPadding = BLOCK_PADDING;
|
||||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
|
|
||||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
|
||||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopViewerWithModals
|
<PopViewerWithModals
|
||||||
|
|
|
||||||
|
|
@ -402,18 +402,9 @@ select {
|
||||||
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
|
||||||
/* 예: Calendar, Table 등의 미세 조정 */
|
/* 예: Calendar, Table 등의 미세 조정 */
|
||||||
|
|
||||||
/* 모바일에서 테이블 레이아웃 고정 (화면 밖으로 넘어가지 않도록) */
|
/* 테이블 레이아웃 고정 (셀 내용이 영역을 벗어나지 않도록) */
|
||||||
@media (max-width: 639px) {
|
.table-mobile-fixed {
|
||||||
.table-mobile-fixed {
|
table-layout: fixed;
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 데스크톱에서 테이블 레이아웃 자동 (기본값이지만 명시적으로 설정) */
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.table-mobile-fixed {
|
|
||||||
table-layout: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 그리드선 숨기기 */
|
/* 그리드선 숨기기 */
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { useCallback, useRef, useState, useEffect, useMemo } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV5,
|
PopLayoutData,
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
|
|
@ -17,8 +17,12 @@ import {
|
||||||
ModalSizePreset,
|
ModalSizePreset,
|
||||||
MODAL_SIZE_PRESETS,
|
MODAL_SIZE_PRESETS,
|
||||||
resolveModalWidth,
|
resolveModalWidth,
|
||||||
|
BLOCK_SIZE,
|
||||||
|
BLOCK_GAP,
|
||||||
|
BLOCK_PADDING,
|
||||||
|
getBlockColumns,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,13 +34,12 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import PopRenderer from "./renderers/PopRenderer";
|
import PopRenderer from "./renderers/PopRenderer";
|
||||||
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
|
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions } from "./utils/gridUtils";
|
||||||
import { DND_ITEM_TYPES } from "./constants";
|
import { DND_ITEM_TYPES } from "./constants";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캔버스 내 상대 좌표 → 그리드 좌표 변환
|
* V6: 캔버스 내 상대 좌표 → 블록 그리드 좌표 변환
|
||||||
* @param relX 캔버스 내 X 좌표 (패딩 포함)
|
* 블록 크기가 고정(BLOCK_SIZE)이므로 1fr 계산 불필요
|
||||||
* @param relY 캔버스 내 Y 좌표 (패딩 포함)
|
|
||||||
*/
|
*/
|
||||||
function calcGridPosition(
|
function calcGridPosition(
|
||||||
relX: number,
|
relX: number,
|
||||||
|
|
@ -47,21 +50,13 @@ function calcGridPosition(
|
||||||
gap: number,
|
gap: number,
|
||||||
padding: number
|
padding: number
|
||||||
): { col: number; row: number } {
|
): { col: number; row: number } {
|
||||||
// 패딩 제외한 좌표
|
|
||||||
const x = relX - padding;
|
const x = relX - padding;
|
||||||
const y = relY - padding;
|
const y = relY - padding;
|
||||||
|
|
||||||
// 사용 가능한 너비 (패딩과 gap 제외)
|
const cellStride = BLOCK_SIZE + gap;
|
||||||
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
|
|
||||||
const colWidth = availableWidth / columns;
|
|
||||||
|
|
||||||
// 셀+gap 단위로 계산
|
|
||||||
const cellStride = colWidth + gap;
|
|
||||||
const rowStride = rowHeight + gap;
|
|
||||||
|
|
||||||
// 그리드 좌표 (1부터 시작)
|
|
||||||
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
|
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
|
||||||
const row = Math.max(1, Math.floor(y / rowStride) + 1);
|
const row = Math.max(1, Math.floor(y / cellStride) + 1);
|
||||||
|
|
||||||
return { col, row };
|
return { col, row };
|
||||||
}
|
}
|
||||||
|
|
@ -78,13 +73,13 @@ interface DragItemMoveComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 프리셋 해상도 (4개 모드) - 너비만 정의
|
// V6: 프리셋 해상도 (블록 칸 수 동적 계산)
|
||||||
// ========================================
|
// ========================================
|
||||||
const VIEWPORT_PRESETS = [
|
const VIEWPORT_PRESETS = [
|
||||||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
|
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: `모바일↕ (${getBlockColumns(375)}칸)`, width: 375, icon: Smartphone },
|
||||||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
|
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: `모바일↔ (${getBlockColumns(600)}칸)`, width: 600, icon: Smartphone },
|
||||||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
|
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: `태블릿↕ (${getBlockColumns(820)}칸)`, width: 820, icon: Tablet },
|
||||||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
|
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: `태블릿↔ (${getBlockColumns(1024)}칸)`, width: 1024, icon: Tablet },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ViewportPreset = GridMode;
|
type ViewportPreset = GridMode;
|
||||||
|
|
@ -100,13 +95,13 @@ const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
|
||||||
// Props
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
interface PopCanvasProps {
|
interface PopCanvasProps {
|
||||||
layout: PopLayoutDataV5;
|
layout: PopLayoutData;
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
currentMode: GridMode;
|
currentMode: GridMode;
|
||||||
onModeChange: (mode: GridMode) => void;
|
onModeChange: (mode: GridMode) => void;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinition>) => void;
|
||||||
onDeleteComponent: (componentId: string) => void;
|
onDeleteComponent: (componentId: string) => void;
|
||||||
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
|
|
@ -168,7 +163,7 @@ export default function PopCanvas({
|
||||||
}, [layout.modals]);
|
}, [layout.modals]);
|
||||||
|
|
||||||
// activeCanvasId에 따라 렌더링할 layout 분기
|
// activeCanvasId에 따라 렌더링할 layout 분기
|
||||||
const activeLayout = useMemo((): PopLayoutDataV5 => {
|
const activeLayout = useMemo((): PopLayoutData => {
|
||||||
if (activeCanvasId === "main") return layout;
|
if (activeCanvasId === "main") return layout;
|
||||||
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||||
if (!modal) return layout; // fallback
|
if (!modal) return layout; // fallback
|
||||||
|
|
@ -202,15 +197,22 @@ export default function PopCanvas({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 현재 뷰포트 해상도
|
// V6: 뷰포트에서 동적 블록 칸 수 계산
|
||||||
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const dynamicColumns = getBlockColumns(customWidth);
|
||||||
|
const breakpoint = {
|
||||||
|
...GRID_BREAKPOINTS[currentMode],
|
||||||
|
columns: dynamicColumns,
|
||||||
|
rowHeight: BLOCK_SIZE,
|
||||||
|
gap: BLOCK_GAP,
|
||||||
|
padding: BLOCK_PADDING,
|
||||||
|
label: `${dynamicColumns}칸 블록`,
|
||||||
|
};
|
||||||
|
|
||||||
// Gap 프리셋 적용
|
// V6: 블록 간격 고정 (프리셋 무관)
|
||||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
const adjustedGap = BLOCK_GAP;
|
||||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
const adjustedPadding = BLOCK_PADDING;
|
||||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
|
||||||
|
|
||||||
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
|
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
|
||||||
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
|
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
|
||||||
|
|
@ -399,9 +401,9 @@ export default function PopCanvas({
|
||||||
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||||
|
|
||||||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||||
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
// 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||||
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
||||||
const componentData = layout.components[dragItem.componentId];
|
const componentData = activeLayout.components[dragItem.componentId];
|
||||||
|
|
||||||
if (!currentEffectivePos && !componentData) return;
|
if (!currentEffectivePos && !componentData) return;
|
||||||
|
|
||||||
|
|
@ -470,22 +472,8 @@ export default function PopCanvas({
|
||||||
);
|
);
|
||||||
}, [activeLayout.components, hiddenComponentIds]);
|
}, [activeLayout.components, hiddenComponentIds]);
|
||||||
|
|
||||||
// 검토 필요 컴포넌트 목록
|
|
||||||
const reviewComponents = useMemo(() => {
|
|
||||||
return visibleComponents.filter(comp => {
|
|
||||||
const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
|
|
||||||
return needsReview(currentMode, hasOverride);
|
|
||||||
});
|
|
||||||
}, [visibleComponents, activeLayout.overrides, currentMode]);
|
|
||||||
|
|
||||||
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
|
||||||
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
|
||||||
|
|
||||||
// 12칸 모드가 아닐 때만 패널 표시
|
|
||||||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
|
||||||
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
|
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
|
||||||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||||||
const showRightPanel = showReviewPanel || showHiddenPanel;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-muted">
|
<div className="flex h-full flex-col bg-muted">
|
||||||
|
|
@ -666,7 +654,7 @@ export default function PopCanvas({
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
|
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
|
||||||
style={{
|
style={{
|
||||||
width: showRightPanel
|
width: showHiddenPanel
|
||||||
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
|
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
|
||||||
: `${customWidth + 32}px`,
|
: `${customWidth + 32}px`,
|
||||||
minHeight: `${dynamicCanvasHeight + 32}px`,
|
minHeight: `${dynamicCanvasHeight + 32}px`,
|
||||||
|
|
@ -774,20 +762,11 @@ export default function PopCanvas({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
|
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
|
||||||
{showRightPanel && (
|
{showHiddenPanel && (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col gap-3"
|
className="flex flex-col gap-3"
|
||||||
style={{ marginTop: "32px" }}
|
style={{ marginTop: "32px" }}
|
||||||
>
|
>
|
||||||
{/* 검토 필요 패널 */}
|
|
||||||
{showReviewPanel && (
|
|
||||||
<ReviewPanel
|
|
||||||
components={reviewComponents}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onSelectComponent={onSelectComponent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 숨김 컴포넌트 패널 */}
|
{/* 숨김 컴포넌트 패널 */}
|
||||||
{showHiddenPanel && (
|
{showHiddenPanel && (
|
||||||
<HiddenPanel
|
<HiddenPanel
|
||||||
|
|
@ -805,7 +784,7 @@ export default function PopCanvas({
|
||||||
{/* 하단 정보 */}
|
{/* 하단 정보 */}
|
||||||
<div className="flex items-center justify-between border-t bg-background px-4 py-2">
|
<div className="flex items-center justify-between border-t bg-background px-4 py-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
|
V6 블록 그리드 - {dynamicColumns}칸 (블록: {BLOCK_SIZE}px, 간격: {BLOCK_GAP}px)
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
||||||
|
|
@ -819,99 +798,12 @@ export default function PopCanvas({
|
||||||
// 검토 필요 영역 (오른쪽 패널)
|
// 검토 필요 영역 (오른쪽 패널)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ReviewPanelProps {
|
|
||||||
components: PopComponentDefinitionV5[];
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewPanel({
|
|
||||||
components,
|
|
||||||
selectedComponentId,
|
|
||||||
onSelectComponent,
|
|
||||||
}: ReviewPanelProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col rounded-lg border-2 border-dashed border-primary/40 bg-primary/5"
|
|
||||||
style={{
|
|
||||||
width: "200px",
|
|
||||||
maxHeight: "300px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-2 border-b border-primary/20 bg-primary/5 px-3 py-2 rounded-t-lg">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-primary" />
|
|
||||||
<span className="text-xs font-semibold text-primary">
|
|
||||||
검토 필요 ({components.length}개)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컴포넌트 목록 */}
|
|
||||||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
|
||||||
{components.map((comp) => (
|
|
||||||
<ReviewItem
|
|
||||||
key={comp.id}
|
|
||||||
component={comp}
|
|
||||||
isSelected={selectedComponentId === comp.id}
|
|
||||||
onSelect={() => onSelectComponent(comp.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 안내 문구 */}
|
|
||||||
<div className="border-t border-primary/20 px-3 py-2 bg-primary/10 rounded-b-lg">
|
|
||||||
<p className="text-[10px] text-primary leading-tight">
|
|
||||||
자동 배치됨. 클릭하여 확인 후 편집 가능
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 검토 필요 아이템 (ReviewPanel 내부)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ReviewItemProps {
|
|
||||||
component: PopComponentDefinitionV5;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewItem({
|
|
||||||
component,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: ReviewItemProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
|
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/10 shadow-sm"
|
|
||||||
: "border-primary/20 bg-background hover:border-primary/60 hover:bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium text-primary line-clamp-1">
|
|
||||||
{component.label || component.id}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-primary bg-primary/10 rounded px-1.5 py-0.5 self-start">
|
|
||||||
자동 배치됨
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 숨김 컴포넌트 영역 (오른쪽 패널)
|
// 숨김 컴포넌트 영역 (오른쪽 패널)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface HiddenPanelProps {
|
interface HiddenPanelProps {
|
||||||
components: PopComponentDefinitionV5[];
|
components: PopComponentDefinition[];
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onHideComponent?: (componentId: string) => void;
|
onHideComponent?: (componentId: string) => void;
|
||||||
|
|
@ -997,7 +889,7 @@ function HiddenPanel({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface HiddenItemProps {
|
interface HiddenItemProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,22 @@ import PopCanvas from "./PopCanvas";
|
||||||
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
||||||
import ComponentPalette from "./panels/ComponentPalette";
|
import ComponentPalette from "./panels/ComponentPalette";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV5,
|
PopLayoutData,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
GapPreset,
|
GapPreset,
|
||||||
createEmptyPopLayoutV5,
|
createEmptyLayout,
|
||||||
isV5Layout,
|
isPopLayout,
|
||||||
addComponentToV5Layout,
|
addComponentToLayout,
|
||||||
createComponentDefinitionV5,
|
createComponentDefinition,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
PopModalDefinition,
|
PopModalDefinition,
|
||||||
PopDataConnection,
|
PopDataConnection,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||||
|
import { loadLegacyLayout } from "./utils/legacyLoader";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { PopDesignerContext } from "./PopDesignerContext";
|
import { PopDesignerContext } from "./PopDesignerContext";
|
||||||
|
|
@ -59,10 +60,10 @@ export default function PopDesigner({
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 상태
|
// 레이아웃 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
const [layout, setLayout] = useState<PopLayoutData>(createEmptyLayout());
|
||||||
|
|
||||||
// 히스토리
|
// 히스토리
|
||||||
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
const [history, setHistory] = useState<PopLayoutData[]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
|
|
@ -84,7 +85,7 @@ export default function PopDesigner({
|
||||||
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
|
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
|
||||||
|
|
||||||
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
|
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
|
||||||
const selectedComponent: PopComponentDefinitionV5 | null = (() => {
|
const selectedComponent: PopComponentDefinition | null = (() => {
|
||||||
if (!selectedComponentId) return null;
|
if (!selectedComponentId) return null;
|
||||||
if (activeCanvasId === "main") {
|
if (activeCanvasId === "main") {
|
||||||
return layout.components[selectedComponentId] || null;
|
return layout.components[selectedComponentId] || null;
|
||||||
|
|
@ -96,7 +97,7 @@ export default function PopDesigner({
|
||||||
// ========================================
|
// ========================================
|
||||||
// 히스토리 관리
|
// 히스토리 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
|
const saveToHistory = useCallback((newLayout: PopLayoutData) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const newHistory = prev.slice(0, historyIndex + 1);
|
const newHistory = prev.slice(0, historyIndex + 1);
|
||||||
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
||||||
|
|
@ -150,14 +151,13 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
if (loadedLayout && isPopLayout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||||
// v5 레이아웃 로드
|
|
||||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
|
||||||
if (!loadedLayout.settings.gapPreset) {
|
if (!loadedLayout.settings.gapPreset) {
|
||||||
loadedLayout.settings.gapPreset = "medium";
|
loadedLayout.settings.gapPreset = "medium";
|
||||||
}
|
}
|
||||||
setLayout(loadedLayout);
|
const v6Layout = loadLegacyLayout(loadedLayout);
|
||||||
setHistory([loadedLayout]);
|
setLayout(v6Layout);
|
||||||
|
setHistory([v6Layout]);
|
||||||
setHistoryIndex(0);
|
setHistoryIndex(0);
|
||||||
|
|
||||||
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
|
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
|
||||||
|
|
@ -175,7 +175,7 @@ export default function PopDesigner({
|
||||||
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
|
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
|
||||||
} else {
|
} else {
|
||||||
// 새 화면 또는 빈 레이아웃
|
// 새 화면 또는 빈 레이아웃
|
||||||
const emptyLayout = createEmptyPopLayoutV5();
|
const emptyLayout = createEmptyLayout();
|
||||||
setLayout(emptyLayout);
|
setLayout(emptyLayout);
|
||||||
setHistory([emptyLayout]);
|
setHistory([emptyLayout]);
|
||||||
setHistoryIndex(0);
|
setHistoryIndex(0);
|
||||||
|
|
@ -184,7 +184,7 @@ export default function PopDesigner({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
const emptyLayout = createEmptyPopLayoutV5();
|
const emptyLayout = createEmptyLayout();
|
||||||
setLayout(emptyLayout);
|
setLayout(emptyLayout);
|
||||||
setHistory([emptyLayout]);
|
setHistory([emptyLayout]);
|
||||||
setHistoryIndex(0);
|
setHistoryIndex(0);
|
||||||
|
|
@ -225,13 +225,13 @@ export default function PopDesigner({
|
||||||
|
|
||||||
if (activeCanvasId === "main") {
|
if (activeCanvasId === "main") {
|
||||||
// 메인 캔버스
|
// 메인 캔버스
|
||||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
const newLayout = addComponentToLayout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||||
setLayout(newLayout);
|
setLayout(newLayout);
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
} else {
|
} else {
|
||||||
// 모달 캔버스
|
// 모달 캔버스
|
||||||
setLayout(prev => {
|
setLayout(prev => {
|
||||||
const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
|
const comp = createComponentDefinition(componentId, type, position, `${type} ${idCounter}`);
|
||||||
const newLayout = {
|
const newLayout = {
|
||||||
...prev,
|
...prev,
|
||||||
modals: (prev.modals || []).map(m => {
|
modals: (prev.modals || []).map(m => {
|
||||||
|
|
@ -250,7 +250,7 @@ export default function PopDesigner({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateComponent = useCallback(
|
const handleUpdateComponent = useCallback(
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
||||||
// 함수적 업데이트로 stale closure 방지
|
// 함수적 업데이트로 stale closure 방지
|
||||||
setLayout((prev) => {
|
setLayout((prev) => {
|
||||||
if (activeCanvasId === "main") {
|
if (activeCanvasId === "main") {
|
||||||
|
|
@ -303,7 +303,7 @@ export default function PopDesigner({
|
||||||
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const newConnection: PopDataConnection = { ...conn, id: newId };
|
const newConnection: PopDataConnection = { ...conn, id: newId };
|
||||||
const prevConnections = prev.dataFlow?.connections || [];
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
const newLayout: PopLayoutDataV5 = {
|
const newLayout: PopLayoutData = {
|
||||||
...prev,
|
...prev,
|
||||||
dataFlow: {
|
dataFlow: {
|
||||||
...prev.dataFlow,
|
...prev.dataFlow,
|
||||||
|
|
@ -322,7 +322,7 @@ export default function PopDesigner({
|
||||||
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
|
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
|
||||||
setLayout((prev) => {
|
setLayout((prev) => {
|
||||||
const prevConnections = prev.dataFlow?.connections || [];
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
const newLayout: PopLayoutDataV5 = {
|
const newLayout: PopLayoutData = {
|
||||||
...prev,
|
...prev,
|
||||||
dataFlow: {
|
dataFlow: {
|
||||||
...prev.dataFlow,
|
...prev.dataFlow,
|
||||||
|
|
@ -343,7 +343,7 @@ export default function PopDesigner({
|
||||||
(connectionId: string) => {
|
(connectionId: string) => {
|
||||||
setLayout((prev) => {
|
setLayout((prev) => {
|
||||||
const prevConnections = prev.dataFlow?.connections || [];
|
const prevConnections = prev.dataFlow?.connections || [];
|
||||||
const newLayout: PopLayoutDataV5 = {
|
const newLayout: PopLayoutData = {
|
||||||
...prev,
|
...prev,
|
||||||
dataFlow: {
|
dataFlow: {
|
||||||
...prev.dataFlow,
|
...prev.dataFlow,
|
||||||
|
|
@ -389,97 +389,156 @@ export default function PopDesigner({
|
||||||
|
|
||||||
const handleMoveComponent = useCallback(
|
const handleMoveComponent = useCallback(
|
||||||
(componentId: string, newPosition: PopGridPosition) => {
|
(componentId: string, newPosition: PopGridPosition) => {
|
||||||
const component = layout.components[componentId];
|
setLayout((prev) => {
|
||||||
if (!component) return;
|
if (activeCanvasId === "main") {
|
||||||
|
const component = prev.components[componentId];
|
||||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
if (!component) return prev;
|
||||||
if (currentMode === "tablet_landscape") {
|
|
||||||
const newLayout = {
|
if (currentMode === "tablet_landscape") {
|
||||||
...layout,
|
const newLayout = {
|
||||||
components: {
|
...prev,
|
||||||
...layout.components,
|
components: {
|
||||||
[componentId]: {
|
...prev.components,
|
||||||
...component,
|
[componentId]: { ...component, position: newPosition },
|
||||||
position: newPosition,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
} else {
|
|
||||||
// 다른 모드인 경우: 오버라이드에 저장
|
|
||||||
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
|
|
||||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
|
||||||
const isHidden = currentHidden.includes(componentId);
|
|
||||||
const newHidden = isHidden
|
|
||||||
? currentHidden.filter(id => id !== componentId)
|
|
||||||
: currentHidden;
|
|
||||||
|
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
overrides: {
|
|
||||||
...layout.overrides,
|
|
||||||
[currentMode]: {
|
|
||||||
...layout.overrides?.[currentMode],
|
|
||||||
positions: {
|
|
||||||
...layout.overrides?.[currentMode]?.positions,
|
|
||||||
[componentId]: newPosition,
|
|
||||||
},
|
},
|
||||||
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
|
};
|
||||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
saveToHistory(newLayout);
|
||||||
},
|
return newLayout;
|
||||||
},
|
} else {
|
||||||
};
|
const currentHidden = prev.overrides?.[currentMode]?.hidden || [];
|
||||||
setLayout(newLayout);
|
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||||
saveToHistory(newLayout);
|
const newLayout = {
|
||||||
setHasChanges(true);
|
...prev,
|
||||||
}
|
overrides: {
|
||||||
|
...prev.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...prev.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...prev.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 모달 캔버스
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
const component = m.components[componentId];
|
||||||
|
if (!component) return m;
|
||||||
|
|
||||||
|
if (currentMode === "tablet_landscape") {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
components: {
|
||||||
|
...m.components,
|
||||||
|
[componentId]: { ...component, position: newPosition },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const currentHidden = m.overrides?.[currentMode]?.hidden || [];
|
||||||
|
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
overrides: {
|
||||||
|
...m.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...m.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...m.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, saveToHistory, currentMode]
|
[saveToHistory, currentMode, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResizeComponent = useCallback(
|
const handleResizeComponent = useCallback(
|
||||||
(componentId: string, newPosition: PopGridPosition) => {
|
(componentId: string, newPosition: PopGridPosition) => {
|
||||||
const component = layout.components[componentId];
|
setLayout((prev) => {
|
||||||
if (!component) return;
|
if (activeCanvasId === "main") {
|
||||||
|
const component = prev.components[componentId];
|
||||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
if (!component) return prev;
|
||||||
if (currentMode === "tablet_landscape") {
|
|
||||||
const newLayout = {
|
if (currentMode === "tablet_landscape") {
|
||||||
...layout,
|
return {
|
||||||
components: {
|
...prev,
|
||||||
...layout.components,
|
components: {
|
||||||
[componentId]: {
|
...prev.components,
|
||||||
...component,
|
[componentId]: { ...component, position: newPosition },
|
||||||
position: newPosition,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setLayout(newLayout);
|
|
||||||
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
|
|
||||||
// 현재는 간단히 매번 저장 (최적화 가능)
|
|
||||||
setHasChanges(true);
|
|
||||||
} else {
|
|
||||||
// 다른 모드인 경우: 오버라이드에 저장
|
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
overrides: {
|
|
||||||
...layout.overrides,
|
|
||||||
[currentMode]: {
|
|
||||||
...layout.overrides?.[currentMode],
|
|
||||||
positions: {
|
|
||||||
...layout.overrides?.[currentMode]?.positions,
|
|
||||||
[componentId]: newPosition,
|
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
},
|
} else {
|
||||||
};
|
return {
|
||||||
setLayout(newLayout);
|
...prev,
|
||||||
setHasChanges(true);
|
overrides: {
|
||||||
}
|
...prev.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...prev.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...prev.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 모달 캔버스
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
const component = m.components[componentId];
|
||||||
|
if (!component) return m;
|
||||||
|
|
||||||
|
if (currentMode === "tablet_landscape") {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
components: {
|
||||||
|
...m.components,
|
||||||
|
[componentId]: { ...component, position: newPosition },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
overrides: {
|
||||||
|
...m.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...m.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...m.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, currentMode]
|
[currentMode, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(
|
const handleResizeEnd = useCallback(
|
||||||
|
|
@ -493,51 +552,87 @@ export default function PopDesigner({
|
||||||
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
||||||
const handleRequestResize = useCallback(
|
const handleRequestResize = useCallback(
|
||||||
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||||
const component = layout.components[componentId];
|
setLayout((prev) => {
|
||||||
if (!component) return;
|
const buildPosition = (comp: PopComponentDefinition) => ({
|
||||||
|
...comp.position,
|
||||||
|
rowSpan: newRowSpan,
|
||||||
|
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
const newPosition = {
|
if (activeCanvasId === "main") {
|
||||||
...component.position,
|
const component = prev.components[componentId];
|
||||||
rowSpan: newRowSpan,
|
if (!component) return prev;
|
||||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
const newPosition = buildPosition(component);
|
||||||
};
|
|
||||||
|
if (currentMode === "tablet_landscape") {
|
||||||
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
const newLayout = {
|
||||||
if (currentMode === "tablet_landscape") {
|
...prev,
|
||||||
const newLayout = {
|
components: {
|
||||||
...layout,
|
...prev.components,
|
||||||
components: {
|
[componentId]: { ...component, position: newPosition },
|
||||||
...layout.components,
|
|
||||||
[componentId]: {
|
|
||||||
...component,
|
|
||||||
position: newPosition,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveToHistory(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
} else {
|
|
||||||
// 다른 모드인 경우: 오버라이드에 저장
|
|
||||||
const newLayout = {
|
|
||||||
...layout,
|
|
||||||
overrides: {
|
|
||||||
...layout.overrides,
|
|
||||||
[currentMode]: {
|
|
||||||
...layout.overrides?.[currentMode],
|
|
||||||
positions: {
|
|
||||||
...layout.overrides?.[currentMode]?.positions,
|
|
||||||
[componentId]: newPosition,
|
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
},
|
saveToHistory(newLayout);
|
||||||
};
|
return newLayout;
|
||||||
setLayout(newLayout);
|
} else {
|
||||||
saveToHistory(newLayout);
|
const newLayout = {
|
||||||
setHasChanges(true);
|
...prev,
|
||||||
}
|
overrides: {
|
||||||
|
...prev.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...prev.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...prev.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 모달 캔버스
|
||||||
|
const newLayout = {
|
||||||
|
...prev,
|
||||||
|
modals: (prev.modals || []).map(m => {
|
||||||
|
if (m.id !== activeCanvasId) return m;
|
||||||
|
const component = m.components[componentId];
|
||||||
|
if (!component) return m;
|
||||||
|
const newPosition = buildPosition(component);
|
||||||
|
|
||||||
|
if (currentMode === "tablet_landscape") {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
components: {
|
||||||
|
...m.components,
|
||||||
|
[componentId]: { ...component, position: newPosition },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
overrides: {
|
||||||
|
...m.overrides,
|
||||||
|
[currentMode]: {
|
||||||
|
...m.overrides?.[currentMode],
|
||||||
|
positions: {
|
||||||
|
...m.overrides?.[currentMode]?.positions,
|
||||||
|
[componentId]: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
return newLayout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layout, currentMode, saveToHistory]
|
[currentMode, saveToHistory, activeCanvasId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -605,9 +700,6 @@ export default function PopDesigner({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const handleHideComponent = useCallback((componentId: string) => {
|
const handleHideComponent = useCallback((componentId: string) => {
|
||||||
// 12칸 모드에서는 숨기기 불가
|
|
||||||
if (currentMode === "tablet_landscape") return;
|
|
||||||
|
|
||||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||||
|
|
||||||
// 이미 숨겨져 있으면 무시
|
// 이미 숨겨져 있으면 무시
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
|
// POP 디자이너 컴포넌트 export (블록 그리드 시스템)
|
||||||
|
|
||||||
// 타입
|
// 타입
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|
@ -17,11 +17,12 @@ export { default as PopRenderer } from "./renderers/PopRenderer";
|
||||||
|
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
export * from "./utils/gridUtils";
|
export * from "./utils/gridUtils";
|
||||||
|
export * from "./utils/legacyLoader";
|
||||||
|
|
||||||
// 핵심 타입 재export (편의)
|
// 핵심 타입 재export (편의)
|
||||||
export type {
|
export type {
|
||||||
PopLayoutDataV5,
|
PopLayoutData,
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
|
BLOCK_SIZE,
|
||||||
|
getBlockColumns,
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -31,15 +33,15 @@ import ConnectionEditor from "./ConnectionEditor";
|
||||||
|
|
||||||
interface ComponentEditorPanelProps {
|
interface ComponentEditorPanelProps {
|
||||||
/** 선택된 컴포넌트 */
|
/** 선택된 컴포넌트 */
|
||||||
component: PopComponentDefinitionV5 | null;
|
component: PopComponentDefinition | null;
|
||||||
/** 현재 모드 */
|
/** 현재 모드 */
|
||||||
currentMode: GridMode;
|
currentMode: GridMode;
|
||||||
/** 컴포넌트 업데이트 */
|
/** 컴포넌트 업데이트 */
|
||||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdateComponent?: (updates: Partial<PopComponentDefinition>) => void;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 그리드에 배치된 모든 컴포넌트 */
|
/** 그리드에 배치된 모든 컴포넌트 */
|
||||||
allComponents?: PopComponentDefinitionV5[];
|
allComponents?: PopComponentDefinition[];
|
||||||
/** 컴포넌트 선택 콜백 */
|
/** 컴포넌트 선택 콜백 */
|
||||||
onSelectComponent?: (componentId: string) => void;
|
onSelectComponent?: (componentId: string) => void;
|
||||||
/** 현재 선택된 컴포넌트 ID */
|
/** 현재 선택된 컴포넌트 ID */
|
||||||
|
|
@ -247,11 +249,11 @@ export default function ComponentEditorPanel({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface PositionFormProps {
|
interface PositionFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
currentMode: GridMode;
|
currentMode: GridMode;
|
||||||
isDefaultMode: boolean;
|
isDefaultMode: boolean;
|
||||||
columns: number;
|
columns: number;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
||||||
|
|
@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
|
높이: {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -400,13 +402,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||||
currentMode?: GridMode;
|
currentMode?: GridMode;
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
modals?: PopModalDefinition[];
|
modals?: PopModalDefinition[];
|
||||||
allComponents?: PopComponentDefinitionV5[];
|
allComponents?: PopComponentDefinition[];
|
||||||
connections?: PopDataConnection[];
|
connections?: PopDataConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,16 +466,16 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface VisibilityFormProps {
|
interface VisibilityFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinition>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||||
const modes: Array<{ key: GridMode; label: string }> = [
|
const modes: Array<{ key: GridMode; label: string }> = [
|
||||||
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
|
{ key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` },
|
||||||
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
|
{ key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` },
|
||||||
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
|
{ key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` },
|
||||||
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
|
{ key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
|
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: UserCircle,
|
icon: UserCircle,
|
||||||
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-work-detail",
|
||||||
|
label: "작업 상세",
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopDataConnection,
|
PopDataConnection,
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,8 +26,8 @@ import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ConnectionEditorProps {
|
interface ConnectionEditorProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinition[];
|
||||||
connections: PopDataConnection[];
|
connections: PopDataConnection[];
|
||||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
|
@ -102,8 +102,8 @@ export default function ConnectionEditor({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface SendSectionProps {
|
interface SendSectionProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinition[];
|
||||||
outgoing: PopDataConnection[];
|
outgoing: PopDataConnection[];
|
||||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
|
|
@ -197,15 +197,15 @@ function SendSection({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface SimpleConnectionFormProps {
|
interface SimpleConnectionFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinition[];
|
||||||
initial?: PopDataConnection;
|
initial?: PopDataConnection;
|
||||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
|
function extractSubTableName(comp: PopComponentDefinition): string | null {
|
||||||
const cfg = comp.config as Record<string, unknown> | undefined;
|
const cfg = comp.config as Record<string, unknown> | undefined;
|
||||||
if (!cfg) return null;
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
|
@ -423,8 +423,8 @@ function SimpleConnectionForm({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ReceiveSectionProps {
|
interface ReceiveSectionProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinition[];
|
||||||
incoming: PopDataConnection[];
|
incoming: PopDataConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,18 @@ import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV5,
|
PopLayoutData,
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
GridBreakpoint,
|
GridBreakpoint,
|
||||||
detectGridMode,
|
detectGridMode,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
|
BLOCK_SIZE,
|
||||||
|
BLOCK_GAP,
|
||||||
|
BLOCK_PADDING,
|
||||||
|
getBlockColumns,
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
import {
|
import {
|
||||||
convertAndResolvePositions,
|
convertAndResolvePositions,
|
||||||
|
|
@ -27,7 +31,7 @@ import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||||
|
|
||||||
interface PopRendererProps {
|
interface PopRendererProps {
|
||||||
/** v5 레이아웃 데이터 */
|
/** v5 레이아웃 데이터 */
|
||||||
layout: PopLayoutDataV5;
|
layout: PopLayoutData;
|
||||||
/** 현재 뷰포트 너비 */
|
/** 현재 뷰포트 너비 */
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
||||||
|
|
@ -80,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-field": "입력",
|
"pop-field": "입력",
|
||||||
"pop-scanner": "스캐너",
|
"pop-scanner": "스캐너",
|
||||||
"pop-profile": "프로필",
|
"pop-profile": "프로필",
|
||||||
|
"pop-work-detail": "작업 상세",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -107,18 +112,27 @@ export default function PopRenderer({
|
||||||
}: PopRendererProps) {
|
}: PopRendererProps) {
|
||||||
const { gridConfig, components, overrides } = layout;
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
// 현재 모드 (자동 감지 또는 지정)
|
// V6: 뷰포트 너비에서 블록 칸 수 동적 계산
|
||||||
const mode = currentMode || detectGridMode(viewportWidth);
|
const mode = currentMode || detectGridMode(viewportWidth);
|
||||||
const breakpoint = GRID_BREAKPOINTS[mode];
|
const columns = getBlockColumns(viewportWidth);
|
||||||
|
|
||||||
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
|
// V6: 블록 간격 고정
|
||||||
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
|
const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP;
|
||||||
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
|
const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING;
|
||||||
|
|
||||||
|
// 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용)
|
||||||
|
const breakpoint: GridBreakpoint = {
|
||||||
|
columns,
|
||||||
|
rowHeight: BLOCK_SIZE,
|
||||||
|
gap: finalGap,
|
||||||
|
padding: finalPadding,
|
||||||
|
label: `${columns}칸 블록`,
|
||||||
|
};
|
||||||
|
|
||||||
// 숨김 컴포넌트 ID 목록
|
// 숨김 컴포넌트 ID 목록
|
||||||
const hiddenIds = overrides?.[mode]?.hidden || [];
|
const hiddenIds = overrides?.[mode]?.hidden || [];
|
||||||
|
|
||||||
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
|
// 동적 행 수 계산
|
||||||
const dynamicRowCount = useMemo(() => {
|
const dynamicRowCount = useMemo(() => {
|
||||||
const visibleComps = Object.values(components).filter(
|
const visibleComps = Object.values(components).filter(
|
||||||
comp => !hiddenIds.includes(comp.id)
|
comp => !hiddenIds.includes(comp.id)
|
||||||
|
|
@ -131,19 +145,17 @@ export default function PopRenderer({
|
||||||
return Math.max(10, maxRowEnd + 3);
|
return Math.max(10, maxRowEnd + 3);
|
||||||
}, [components, overrides, mode, hiddenIds]);
|
}, [components, overrides, mode, hiddenIds]);
|
||||||
|
|
||||||
// CSS Grid 스타일
|
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
|
||||||
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
|
|
||||||
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
|
|
||||||
const rowTemplate = isDesignMode
|
const rowTemplate = isDesignMode
|
||||||
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
|
? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
|
||||||
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
|
: `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
|
||||||
const autoRowHeight = isDesignMode
|
const autoRowHeight = isDesignMode
|
||||||
? `${breakpoint.rowHeight}px`
|
? `${BLOCK_SIZE}px`
|
||||||
: `minmax(${breakpoint.rowHeight}px, auto)`;
|
: `minmax(${BLOCK_SIZE}px, auto)`;
|
||||||
|
|
||||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
gridTemplateRows: rowTemplate,
|
gridTemplateRows: rowTemplate,
|
||||||
gridAutoRows: autoRowHeight,
|
gridAutoRows: autoRowHeight,
|
||||||
gap: `${finalGap}px`,
|
gap: `${finalGap}px`,
|
||||||
|
|
@ -151,15 +163,15 @@ export default function PopRenderer({
|
||||||
minHeight: "100%",
|
minHeight: "100%",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
}), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||||
|
|
||||||
// 그리드 가이드 셀 생성 (동적 행 수)
|
// 그리드 가이드 셀 생성
|
||||||
const gridCells = useMemo(() => {
|
const gridCells = useMemo(() => {
|
||||||
if (!isDesignMode || !showGridGuide) return [];
|
if (!isDesignMode || !showGridGuide) return [];
|
||||||
|
|
||||||
const cells = [];
|
const cells = [];
|
||||||
for (let row = 1; row <= dynamicRowCount; row++) {
|
for (let row = 1; row <= dynamicRowCount; row++) {
|
||||||
for (let col = 1; col <= breakpoint.columns; col++) {
|
for (let col = 1; col <= columns; col++) {
|
||||||
cells.push({
|
cells.push({
|
||||||
id: `cell-${col}-${row}`,
|
id: `cell-${col}-${row}`,
|
||||||
col,
|
col,
|
||||||
|
|
@ -168,10 +180,10 @@ export default function PopRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cells;
|
return cells;
|
||||||
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
|
}, [isDesignMode, showGridGuide, columns, dynamicRowCount]);
|
||||||
|
|
||||||
// visibility 체크
|
// visibility 체크
|
||||||
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
const isVisible = (comp: PopComponentDefinition): boolean => {
|
||||||
if (!comp.visibility) return true;
|
if (!comp.visibility) return true;
|
||||||
const modeVisibility = comp.visibility[mode];
|
const modeVisibility = comp.visibility[mode];
|
||||||
return modeVisibility !== false;
|
return modeVisibility !== false;
|
||||||
|
|
@ -196,7 +208,7 @@ export default function PopRenderer({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 오버라이드 적용 또는 자동 재배치
|
// 오버라이드 적용 또는 자동 재배치
|
||||||
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
const getEffectivePosition = (comp: PopComponentDefinition): PopGridPosition => {
|
||||||
// 1순위: 오버라이드가 있으면 사용
|
// 1순위: 오버라이드가 있으면 사용
|
||||||
const override = overrides?.[mode]?.positions?.[comp.id];
|
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||||
if (override) {
|
if (override) {
|
||||||
|
|
@ -214,7 +226,7 @@ export default function PopRenderer({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 오버라이드 숨김 체크
|
// 오버라이드 숨김 체크
|
||||||
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
|
const isHiddenByOverride = (comp: PopComponentDefinition): boolean => {
|
||||||
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -311,7 +323,7 @@ export default function PopRenderer({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface DraggableComponentProps {
|
interface DraggableComponentProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
position: PopGridPosition;
|
position: PopGridPosition;
|
||||||
positionStyle: React.CSSProperties;
|
positionStyle: React.CSSProperties;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -412,7 +424,7 @@ function DraggableComponent({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ResizeHandlesProps {
|
interface ResizeHandlesProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
position: PopGridPosition;
|
position: PopGridPosition;
|
||||||
breakpoint: GridBreakpoint;
|
breakpoint: GridBreakpoint;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
|
|
@ -533,7 +545,7 @@ function ResizeHandles({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ComponentContentProps {
|
interface ComponentContentProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinition;
|
||||||
effectivePosition: PopGridPosition;
|
effectivePosition: PopGridPosition;
|
||||||
isDesignMode: boolean;
|
isDesignMode: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -603,7 +615,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function renderActualComponent(
|
function renderActualComponent(
|
||||||
component: PopComponentDefinitionV5,
|
component: PopComponentDefinition,
|
||||||
effectivePosition?: PopGridPosition,
|
effectivePosition?: PopGridPosition,
|
||||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
|
||||||
screenId?: string,
|
screenId?: string,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
// POP 디자이너 레이아웃 타입 정의
|
// POP 블록 그리드 레이아웃 타입 정의
|
||||||
// v5.0: CSS Grid 기반 그리드 시스템
|
|
||||||
// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 공통 타입
|
// 공통 타입
|
||||||
|
|
@ -9,7 +7,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -99,24 +97,39 @@ export interface PopLayoutMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// v5 그리드 기반 레이아웃
|
// v6 정사각형 블록 그리드 시스템
|
||||||
// ========================================
|
// ========================================
|
||||||
// 핵심: CSS Grid로 정확한 위치 지정
|
// 핵심: 균일한 정사각형 블록 (24px x 24px)
|
||||||
// - 열/행 좌표로 배치 (col, row)
|
// - 열/행 좌표로 배치 (col, row) - 블록 단위
|
||||||
// - 칸 단위 크기 (colSpan, rowSpan)
|
// - 뷰포트 너비에 따라 칸 수 동적 계산
|
||||||
// - Material Design 브레이크포인트 기반
|
// - 단일 좌표계 (모드별 변환 불필요)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 모드 (4가지)
|
* V6 블록 상수
|
||||||
|
*/
|
||||||
|
export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형)
|
||||||
|
export const BLOCK_GAP = 2; // 블록 간격 (px)
|
||||||
|
export const BLOCK_PADDING = 8; // 캔버스 패딩 (px)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 뷰포트 너비에서 블록 칸 수 계산
|
||||||
|
*/
|
||||||
|
export function getBlockColumns(viewportWidth: number): number {
|
||||||
|
const available = viewportWidth - BLOCK_PADDING * 2;
|
||||||
|
return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 뷰포트 프리셋 (디자이너 해상도 전환용)
|
||||||
*/
|
*/
|
||||||
export type GridMode =
|
export type GridMode =
|
||||||
| "mobile_portrait" // 4칸
|
| "mobile_portrait"
|
||||||
| "mobile_landscape" // 6칸
|
| "mobile_landscape"
|
||||||
| "tablet_portrait" // 8칸
|
| "tablet_portrait"
|
||||||
| "tablet_landscape"; // 12칸 (기본)
|
| "tablet_landscape";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 브레이크포인트 설정
|
* 뷰포트 프리셋 설정
|
||||||
*/
|
*/
|
||||||
export interface GridBreakpoint {
|
export interface GridBreakpoint {
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
|
|
@ -129,50 +142,43 @@ export interface GridBreakpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 브레이크포인트 상수
|
* V6 브레이크포인트 (블록 기반 동적 칸 수)
|
||||||
* 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반
|
* columns는 각 뷰포트 너비에서의 블록 수
|
||||||
*/
|
*/
|
||||||
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
|
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
|
||||||
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
|
|
||||||
mobile_portrait: {
|
mobile_portrait: {
|
||||||
maxWidth: 479,
|
maxWidth: 479,
|
||||||
columns: 4,
|
columns: getBlockColumns(375),
|
||||||
rowHeight: 40,
|
rowHeight: BLOCK_SIZE,
|
||||||
gap: 8,
|
gap: BLOCK_GAP,
|
||||||
padding: 12,
|
padding: BLOCK_PADDING,
|
||||||
label: "모바일 세로 (4칸)",
|
label: `모바일 세로 (${getBlockColumns(375)}칸)`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 스마트폰 가로 + 소형 태블릿
|
|
||||||
mobile_landscape: {
|
mobile_landscape: {
|
||||||
minWidth: 480,
|
minWidth: 480,
|
||||||
maxWidth: 767,
|
maxWidth: 767,
|
||||||
columns: 6,
|
columns: getBlockColumns(600),
|
||||||
rowHeight: 44,
|
rowHeight: BLOCK_SIZE,
|
||||||
gap: 8,
|
gap: BLOCK_GAP,
|
||||||
padding: 16,
|
padding: BLOCK_PADDING,
|
||||||
label: "모바일 가로 (6칸)",
|
label: `모바일 가로 (${getBlockColumns(600)}칸)`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 태블릿 세로 (iPad Mini ~ iPad Pro)
|
|
||||||
tablet_portrait: {
|
tablet_portrait: {
|
||||||
minWidth: 768,
|
minWidth: 768,
|
||||||
maxWidth: 1023,
|
maxWidth: 1023,
|
||||||
columns: 8,
|
columns: getBlockColumns(820),
|
||||||
rowHeight: 48,
|
rowHeight: BLOCK_SIZE,
|
||||||
gap: 12,
|
gap: BLOCK_GAP,
|
||||||
padding: 16,
|
padding: BLOCK_PADDING,
|
||||||
label: "태블릿 세로 (8칸)",
|
label: `태블릿 세로 (${getBlockColumns(820)}칸)`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 태블릿 가로 + 데스크톱 (기본)
|
|
||||||
tablet_landscape: {
|
tablet_landscape: {
|
||||||
minWidth: 1024,
|
minWidth: 1024,
|
||||||
columns: 12,
|
columns: getBlockColumns(1024),
|
||||||
rowHeight: 48,
|
rowHeight: BLOCK_SIZE,
|
||||||
gap: 16,
|
gap: BLOCK_GAP,
|
||||||
padding: 24,
|
padding: BLOCK_PADDING,
|
||||||
label: "태블릿 가로 (12칸)",
|
label: `태블릿 가로 (${getBlockColumns(1024)}칸)`,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -183,7 +189,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 뷰포트 너비로 모드 감지
|
* 뷰포트 너비로 모드 감지
|
||||||
* GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용
|
|
||||||
*/
|
*/
|
||||||
export function detectGridMode(viewportWidth: number): GridMode {
|
export function detectGridMode(viewportWidth: number): GridMode {
|
||||||
if (viewportWidth < 480) return "mobile_portrait";
|
if (viewportWidth < 480) return "mobile_portrait";
|
||||||
|
|
@ -193,31 +198,31 @@ export function detectGridMode(viewportWidth: number): GridMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 레이아웃 (그리드 기반)
|
* POP 레이아웃 데이터
|
||||||
*/
|
*/
|
||||||
export interface PopLayoutDataV5 {
|
export interface PopLayoutData {
|
||||||
version: "pop-5.0";
|
version: "pop-5.0";
|
||||||
|
|
||||||
// 그리드 설정
|
// 그리드 설정
|
||||||
gridConfig: PopGridConfig;
|
gridConfig: PopGridConfig;
|
||||||
|
|
||||||
// 컴포넌트 정의 (ID → 정의)
|
// 컴포넌트 정의 (ID → 정의)
|
||||||
components: Record<string, PopComponentDefinitionV5>;
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
|
||||||
// 데이터 흐름
|
// 데이터 흐름
|
||||||
dataFlow: PopDataFlow;
|
dataFlow: PopDataFlow;
|
||||||
|
|
||||||
// 전역 설정
|
// 전역 설정
|
||||||
settings: PopGlobalSettingsV5;
|
settings: PopGlobalSettings;
|
||||||
|
|
||||||
// 메타데이터
|
// 메타데이터
|
||||||
metadata?: PopLayoutMetadata;
|
metadata?: PopLayoutMetadata;
|
||||||
|
|
||||||
// 모드별 오버라이드 (위치 변경용)
|
// 모드별 오버라이드 (위치 변경용)
|
||||||
overrides?: {
|
overrides?: {
|
||||||
mobile_portrait?: PopModeOverrideV5;
|
mobile_portrait?: PopModeOverride;
|
||||||
mobile_landscape?: PopModeOverrideV5;
|
mobile_landscape?: PopModeOverride;
|
||||||
tablet_portrait?: PopModeOverrideV5;
|
tablet_portrait?: PopModeOverride;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
|
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
|
||||||
|
|
@ -225,17 +230,17 @@ export interface PopLayoutDataV5 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 설정
|
* 그리드 설정 (V6: 블록 단위)
|
||||||
*/
|
*/
|
||||||
export interface PopGridConfig {
|
export interface PopGridConfig {
|
||||||
// 행 높이 (px) - 1행의 기본 높이
|
// 행 높이 = 블록 크기 (px)
|
||||||
rowHeight: number; // 기본 48px
|
rowHeight: number; // V6 기본 24px (= BLOCK_SIZE)
|
||||||
|
|
||||||
// 간격 (px)
|
// 간격 (px)
|
||||||
gap: number; // 기본 8px
|
gap: number; // V6 기본 2px (= BLOCK_GAP)
|
||||||
|
|
||||||
// 패딩 (px)
|
// 패딩 (px)
|
||||||
padding: number; // 기본 16px
|
padding: number; // V6 기본 8px (= BLOCK_PADDING)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -249,9 +254,9 @@ export interface PopGridPosition {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 컴포넌트 정의
|
* POP 컴포넌트 정의
|
||||||
*/
|
*/
|
||||||
export interface PopComponentDefinitionV5 {
|
export interface PopComponentDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
type: PopComponentType;
|
type: PopComponentType;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -274,7 +279,7 @@ export interface PopComponentDefinitionV5 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gap 프리셋 타입
|
* Gap 프리셋 타입 (V6: 단일 간격이므로 medium만 유효, 하위 호환용 유지)
|
||||||
*/
|
*/
|
||||||
export type GapPreset = "narrow" | "medium" | "wide";
|
export type GapPreset = "narrow" | "medium" | "wide";
|
||||||
|
|
||||||
|
|
@ -287,18 +292,18 @@ export interface GapPresetConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gap 프리셋 상수
|
* Gap 프리셋 상수 (V6: 모두 동일 - 블록 간격 고정)
|
||||||
*/
|
*/
|
||||||
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
|
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
|
||||||
narrow: { multiplier: 0.5, label: "좁게" },
|
narrow: { multiplier: 1.0, label: "기본" },
|
||||||
medium: { multiplier: 1.0, label: "보통" },
|
medium: { multiplier: 1.0, label: "기본" },
|
||||||
wide: { multiplier: 1.5, label: "넓게" },
|
wide: { multiplier: 1.0, label: "기본" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 전역 설정
|
* POP 전역 설정
|
||||||
*/
|
*/
|
||||||
export interface PopGlobalSettingsV5 {
|
export interface PopGlobalSettings {
|
||||||
// 터치 최소 크기 (px)
|
// 터치 최소 크기 (px)
|
||||||
touchTargetMin: number; // 기본 48
|
touchTargetMin: number; // 기본 48
|
||||||
|
|
||||||
|
|
@ -310,9 +315,9 @@ export interface PopGlobalSettingsV5 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 모드별 오버라이드
|
* 모드별 오버라이드 (위치/숨김)
|
||||||
*/
|
*/
|
||||||
export interface PopModeOverrideV5 {
|
export interface PopModeOverride {
|
||||||
// 컴포넌트별 위치 오버라이드
|
// 컴포넌트별 위치 오버라이드
|
||||||
positions?: Record<string, Partial<PopGridPosition>>;
|
positions?: Record<string, Partial<PopGridPosition>>;
|
||||||
|
|
||||||
|
|
@ -321,18 +326,18 @@ export interface PopModeOverrideV5 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// v5 유틸리티 함수
|
// 레이아웃 유틸리티 함수
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 v5 레이아웃 생성
|
* 빈 POP 레이아웃 생성
|
||||||
*/
|
*/
|
||||||
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
export const createEmptyLayout = (): PopLayoutData => ({
|
||||||
version: "pop-5.0",
|
version: "pop-5.0",
|
||||||
gridConfig: {
|
gridConfig: {
|
||||||
rowHeight: 48,
|
rowHeight: BLOCK_SIZE,
|
||||||
gap: 8,
|
gap: BLOCK_GAP,
|
||||||
padding: 16,
|
padding: BLOCK_PADDING,
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
dataFlow: { connections: [] },
|
dataFlow: { connections: [] },
|
||||||
|
|
@ -344,40 +349,46 @@ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 레이아웃 여부 확인
|
* POP 레이아웃 데이터인지 확인
|
||||||
*/
|
*/
|
||||||
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
export const isPopLayout = (layout: any): layout is PopLayoutData => {
|
||||||
return layout?.version === "pop-5.0";
|
return layout?.version === "pop-5.0";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 타입별 기본 크기 (칸 단위)
|
* 컴포넌트 타입별 기본 크기 (블록 단위, V6)
|
||||||
|
*
|
||||||
|
* 소형 (2x2) : 최소 단위. 아이콘, 프로필, 스캐너 등 단일 요소
|
||||||
|
* 중형 (8x4) : 검색, 버튼, 텍스트 등 한 줄 입력/표시
|
||||||
|
* 대형 (8x6) : 샘플, 상태바, 필드 등 여러 줄 컨텐츠
|
||||||
|
* 초대형 (19x8~) : 카드, 리스트, 대시보드 등 메인 영역
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
"pop-sample": { colSpan: 8, rowSpan: 6 },
|
||||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
"pop-text": { colSpan: 8, rowSpan: 4 },
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
"pop-icon": { colSpan: 2, rowSpan: 2 },
|
||||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
"pop-dashboard": { colSpan: 19, rowSpan: 10 },
|
||||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
"pop-card-list": { colSpan: 19, rowSpan: 10 },
|
||||||
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
|
"pop-card-list-v2": { colSpan: 19, rowSpan: 10 },
|
||||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
"pop-button": { colSpan: 8, rowSpan: 4 },
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"pop-string-list": { colSpan: 19, rowSpan: 10 },
|
||||||
"pop-search": { colSpan: 2, rowSpan: 1 },
|
"pop-search": { colSpan: 8, rowSpan: 4 },
|
||||||
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
|
"pop-status-bar": { colSpan: 19, rowSpan: 4 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 19, rowSpan: 6 },
|
||||||
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
"pop-scanner": { colSpan: 2, rowSpan: 2 },
|
||||||
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
"pop-profile": { colSpan: 2, rowSpan: 2 },
|
||||||
|
"pop-work-detail": { colSpan: 38, rowSpan: 26 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 컴포넌트 정의 생성
|
* POP 컴포넌트 정의 생성
|
||||||
*/
|
*/
|
||||||
export const createComponentDefinitionV5 = (
|
export const createComponentDefinition = (
|
||||||
id: string,
|
id: string,
|
||||||
type: PopComponentType,
|
type: PopComponentType,
|
||||||
position: PopGridPosition,
|
position: PopGridPosition,
|
||||||
label?: string
|
label?: string
|
||||||
): PopComponentDefinitionV5 => ({
|
): PopComponentDefinition => ({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
label,
|
label,
|
||||||
|
|
@ -385,21 +396,21 @@ export const createComponentDefinitionV5 = (
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v5 레이아웃에 컴포넌트 추가
|
* POP 레이아웃에 컴포넌트 추가
|
||||||
*/
|
*/
|
||||||
export const addComponentToV5Layout = (
|
export const addComponentToLayout = (
|
||||||
layout: PopLayoutDataV5,
|
layout: PopLayoutData,
|
||||||
componentId: string,
|
componentId: string,
|
||||||
type: PopComponentType,
|
type: PopComponentType,
|
||||||
position: PopGridPosition,
|
position: PopGridPosition,
|
||||||
label?: string
|
label?: string
|
||||||
): PopLayoutDataV5 => {
|
): PopLayoutData => {
|
||||||
const newLayout = { ...layout };
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
// 컴포넌트 정의 추가
|
// 컴포넌트 정의 추가
|
||||||
newLayout.components = {
|
newLayout.components = {
|
||||||
...newLayout.components,
|
...newLayout.components,
|
||||||
[componentId]: createComponentDefinitionV5(componentId, type, position, label),
|
[componentId]: createComponentDefinition(componentId, type, position, label),
|
||||||
};
|
};
|
||||||
|
|
||||||
return newLayout;
|
return newLayout;
|
||||||
|
|
@ -474,12 +485,12 @@ export interface PopModalDefinition {
|
||||||
/** 모달 내부 그리드 설정 */
|
/** 모달 내부 그리드 설정 */
|
||||||
gridConfig: PopGridConfig;
|
gridConfig: PopGridConfig;
|
||||||
/** 모달 내부 컴포넌트 */
|
/** 모달 내부 컴포넌트 */
|
||||||
components: Record<string, PopComponentDefinitionV5>;
|
components: Record<string, PopComponentDefinition>;
|
||||||
/** 모드별 오버라이드 */
|
/** 모드별 오버라이드 */
|
||||||
overrides?: {
|
overrides?: {
|
||||||
mobile_portrait?: PopModeOverrideV5;
|
mobile_portrait?: PopModeOverride;
|
||||||
mobile_landscape?: PopModeOverrideV5;
|
mobile_landscape?: PopModeOverride;
|
||||||
tablet_portrait?: PopModeOverrideV5;
|
tablet_portrait?: PopModeOverride;
|
||||||
};
|
};
|
||||||
/** 모달 프레임 설정 (닫기 방식) */
|
/** 모달 프레임 설정 (닫기 방식) */
|
||||||
frameConfig?: {
|
frameConfig?: {
|
||||||
|
|
@ -495,15 +506,29 @@ export interface PopModalDefinition {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
// 레거시 타입 별칭 (이전 코드 호환용)
|
||||||
// ========================================
|
// ========================================
|
||||||
// 기존 코드에서 import 오류 방지용
|
|
||||||
|
|
||||||
/** @deprecated v5에서는 PopLayoutDataV5 사용 */
|
/** @deprecated PopLayoutData 사용 */
|
||||||
export type PopLayoutData = PopLayoutDataV5;
|
export type PopLayoutDataV5 = PopLayoutData;
|
||||||
|
|
||||||
/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */
|
/** @deprecated PopComponentDefinition 사용 */
|
||||||
export type PopComponentDefinition = PopComponentDefinitionV5;
|
export type PopComponentDefinitionV5 = PopComponentDefinition;
|
||||||
|
|
||||||
/** @deprecated v5에서는 PopGridPosition 사용 */
|
/** @deprecated PopGlobalSettings 사용 */
|
||||||
export type GridPosition = PopGridPosition;
|
export type PopGlobalSettingsV5 = PopGlobalSettings;
|
||||||
|
|
||||||
|
/** @deprecated PopModeOverride 사용 */
|
||||||
|
export type PopModeOverrideV5 = PopModeOverride;
|
||||||
|
|
||||||
|
/** @deprecated createEmptyLayout 사용 */
|
||||||
|
export const createEmptyPopLayoutV5 = createEmptyLayout;
|
||||||
|
|
||||||
|
/** @deprecated isPopLayout 사용 */
|
||||||
|
export const isV5Layout = isPopLayout;
|
||||||
|
|
||||||
|
/** @deprecated addComponentToLayout 사용 */
|
||||||
|
export const addComponentToV5Layout = addComponentToLayout;
|
||||||
|
|
||||||
|
/** @deprecated createComponentDefinition 사용 */
|
||||||
|
export const createComponentDefinitionV5 = createComponentDefinition;
|
||||||
|
|
|
||||||
|
|
@ -1,217 +1,106 @@
|
||||||
|
// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PopGridPosition,
|
PopGridPosition,
|
||||||
GridMode,
|
GridMode,
|
||||||
GRID_BREAKPOINTS,
|
GRID_BREAKPOINTS,
|
||||||
GridBreakpoint,
|
PopLayoutData,
|
||||||
GapPreset,
|
|
||||||
GAP_PRESETS,
|
|
||||||
PopLayoutDataV5,
|
|
||||||
PopComponentDefinitionV5,
|
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Gap/Padding 조정
|
// 리플로우 (행 그룹 기반 자동 재배치)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gap 프리셋에 따라 breakpoint의 gap/padding 조정
|
* 행 그룹 리플로우
|
||||||
*
|
*
|
||||||
* @param base 기본 breakpoint 설정
|
* CSS Flexbox wrap 원리로 자동 재배치한다.
|
||||||
* @param preset Gap 프리셋 ("narrow" | "medium" | "wide")
|
* 1. 같은 행의 컴포넌트를 한 묶음으로 처리
|
||||||
* @returns 조정된 breakpoint (gap, padding 계산됨)
|
* 2. 최소 2x2칸 보장 (터치 가능한 최소 크기)
|
||||||
*/
|
* 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음)
|
||||||
export function getAdjustedBreakpoint(
|
* 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장
|
||||||
base: GridBreakpoint,
|
* 5. 리플로우 후 겹침 해결
|
||||||
preset: GapPreset
|
|
||||||
): GridBreakpoint {
|
|
||||||
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
gap: Math.round(base.gap * multiplier),
|
|
||||||
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 그리드 위치 변환
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 12칸 기준 위치를 다른 모드로 변환
|
|
||||||
*/
|
|
||||||
export function convertPositionToMode(
|
|
||||||
position: PopGridPosition,
|
|
||||||
targetMode: GridMode
|
|
||||||
): PopGridPosition {
|
|
||||||
const sourceColumns = 12;
|
|
||||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
|
||||||
|
|
||||||
// 같은 칸 수면 그대로 반환
|
|
||||||
if (sourceColumns === targetColumns) {
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = targetColumns / sourceColumns;
|
|
||||||
|
|
||||||
// 열 위치 변환
|
|
||||||
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
|
||||||
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
|
||||||
|
|
||||||
// 범위 초과 방지
|
|
||||||
if (newCol > targetColumns) {
|
|
||||||
newCol = 1;
|
|
||||||
}
|
|
||||||
if (newCol + newColSpan - 1 > targetColumns) {
|
|
||||||
newColSpan = targetColumns - newCol + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
col: newCol,
|
|
||||||
row: position.row,
|
|
||||||
colSpan: Math.max(1, newColSpan),
|
|
||||||
rowSpan: position.rowSpan,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 여러 컴포넌트를 모드별로 변환하고 겹침 해결
|
|
||||||
*
|
|
||||||
* v5.1 자동 줄바꿈:
|
|
||||||
* - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치
|
|
||||||
* - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨
|
|
||||||
*/
|
*/
|
||||||
export function convertAndResolvePositions(
|
export function convertAndResolvePositions(
|
||||||
components: Array<{ id: string; position: PopGridPosition }>,
|
components: Array<{ id: string; position: PopGridPosition }>,
|
||||||
targetMode: GridMode
|
targetMode: GridMode
|
||||||
): Array<{ id: string; position: PopGridPosition }> {
|
): Array<{ id: string; position: PopGridPosition }> {
|
||||||
// 엣지 케이스: 빈 배열
|
if (components.length === 0) return [];
|
||||||
if (components.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||||
|
const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns;
|
||||||
|
|
||||||
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
|
if (targetColumns >= designColumns) {
|
||||||
const converted = components.map(comp => ({
|
return components.map(c => ({ id: c.id, position: { ...c.position } }));
|
||||||
id: comp.id,
|
}
|
||||||
position: convertPositionToMode(comp.position, targetMode),
|
|
||||||
originalCol: comp.position.col, // 원본 col 보존
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
|
const ratio = targetColumns / designColumns;
|
||||||
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
|
const MIN_COL_SPAN = 2;
|
||||||
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
|
const MIN_ROW_SPAN = 2;
|
||||||
|
|
||||||
// 3단계: 정상 컴포넌트의 최대 row 계산
|
const rowGroups: Record<number, Array<{ id: string; position: PopGridPosition }>> = {};
|
||||||
const maxRow = normalComponents.length > 0
|
components.forEach(comp => {
|
||||||
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
|
const r = comp.position.row;
|
||||||
: 0;
|
if (!rowGroups[r]) rowGroups[r] = [];
|
||||||
|
rowGroups[r].push(comp);
|
||||||
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
|
|
||||||
let currentRow = maxRow + 1;
|
|
||||||
const wrappedComponents = overflowComponents.map(comp => {
|
|
||||||
const wrappedPosition: PopGridPosition = {
|
|
||||||
col: 1, // 왼쪽 끝부터 시작
|
|
||||||
row: currentRow,
|
|
||||||
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
|
|
||||||
rowSpan: comp.position.rowSpan,
|
|
||||||
};
|
|
||||||
currentRow += comp.position.rowSpan; // 다음 행으로 이동
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: comp.id,
|
|
||||||
position: wrappedPosition,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
|
const placed: Array<{ id: string; position: PopGridPosition }> = [];
|
||||||
const adjusted = [
|
let outputRow = 1;
|
||||||
...normalComponents.map(c => ({ id: c.id, position: c.position })),
|
|
||||||
...wrappedComponents,
|
|
||||||
];
|
|
||||||
|
|
||||||
// 6단계: 겹침 해결 (아래로 밀기)
|
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
|
||||||
return resolveOverlaps(adjusted, targetColumns);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 검토 필요 판별
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인
|
|
||||||
*
|
|
||||||
* v5.1 검토 필요 기준:
|
|
||||||
* - 12칸 모드(기본 모드)가 아님
|
|
||||||
* - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함)
|
|
||||||
*
|
|
||||||
* @param currentMode 현재 그리드 모드
|
|
||||||
* @param hasOverride 해당 모드에서 오버라이드 존재 여부
|
|
||||||
* @returns true = 검토 필요, false = 검토 완료 또는 불필요
|
|
||||||
*/
|
|
||||||
export function needsReview(
|
|
||||||
currentMode: GridMode,
|
|
||||||
hasOverride: boolean
|
|
||||||
): boolean {
|
|
||||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
|
||||||
|
|
||||||
// 12칸 모드는 기본 모드이므로 검토 불필요
|
for (const rowKey of sortedRows) {
|
||||||
if (targetColumns === 12) {
|
const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col);
|
||||||
return false;
|
let currentCol = 1;
|
||||||
|
let maxRowSpanInLine = 0;
|
||||||
|
|
||||||
|
for (const comp of group) {
|
||||||
|
const pos = comp.position;
|
||||||
|
const isMainContent = pos.colSpan >= designColumns * 0.5;
|
||||||
|
|
||||||
|
let scaledSpan = isMainContent
|
||||||
|
? targetColumns
|
||||||
|
: Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio));
|
||||||
|
scaledSpan = Math.min(scaledSpan, targetColumns);
|
||||||
|
|
||||||
|
const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan);
|
||||||
|
|
||||||
|
if (currentCol + scaledSpan - 1 > targetColumns) {
|
||||||
|
outputRow += Math.max(1, maxRowSpanInLine);
|
||||||
|
currentCol = 1;
|
||||||
|
maxRowSpanInLine = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
placed.push({
|
||||||
|
id: comp.id,
|
||||||
|
position: {
|
||||||
|
col: currentCol,
|
||||||
|
row: outputRow,
|
||||||
|
colSpan: scaledSpan,
|
||||||
|
rowSpan: scaledRowSpan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan);
|
||||||
|
currentCol += scaledSpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputRow += Math.max(1, maxRowSpanInLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오버라이드가 있으면 이미 편집함 → 검토 완료
|
return resolveOverlaps(placed, targetColumns);
|
||||||
if (hasOverride) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오버라이드 없으면 → 검토 필요
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated v5.1부터 needsReview() 사용 권장
|
|
||||||
*
|
|
||||||
* 기존 isOutOfBounds는 "화면 밖" 개념이었으나,
|
|
||||||
* v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다.
|
|
||||||
* 대신 needsReview()로 "검토 필요" 여부를 판별하세요.
|
|
||||||
*/
|
|
||||||
export function isOutOfBounds(
|
|
||||||
originalPosition: PopGridPosition,
|
|
||||||
currentMode: GridMode,
|
|
||||||
overridePosition?: PopGridPosition | null
|
|
||||||
): boolean {
|
|
||||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
|
||||||
|
|
||||||
// 12칸 모드면 초과 불가
|
|
||||||
if (targetColumns === 12) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오버라이드가 있으면 오버라이드 위치로 판단
|
|
||||||
if (overridePosition) {
|
|
||||||
return overridePosition.col > targetColumns;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오버라이드 없으면 원본 col로 판단
|
|
||||||
return originalPosition.col > targetColumns;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 겹침 감지 및 해결
|
// 겹침 감지 및 해결
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 두 위치가 겹치는지 확인
|
|
||||||
*/
|
|
||||||
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||||
// 열 겹침 체크
|
|
||||||
const aColEnd = a.col + a.colSpan - 1;
|
const aColEnd = a.col + a.colSpan - 1;
|
||||||
const bColEnd = b.col + b.colSpan - 1;
|
const bColEnd = b.col + b.colSpan - 1;
|
||||||
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
||||||
|
|
||||||
// 행 겹침 체크
|
|
||||||
const aRowEnd = a.row + a.rowSpan - 1;
|
const aRowEnd = a.row + a.rowSpan - 1;
|
||||||
const bRowEnd = b.row + b.rowSpan - 1;
|
const bRowEnd = b.row + b.rowSpan - 1;
|
||||||
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
||||||
|
|
@ -219,14 +108,10 @@ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||||
return colOverlap && rowOverlap;
|
return colOverlap && rowOverlap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 겹침 해결 (아래로 밀기)
|
|
||||||
*/
|
|
||||||
export function resolveOverlaps(
|
export function resolveOverlaps(
|
||||||
positions: Array<{ id: string; position: PopGridPosition }>,
|
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||||
columns: number
|
columns: number
|
||||||
): Array<{ id: string; position: PopGridPosition }> {
|
): Array<{ id: string; position: PopGridPosition }> {
|
||||||
// row, col 순으로 정렬
|
|
||||||
const sorted = [...positions].sort((a, b) =>
|
const sorted = [...positions].sort((a, b) =>
|
||||||
a.position.row - b.position.row || a.position.col - b.position.col
|
a.position.row - b.position.row || a.position.col - b.position.col
|
||||||
);
|
);
|
||||||
|
|
@ -236,21 +121,15 @@ export function resolveOverlaps(
|
||||||
sorted.forEach((item) => {
|
sorted.forEach((item) => {
|
||||||
let { row, col, colSpan, rowSpan } = item.position;
|
let { row, col, colSpan, rowSpan } = item.position;
|
||||||
|
|
||||||
// 열이 범위를 초과하면 조정
|
|
||||||
if (col + colSpan - 1 > columns) {
|
if (col + colSpan - 1 > columns) {
|
||||||
colSpan = columns - col + 1;
|
colSpan = columns - col + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 배치와 겹치면 아래로 이동
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 100;
|
while (attempts < 100) {
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||||
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||||
|
|
||||||
if (!hasOverlap) break;
|
if (!hasOverlap) break;
|
||||||
|
|
||||||
row++;
|
row++;
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
|
|
@ -265,124 +144,9 @@ export function resolveOverlaps(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 좌표 변환
|
// 자동 배치 (새 컴포넌트 드롭 시)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 마우스 좌표 → 그리드 좌표 변환
|
|
||||||
*
|
|
||||||
* CSS Grid 계산 방식:
|
|
||||||
* - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1)
|
|
||||||
* - 각 칸 너비 = 사용 가능 너비 / columns
|
|
||||||
* - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap)
|
|
||||||
*/
|
|
||||||
export function mouseToGridPosition(
|
|
||||||
mouseX: number,
|
|
||||||
mouseY: number,
|
|
||||||
canvasRect: DOMRect,
|
|
||||||
columns: number,
|
|
||||||
rowHeight: number,
|
|
||||||
gap: number,
|
|
||||||
padding: number
|
|
||||||
): { col: number; row: number } {
|
|
||||||
// 캔버스 내 상대 위치 (패딩 영역 포함)
|
|
||||||
const relX = mouseX - canvasRect.left - padding;
|
|
||||||
const relY = mouseY - canvasRect.top - padding;
|
|
||||||
|
|
||||||
// CSS Grid 1fr 계산과 동일하게
|
|
||||||
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
|
|
||||||
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
|
|
||||||
const colWidth = availableWidth / columns;
|
|
||||||
|
|
||||||
// 각 셀의 실제 간격 (셀 너비 + gap)
|
|
||||||
const cellStride = colWidth + gap;
|
|
||||||
|
|
||||||
// 그리드 좌표 계산 (1부터 시작)
|
|
||||||
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
|
|
||||||
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
|
|
||||||
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
|
||||||
|
|
||||||
return { col, row };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 그리드 좌표 → 픽셀 좌표 변환
|
|
||||||
*/
|
|
||||||
export function gridToPixelPosition(
|
|
||||||
col: number,
|
|
||||||
row: number,
|
|
||||||
colSpan: number,
|
|
||||||
rowSpan: number,
|
|
||||||
canvasWidth: number,
|
|
||||||
columns: number,
|
|
||||||
rowHeight: number,
|
|
||||||
gap: number,
|
|
||||||
padding: number
|
|
||||||
): { x: number; y: number; width: number; height: number } {
|
|
||||||
const totalGap = gap * (columns - 1);
|
|
||||||
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: padding + (col - 1) * (colWidth + gap),
|
|
||||||
y: padding + (row - 1) * (rowHeight + gap),
|
|
||||||
width: colWidth * colSpan + gap * (colSpan - 1),
|
|
||||||
height: rowHeight * rowSpan + gap * (rowSpan - 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 위치 검증
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치가 그리드 범위 내에 있는지 확인
|
|
||||||
*/
|
|
||||||
export function isValidPosition(
|
|
||||||
position: PopGridPosition,
|
|
||||||
columns: number
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
position.col >= 1 &&
|
|
||||||
position.row >= 1 &&
|
|
||||||
position.colSpan >= 1 &&
|
|
||||||
position.rowSpan >= 1 &&
|
|
||||||
position.col + position.colSpan - 1 <= columns
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치를 그리드 범위 내로 조정
|
|
||||||
*/
|
|
||||||
export function clampPosition(
|
|
||||||
position: PopGridPosition,
|
|
||||||
columns: number
|
|
||||||
): PopGridPosition {
|
|
||||||
let { col, row, colSpan, rowSpan } = position;
|
|
||||||
|
|
||||||
// 최소값 보장
|
|
||||||
col = Math.max(1, col);
|
|
||||||
row = Math.max(1, row);
|
|
||||||
colSpan = Math.max(1, colSpan);
|
|
||||||
rowSpan = Math.max(1, rowSpan);
|
|
||||||
|
|
||||||
// 열 범위 초과 방지
|
|
||||||
if (col + colSpan - 1 > columns) {
|
|
||||||
if (col > columns) {
|
|
||||||
col = 1;
|
|
||||||
}
|
|
||||||
colSpan = columns - col + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { col, row, colSpan, rowSpan };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 자동 배치
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다음 빈 위치 찾기
|
|
||||||
*/
|
|
||||||
export function findNextEmptyPosition(
|
export function findNextEmptyPosition(
|
||||||
existingPositions: PopGridPosition[],
|
existingPositions: PopGridPosition[],
|
||||||
colSpan: number,
|
colSpan: number,
|
||||||
|
|
@ -391,168 +155,94 @@ export function findNextEmptyPosition(
|
||||||
): PopGridPosition {
|
): PopGridPosition {
|
||||||
let row = 1;
|
let row = 1;
|
||||||
let col = 1;
|
let col = 1;
|
||||||
|
|
||||||
const maxAttempts = 1000;
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < 1000) {
|
||||||
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||||
|
|
||||||
// 범위 체크
|
|
||||||
if (col + colSpan - 1 > columns) {
|
if (col + colSpan - 1 > columns) {
|
||||||
col = 1;
|
col = 1;
|
||||||
row++;
|
row++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 겹침 체크
|
const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos));
|
||||||
const hasOverlap = existingPositions.some(pos =>
|
if (!hasOverlap) return candidatePos;
|
||||||
isOverlapping(candidatePos, pos)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasOverlap) {
|
|
||||||
return candidatePos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다음 위치로 이동
|
|
||||||
col++;
|
col++;
|
||||||
if (col + colSpan - 1 > columns) {
|
if (col + colSpan - 1 > columns) {
|
||||||
col = 1;
|
col = 1;
|
||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
|
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실패 시 마지막 행에 배치
|
|
||||||
return { col: 1, row: row + 1, colSpan, rowSpan };
|
return { col: 1, row: row + 1, colSpan, rowSpan };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트들을 자동으로 배치
|
|
||||||
*/
|
|
||||||
export function autoLayoutComponents(
|
|
||||||
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
|
|
||||||
columns: number
|
|
||||||
): Array<{ id: string; position: PopGridPosition }> {
|
|
||||||
const result: Array<{ id: string; position: PopGridPosition }> = [];
|
|
||||||
|
|
||||||
let currentRow = 1;
|
|
||||||
let currentCol = 1;
|
|
||||||
|
|
||||||
components.forEach(comp => {
|
|
||||||
// 현재 행에 공간이 부족하면 다음 행으로
|
|
||||||
if (currentCol + comp.colSpan - 1 > columns) {
|
|
||||||
currentRow++;
|
|
||||||
currentCol = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
id: comp.id,
|
|
||||||
position: {
|
|
||||||
col: currentCol,
|
|
||||||
row: currentRow,
|
|
||||||
colSpan: comp.colSpan,
|
|
||||||
rowSpan: comp.rowSpan,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
currentCol += comp.colSpan;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 유효 위치 계산 (통합 함수)
|
// 유효 위치 계산
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트의 유효 위치를 계산합니다.
|
* 컴포넌트의 유효 위치를 계산한다.
|
||||||
* 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치
|
* 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치
|
||||||
*
|
|
||||||
* @param componentId 컴포넌트 ID
|
|
||||||
* @param layout 전체 레이아웃 데이터
|
|
||||||
* @param mode 현재 그리드 모드
|
|
||||||
* @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적)
|
|
||||||
*/
|
*/
|
||||||
export function getEffectiveComponentPosition(
|
function getEffectiveComponentPosition(
|
||||||
componentId: string,
|
componentId: string,
|
||||||
layout: PopLayoutDataV5,
|
layout: PopLayoutData,
|
||||||
mode: GridMode,
|
mode: GridMode,
|
||||||
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
|
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
|
||||||
): PopGridPosition | null {
|
): PopGridPosition | null {
|
||||||
const component = layout.components[componentId];
|
const component = layout.components[componentId];
|
||||||
if (!component) return null;
|
if (!component) return null;
|
||||||
|
|
||||||
// 1순위: 오버라이드가 있으면 사용
|
|
||||||
const override = layout.overrides?.[mode]?.positions?.[componentId];
|
const override = layout.overrides?.[mode]?.positions?.[componentId];
|
||||||
if (override) {
|
if (override) {
|
||||||
return { ...component.position, ...override };
|
return { ...component.position, ...override };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: 자동 재배치된 위치 사용
|
|
||||||
if (autoResolvedPositions) {
|
if (autoResolvedPositions) {
|
||||||
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
|
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
|
||||||
if (autoResolved) {
|
if (autoResolved) return autoResolved.position;
|
||||||
return autoResolved.position;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 자동 재배치 직접 계산
|
|
||||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||||
id,
|
id,
|
||||||
position: comp.position,
|
position: comp.position,
|
||||||
}));
|
}));
|
||||||
const resolved = convertAndResolvePositions(componentsArray, mode);
|
const resolved = convertAndResolvePositions(componentsArray, mode);
|
||||||
const autoResolved = resolved.find(p => p.id === componentId);
|
const autoResolved = resolved.find(p => p.id === componentId);
|
||||||
if (autoResolved) {
|
if (autoResolved) return autoResolved.position;
|
||||||
return autoResolved.position;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3순위: 원본 위치 (12칸 모드)
|
|
||||||
return component.position;
|
return component.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 컴포넌트의 유효 위치를 일괄 계산합니다.
|
* 모든 컴포넌트의 유효 위치를 일괄 계산한다.
|
||||||
* 숨김 처리된 컴포넌트는 제외됩니다.
|
* 숨김 처리된 컴포넌트는 제외.
|
||||||
*
|
|
||||||
* v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로
|
|
||||||
* "화면 밖" 개념이 제거되었습니다.
|
|
||||||
*/
|
*/
|
||||||
export function getAllEffectivePositions(
|
export function getAllEffectivePositions(
|
||||||
layout: PopLayoutDataV5,
|
layout: PopLayoutData,
|
||||||
mode: GridMode
|
mode: GridMode
|
||||||
): Map<string, PopGridPosition> {
|
): Map<string, PopGridPosition> {
|
||||||
const result = new Map<string, PopGridPosition>();
|
const result = new Map<string, PopGridPosition>();
|
||||||
|
|
||||||
// 숨김 처리된 컴포넌트 ID 목록
|
|
||||||
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
|
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
|
||||||
|
|
||||||
// 자동 재배치 위치 미리 계산
|
|
||||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||||
id,
|
id,
|
||||||
position: comp.position,
|
position: comp.position,
|
||||||
}));
|
}));
|
||||||
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
|
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
|
||||||
|
|
||||||
// 각 컴포넌트의 유효 위치 계산
|
|
||||||
Object.keys(layout.components).forEach(componentId => {
|
Object.keys(layout.components).forEach(componentId => {
|
||||||
// 숨김 처리된 컴포넌트는 제외
|
if (hiddenIds.includes(componentId)) return;
|
||||||
if (hiddenIds.includes(componentId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = getEffectiveComponentPosition(
|
const position = getEffectiveComponentPosition(
|
||||||
componentId,
|
componentId, layout, mode, autoResolvedPositions
|
||||||
layout,
|
|
||||||
mode,
|
|
||||||
autoResolvedPositions
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
|
|
||||||
// 따라서 추가 필터링 불필요
|
|
||||||
if (position) {
|
if (position) {
|
||||||
result.set(componentId, position);
|
result.set(componentId, position);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
// 레거시 레이아웃 로더
|
||||||
|
// DB에 저장된 V5(12칸) 좌표를 현재 블록 좌표로 변환한다.
|
||||||
|
// DB 데이터는 건드리지 않고, 로드 시 메모리에서만 변환.
|
||||||
|
|
||||||
|
import {
|
||||||
|
PopGridPosition,
|
||||||
|
PopLayoutData,
|
||||||
|
BLOCK_SIZE,
|
||||||
|
BLOCK_GAP,
|
||||||
|
BLOCK_PADDING,
|
||||||
|
getBlockColumns,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
const LEGACY_COLUMNS = 12;
|
||||||
|
const LEGACY_ROW_HEIGHT = 48;
|
||||||
|
const LEGACY_GAP = 16;
|
||||||
|
const DESIGN_WIDTH = 1024;
|
||||||
|
|
||||||
|
function isLegacyGridConfig(layout: PopLayoutData): boolean {
|
||||||
|
if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false;
|
||||||
|
|
||||||
|
const maxCol = Object.values(layout.components).reduce((max, comp) => {
|
||||||
|
const end = comp.position.col + comp.position.colSpan - 1;
|
||||||
|
return Math.max(max, end);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return maxCol <= LEGACY_COLUMNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLegacyPosition(
|
||||||
|
pos: PopGridPosition,
|
||||||
|
targetColumns: number,
|
||||||
|
): PopGridPosition {
|
||||||
|
const colRatio = targetColumns / LEGACY_COLUMNS;
|
||||||
|
const rowRatio = (LEGACY_ROW_HEIGHT + LEGACY_GAP) / (BLOCK_SIZE + BLOCK_GAP);
|
||||||
|
|
||||||
|
const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1);
|
||||||
|
let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio));
|
||||||
|
const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio));
|
||||||
|
|
||||||
|
if (newCol + newColSpan - 1 > targetColumns) {
|
||||||
|
newColSpan = targetColumns - newCol + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_GRID_CONFIG = {
|
||||||
|
rowHeight: BLOCK_SIZE,
|
||||||
|
gap: BLOCK_GAP,
|
||||||
|
padding: BLOCK_PADDING,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 로드한 레이아웃을 현재 블록 좌표로 변환한다.
|
||||||
|
*
|
||||||
|
* - 12칸 레거시 좌표 → 블록 좌표 변환
|
||||||
|
* - 이미 블록 좌표인 경우 → gridConfig만 보정
|
||||||
|
* - 구 모드별 overrides는 항상 제거 (리플로우가 대체)
|
||||||
|
*/
|
||||||
|
export function loadLegacyLayout(layout: PopLayoutData): PopLayoutData {
|
||||||
|
if (!isLegacyGridConfig(layout)) {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
gridConfig: BLOCK_GRID_CONFIG,
|
||||||
|
overrides: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockColumns = getBlockColumns(DESIGN_WIDTH);
|
||||||
|
|
||||||
|
const rowGroups: Record<number, string[]> = {};
|
||||||
|
Object.entries(layout.components).forEach(([id, comp]) => {
|
||||||
|
const r = comp.position.row;
|
||||||
|
if (!rowGroups[r]) rowGroups[r] = [];
|
||||||
|
rowGroups[r].push(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertedPositions: Record<string, PopGridPosition> = {};
|
||||||
|
Object.entries(layout.components).forEach(([id, comp]) => {
|
||||||
|
convertedPositions[id] = convertLegacyPosition(comp.position, blockColumns);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
|
||||||
|
const rowMapping: Record<number, number> = {};
|
||||||
|
let currentRow = 1;
|
||||||
|
for (const legacyRow of sortedRows) {
|
||||||
|
rowMapping[legacyRow] = currentRow;
|
||||||
|
const maxSpan = Math.max(
|
||||||
|
...rowGroups[legacyRow].map(id => convertedPositions[id].rowSpan)
|
||||||
|
);
|
||||||
|
currentRow += maxSpan;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComponents = { ...layout.components };
|
||||||
|
Object.entries(newComponents).forEach(([id, comp]) => {
|
||||||
|
const converted = convertedPositions[id];
|
||||||
|
const mappedRow = rowMapping[comp.position.row] ?? converted.row;
|
||||||
|
newComponents[id] = {
|
||||||
|
...comp,
|
||||||
|
position: { ...converted, row: mappedRow },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const newModals = layout.modals?.map(modal => {
|
||||||
|
const modalComps = { ...modal.components };
|
||||||
|
Object.entries(modalComps).forEach(([id, comp]) => {
|
||||||
|
modalComps[id] = {
|
||||||
|
...comp,
|
||||||
|
position: convertLegacyPosition(comp.position, blockColumns),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...modal,
|
||||||
|
gridConfig: BLOCK_GRID_CONFIG,
|
||||||
|
components: modalComps,
|
||||||
|
overrides: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
gridConfig: BLOCK_GRID_CONFIG,
|
||||||
|
components: newComponents,
|
||||||
|
overrides: undefined,
|
||||||
|
modals: newModals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import PopRenderer from "../designer/renderers/PopRenderer";
|
import PopRenderer from "../designer/renderers/PopRenderer";
|
||||||
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
import type { PopLayoutData, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||||
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
||||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||||
|
|
@ -31,7 +31,7 @@ import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||||
|
|
||||||
interface PopViewerWithModalsProps {
|
interface PopViewerWithModalsProps {
|
||||||
/** 전체 레이아웃 (모달 정의 포함) */
|
/** 전체 레이아웃 (모달 정의 포함) */
|
||||||
layout: PopLayoutDataV5;
|
layout: PopLayoutData;
|
||||||
/** 뷰포트 너비 */
|
/** 뷰포트 너비 */
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
/** 화면 ID (이벤트 버스용) */
|
/** 화면 ID (이벤트 버스용) */
|
||||||
|
|
@ -42,12 +42,15 @@ interface PopViewerWithModalsProps {
|
||||||
overrideGap?: number;
|
overrideGap?: number;
|
||||||
/** Padding 오버라이드 */
|
/** Padding 오버라이드 */
|
||||||
overridePadding?: number;
|
overridePadding?: number;
|
||||||
|
/** 부모 화면에서 선택된 행 데이터 (모달 내부 컴포넌트가 sharedData로 조회) */
|
||||||
|
parentRow?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 열린 모달 상태 */
|
/** 열린 모달 상태 */
|
||||||
interface OpenModal {
|
interface OpenModal {
|
||||||
definition: PopModalDefinition;
|
definition: PopModalDefinition;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -61,10 +64,17 @@ export default function PopViewerWithModals({
|
||||||
currentMode,
|
currentMode,
|
||||||
overrideGap,
|
overrideGap,
|
||||||
overridePadding,
|
overridePadding,
|
||||||
|
parentRow,
|
||||||
}: PopViewerWithModalsProps) {
|
}: PopViewerWithModalsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||||
const { subscribe, publish } = usePopEvent(screenId);
|
const { subscribe, publish, setSharedData } = usePopEvent(screenId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentRow) {
|
||||||
|
setSharedData("parentRow", parentRow);
|
||||||
|
}
|
||||||
|
}, [parentRow, setSharedData]);
|
||||||
|
|
||||||
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||||
const stableConnections = useMemo(
|
const stableConnections = useMemo(
|
||||||
|
|
@ -96,6 +106,7 @@ export default function PopViewerWithModals({
|
||||||
title?: string;
|
title?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data?.modalId) {
|
if (data?.modalId) {
|
||||||
|
|
@ -104,6 +115,7 @@ export default function PopViewerWithModals({
|
||||||
setModalStack(prev => [...prev, {
|
setModalStack(prev => [...prev, {
|
||||||
definition: modalDef,
|
definition: modalDef,
|
||||||
returnTo: data.returnTo,
|
returnTo: data.returnTo,
|
||||||
|
fullscreen: data.fullscreen,
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,22 +185,27 @@ export default function PopViewerWithModals({
|
||||||
|
|
||||||
{/* 모달 스택 렌더링 */}
|
{/* 모달 스택 렌더링 */}
|
||||||
{modalStack.map((modal, index) => {
|
{modalStack.map((modal, index) => {
|
||||||
const { definition } = modal;
|
const { definition, fullscreen } = modal;
|
||||||
const isTopModal = index === modalStack.length - 1;
|
const isTopModal = index === modalStack.length - 1;
|
||||||
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
||||||
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
||||||
|
|
||||||
const modalLayout: PopLayoutDataV5 = {
|
const modalLayout: PopLayoutData = {
|
||||||
...layout,
|
...layout,
|
||||||
gridConfig: definition.gridConfig,
|
gridConfig: definition.gridConfig,
|
||||||
components: definition.components,
|
components: definition.components,
|
||||||
overrides: definition.overrides,
|
overrides: definition.overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
const isFull = fullscreen || (() => {
|
||||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||||
const isFull = modalWidth >= viewportWidth;
|
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||||
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
|
return modalWidth >= viewportWidth;
|
||||||
|
})();
|
||||||
|
const rendererWidth = isFull
|
||||||
|
? viewportWidth
|
||||||
|
: resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32;
|
||||||
|
const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -200,7 +217,7 @@ export default function PopViewerWithModals({
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={isFull
|
className={isFull
|
||||||
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
|
? "flex h-dvh max-h-dvh w-screen max-w-[100vw] flex-col gap-0 overflow-hidden rounded-none border-none p-0"
|
||||||
: "max-h-[90vh] overflow-auto p-0"
|
: "max-h-[90vh] overflow-auto p-0"
|
||||||
}
|
}
|
||||||
style={isFull ? undefined : {
|
style={isFull ? undefined : {
|
||||||
|
|
@ -208,14 +225,13 @@ export default function PopViewerWithModals({
|
||||||
width: `${modalWidth}px`,
|
width: `${modalWidth}px`,
|
||||||
}}
|
}}
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
|
|
||||||
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
|
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
|
||||||
<DialogTitle className="text-base">
|
<DialogTitle className="text-base">
|
||||||
{definition.title}
|
{definition.title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
|
||||||
|
|
@ -583,7 +583,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
||||||
const safeComponentStyle = needsStripBorder
|
const safeComponentStyle = needsStripBorder
|
||||||
? (() => {
|
? (() => {
|
||||||
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
const { borderWidth, borderColor, borderStyle, border, ...rest } = componentStyle as any;
|
||||||
return rest;
|
return rest;
|
||||||
})()
|
})()
|
||||||
: componentStyle;
|
: componentStyle;
|
||||||
|
|
|
||||||
|
|
@ -764,7 +764,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
|
|
||||||
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
|
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
|
||||||
const currentFormData = formDataRef.current;
|
const currentFormData = formDataRef.current;
|
||||||
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
|
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
|
||||||
|
|
||||||
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
||||||
const generatedCode = previewResponse.data.generatedCode;
|
const generatedCode = previewResponse.data.generatedCode;
|
||||||
|
|
@ -852,6 +852,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
};
|
};
|
||||||
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
|
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
|
||||||
|
|
||||||
|
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
|
||||||
|
useEffect(() => {
|
||||||
|
const inputType = propsInputType || config.inputType || config.type || "text";
|
||||||
|
if (inputType !== "numbering") return;
|
||||||
|
if (!numberingTemplateRef.current?.includes("____")) return;
|
||||||
|
|
||||||
|
const ruleId = numberingRuleIdRef.current;
|
||||||
|
if (!ruleId) return;
|
||||||
|
|
||||||
|
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
|
||||||
|
if (!userEditedNumberingRef.current) return;
|
||||||
|
|
||||||
|
const debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const currentFormData = formDataRef.current;
|
||||||
|
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
|
||||||
|
|
||||||
|
if (resp.success && resp.data?.generatedCode) {
|
||||||
|
const newTemplate = resp.data.generatedCode;
|
||||||
|
if (newTemplate.includes("____")) {
|
||||||
|
numberingTemplateRef.current = newTemplate;
|
||||||
|
|
||||||
|
const parts = newTemplate.split("____");
|
||||||
|
const prefix = parts[0] || "";
|
||||||
|
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
|
||||||
|
const combined = prefix + manualInputValue + suffix;
|
||||||
|
|
||||||
|
setAutoGeneratedValue(combined);
|
||||||
|
onChange?.(combined);
|
||||||
|
if (onFormDataChange && columnName) {
|
||||||
|
onFormDataChange(columnName, combined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 미리보기 실패 시 기존 suffix 유지 */
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [manualInputValue]);
|
||||||
|
|
||||||
// 실제 표시할 값 (자동생성 값 또는 props value)
|
// 실제 표시할 값 (자동생성 값 또는 props value)
|
||||||
const displayValue = autoGeneratedValue ?? value;
|
const displayValue = autoGeneratedValue ?? value;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react";
|
import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types";
|
import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types";
|
||||||
import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
|
import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config";
|
||||||
|
|
||||||
interface V2TimelineSchedulerConfigPanelProps {
|
interface V2TimelineSchedulerConfigPanelProps {
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
|
|
@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
|
||||||
const [resourceTableOpen, setResourceTableOpen] = useState(false);
|
const [resourceTableOpen, setResourceTableOpen] = useState(false);
|
||||||
const [customTableOpen, setCustomTableOpen] = useState(false);
|
const [customTableOpen, setCustomTableOpen] = useState(false);
|
||||||
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
|
const [scheduleDataOpen, setScheduleDataOpen] = useState(true);
|
||||||
|
const [filterLinkOpen, setFilterLinkOpen] = useState(false);
|
||||||
const [sourceDataOpen, setSourceDataOpen] = useState(true);
|
const [sourceDataOpen, setSourceDataOpen] = useState(true);
|
||||||
const [resourceOpen, setResourceOpen] = useState(true);
|
const [resourceOpen, setResourceOpen] = useState(true);
|
||||||
const [displayOpen, setDisplayOpen] = useState(false);
|
const [displayOpen, setDisplayOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
|
const [newFilterKey, setNewFilterKey] = useState("");
|
||||||
|
const [newFilterValue, setNewFilterValue] = useState("");
|
||||||
|
const [linkedFilterTableOpen, setLinkedFilterTableOpen] = useState(false);
|
||||||
|
const [expandedActionId, setExpandedActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
|
|
@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 뷰 모드 */}
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">표시 모드</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={config.viewMode || "resource"}
|
||||||
|
onValueChange={(v) => updateConfig({ viewMode: v as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-[140px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{viewModeOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 커스텀 테이블 사용 여부 */}
|
{/* 커스텀 테이블 사용 여부 */}
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ─── 필터 & 연동 설정 ─── */}
|
||||||
|
<Collapsible open={filterLinkOpen} onOpenChange={setFilterLinkOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">필터 & 연동 설정</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
|
{Object.keys(config.staticFilters || {}).length + (config.linkedFilter ? 1 : 0)}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterLinkOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-4">
|
||||||
|
{/* 정적 필터 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-primary">정적 필터 (staticFilters)</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">데이터 조회 시 항상 적용되는 고정 필터 조건</p>
|
||||||
|
|
||||||
|
{Object.entries(config.staticFilters || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<Input value={key} disabled className="h-7 flex-1 text-xs bg-muted/30" />
|
||||||
|
<span className="text-xs text-muted-foreground">=</span>
|
||||||
|
<Input value={value} disabled className="h-7 flex-1 text-xs bg-muted/30" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = { ...config.staticFilters };
|
||||||
|
delete updated[key];
|
||||||
|
updateConfig({ staticFilters: Object.keys(updated).length > 0 ? updated : undefined });
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={newFilterKey}
|
||||||
|
onChange={(e) => setNewFilterKey(e.target.value)}
|
||||||
|
placeholder="필드명 (예: product_type)"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">=</span>
|
||||||
|
<Input
|
||||||
|
value={newFilterValue}
|
||||||
|
onChange={(e) => setNewFilterValue(e.target.value)}
|
||||||
|
placeholder="값 (예: 완제품)"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!newFilterKey.trim()) return;
|
||||||
|
updateConfig({
|
||||||
|
staticFilters: {
|
||||||
|
...(config.staticFilters || {}),
|
||||||
|
[newFilterKey.trim()]: newFilterValue.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNewFilterKey("");
|
||||||
|
setNewFilterValue("");
|
||||||
|
}}
|
||||||
|
disabled={!newFilterKey.trim()}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
{/* 연결 필터 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-primary flex items-center gap-1">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
연결 필터 (linkedFilter)
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">다른 컴포넌트 선택에 따라 데이터를 필터링</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!!config.linkedFilter}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
updateConfig({
|
||||||
|
linkedFilter: {
|
||||||
|
sourceField: "",
|
||||||
|
targetField: "",
|
||||||
|
showEmptyWhenNoSelection: true,
|
||||||
|
emptyMessage: "좌측 목록에서 항목을 선택하세요",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateConfig({ linkedFilter: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.linkedFilter && (
|
||||||
|
<div className="ml-1 border-l-2 border-primary/20 pl-3 space-y-2 pt-1">
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">소스 테이블명</span>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">선택 이벤트의 tableName 매칭</p>
|
||||||
|
</div>
|
||||||
|
<Popover open={linkedFilterTableOpen} onOpenChange={setLinkedFilterTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-[140px] justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{config.linkedFilter.sourceTableName || "선택..."}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[200px]" align="end">
|
||||||
|
<Command filter={(value, search) => value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs p-2">없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({
|
||||||
|
linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName },
|
||||||
|
});
|
||||||
|
setLinkedFilterTableOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", config.linkedFilter?.sourceTableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
{table.displayName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">소스 필드 (sourceField) *</span>
|
||||||
|
<Input
|
||||||
|
value={config.linkedFilter.sourceField || ""}
|
||||||
|
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })}
|
||||||
|
placeholder="예: part_code"
|
||||||
|
className="h-7 w-[140px] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">타겟 필드 (targetField) *</span>
|
||||||
|
<Input
|
||||||
|
value={config.linkedFilter.targetField || ""}
|
||||||
|
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })}
|
||||||
|
placeholder="예: item_code"
|
||||||
|
className="h-7 w-[140px] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">빈 상태 메시지</span>
|
||||||
|
<Input
|
||||||
|
value={config.linkedFilter.emptyMessage || ""}
|
||||||
|
onChange={(e) => updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })}
|
||||||
|
placeholder="선택 안내 문구"
|
||||||
|
className="h-7 w-[180px] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">선택 없을 때 빈 화면</span>
|
||||||
|
<Switch
|
||||||
|
checked={config.linkedFilter.showEmptyWhenNoSelection ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 2단계: 소스 데이터 설정 ─── */}
|
{/* ─── 2단계: 소스 데이터 설정 ─── */}
|
||||||
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
|
<Collapsible open={sourceDataOpen} onOpenChange={setSourceDataOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
|
|
@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
|
||||||
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
|
onCheckedChange={(v) => updateConfig({ showAddButton: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">범례 표시</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">상태별 색상 범례를 보여줘요</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showLegend ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showLegend: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC<V2TimelineSchedulerConfigP
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
{/* ─── 6단계: 툴바 액션 설정 ─── */}
|
||||||
|
<Collapsible open={actionsOpen} onOpenChange={setActionsOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">툴바 액션</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
|
{(config.toolbarActions || []).length}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", actionsOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
툴바에 커스텀 버튼을 추가하여 API 호출 (미리보기 → 확인 → 적용) 워크플로우를 구성해요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 기존 액션 목록 */}
|
||||||
|
{(config.toolbarActions || []).map((action, index) => (
|
||||||
|
<Collapsible
|
||||||
|
key={action.id}
|
||||||
|
open={expandedActionId === action.id}
|
||||||
|
onOpenChange={(open) => setExpandedActionId(open ? action.id : null)}
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="h-3 w-3 text-muted-foreground/50" />
|
||||||
|
<div className={cn("h-3 w-3 rounded-sm", action.color?.split(" ")[0] || "bg-primary")} />
|
||||||
|
<span className="text-xs font-medium">{action.label || "새 액션"}</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4">
|
||||||
|
{action.dataSource === "linkedSelection" ? "연결선택" : "스케줄"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const updated = (config.toolbarActions || []).filter((_, i) => i !== index);
|
||||||
|
updateConfig({ toolbarActions: updated.length > 0 ? updated : undefined });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<ChevronDown className={cn("h-3 w-3 text-muted-foreground transition-transform", expandedActionId === action.id && "rotate-180")} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-3 py-3 space-y-2.5">
|
||||||
|
{/* 기본 설정 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">버튼명</span>
|
||||||
|
<Input
|
||||||
|
value={action.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], label: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[110px]">
|
||||||
|
<span className="text-[10px] text-muted-foreground">아이콘</span>
|
||||||
|
<Select
|
||||||
|
value={action.icon || "Zap"}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], icon: v as any };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{toolbarIconOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">버튼 색상 (Tailwind 클래스)</span>
|
||||||
|
<Input
|
||||||
|
value={action.color || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], color: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="예: bg-emerald-600 hover:bg-emerald-700"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 설정 */}
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<p className="text-[10px] font-medium text-primary mb-1.5">API 설정</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">미리보기 API *</span>
|
||||||
|
<Input
|
||||||
|
value={action.previewApi}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], previewApi: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="/production/generate-schedule/preview"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">적용 API *</span>
|
||||||
|
<Input
|
||||||
|
value={action.applyApi}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], applyApi: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="/production/generate-schedule"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다이얼로그 설정 */}
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<p className="text-[10px] font-medium text-primary mb-1.5">다이얼로그</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">제목</span>
|
||||||
|
<Input
|
||||||
|
value={action.dialogTitle || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], dialogTitle: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">설명</span>
|
||||||
|
<Input
|
||||||
|
value={action.dialogDescription || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], dialogDescription: e.target.value };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="미리보기 후 확인하여 적용합니다"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 소스 설정 */}
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<p className="text-[10px] font-medium text-primary mb-1.5">데이터 소스</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">데이터 소스 유형 *</span>
|
||||||
|
<Select
|
||||||
|
value={action.dataSource}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], dataSource: v as any };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dataSourceOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
<div>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">({opt.description})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action.dataSource === "linkedSelection" && (
|
||||||
|
<div className="ml-2 border-l-2 border-blue-200 pl-2 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">그룹 필드</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.groupByField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="linkedFilter.sourceField 사용"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">수량 필드</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.quantityField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="balance_qty"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">기준일 필드</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.dueDateField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="due_date"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">표시명 필드</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.nameField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="part_name"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action.dataSource === "currentSchedules" && (
|
||||||
|
<div className="ml-2 border-l-2 border-amber-200 pl-2 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">필터 필드</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.scheduleFilterField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="product_type"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">필터 값</span>
|
||||||
|
<Input
|
||||||
|
value={action.payloadConfig?.scheduleFilterValue || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
placeholder="완제품"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 조건 */}
|
||||||
|
<div className="border-t pt-2">
|
||||||
|
<p className="text-[10px] font-medium text-primary mb-1.5">표시 조건 (showWhen)</p>
|
||||||
|
<p className="text-[9px] text-muted-foreground mb-1">staticFilters 값과 비교하여 일치할 때만 버튼 표시</p>
|
||||||
|
{Object.entries(action.showWhen || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1 mb-1">
|
||||||
|
<Input value={key} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
|
||||||
|
<span className="text-[10px]">=</span>
|
||||||
|
<Input value={value} disabled className="h-6 flex-1 text-[10px] bg-muted/30" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
const newShowWhen = { ...updated[index].showWhen };
|
||||||
|
delete newShowWhen[key];
|
||||||
|
updated[index] = { ...updated[index], showWhen: Object.keys(newShowWhen).length > 0 ? newShowWhen : undefined };
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
id={`showWhen-key-${index}`}
|
||||||
|
placeholder="필드명"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px]">=</span>
|
||||||
|
<Input
|
||||||
|
id={`showWhen-val-${index}`}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const keyEl = document.getElementById(`showWhen-key-${index}`) as HTMLInputElement;
|
||||||
|
const valEl = document.getElementById(`showWhen-val-${index}`) as HTMLInputElement;
|
||||||
|
if (!keyEl?.value?.trim()) return;
|
||||||
|
const updated = [...(config.toolbarActions || [])];
|
||||||
|
updated[index] = {
|
||||||
|
...updated[index],
|
||||||
|
showWhen: { ...(updated[index].showWhen || {}), [keyEl.value.trim()]: valEl?.value?.trim() || "" },
|
||||||
|
};
|
||||||
|
updateConfig({ toolbarActions: updated });
|
||||||
|
keyEl.value = "";
|
||||||
|
if (valEl) valEl.value = "";
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 액션 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newAction: ToolbarAction = {
|
||||||
|
id: `action_${Date.now()}`,
|
||||||
|
label: "새 액션",
|
||||||
|
icon: "Zap",
|
||||||
|
color: "bg-primary hover:bg-primary/90",
|
||||||
|
previewApi: "",
|
||||||
|
applyApi: "",
|
||||||
|
dataSource: "linkedSelection",
|
||||||
|
};
|
||||||
|
updateConfig({
|
||||||
|
toolbarActions: [...(config.toolbarActions || []), newAction],
|
||||||
|
});
|
||||||
|
setExpandedActionId(newAction.id);
|
||||||
|
}}
|
||||||
|
className="w-full h-8 text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
액션 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
||||||
export async function previewNumberingCode(
|
export async function previewNumberingCode(
|
||||||
ruleId: string,
|
ruleId: string,
|
||||||
formData?: Record<string, unknown>,
|
formData?: Record<string, unknown>,
|
||||||
|
manualInputValue?: string,
|
||||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||||
// ruleId 유효성 검사
|
// ruleId 유효성 검사
|
||||||
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
||||||
|
|
@ -114,6 +115,7 @@ export async function previewNumberingCode(
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
|
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
|
||||||
formData: formData || {},
|
formData: formData || {},
|
||||||
|
manualInputValue,
|
||||||
});
|
});
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
return { success: false, error: "서버 응답이 비어있습니다" };
|
return { success: false, error: "서버 응답이 비어있습니다" };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
/**
|
||||||
|
* 생산계획 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from "./client";
|
||||||
|
|
||||||
|
// ─── 타입 정의 ───
|
||||||
|
|
||||||
|
export interface OrderSummaryItem {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
total_order_qty: number;
|
||||||
|
total_ship_qty: number;
|
||||||
|
total_balance_qty: number;
|
||||||
|
order_count: number;
|
||||||
|
earliest_due_date: string | null;
|
||||||
|
current_stock: number;
|
||||||
|
safety_stock: number;
|
||||||
|
existing_plan_qty: number;
|
||||||
|
in_progress_qty: number;
|
||||||
|
required_plan_qty: number;
|
||||||
|
orders: OrderDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderDetail {
|
||||||
|
id: string;
|
||||||
|
order_no: string;
|
||||||
|
part_code: string;
|
||||||
|
part_name: string;
|
||||||
|
order_qty: number;
|
||||||
|
ship_qty: number;
|
||||||
|
balance_qty: number;
|
||||||
|
due_date: string | null;
|
||||||
|
status: string;
|
||||||
|
customer_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockShortageItem {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
current_qty: number;
|
||||||
|
safety_qty: number;
|
||||||
|
shortage_qty: number;
|
||||||
|
recommended_qty: number;
|
||||||
|
last_in_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionPlan {
|
||||||
|
id: number;
|
||||||
|
company_code: string;
|
||||||
|
plan_no: string;
|
||||||
|
plan_date: string;
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
product_type: string;
|
||||||
|
plan_qty: number;
|
||||||
|
completed_qty: number;
|
||||||
|
progress_rate: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
due_date: string | null;
|
||||||
|
equipment_id: number | null;
|
||||||
|
equipment_code: string | null;
|
||||||
|
equipment_name: string | null;
|
||||||
|
status: string;
|
||||||
|
priority: string | null;
|
||||||
|
order_no: string | null;
|
||||||
|
parent_plan_id: number | null;
|
||||||
|
remarks: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateScheduleRequest {
|
||||||
|
items: {
|
||||||
|
item_code: string;
|
||||||
|
item_name: string;
|
||||||
|
required_qty: number;
|
||||||
|
earliest_due_date: string;
|
||||||
|
hourly_capacity?: number;
|
||||||
|
daily_capacity?: number;
|
||||||
|
lead_time?: number;
|
||||||
|
}[];
|
||||||
|
options?: {
|
||||||
|
safety_lead_time?: number;
|
||||||
|
recalculate_unstarted?: boolean;
|
||||||
|
product_type?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateScheduleResponse {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
new_count: number;
|
||||||
|
kept_count: number;
|
||||||
|
deleted_count: number;
|
||||||
|
};
|
||||||
|
schedules: ProductionPlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API 함수 ───
|
||||||
|
|
||||||
|
/** 수주 데이터 조회 (품목별 그룹핑) */
|
||||||
|
export async function getOrderSummary(params?: {
|
||||||
|
excludePlanned?: boolean;
|
||||||
|
itemCode?: string;
|
||||||
|
itemName?: string;
|
||||||
|
}) {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params?.excludePlanned) queryParams.set("excludePlanned", "true");
|
||||||
|
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
|
||||||
|
if (params?.itemName) queryParams.set("itemName", params.itemName);
|
||||||
|
|
||||||
|
const qs = queryParams.toString();
|
||||||
|
const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`;
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data as { success: boolean; data: OrderSummaryItem[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 안전재고 부족분 조회 */
|
||||||
|
export async function getStockShortage() {
|
||||||
|
const response = await apiClient.get("/api/production/stock-shortage");
|
||||||
|
return response.data as { success: boolean; data: StockShortageItem[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 생산계획 상세 조회 */
|
||||||
|
export async function getPlanById(planId: number) {
|
||||||
|
const response = await apiClient.get(`/api/production/plan/${planId}`);
|
||||||
|
return response.data as { success: boolean; data: ProductionPlan };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 생산계획 수정 */
|
||||||
|
export async function updatePlan(planId: number, data: Partial<ProductionPlan>) {
|
||||||
|
const response = await apiClient.put(`/api/production/plan/${planId}`, data);
|
||||||
|
return response.data as { success: boolean; data: ProductionPlan };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 생산계획 삭제 */
|
||||||
|
export async function deletePlan(planId: number) {
|
||||||
|
const response = await apiClient.delete(`/api/production/plan/${planId}`);
|
||||||
|
return response.data as { success: boolean; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자동 스케줄 생성 */
|
||||||
|
export async function generateSchedule(request: GenerateScheduleRequest) {
|
||||||
|
const response = await apiClient.post("/api/production/generate-schedule", request);
|
||||||
|
return response.data as { success: boolean; data: GenerateScheduleResponse };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스케줄 병합 */
|
||||||
|
export async function mergeSchedules(scheduleIds: number[], productType?: string) {
|
||||||
|
const response = await apiClient.post("/api/production/merge-schedules", {
|
||||||
|
schedule_ids: scheduleIds,
|
||||||
|
product_type: productType || "완제품",
|
||||||
|
});
|
||||||
|
return response.data as { success: boolean; data: ProductionPlan };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 반제품 계획 자동 생성 */
|
||||||
|
export async function generateSemiSchedule(
|
||||||
|
planIds: number[],
|
||||||
|
options?: { considerStock?: boolean; excludeUsed?: boolean }
|
||||||
|
) {
|
||||||
|
const response = await apiClient.post("/api/production/generate-semi-schedule", {
|
||||||
|
plan_ids: planIds,
|
||||||
|
options: options || {},
|
||||||
|
});
|
||||||
|
return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스케줄 분할 */
|
||||||
|
export async function splitSchedule(planId: number, splitQty: number) {
|
||||||
|
const response = await apiClient.post(`/api/production/plan/${planId}/split`, {
|
||||||
|
split_qty: splitQty,
|
||||||
|
});
|
||||||
|
return response.data as {
|
||||||
|
success: boolean;
|
||||||
|
data: { original: { id: number; plan_qty: number }; split: ProductionPlan };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -19,11 +19,12 @@ import {
|
||||||
Send, Radio, Megaphone, Podcast, BellRing,
|
Send, Radio, Megaphone, Podcast, BellRing,
|
||||||
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
|
icons as allLucideIcons,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
|
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const iconMap: Record<string, LucideIcon> = {
|
export const iconMap: Record<string, LucideIcon> = {
|
||||||
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
||||||
|
|
@ -109,15 +110,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 아이콘 조회 / 동적 등록
|
// 아이콘 조회 / 동적 등록
|
||||||
|
// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export function getLucideIcon(name: string): LucideIcon | undefined {
|
export function getLucideIcon(name: string): LucideIcon | undefined {
|
||||||
return iconMap[name];
|
if (iconMap[name]) return iconMap[name];
|
||||||
|
|
||||||
|
const found = allLucideIcons[name as keyof typeof allLucideIcons];
|
||||||
|
if (found) {
|
||||||
|
iconMap[name] = found;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addToIconMap(name: string, component: LucideIcon): void {
|
export function addToIconMap(name: string, component: LucideIcon): void {
|
||||||
iconMap[name] = component;
|
iconMap[name] = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용
|
||||||
|
export { allLucideIcons };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SVG 정화
|
// SVG 정화
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2670,7 +2670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
|
|
@ -2732,7 +2732,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
|
|
@ -3415,7 +3415,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
|
|
|
||||||
|
|
@ -379,12 +379,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [tableConfig.selectedTable, currentUserId]);
|
}, [tableConfig.selectedTable, currentUserId]);
|
||||||
|
|
||||||
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
// columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (columnVisibility.length > 0) {
|
if (columnVisibility.length > 0) {
|
||||||
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
||||||
setColumnOrder(newOrder);
|
setColumnOrder(newOrder);
|
||||||
|
|
||||||
|
// 너비 적용
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
columnVisibility.forEach((cv) => {
|
||||||
|
if (cv.width) {
|
||||||
|
newWidths[cv.columnName] = cv.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(newWidths).length > 0) {
|
||||||
|
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||||
|
|
||||||
|
// table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용)
|
||||||
|
if (tableConfig.selectedTable && userId) {
|
||||||
|
const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(widthsKey);
|
||||||
|
const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths;
|
||||||
|
localStorage.setItem(widthsKey, JSON.stringify(merged));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// localStorage에 저장 (사용자별)
|
// localStorage에 저장 (사용자별)
|
||||||
if (tableConfig.selectedTable && currentUserId) {
|
if (tableConfig.selectedTable && currentUserId) {
|
||||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||||
|
|
|
||||||
|
|
@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
if (component.style?.backgroundColor) {
|
if (component.style?.backgroundColor) {
|
||||||
return component.style.backgroundColor;
|
return component.style.backgroundColor;
|
||||||
}
|
}
|
||||||
// 4순위: style.labelColor (레거시)
|
// 4순위: componentConfig.style.backgroundColor
|
||||||
|
if (componentConfig.style?.backgroundColor) {
|
||||||
|
return componentConfig.style.backgroundColor;
|
||||||
|
}
|
||||||
|
// 5순위: style.labelColor (레거시 호환)
|
||||||
if (component.style?.labelColor) {
|
if (component.style?.labelColor) {
|
||||||
return component.style.labelColor;
|
return component.style.labelColor;
|
||||||
}
|
}
|
||||||
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
|
// 6순위: 액션별 기본 배경색
|
||||||
if (isDeleteAction()) {
|
const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"];
|
||||||
return "#ef4444"; // 빨간색 (Tailwind red-500)
|
const actionType = typeof componentConfig.action === "string"
|
||||||
}
|
? componentConfig.action
|
||||||
return "#3b82f6"; // 파란색 (Tailwind blue-500)
|
: componentConfig.action?.type || "";
|
||||||
|
if (actionType === "delete") return "#F04544";
|
||||||
|
if (excelActions.includes(actionType)) return "#212121";
|
||||||
|
return "#3B83F6";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getButtonTextColor = () => {
|
const getButtonTextColor = () => {
|
||||||
|
|
@ -570,6 +577,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...restComponentStyle,
|
...restComponentStyle,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
borderRadius: _br || "0.5rem",
|
||||||
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types";
|
||||||
* ButtonPrimary 컴포넌트 기본 설정
|
* ButtonPrimary 컴포넌트 기본 설정
|
||||||
*/
|
*/
|
||||||
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
|
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
|
||||||
text: "버튼",
|
text: "저장",
|
||||||
actionType: "button",
|
actionType: "button",
|
||||||
variant: "primary",
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
// 공통 기본값
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
required: false,
|
required: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
variant: "default",
|
displayMode: "icon-text",
|
||||||
size: "md",
|
icon: {
|
||||||
|
name: "Check",
|
||||||
|
type: "lucide",
|
||||||
|
size: "보통",
|
||||||
|
},
|
||||||
|
iconTextPosition: "right",
|
||||||
|
iconGap: 6,
|
||||||
|
style: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
labelColor: "#FFFFFF",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "normal",
|
||||||
|
labelTextAlign: "left",
|
||||||
|
backgroundColor: "#3B83F6",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
|
||||||
successMessage: "저장되었습니다.",
|
successMessage: "저장되었습니다.",
|
||||||
errorMessage: "저장 중 오류가 발생했습니다.",
|
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
|
displayMode: "icon-text",
|
||||||
|
icon: {
|
||||||
|
name: "Check",
|
||||||
|
type: "lucide",
|
||||||
|
size: "보통",
|
||||||
|
},
|
||||||
|
iconTextPosition: "right",
|
||||||
|
iconGap: 6,
|
||||||
|
style: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
labelColor: "#FFFFFF",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: "normal",
|
||||||
|
labelTextAlign: "left",
|
||||||
|
backgroundColor: "#3B83F6",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultSize: { width: 120, height: 40 },
|
defaultSize: { width: 100, height: 40 },
|
||||||
configPanel: V2ButtonConfigPanel,
|
configPanel: V2ButtonConfigPanel,
|
||||||
icon: "MousePointer",
|
icon: "MousePointer",
|
||||||
tags: ["버튼", "액션", "클릭"],
|
tags: ["버튼", "액션", "클릭"],
|
||||||
|
|
|
||||||
|
|
@ -3607,7 +3607,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
|
|
@ -3704,7 +3704,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
className="px-3 py-2 text-sm whitespace-nowrap text-foreground"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
|
|
@ -4201,7 +4201,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||||
>
|
>
|
||||||
{tabSummaryColumns.map((col: any) => (
|
{tabSummaryColumns.map((col: any) => (
|
||||||
<td key={col.name} className="px-3 py-2 text-xs">
|
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{col.type === "progress"
|
{col.type === "progress"
|
||||||
? renderProgressCell(col, item, selectedLeftItem)
|
? renderProgressCell(col, item, selectedLeftItem)
|
||||||
: formatCellValue(
|
: formatCellValue(
|
||||||
|
|
@ -4317,7 +4317,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||||
>
|
>
|
||||||
{listSummaryColumns.map((col: any) => (
|
{listSummaryColumns.map((col: any) => (
|
||||||
<td key={col.name} className="px-3 py-2 text-xs">
|
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{col.type === "progress"
|
{col.type === "progress"
|
||||||
? renderProgressCell(col, item, selectedLeftItem)
|
? renderProgressCell(col, item, selectedLeftItem)
|
||||||
: formatCellValue(
|
: formatCellValue(
|
||||||
|
|
@ -4384,9 +4384,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 커스텀 모드: alwaysShow가 아닌 경우에만 좌측 선택 필요
|
||||||
// 실행 모드에서 좌측 미선택 시 안내 메시지 표시
|
!isDesignMode && !selectedLeftItem && !componentConfig.rightPanel?.alwaysShow ? (
|
||||||
!isDesignMode && !selectedLeftItem ? (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-muted-foreground text-center text-sm">
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||||
|
|
@ -4710,8 +4709,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
className="px-3 py-2 text-xs"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
{col.type === "progress"
|
{col.type === "progress"
|
||||||
? renderProgressCell(col, item, selectedLeftItem)
|
? renderProgressCell(col, item, selectedLeftItem)
|
||||||
|
|
@ -4857,7 +4856,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
>
|
>
|
||||||
{columnsToDisplay.map((col) => (
|
{columnsToDisplay.map((col) => (
|
||||||
<td key={col.name} className="px-3 py-2 text-xs">
|
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
col.name,
|
col.name,
|
||||||
getEntityJoinValue(item, col.name),
|
getEntityJoinValue(item, col.name),
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ export interface SplitPanelLayoutConfig {
|
||||||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
|
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
|
||||||
|
alwaysShow?: boolean; // true면 좌측 선택 없이도 우측 패널 항상 표시
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
|
||||||
components?: PanelInlineComponent[];
|
components?: PanelInlineComponent[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -153,15 +153,37 @@ export function useGroupedData(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
let responseData = response.data?.data?.data || response.data?.data || [];
|
||||||
setRawData(Array.isArray(responseData) ? responseData : []);
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.message || "데이터 로드 중 오류 발생");
|
setError(err.message || "데이터 로드 중 오류 발생");
|
||||||
setRawData([]);
|
setRawData([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [tableName, externalData, searchFilters]);
|
}, [tableName, externalData, searchFilters, config.dataFilter]);
|
||||||
|
|
||||||
// 초기 데이터 로드
|
// 초기 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -521,12 +521,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [tableConfig.selectedTable, currentUserId]);
|
}, [tableConfig.selectedTable, currentUserId]);
|
||||||
|
|
||||||
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
// columnVisibility 변경 시 컬럼 순서, 가시성, 너비 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (columnVisibility.length > 0) {
|
if (columnVisibility.length > 0) {
|
||||||
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
const newOrder = columnVisibility.map((cv) => cv.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
||||||
setColumnOrder(newOrder);
|
setColumnOrder(newOrder);
|
||||||
|
|
||||||
|
// 너비 적용
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
columnVisibility.forEach((cv) => {
|
||||||
|
if (cv.width) {
|
||||||
|
newWidths[cv.columnName] = cv.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(newWidths).length > 0) {
|
||||||
|
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||||
|
|
||||||
|
// table_column_widths_* localStorage도 동기화 (초기 너비 로드 시 올바른 값 사용)
|
||||||
|
if (tableConfig.selectedTable && userId) {
|
||||||
|
const widthsKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(widthsKey);
|
||||||
|
const merged = existing ? { ...JSON.parse(existing), ...newWidths } : newWidths;
|
||||||
|
localStorage.setItem(widthsKey, JSON.stringify(merged));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// localStorage에 저장 (사용자별)
|
// localStorage에 저장 (사용자별)
|
||||||
if (tableConfig.selectedTable && currentUserId) {
|
if (tableConfig.selectedTable && currentUserId) {
|
||||||
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -2,54 +2,44 @@
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
|
import {
|
||||||
|
Resource,
|
||||||
|
ScheduleItem,
|
||||||
|
ZoomLevel,
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
} from "../types";
|
||||||
import { ScheduleBar } from "./ScheduleBar";
|
import { ScheduleBar } from "./ScheduleBar";
|
||||||
|
|
||||||
interface ResourceRowProps {
|
interface ResourceRowProps {
|
||||||
/** 리소스 */
|
|
||||||
resource: Resource;
|
resource: Resource;
|
||||||
/** 해당 리소스의 스케줄 목록 */
|
|
||||||
schedules: ScheduleItem[];
|
schedules: ScheduleItem[];
|
||||||
/** 시작 날짜 */
|
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
/** 종료 날짜 */
|
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
/** 줌 레벨 */
|
|
||||||
zoomLevel: ZoomLevel;
|
zoomLevel: ZoomLevel;
|
||||||
/** 행 높이 */
|
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
/** 셀 너비 */
|
|
||||||
cellWidth: number;
|
cellWidth: number;
|
||||||
/** 리소스 컬럼 너비 */
|
|
||||||
resourceColumnWidth: number;
|
resourceColumnWidth: number;
|
||||||
/** 설정 */
|
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
/** 스케줄 클릭 */
|
/** 충돌 스케줄 ID 목록 */
|
||||||
|
conflictIds?: Set<string>;
|
||||||
onScheduleClick?: (schedule: ScheduleItem) => void;
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
/** 빈 셀 클릭 */
|
|
||||||
onCellClick?: (resourceId: string, date: Date) => void;
|
onCellClick?: (resourceId: string, date: Date) => void;
|
||||||
/** 드래그 시작 */
|
/** 드래그 완료: deltaX(픽셀) 전달 */
|
||||||
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
|
||||||
/** 드래그 종료 */
|
/** 리사이즈 완료: direction + deltaX(픽셀) 전달 */
|
||||||
onDragEnd?: () => void;
|
onResizeComplete?: (
|
||||||
/** 리사이즈 시작 */
|
schedule: ScheduleItem,
|
||||||
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
direction: "start" | "end",
|
||||||
/** 리사이즈 종료 */
|
deltaX: number
|
||||||
onResizeEnd?: () => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 차이 계산 (일수)
|
|
||||||
*/
|
|
||||||
const getDaysDiff = (start: Date, end: Date): number => {
|
const getDaysDiff = (start: Date, end: Date): number => {
|
||||||
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
||||||
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
||||||
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 범위 내의 셀 개수 계산
|
|
||||||
*/
|
|
||||||
const getCellCount = (startDate: Date, endDate: Date): number => {
|
const getCellCount = (startDate: Date, endDate: Date): number => {
|
||||||
return getDaysDiff(startDate, endDate) + 1;
|
return getDaysDiff(startDate, endDate) + 1;
|
||||||
};
|
};
|
||||||
|
|
@ -64,20 +54,18 @@ export function ResourceRow({
|
||||||
cellWidth,
|
cellWidth,
|
||||||
resourceColumnWidth,
|
resourceColumnWidth,
|
||||||
config,
|
config,
|
||||||
|
conflictIds,
|
||||||
onScheduleClick,
|
onScheduleClick,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
onDragStart,
|
onDragComplete,
|
||||||
onDragEnd,
|
onResizeComplete,
|
||||||
onResizeStart,
|
|
||||||
onResizeEnd,
|
|
||||||
}: ResourceRowProps) {
|
}: ResourceRowProps) {
|
||||||
// 총 셀 개수
|
const totalCells = useMemo(
|
||||||
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
|
() => getCellCount(startDate, endDate),
|
||||||
|
[startDate, endDate]
|
||||||
// 총 그리드 너비
|
);
|
||||||
const gridWidth = totalCells * cellWidth;
|
const gridWidth = totalCells * cellWidth;
|
||||||
|
|
||||||
// 오늘 날짜
|
|
||||||
const today = useMemo(() => {
|
const today = useMemo(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -92,21 +80,26 @@ export function ResourceRow({
|
||||||
scheduleStart.setHours(0, 0, 0, 0);
|
scheduleStart.setHours(0, 0, 0, 0);
|
||||||
scheduleEnd.setHours(0, 0, 0, 0);
|
scheduleEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// 시작 위치 계산
|
|
||||||
const startOffset = getDaysDiff(startDate, scheduleStart);
|
const startOffset = getDaysDiff(startDate, scheduleStart);
|
||||||
const left = Math.max(0, startOffset * cellWidth);
|
const left = Math.max(0, startOffset * cellWidth);
|
||||||
|
|
||||||
// 너비 계산
|
|
||||||
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
||||||
const visibleStartOffset = Math.max(0, startOffset);
|
const visibleStartOffset = Math.max(0, startOffset);
|
||||||
const visibleEndOffset = Math.min(
|
const visibleEndOffset = Math.min(
|
||||||
totalCells,
|
totalCells,
|
||||||
startOffset + durationDays
|
startOffset + durationDays
|
||||||
);
|
);
|
||||||
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
|
const width = Math.max(
|
||||||
|
cellWidth,
|
||||||
|
(visibleEndOffset - visibleStartOffset) * cellWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
// 시작일 = 종료일이면 마일스톤
|
||||||
|
const isMilestone = schedule.startDate === schedule.endDate;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schedule,
|
schedule,
|
||||||
|
isMilestone,
|
||||||
position: {
|
position: {
|
||||||
left: resourceColumnWidth + left,
|
left: resourceColumnWidth + left,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -115,9 +108,15 @@ export function ResourceRow({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
|
}, [
|
||||||
|
schedules,
|
||||||
|
startDate,
|
||||||
|
cellWidth,
|
||||||
|
resourceColumnWidth,
|
||||||
|
rowHeight,
|
||||||
|
totalCells,
|
||||||
|
]);
|
||||||
|
|
||||||
// 그리드 셀 클릭 핸들러
|
|
||||||
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!onCellClick) return;
|
if (!onCellClick) return;
|
||||||
|
|
||||||
|
|
@ -138,13 +137,15 @@ export function ResourceRow({
|
||||||
>
|
>
|
||||||
{/* 리소스 컬럼 */}
|
{/* 리소스 컬럼 */}
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 border-r bg-muted/30 flex items-center px-3 sticky left-0 z-10"
|
className="sticky left-0 z-10 flex shrink-0 items-center border-r bg-muted/30 px-2 sm:px-3"
|
||||||
style={{ width: resourceColumnWidth }}
|
style={{ width: resourceColumnWidth }}
|
||||||
>
|
>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="font-medium text-sm truncate">{resource.name}</div>
|
<div className="truncate text-[10px] font-medium sm:text-sm">
|
||||||
|
{resource.name}
|
||||||
|
</div>
|
||||||
{resource.group && (
|
{resource.group && (
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
|
||||||
{resource.group}
|
{resource.group}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -162,7 +163,8 @@ export function ResourceRow({
|
||||||
{Array.from({ length: totalCells }).map((_, idx) => {
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
||||||
const cellDate = new Date(startDate);
|
const cellDate = new Date(startDate);
|
||||||
cellDate.setDate(cellDate.getDate() + idx);
|
cellDate.setDate(cellDate.getDate() + idx);
|
||||||
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
const isWeekend =
|
||||||
|
cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
||||||
const isToday = cellDate.getTime() === today.getTime();
|
const isToday = cellDate.getTime() === today.getTime();
|
||||||
const isMonthStart = cellDate.getDate() === 1;
|
const isMonthStart = cellDate.getDate() === 1;
|
||||||
|
|
||||||
|
|
@ -170,7 +172,7 @@ export function ResourceRow({
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r h-full",
|
"h-full border-r",
|
||||||
isWeekend && "bg-muted/20",
|
isWeekend && "bg-muted/20",
|
||||||
isToday && "bg-primary/5",
|
isToday && "bg-primary/5",
|
||||||
isMonthStart && "border-l-2 border-l-primary/20"
|
isMonthStart && "border-l-2 border-l-primary/20"
|
||||||
|
|
@ -182,22 +184,22 @@ export function ResourceRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스케줄 바들 */}
|
{/* 스케줄 바들 */}
|
||||||
{schedulePositions.map(({ schedule, position }) => (
|
{schedulePositions.map(({ schedule, position, isMilestone }) => (
|
||||||
<ScheduleBar
|
<ScheduleBar
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
position={{
|
position={{
|
||||||
...position,
|
...position,
|
||||||
left: position.left - resourceColumnWidth, // 상대 위치
|
left: position.left - resourceColumnWidth,
|
||||||
}}
|
}}
|
||||||
config={config}
|
config={config}
|
||||||
draggable={config.draggable}
|
draggable={config.draggable}
|
||||||
resizable={config.resizable}
|
resizable={config.resizable}
|
||||||
|
hasConflict={conflictIds?.has(schedule.id) ?? false}
|
||||||
|
isMilestone={isMilestone}
|
||||||
onClick={() => onScheduleClick?.(schedule)}
|
onClick={() => onScheduleClick?.(schedule)}
|
||||||
onDragStart={onDragStart}
|
onDragComplete={onDragComplete}
|
||||||
onDragEnd={onDragEnd}
|
onResizeComplete={onResizeComplete}
|
||||||
onResizeStart={onResizeStart}
|
|
||||||
onResizeEnd={onResizeEnd}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,79 +2,99 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } from "react";
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ScheduleItem,
|
||||||
|
ScheduleBarPosition,
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
} from "../types";
|
||||||
import { statusOptions } from "../config";
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
interface ScheduleBarProps {
|
interface ScheduleBarProps {
|
||||||
/** 스케줄 항목 */
|
|
||||||
schedule: ScheduleItem;
|
schedule: ScheduleItem;
|
||||||
/** 위치 정보 */
|
|
||||||
position: ScheduleBarPosition;
|
position: ScheduleBarPosition;
|
||||||
/** 설정 */
|
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
/** 드래그 가능 여부 */
|
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
/** 리사이즈 가능 여부 */
|
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
/** 클릭 이벤트 */
|
hasConflict?: boolean;
|
||||||
|
isMilestone?: boolean;
|
||||||
onClick?: (schedule: ScheduleItem) => void;
|
onClick?: (schedule: ScheduleItem) => void;
|
||||||
/** 드래그 시작 */
|
/** 드래그 완료 시 deltaX(픽셀) 전달 */
|
||||||
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
|
||||||
/** 드래그 중 */
|
/** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */
|
||||||
onDrag?: (deltaX: number, deltaY: number) => void;
|
onResizeComplete?: (
|
||||||
/** 드래그 종료 */
|
schedule: ScheduleItem,
|
||||||
onDragEnd?: () => void;
|
direction: "start" | "end",
|
||||||
/** 리사이즈 시작 */
|
deltaX: number
|
||||||
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
) => void;
|
||||||
/** 리사이즈 중 */
|
|
||||||
onResize?: (deltaX: number, direction: "start" | "end") => void;
|
|
||||||
/** 리사이즈 종료 */
|
|
||||||
onResizeEnd?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 드래그/리사이즈 판정 최소 이동 거리 (px)
|
||||||
|
const MIN_MOVE_THRESHOLD = 5;
|
||||||
|
|
||||||
export function ScheduleBar({
|
export function ScheduleBar({
|
||||||
schedule,
|
schedule,
|
||||||
position,
|
position,
|
||||||
config,
|
config,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
resizable = true,
|
resizable = true,
|
||||||
|
hasConflict = false,
|
||||||
|
isMilestone = false,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragComplete,
|
||||||
onDragEnd,
|
onResizeComplete,
|
||||||
onResizeStart,
|
|
||||||
onResizeEnd,
|
|
||||||
}: ScheduleBarProps) {
|
}: ScheduleBarProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
|
const [resizeOffset, setResizeOffset] = useState(0);
|
||||||
|
const [resizeDir, setResizeDir] = useState<"start" | "end">("end");
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startXRef = useRef(0);
|
||||||
|
const movedRef = useRef(false);
|
||||||
|
|
||||||
// 상태에 따른 색상
|
const statusColor =
|
||||||
const statusColor = schedule.color ||
|
schedule.color ||
|
||||||
config.statusColors?.[schedule.status] ||
|
config.statusColors?.[schedule.status] ||
|
||||||
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
||||||
"#3b82f6";
|
"#3b82f6";
|
||||||
|
|
||||||
// 진행률 바 너비
|
const progressWidth =
|
||||||
const progressWidth = config.showProgress && schedule.progress !== undefined
|
config.showProgress && schedule.progress !== undefined
|
||||||
? `${schedule.progress}%`
|
? `${schedule.progress}%`
|
||||||
: "0%";
|
: "0%";
|
||||||
|
|
||||||
// 드래그 시작 핸들러
|
const isEditable = config.editable !== false;
|
||||||
|
|
||||||
|
// ────────── 드래그 핸들러 ──────────
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!draggable || isResizing) return;
|
if (!draggable || isResizing || !isEditable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
movedRef.current = false;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onDragStart?.(schedule, e);
|
setDragOffset(0);
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
// 드래그 중 로직은 부모에서 처리
|
const delta = moveEvent.clientX - startXRef.current;
|
||||||
|
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
setDragOffset(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
const finalDelta = upEvent.clientX - startXRef.current;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragEnd?.();
|
setDragOffset(0);
|
||||||
|
|
||||||
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
onDragComplete?.(schedule, finalDelta);
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
@ -82,25 +102,39 @@ export function ScheduleBar({
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
},
|
},
|
||||||
[draggable, isResizing, schedule, onDragStart, onDragEnd]
|
[draggable, isResizing, isEditable, schedule, onDragComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 시작 핸들러
|
// ────────── 리사이즈 핸들러 ──────────
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeMouseDown = useCallback(
|
||||||
(direction: "start" | "end", e: React.MouseEvent) => {
|
(direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
if (!resizable) return;
|
if (!resizable || !isEditable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
movedRef.current = false;
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
onResizeStart?.(schedule, direction, e);
|
setResizeOffset(0);
|
||||||
|
setResizeDir(direction);
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
// 리사이즈 중 로직은 부모에서 처리
|
const delta = moveEvent.clientX - startXRef.current;
|
||||||
|
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
setResizeOffset(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
const finalDelta = upEvent.clientX - startXRef.current;
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
onResizeEnd?.();
|
setResizeOffset(0);
|
||||||
|
|
||||||
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
onResizeComplete?.(schedule, direction, finalDelta);
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
@ -108,73 +142,125 @@ export function ScheduleBar({
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
},
|
},
|
||||||
[resizable, schedule, onResizeStart, onResizeEnd]
|
[resizable, isEditable, schedule, onResizeComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 클릭 핸들러
|
// ────────── 클릭 핸들러 ──────────
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isDragging || isResizing) return;
|
if (movedRef.current) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.(schedule);
|
onClick?.(schedule);
|
||||||
},
|
},
|
||||||
[isDragging, isResizing, onClick, schedule]
|
[onClick, schedule]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ────────── 드래그/리사이즈 중 시각적 위치 계산 ──────────
|
||||||
|
let visualLeft = position.left;
|
||||||
|
let visualWidth = position.width;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
visualLeft += dragOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
if (resizeDir === "start") {
|
||||||
|
visualLeft += resizeOffset;
|
||||||
|
visualWidth -= resizeOffset;
|
||||||
|
} else {
|
||||||
|
visualWidth += resizeOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visualWidth = Math.max(10, visualWidth);
|
||||||
|
|
||||||
|
// ────────── 마일스톤 렌더링 (단일 날짜 마커) ──────────
|
||||||
|
if (isMilestone) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
className="absolute flex cursor-pointer items-center justify-center"
|
||||||
|
style={{
|
||||||
|
left: visualLeft + position.width / 2 - 8,
|
||||||
|
top: position.top + position.height / 2 - 8,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={schedule.title}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rotate-45 shadow-sm transition-transform hover:scale-125 sm:h-3 sm:w-3"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────── 일반 스케줄 바 렌더링 ──────────
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={barRef}
|
ref={barRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute rounded-md shadow-sm cursor-pointer transition-shadow",
|
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
|
||||||
"hover:shadow-md hover:z-10",
|
"hover:z-10 hover:shadow-md",
|
||||||
isDragging && "opacity-70 shadow-lg z-20",
|
isDragging && "z-20 opacity-70 shadow-lg",
|
||||||
isResizing && "z-20",
|
isResizing && "z-20 opacity-80",
|
||||||
draggable && "cursor-grab",
|
draggable && isEditable && "cursor-grab",
|
||||||
isDragging && "cursor-grabbing"
|
isDragging && "cursor-grabbing",
|
||||||
|
hasConflict && "ring-2 ring-destructive ring-offset-1"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: position.left,
|
left: visualLeft,
|
||||||
top: position.top + 4,
|
top: position.top + 4,
|
||||||
width: position.width,
|
width: visualWidth,
|
||||||
height: position.height - 8,
|
height: position.height - 8,
|
||||||
backgroundColor: statusColor,
|
backgroundColor: statusColor,
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
title={schedule.title}
|
||||||
>
|
>
|
||||||
{/* 진행률 바 */}
|
{/* 진행률 바 */}
|
||||||
{config.showProgress && schedule.progress !== undefined && (
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 left-0 rounded-l-md opacity-30 bg-white"
|
className="absolute inset-y-0 left-0 rounded-l-md bg-white opacity-30"
|
||||||
style={{ width: progressWidth }}
|
style={{ width: progressWidth }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<div className="relative z-10 px-2 py-1 text-xs text-white truncate font-medium">
|
<div className="relative z-10 truncate px-1.5 py-0.5 text-[10px] font-medium text-white sm:px-2 sm:py-1 sm:text-xs">
|
||||||
{schedule.title}
|
{schedule.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 진행률 텍스트 */}
|
{/* 진행률 텍스트 */}
|
||||||
{config.showProgress && schedule.progress !== undefined && (
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-white/80 font-medium">
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-[8px] font-medium text-white/80 sm:right-2 sm:text-[10px]">
|
||||||
{schedule.progress}%
|
{schedule.progress}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 충돌 인디케이터 */}
|
||||||
|
{hasConflict && (
|
||||||
|
<div className="absolute -right-0.5 -top-0.5 sm:-right-1 sm:-top-1">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 fill-destructive text-white sm:h-3 sm:w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 리사이즈 핸들 - 왼쪽 */}
|
{/* 리사이즈 핸들 - 왼쪽 */}
|
||||||
{resizable && (
|
{resizable && isEditable && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-l-md"
|
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
|
||||||
onMouseDown={(e) => handleResizeStart("start", e)}
|
onMouseDown={(e) => handleResizeMouseDown("start", e)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 리사이즈 핸들 - 오른쪽 */}
|
{/* 리사이즈 핸들 - 오른쪽 */}
|
||||||
{resizable && (
|
{resizable && isEditable && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-r-md"
|
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
|
||||||
onMouseDown={(e) => handleResizeStart("end", e)}
|
onMouseDown={(e) => handleResizeMouseDown("end", e)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -140,7 +140,7 @@ export function TimelineHeader({
|
||||||
<div className="flex" style={{ height: headerHeight / 2 }}>
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
{/* 리소스 컬럼 헤더 */}
|
{/* 리소스 컬럼 헤더 */}
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 border-r bg-muted/50 flex items-center justify-center font-medium text-sm"
|
className="flex shrink-0 items-center justify-center border-r bg-muted/50 text-[10px] font-medium sm:text-sm"
|
||||||
style={{ width: resourceColumnWidth }}
|
style={{ width: resourceColumnWidth }}
|
||||||
>
|
>
|
||||||
리소스
|
리소스
|
||||||
|
|
@ -150,7 +150,7 @@ export function TimelineHeader({
|
||||||
{monthGroups.map((group, idx) => (
|
{monthGroups.map((group, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${group.year}-${group.month}-${idx}`}
|
key={`${group.year}-${group.month}-${idx}`}
|
||||||
className="border-r flex items-center justify-center text-xs font-medium text-muted-foreground"
|
className="flex items-center justify-center border-r text-[10px] font-medium text-muted-foreground sm:text-xs"
|
||||||
style={{ width: group.count * cellWidth }}
|
style={{ width: group.count * cellWidth }}
|
||||||
>
|
>
|
||||||
{group.year}년 {group.month}
|
{group.year}년 {group.month}
|
||||||
|
|
@ -162,7 +162,7 @@ export function TimelineHeader({
|
||||||
<div className="flex" style={{ height: headerHeight / 2 }}>
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
{/* 리소스 컬럼 (빈칸) */}
|
{/* 리소스 컬럼 (빈칸) */}
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 border-r bg-muted/50"
|
className="shrink-0 border-r bg-muted/50"
|
||||||
style={{ width: resourceColumnWidth }}
|
style={{ width: resourceColumnWidth }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -171,7 +171,7 @@ export function TimelineHeader({
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r flex items-center justify-center text-xs",
|
"flex items-center justify-center border-r text-[10px] sm:text-xs",
|
||||||
cell.isToday && "bg-primary/10 font-bold text-primary",
|
cell.isToday && "bg-primary/10 font-bold text-primary",
|
||||||
cell.isWeekend && !cell.isToday && "bg-muted/30 text-muted-foreground",
|
cell.isWeekend && !cell.isToday && "bg-muted/30 text-muted-foreground",
|
||||||
cell.isMonthStart && "border-l-2 border-l-primary/30"
|
cell.isMonthStart && "border-l-2 border-l-primary/30"
|
||||||
|
|
@ -186,7 +186,7 @@ export function TimelineHeader({
|
||||||
{/* 오늘 표시선 */}
|
{/* 오늘 표시선 */}
|
||||||
{showTodayLine && todayPosition !== null && (
|
{showTodayLine && todayPosition !== null && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-0.5 bg-primary z-30 pointer-events-none"
|
className="pointer-events-none absolute bottom-0 top-0 z-30 w-0.5 bg-primary"
|
||||||
style={{ left: todayPosition }}
|
style={{ left: todayPosition }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { TimelineSchedulerConfig } from "../types";
|
||||||
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
|
interface TimelineLegendProps {
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineLegend({ config }: TimelineLegendProps) {
|
||||||
|
const colors = config.statusColors || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/20 px-2 py-1 sm:gap-3 sm:px-3 sm:py-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||||
|
범례:
|
||||||
|
</span>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<div key={status.value} className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="h-2 w-4 rounded-sm sm:h-2.5 sm:w-5"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
colors[status.value as keyof typeof colors] || status.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 마일스톤 범례 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex h-2.5 w-4 items-center justify-center sm:h-3 sm:w-5">
|
||||||
|
<div className="h-1.5 w-1.5 rotate-45 bg-foreground/60 sm:h-2 sm:w-2" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
마일스톤
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 충돌 범례 */}
|
||||||
|
{config.showConflicts && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-4 rounded-sm ring-1.5 ring-destructive sm:h-2.5 sm:w-5" />
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
충돌
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
export { TimelineHeader } from "./TimelineHeader";
|
export { TimelineHeader } from "./TimelineHeader";
|
||||||
export { ScheduleBar } from "./ScheduleBar";
|
export { ScheduleBar } from "./ScheduleBar";
|
||||||
export { ResourceRow } from "./ResourceRow";
|
export { ResourceRow } from "./ResourceRow";
|
||||||
|
export { TimelineLegend } from "./TimelineLegend";
|
||||||
|
export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard";
|
||||||
|
export { SchedulePreviewDialog } from "./SchedulePreviewDialog";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types";
|
import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 타임라인 스케줄러 설정
|
* 기본 타임라인 스케줄러 설정
|
||||||
|
|
@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [
|
||||||
{ value: "WORK_ASSIGN", label: "작업배정" },
|
{ value: "WORK_ASSIGN", label: "작업배정" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 뷰 모드 옵션
|
||||||
|
*/
|
||||||
|
export const viewModeOptions: { value: string; label: string; description: string }[] = [
|
||||||
|
{ value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" },
|
||||||
|
{ value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 소스 옵션
|
||||||
|
*/
|
||||||
|
export const dataSourceOptions: { value: string; label: string; description: string }[] = [
|
||||||
|
{ value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" },
|
||||||
|
{ value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아이콘 옵션
|
||||||
|
*/
|
||||||
|
export const toolbarIconOptions: { value: string; label: string }[] = [
|
||||||
|
{ value: "Zap", label: "Zap (번개)" },
|
||||||
|
{ value: "Package", label: "Package (박스)" },
|
||||||
|
{ value: "Plus", label: "Plus (추가)" },
|
||||||
|
{ value: "Download", label: "Download (다운로드)" },
|
||||||
|
{ value: "Upload", label: "Upload (업로드)" },
|
||||||
|
{ value: "RefreshCw", label: "RefreshCw (새로고침)" },
|
||||||
|
{ value: "Play", label: "Play (재생)" },
|
||||||
|
{ value: "FileText", label: "FileText (문서)" },
|
||||||
|
{ value: "Send", label: "Send (전송)" },
|
||||||
|
{ value: "Sparkles", label: "Sparkles (반짝)" },
|
||||||
|
{ value: "Wand2", label: "Wand2 (마법봉)" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 줌 레벨별 표시 일수
|
* 줌 레벨별 표시 일수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,16 @@ export function useTimelineData(
|
||||||
sourceKeys: currentSourceKeys,
|
sourceKeys: currentSourceKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const searchParams: Record<string, any> = {};
|
||||||
|
if (!isScheduleMng && config.staticFilters) {
|
||||||
|
Object.assign(searchParams, config.staticFilters);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10000,
|
size: 10000,
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
|
...(Object.keys(searchParams).length > 0 ? { search: searchParams } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData = response.data?.data?.data || response.data?.data || [];
|
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||||
|
|
@ -195,7 +201,8 @@ export function useTimelineData(
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tableName, externalSchedules, fieldMappingKey, config.scheduleType, JSON.stringify(config.staticFilters)]);
|
||||||
|
|
||||||
// 리소스 데이터 로드
|
// 리소스 데이터 로드
|
||||||
const fetchResources = useCallback(async () => {
|
const fetchResources = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,58 @@ export interface SourceDataConfig {
|
||||||
groupNameField?: string;
|
groupNameField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 툴바 액션 설정 (커스텀 버튼)
|
||||||
|
* 타임라인 툴바에 표시되는 커스텀 액션 버튼을 정의
|
||||||
|
* preview -> confirm -> apply 워크플로우 지원
|
||||||
|
*/
|
||||||
|
export interface ToolbarAction {
|
||||||
|
/** 고유 ID */
|
||||||
|
id: string;
|
||||||
|
/** 버튼 텍스트 */
|
||||||
|
label: string;
|
||||||
|
/** lucide-react 아이콘명 */
|
||||||
|
icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2";
|
||||||
|
/** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */
|
||||||
|
color?: string;
|
||||||
|
/** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */
|
||||||
|
previewApi: string;
|
||||||
|
/** 적용 API 엔드포인트 (예: "/production/generate-schedule") */
|
||||||
|
applyApi: string;
|
||||||
|
/** 다이얼로그 제목 */
|
||||||
|
dialogTitle?: string;
|
||||||
|
/** 다이얼로그 설명 */
|
||||||
|
dialogDescription?: string;
|
||||||
|
/**
|
||||||
|
* 데이터 소스 유형
|
||||||
|
* - linkedSelection: 연결 필터(좌측 테이블)에서 선택된 행 사용
|
||||||
|
* - currentSchedules: 현재 타임라인의 스케줄 ID 사용
|
||||||
|
*/
|
||||||
|
dataSource: "linkedSelection" | "currentSchedules";
|
||||||
|
/** 페이로드 구성 설정 */
|
||||||
|
payloadConfig?: {
|
||||||
|
/** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */
|
||||||
|
groupByField?: string;
|
||||||
|
/** linkedSelection: 수량 합계 필드 (예: "balance_qty") */
|
||||||
|
quantityField?: string;
|
||||||
|
/** linkedSelection: 기준일 필드 (예: "due_date") */
|
||||||
|
dueDateField?: string;
|
||||||
|
/** linkedSelection: 표시명 필드 (예: "part_name") */
|
||||||
|
nameField?: string;
|
||||||
|
/** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */
|
||||||
|
scheduleFilterField?: string;
|
||||||
|
/** currentSchedules: 스케줄 필터 값 (예: "완제품") */
|
||||||
|
scheduleFilterValue?: string;
|
||||||
|
/** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */
|
||||||
|
extraOptions?: Record<string, any>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 표시 조건: staticFilters와 비교하여 모든 조건이 일치할 때만 버튼 표시
|
||||||
|
* 예: { "product_type": "완제품" } → staticFilters.product_type === "완제품"일 때만 표시
|
||||||
|
*/
|
||||||
|
showWhen?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 타임라인 스케줄러 설정
|
* 타임라인 스케줄러 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -144,6 +196,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
/** 커스텀 테이블명 */
|
/** 커스텀 테이블명 */
|
||||||
customTableName?: string;
|
customTableName?: string;
|
||||||
|
|
||||||
|
/** 정적 필터 조건 (커스텀 테이블에서 특정 조건으로 필터링) */
|
||||||
|
staticFilters?: Record<string, string>;
|
||||||
|
|
||||||
/** 리소스 테이블명 (설비/작업자) */
|
/** 리소스 테이블명 (설비/작업자) */
|
||||||
resourceTable?: string;
|
resourceTable?: string;
|
||||||
|
|
||||||
|
|
@ -222,6 +277,38 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
|
|
||||||
/** 최대 높이 */
|
/** 최대 높이 */
|
||||||
maxHeight?: number | string;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 툴바 커스텀 액션 버튼 설정 */
|
||||||
|
toolbarActions?: ToolbarAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ScheduleItem } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 리소스에서 시간이 겹치는 스케줄을 감지
|
||||||
|
* @returns 충돌이 있는 스케줄 ID Set
|
||||||
|
*/
|
||||||
|
export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
|
||||||
|
const conflictIds = new Set<string>();
|
||||||
|
|
||||||
|
// 리소스별로 그룹화
|
||||||
|
const byResource = new Map<string, ScheduleItem[]>();
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
if (!byResource.has(schedule.resourceId)) {
|
||||||
|
byResource.set(schedule.resourceId, []);
|
||||||
|
}
|
||||||
|
byResource.get(schedule.resourceId)!.push(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리소스별 충돌 검사
|
||||||
|
for (const [, resourceSchedules] of byResource) {
|
||||||
|
if (resourceSchedules.length < 2) continue;
|
||||||
|
|
||||||
|
// 시작일 기준 정렬
|
||||||
|
const sorted = [...resourceSchedules].sort(
|
||||||
|
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const aEnd = new Date(sorted[i].endDate).getTime();
|
||||||
|
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
const bStart = new Date(sorted[j].startDate).getTime();
|
||||||
|
|
||||||
|
// 정렬되어 있으므로 aStart <= bStart
|
||||||
|
// 겹치는 조건: aEnd > bStart
|
||||||
|
if (aEnd > bStart) {
|
||||||
|
conflictIds.add(sorted[i].id);
|
||||||
|
conflictIds.add(sorted[j].id);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflictIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 일수만큼 이동
|
||||||
|
*/
|
||||||
|
export function addDaysToDateString(dateStr: string, days: number): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
@ -26,3 +26,4 @@ import "./pop-status-bar";
|
||||||
import "./pop-field";
|
import "./pop-field";
|
||||||
import "./pop-scanner";
|
import "./pop-scanner";
|
||||||
import "./pop-profile";
|
import "./pop-profile";
|
||||||
|
import "./pop-work-detail";
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import type {
|
||||||
TimelineDataSource,
|
TimelineDataSource,
|
||||||
ActionButtonUpdate,
|
ActionButtonUpdate,
|
||||||
ActionButtonClickAction,
|
ActionButtonClickAction,
|
||||||
|
QuantityInputConfig,
|
||||||
StatusValueMapping,
|
StatusValueMapping,
|
||||||
SelectModeConfig,
|
SelectModeConfig,
|
||||||
SelectModeButtonConfig,
|
SelectModeButtonConfig,
|
||||||
|
|
@ -47,15 +48,42 @@ import { screenApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
import { useCartSync } from "@/hooks/pop/useCartSync";
|
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
||||||
import { renderCellV2 } from "./cell-renderers";
|
import { renderCellV2 } from "./cell-renderers";
|
||||||
import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout";
|
import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout";
|
||||||
import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
|
import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
|
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
|
||||||
|
|
||||||
type RowData = Record<string, unknown>;
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
function calculateMaxQty(
|
||||||
|
row: RowData,
|
||||||
|
processId: string | number | undefined,
|
||||||
|
cfg?: QuantityInputConfig,
|
||||||
|
): number {
|
||||||
|
if (!cfg) return 999999;
|
||||||
|
const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999;
|
||||||
|
if (!cfg.currentColumn) return maxVal;
|
||||||
|
|
||||||
|
const processFlow = row.__processFlow__ as Array<{
|
||||||
|
isCurrent: boolean;
|
||||||
|
processId?: string | number;
|
||||||
|
rawData?: Record<string, unknown>;
|
||||||
|
}> | undefined;
|
||||||
|
|
||||||
|
const currentProcess = processId
|
||||||
|
? processFlow?.find((p) => String(p.processId) === String(processId))
|
||||||
|
: processFlow?.find((p) => p.isCurrent);
|
||||||
|
|
||||||
|
if (currentProcess?.rawData) {
|
||||||
|
const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0;
|
||||||
|
return Math.max(0, maxVal - currentVal);
|
||||||
|
}
|
||||||
|
return maxVal;
|
||||||
|
}
|
||||||
|
|
||||||
// cart_items 행 파싱 (pop-card-list에서 그대로 차용)
|
// cart_items 행 파싱 (pop-card-list에서 그대로 차용)
|
||||||
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
|
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
|
||||||
let rowData: Record<string, unknown> = {};
|
let rowData: Record<string, unknown> = {};
|
||||||
|
|
@ -111,8 +139,9 @@ export function PopCardListV2Component({
|
||||||
currentColSpan,
|
currentColSpan,
|
||||||
onRequestResize,
|
onRequestResize,
|
||||||
}: PopCardListV2ComponentProps) {
|
}: PopCardListV2ComponentProps) {
|
||||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { userId: currentUserId } = useAuth();
|
||||||
|
|
||||||
const isCartListMode = config?.cartListMode?.enabled === true;
|
const isCartListMode = config?.cartListMode?.enabled === true;
|
||||||
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListV2Config> | null>(null);
|
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListV2Config> | null>(null);
|
||||||
|
|
@ -216,11 +245,18 @@ export function PopCardListV2Component({
|
||||||
|
|
||||||
// ===== 모달 열기 (POP 화면) =====
|
// ===== 모달 열기 (POP 화면) =====
|
||||||
const [popModalOpen, setPopModalOpen] = useState(false);
|
const [popModalOpen, setPopModalOpen] = useState(false);
|
||||||
const [popModalLayout, setPopModalLayout] = useState<PopLayoutDataV5 | null>(null);
|
const [popModalLayout, setPopModalLayout] = useState<PopLayoutData | null>(null);
|
||||||
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
|
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
|
||||||
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
|
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
|
||||||
|
|
||||||
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
|
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
|
||||||
|
// 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행
|
||||||
|
if (screenIdStr.startsWith("modal-")) {
|
||||||
|
setSharedData("parentRow", row);
|
||||||
|
publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 외부 POP 화면 ID인 경우 기존 fetch 방식
|
||||||
try {
|
try {
|
||||||
const sid = parseInt(screenIdStr, 10);
|
const sid = parseInt(screenIdStr, 10);
|
||||||
if (isNaN(sid)) {
|
if (isNaN(sid)) {
|
||||||
|
|
@ -228,7 +264,7 @@ export function PopCardListV2Component({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const popLayout = await screenApi.getLayoutPop(sid);
|
const popLayout = await screenApi.getLayoutPop(sid);
|
||||||
if (popLayout && isV5Layout(popLayout)) {
|
if (popLayout && isPopLayout(popLayout)) {
|
||||||
setPopModalLayout(popLayout);
|
setPopModalLayout(popLayout);
|
||||||
setPopModalScreenId(String(sid));
|
setPopModalScreenId(String(sid));
|
||||||
setPopModalRow(row);
|
setPopModalRow(row);
|
||||||
|
|
@ -239,7 +275,7 @@ export function PopCardListV2Component({
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("POP 화면을 불러오는데 실패했습니다.");
|
toast.error("POP 화면을 불러오는데 실패했습니다.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [publish, setSharedData]);
|
||||||
|
|
||||||
const handleCardSelect = useCallback((row: RowData) => {
|
const handleCardSelect = useCallback((row: RowData) => {
|
||||||
|
|
||||||
|
|
@ -469,7 +505,7 @@ export function PopCardListV2Component({
|
||||||
type: "data-update" as const,
|
type: "data-update" as const,
|
||||||
targetTable: btnConfig.targetTable!,
|
targetTable: btnConfig.targetTable!,
|
||||||
targetColumn: u.column,
|
targetColumn: u.column,
|
||||||
operationType: "assign" as const,
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||||
valueSource: "fixed" as const,
|
valueSource: "fixed" as const,
|
||||||
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||||
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||||
|
|
@ -619,11 +655,28 @@ export function PopCardListV2Component({
|
||||||
|
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const ownerSortColumn = config?.ownerSortColumn;
|
||||||
|
|
||||||
const displayCards = useMemo(() => {
|
const displayCards = useMemo(() => {
|
||||||
if (!isExpanded) return filteredRows.slice(0, visibleCardCount);
|
let source = filteredRows;
|
||||||
|
|
||||||
|
if (ownerSortColumn && currentUserId) {
|
||||||
|
const mine: RowData[] = [];
|
||||||
|
const others: RowData[] = [];
|
||||||
|
for (const row of source) {
|
||||||
|
if (String(row[ownerSortColumn] ?? "") === currentUserId) {
|
||||||
|
mine.push(row);
|
||||||
|
} else {
|
||||||
|
others.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source = [...mine, ...others];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExpanded) return source.slice(0, visibleCardCount);
|
||||||
const start = (currentPage - 1) * expandedCardsPerPage;
|
const start = (currentPage - 1) * expandedCardsPerPage;
|
||||||
return filteredRows.slice(start, start + expandedCardsPerPage);
|
return source.slice(start, start + expandedCardsPerPage);
|
||||||
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
|
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, currentUserId]);
|
||||||
|
|
||||||
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
|
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
|
||||||
const needsPagination = isExpanded && totalPages > 1;
|
const needsPagination = isExpanded && totalPages > 1;
|
||||||
|
|
@ -756,10 +809,17 @@ export function PopCardListV2Component({
|
||||||
if (firstPending) { firstPending.isCurrent = true; }
|
if (firstPending) { firstPending.isCurrent = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchedRows.map((row) => ({
|
return fetchedRows.map((row) => {
|
||||||
...row,
|
const steps = processMap.get(String(row.id)) || [];
|
||||||
__processFlow__: processMap.get(String(row.id)) || [],
|
const current = steps.find((s) => s.isCurrent);
|
||||||
}));
|
const processFields: Record<string, unknown> = {};
|
||||||
|
if (current?.rawData) {
|
||||||
|
for (const [key, val] of Object.entries(current.rawData)) {
|
||||||
|
processFields[`__process_${key}`] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...row, __processFlow__: steps, ...processFields };
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
|
|
@ -1014,35 +1074,42 @@ export function PopCardListV2Component({
|
||||||
className={`min-h-0 flex-1 grid ${scrollClassName}`}
|
className={`min-h-0 flex-1 grid ${scrollClassName}`}
|
||||||
style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }}
|
style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }}
|
||||||
>
|
>
|
||||||
{displayCards.map((row, index) => (
|
{displayCards.map((row, index) => {
|
||||||
<CardV2
|
const locked = !!ownerSortColumn
|
||||||
key={`card-${index}`}
|
&& !!String(row[ownerSortColumn] ?? "")
|
||||||
row={row}
|
&& String(row[ownerSortColumn] ?? "") !== (currentUserId ?? "");
|
||||||
cardGrid={cardGrid}
|
return (
|
||||||
spec={spec}
|
<CardV2
|
||||||
config={effectiveConfig}
|
key={`card-${index}`}
|
||||||
onSelect={handleCardSelect}
|
row={row}
|
||||||
cart={cart}
|
cardGrid={cardGrid}
|
||||||
publish={publish}
|
spec={spec}
|
||||||
parentComponentId={componentId}
|
config={effectiveConfig}
|
||||||
isCartListMode={isCartListMode}
|
onSelect={handleCardSelect}
|
||||||
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
|
cart={cart}
|
||||||
onToggleSelect={() => {
|
publish={publish}
|
||||||
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
parentComponentId={componentId}
|
||||||
if (!cartId) return;
|
isCartListMode={isCartListMode}
|
||||||
setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; });
|
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
|
||||||
}}
|
onToggleSelect={() => {
|
||||||
onDeleteItem={handleDeleteItem}
|
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||||
onUpdateQuantity={handleUpdateQuantity}
|
if (!cartId) return;
|
||||||
onRefresh={fetchData}
|
setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; });
|
||||||
selectMode={selectMode}
|
}}
|
||||||
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
|
onDeleteItem={handleDeleteItem}
|
||||||
isSelectable={isRowSelectable(row)}
|
onUpdateQuantity={handleUpdateQuantity}
|
||||||
onToggleRowSelect={() => toggleRowSelection(row)}
|
onRefresh={fetchData}
|
||||||
onEnterSelectMode={enterSelectMode}
|
selectMode={selectMode}
|
||||||
onOpenPopModal={openPopModal}
|
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
|
||||||
/>
|
isSelectable={isRowSelectable(row)}
|
||||||
))}
|
onToggleRowSelect={() => toggleRowSelection(row)}
|
||||||
|
onEnterSelectMode={enterSelectMode}
|
||||||
|
onOpenPopModal={openPopModal}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isLockedByOther={locked}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택 모드 하단 액션 바 */}
|
{/* 선택 모드 하단 액션 바 */}
|
||||||
|
|
@ -1116,6 +1183,7 @@ export function PopCardListV2Component({
|
||||||
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
|
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
|
||||||
screenId={popModalScreenId}
|
screenId={popModalScreenId}
|
||||||
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
|
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
|
||||||
|
parentRow={popModalRow ?? undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1148,6 +1216,8 @@ interface CardV2Props {
|
||||||
onToggleRowSelect?: () => void;
|
onToggleRowSelect?: () => void;
|
||||||
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||||
onOpenPopModal?: (screenId: string, row: RowData) => void;
|
onOpenPopModal?: (screenId: string, row: RowData) => void;
|
||||||
|
currentUserId?: string;
|
||||||
|
isLockedByOther?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardV2({
|
function CardV2({
|
||||||
|
|
@ -1155,7 +1225,7 @@ function CardV2({
|
||||||
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
||||||
onDeleteItem, onUpdateQuantity, onRefresh,
|
onDeleteItem, onUpdateQuantity, onRefresh,
|
||||||
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
||||||
onOpenPopModal,
|
onOpenPopModal, currentUserId, isLockedByOther,
|
||||||
}: CardV2Props) {
|
}: CardV2Props) {
|
||||||
const inputField = config?.inputField;
|
const inputField = config?.inputField;
|
||||||
const cartAction = config?.cartAction;
|
const cartAction = config?.cartAction;
|
||||||
|
|
@ -1167,6 +1237,72 @@ function CardV2({
|
||||||
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [qtyModalState, setQtyModalState] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
row: RowData;
|
||||||
|
processId?: string | number;
|
||||||
|
action: ActionButtonClickAction;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleQtyConfirm = useCallback(async (value: number) => {
|
||||||
|
if (!qtyModalState) return;
|
||||||
|
const { row: actionRow, processId: qtyProcessId, action } = qtyModalState;
|
||||||
|
setQtyModalState(null);
|
||||||
|
if (!action.targetTable || !action.updates) return;
|
||||||
|
|
||||||
|
const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk;
|
||||||
|
if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; }
|
||||||
|
|
||||||
|
const lookupValue = action.joinConfig
|
||||||
|
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
|
||||||
|
: rowId;
|
||||||
|
const lookupColumn = action.joinConfig?.targetColumn || "id";
|
||||||
|
|
||||||
|
const tasks = action.updates.map((u, idx) => ({
|
||||||
|
id: `qty-update-${idx}`,
|
||||||
|
type: "data-update" as const,
|
||||||
|
targetTable: action.targetTable!,
|
||||||
|
targetColumn: u.column,
|
||||||
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||||
|
valueSource: "fixed" as const,
|
||||||
|
fixedValue: u.valueType === "userInput" ? String(value) :
|
||||||
|
u.valueType === "static" ? (u.value ?? "") :
|
||||||
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||||
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
||||||
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||||
|
(u.value ?? ""),
|
||||||
|
lookupMode: "manual" as const,
|
||||||
|
manualItemField: lookupColumn,
|
||||||
|
manualPkColumn: lookupColumn,
|
||||||
|
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const targetRow = action.joinConfig
|
||||||
|
? { ...actionRow, [lookupColumn]: lookupValue }
|
||||||
|
: qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks,
|
||||||
|
data: { items: [targetRow], fieldValues: {} },
|
||||||
|
mappings: {},
|
||||||
|
});
|
||||||
|
if (result.data?.success) {
|
||||||
|
toast.success(result.data.message || "처리 완료");
|
||||||
|
onRefresh?.();
|
||||||
|
} else {
|
||||||
|
toast.error(result.data?.message || "처리 실패");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as any)?.response?.status === 409) {
|
||||||
|
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
||||||
|
onRefresh?.();
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [qtyModalState, onRefresh]);
|
||||||
|
|
||||||
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
||||||
const isCarted = cart.isItemInCart(rowKey);
|
const isCarted = cart.isItemInCart(rowKey);
|
||||||
const existingCartItem = cart.getCartItem(rowKey);
|
const existingCartItem = cart.getCartItem(rowKey);
|
||||||
|
|
@ -1272,16 +1408,24 @@ function CardV2({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
className={cn(
|
||||||
|
"relative flex flex-col rounded-lg border bg-card shadow-sm transition-all duration-150",
|
||||||
|
isLockedByOther
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "cursor-pointer hover:shadow-md",
|
||||||
|
borderClass,
|
||||||
|
)}
|
||||||
style={{ minHeight: `${spec.height}px` }}
|
style={{ minHeight: `${spec.height}px` }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isLockedByOther) return;
|
||||||
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
||||||
if (!selectMode) onSelect?.(row);
|
if (!selectMode) onSelect?.(row);
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={isLockedByOther ? -1 : 0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (isLockedByOther) return;
|
||||||
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
||||||
if (!selectMode) onSelect?.(row);
|
if (!selectMode) onSelect?.(row);
|
||||||
}
|
}
|
||||||
|
|
@ -1365,7 +1509,11 @@ function CardV2({
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const action of actionsToRun) {
|
for (const action of actionsToRun) {
|
||||||
if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
|
if (action.type === "quantity-input" && action.targetTable && action.updates) {
|
||||||
|
if (action.confirmMessage && !window.confirm(action.confirmMessage)) return;
|
||||||
|
setQtyModalState({ open: true, row: actionRow, processId, action });
|
||||||
|
return;
|
||||||
|
} else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
|
||||||
if (action.confirmMessage) {
|
if (action.confirmMessage) {
|
||||||
if (!window.confirm(action.confirmMessage)) return;
|
if (!window.confirm(action.confirmMessage)) return;
|
||||||
}
|
}
|
||||||
|
|
@ -1381,7 +1529,7 @@ function CardV2({
|
||||||
type: "data-update" as const,
|
type: "data-update" as const,
|
||||||
targetTable: action.targetTable!,
|
targetTable: action.targetTable!,
|
||||||
targetColumn: u.column,
|
targetColumn: u.column,
|
||||||
operationType: "assign" as const,
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
||||||
valueSource: "fixed" as const,
|
valueSource: "fixed" as const,
|
||||||
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||||
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||||
|
|
@ -1391,6 +1539,7 @@ function CardV2({
|
||||||
lookupMode: "manual" as const,
|
lookupMode: "manual" as const,
|
||||||
manualItemField: lookupColumn,
|
manualItemField: lookupColumn,
|
||||||
manualPkColumn: lookupColumn,
|
manualPkColumn: lookupColumn,
|
||||||
|
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
||||||
}));
|
}));
|
||||||
const targetRow = action.joinConfig
|
const targetRow = action.joinConfig
|
||||||
? { ...actionRow, [lookupColumn]: lookupValue }
|
? { ...actionRow, [lookupColumn]: lookupValue }
|
||||||
|
|
@ -1408,7 +1557,12 @@ function CardV2({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
if ((err as any)?.response?.status === 409) {
|
||||||
|
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
||||||
|
onRefresh?.();
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (action.type === "modal-open" && action.modalScreenId) {
|
} else if (action.type === "modal-open" && action.modalScreenId) {
|
||||||
|
|
@ -1418,6 +1572,7 @@ function CardV2({
|
||||||
},
|
},
|
||||||
packageEntries,
|
packageEntries,
|
||||||
inputUnit: inputField?.unit,
|
inputUnit: inputField?.unit,
|
||||||
|
currentUserId,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1437,6 +1592,17 @@ function CardV2({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{qtyModalState?.open && (
|
||||||
|
<NumberInputModal
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => { if (!open) setQtyModalState(null); }}
|
||||||
|
unit={qtyModalState.action.quantityInput?.unit || "EA"}
|
||||||
|
maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
|
||||||
|
showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false}
|
||||||
|
onConfirm={(value) => handleQtyConfirm(value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -65,6 +66,33 @@ import {
|
||||||
type ColumnInfo,
|
type ColumnInfo,
|
||||||
} from "../pop-dashboard/utils/dataFetcher";
|
} from "../pop-dashboard/utils/dataFetcher";
|
||||||
|
|
||||||
|
// ===== 컬럼 옵션 그룹 =====
|
||||||
|
|
||||||
|
interface ColumnOptionGroup {
|
||||||
|
groupLabel: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumnOptionGroups(groups: ColumnOptionGroup[]) {
|
||||||
|
if (groups.length <= 1) {
|
||||||
|
return groups.flatMap((g) =>
|
||||||
|
g.options.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
.filter((g) => g.options.length > 0)
|
||||||
|
.map((g) => (
|
||||||
|
<SelectGroup key={g.groupLabel}>
|
||||||
|
<SelectLabel className="text-[9px] font-semibold text-muted-foreground px-2 py-1">{g.groupLabel}</SelectLabel>
|
||||||
|
{g.options.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Props =====
|
// ===== Props =====
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
|
|
@ -271,6 +299,7 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
|
||||||
<TabActions
|
<TabActions
|
||||||
cfg={cfg}
|
cfg={cfg}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
|
columns={columns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -759,10 +788,36 @@ function TabCardDesign({
|
||||||
sourceTable: j.targetTable,
|
sourceTable: j.targetTable,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const allColumnOptions = [
|
|
||||||
...availableColumns.map((c) => ({ value: c.name, label: c.name })),
|
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
|
||||||
...joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
|
const timelineCell = cfg.cardGrid.cells.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
|
||||||
|
const processTableName = timelineCell?.timelineSource?.processTable || "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processTableName) { setProcessColumns([]); return; }
|
||||||
|
fetchTableColumns(processTableName)
|
||||||
|
.then(setProcessColumns)
|
||||||
|
.catch(() => setProcessColumns([]));
|
||||||
|
}, [processTableName]);
|
||||||
|
|
||||||
|
const columnOptionGroups: ColumnOptionGroup[] = [
|
||||||
|
{
|
||||||
|
groupLabel: `메인 (${cfg.dataSource.tableName || "테이블"})`,
|
||||||
|
options: availableColumns.map((c) => ({ value: c.name, label: c.name })),
|
||||||
|
},
|
||||||
|
...(joinedColumns.length > 0
|
||||||
|
? [{
|
||||||
|
groupLabel: "조인",
|
||||||
|
options: joinedColumns.map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})` })),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(processColumns.length > 0
|
||||||
|
? [{
|
||||||
|
groupLabel: `공정 (${processTableName})`,
|
||||||
|
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
const allColumnOptions = columnOptionGroups.flatMap((g) => g.options);
|
||||||
|
|
||||||
const [selectedCellId, setSelectedCellId] = useState<string | null>(null);
|
const [selectedCellId, setSelectedCellId] = useState<string | null>(null);
|
||||||
const [mergeMode, setMergeMode] = useState(false);
|
const [mergeMode, setMergeMode] = useState(false);
|
||||||
|
|
@ -1273,6 +1328,7 @@ function TabCardDesign({
|
||||||
cell={selectedCell}
|
cell={selectedCell}
|
||||||
allCells={grid.cells}
|
allCells={grid.cells}
|
||||||
allColumnOptions={allColumnOptions}
|
allColumnOptions={allColumnOptions}
|
||||||
|
columnOptionGroups={columnOptionGroups}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
selectedColumns={selectedColumns}
|
selectedColumns={selectedColumns}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
|
|
@ -1291,6 +1347,7 @@ function CellDetailEditor({
|
||||||
cell,
|
cell,
|
||||||
allCells,
|
allCells,
|
||||||
allColumnOptions,
|
allColumnOptions,
|
||||||
|
columnOptionGroups,
|
||||||
columns,
|
columns,
|
||||||
selectedColumns,
|
selectedColumns,
|
||||||
tables,
|
tables,
|
||||||
|
|
@ -1301,6 +1358,7 @@ function CellDetailEditor({
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
allCells: CardCellDefinitionV2[];
|
allCells: CardCellDefinitionV2[];
|
||||||
allColumnOptions: { value: string; label: string }[];
|
allColumnOptions: { value: string; label: string }[];
|
||||||
|
columnOptionGroups: ColumnOptionGroup[];
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
selectedColumns: string[];
|
selectedColumns: string[];
|
||||||
tables: TableInfo[];
|
tables: TableInfo[];
|
||||||
|
|
@ -1348,9 +1406,7 @@ function CellDetailEditor({
|
||||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none" className="text-[10px]">미지정</SelectItem>
|
<SelectItem value="none" className="text-[10px]">미지정</SelectItem>
|
||||||
{allColumnOptions.map((o) => (
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1417,9 +1473,9 @@ function CellDetailEditor({
|
||||||
{/* 타입별 상세 설정 */}
|
{/* 타입별 상세 설정 */}
|
||||||
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
|
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
|
||||||
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
||||||
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
|
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allCells={allCells} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} availableTableOptions={availableTableOptions} onUpdate={onUpdate} />}
|
||||||
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
|
||||||
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
{cell.type === "field" && <FieldConfigEditor cell={cell} allColumnOptions={allColumnOptions} columnOptionGroups={columnOptionGroups} onUpdate={onUpdate} />}
|
||||||
{cell.type === "number-input" && (
|
{cell.type === "number-input" && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[9px] font-medium text-muted-foreground">숫자 입력 설정</span>
|
<span className="text-[9px] font-medium text-muted-foreground">숫자 입력 설정</span>
|
||||||
|
|
@ -1429,7 +1485,7 @@ function CellDetailEditor({
|
||||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="제한 컬럼" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
||||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1809,12 +1865,14 @@ function ActionButtonsEditor({
|
||||||
cell,
|
cell,
|
||||||
allCells,
|
allCells,
|
||||||
allColumnOptions,
|
allColumnOptions,
|
||||||
|
columnOptionGroups,
|
||||||
availableTableOptions,
|
availableTableOptions,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
allCells: CardCellDefinitionV2[];
|
allCells: CardCellDefinitionV2[];
|
||||||
allColumnOptions: { value: string; label: string }[];
|
allColumnOptions: { value: string; label: string }[];
|
||||||
|
columnOptionGroups: ColumnOptionGroup[];
|
||||||
availableTableOptions: { value: string; label: string }[];
|
availableTableOptions: { value: string; label: string }[];
|
||||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1975,7 +2033,7 @@ function ActionButtonsEditor({
|
||||||
|
|
||||||
const isSectionOpen = (key: string) => expandedSections[key] !== false;
|
const isSectionOpen = (key: string) => expandedSections[key] !== false;
|
||||||
|
|
||||||
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기" };
|
const ACTION_TYPE_LABELS: Record<string, string> = { immediate: "즉시 실행", "select-mode": "선택 후 실행", "modal-open": "모달 열기", "quantity-input": "수량 입력" };
|
||||||
|
|
||||||
const getCondSummary = (btn: ActionButtonDef) => {
|
const getCondSummary = (btn: ActionButtonDef) => {
|
||||||
const c = btn.showCondition;
|
const c = btn.showCondition;
|
||||||
|
|
@ -1985,6 +2043,7 @@ function ActionButtonsEditor({
|
||||||
return opt ? opt.label : (c.value || "미설정");
|
return opt ? opt.label : (c.value || "미설정");
|
||||||
}
|
}
|
||||||
if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`;
|
if (c.type === "column-value") return `${c.column || "?"} = ${c.value || "?"}`;
|
||||||
|
if (c.type === "owner-match") return `소유자(${c.column || "?"})`;
|
||||||
return "항상";
|
return "항상";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2081,8 +2140,21 @@ function ActionButtonsEditor({
|
||||||
<SelectItem value="always" className="text-[10px]">항상</SelectItem>
|
<SelectItem value="always" className="text-[10px]">항상</SelectItem>
|
||||||
<SelectItem value="timeline-status" className="text-[10px]">타임라인</SelectItem>
|
<SelectItem value="timeline-status" className="text-[10px]">타임라인</SelectItem>
|
||||||
<SelectItem value="column-value" className="text-[10px]">카드 컬럼</SelectItem>
|
<SelectItem value="column-value" className="text-[10px]">카드 컬럼</SelectItem>
|
||||||
|
<SelectItem value="owner-match" className="text-[10px]">소유자 일치</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{condType === "owner-match" && (
|
||||||
|
<Select
|
||||||
|
value={btn.showCondition?.column || "__none__"}
|
||||||
|
onValueChange={(v) => updateCondition(bi, { column: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||||
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
{condType === "timeline-status" && (
|
{condType === "timeline-status" && (
|
||||||
<Select
|
<Select
|
||||||
value={btn.showCondition?.value || "__none__"}
|
value={btn.showCondition?.value || "__none__"}
|
||||||
|
|
@ -2106,9 +2178,7 @@ function ActionButtonsEditor({
|
||||||
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
<SelectTrigger className="h-6 w-24 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||||
{allColumnOptions.map((o) => (
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -2168,6 +2238,7 @@ function ActionButtonsEditor({
|
||||||
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="immediate" className="text-[10px]">즉시 실행</SelectItem>
|
<SelectItem value="immediate" className="text-[10px]">즉시 실행</SelectItem>
|
||||||
|
<SelectItem value="quantity-input" className="text-[10px]">수량 입력</SelectItem>
|
||||||
<SelectItem value="select-mode" className="text-[10px]">선택 후 실행</SelectItem>
|
<SelectItem value="select-mode" className="text-[10px]">선택 후 실행</SelectItem>
|
||||||
<SelectItem value="modal-open" className="text-[10px]">모달 열기</SelectItem>
|
<SelectItem value="modal-open" className="text-[10px]">모달 열기</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -2191,6 +2262,50 @@ function ActionButtonsEditor({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{aType === "quantity-input" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<ImmediateActionEditor
|
||||||
|
action={action}
|
||||||
|
allColumnOptions={allColumnOptions}
|
||||||
|
availableTableOptions={availableTableOptions}
|
||||||
|
onAddUpdate={() => addActionUpdate(bi, ai)}
|
||||||
|
onUpdateUpdate={(ui, p) => updateActionUpdate(bi, ai, ui, p)}
|
||||||
|
onRemoveUpdate={(ui) => removeActionUpdate(bi, ai, ui)}
|
||||||
|
onUpdateAction={(p) => updateAction(bi, ai, p)}
|
||||||
|
/>
|
||||||
|
<div className="rounded border bg-background/50 p-1.5 space-y-1">
|
||||||
|
<span className="text-[8px] font-medium text-muted-foreground">수량 모달 설정</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">최대값 컬럼</span>
|
||||||
|
<Input
|
||||||
|
value={action.quantityInput?.maxColumn || ""}
|
||||||
|
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, maxColumn: e.target.value } })}
|
||||||
|
placeholder="예: qty"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">현재값 컬럼</span>
|
||||||
|
<Input
|
||||||
|
value={action.quantityInput?.currentColumn || ""}
|
||||||
|
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, currentColumn: e.target.value } })}
|
||||||
|
placeholder="예: input_qty"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">단위</span>
|
||||||
|
<Input
|
||||||
|
value={action.quantityInput?.unit || ""}
|
||||||
|
onChange={(e) => updateAction(bi, ai, { quantityInput: { ...action.quantityInput, unit: e.target.value } })}
|
||||||
|
placeholder="예: EA"
|
||||||
|
className="h-6 w-20 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{aType === "select-mode" && (
|
{aType === "select-mode" && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -2455,6 +2570,70 @@ function ImmediateActionEditor({
|
||||||
className="h-6 flex-1 text-[10px]"
|
className="h-6 flex-1 text-[10px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 사전 조건 (중복 방지) */}
|
||||||
|
<div className="rounded border bg-background/50 p-1.5 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[8px] font-medium text-muted-foreground">사전 조건 (중복 방지)</span>
|
||||||
|
<Switch
|
||||||
|
checked={!!action.preCondition}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
onUpdateAction({ preCondition: { column: "", expectedValue: "", failMessage: "" } });
|
||||||
|
} else {
|
||||||
|
onUpdateAction({ preCondition: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-7 [&>span]:h-2.5 [&>span]:w-2.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{action.preCondition && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">검증 컬럼</span>
|
||||||
|
<Select
|
||||||
|
value={action.preCondition.column || "__none__"}
|
||||||
|
onValueChange={(v) => onUpdateAction({ preCondition: { ...action.preCondition!, column: v === "__none__" ? "" : v } })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-[10px]"><SelectValue placeholder="컬럼" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-[10px]">선택</SelectItem>
|
||||||
|
{businessCols.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel className="text-[8px] text-muted-foreground">{tableName}</SelectLabel>
|
||||||
|
{businessCols.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">기대값</span>
|
||||||
|
<Input
|
||||||
|
value={action.preCondition.expectedValue || ""}
|
||||||
|
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, expectedValue: e.target.value } })}
|
||||||
|
placeholder="예: waiting"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-14 shrink-0 text-[8px] text-muted-foreground">실패 메시지</span>
|
||||||
|
<Input
|
||||||
|
value={action.preCondition.failMessage || ""}
|
||||||
|
onChange={(e) => onUpdateAction({ preCondition: { ...action.preCondition!, failMessage: e.target.value } })}
|
||||||
|
placeholder="이미 다른 사용자가 처리했습니다"
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[7px] text-muted-foreground/70 pl-0.5">
|
||||||
|
실행 시 해당 컬럼의 현재 DB 값이 기대값과 일치할 때만 처리됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[8px] font-medium text-muted-foreground">
|
<span className="text-[8px] font-medium text-muted-foreground">
|
||||||
변경할 컬럼{tableName ? ` (${tableName})` : ""}
|
변경할 컬럼{tableName ? ` (${tableName})` : ""}
|
||||||
|
|
@ -2491,11 +2670,22 @@ function ImmediateActionEditor({
|
||||||
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-6 w-20 text-[10px]"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static" className="text-[10px]">직접입력</SelectItem>
|
<SelectItem value="static" className="text-[10px]">직접입력</SelectItem>
|
||||||
|
<SelectItem value="userInput" className="text-[10px]">사용자 입력</SelectItem>
|
||||||
<SelectItem value="currentUser" className="text-[10px]">현재 사용자</SelectItem>
|
<SelectItem value="currentUser" className="text-[10px]">현재 사용자</SelectItem>
|
||||||
<SelectItem value="currentTime" className="text-[10px]">현재 시간</SelectItem>
|
<SelectItem value="currentTime" className="text-[10px]">현재 시간</SelectItem>
|
||||||
<SelectItem value="columnRef" className="text-[10px]">컬럼 참조</SelectItem>
|
<SelectItem value="columnRef" className="text-[10px]">컬럼 참조</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{u.valueType === "userInput" && (
|
||||||
|
<Select value={u.operationType || "assign"} onValueChange={(v) => onUpdateUpdate(ui, { operationType: v as ActionButtonUpdate["operationType"] })}>
|
||||||
|
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="assign" className="text-[10px]">대입</SelectItem>
|
||||||
|
<SelectItem value="add" className="text-[10px]">합산</SelectItem>
|
||||||
|
<SelectItem value="subtract" className="text-[10px]">차감</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
{(u.valueType === "static" || u.valueType === "columnRef") && (
|
{(u.valueType === "static" || u.valueType === "columnRef") && (
|
||||||
<Input
|
<Input
|
||||||
value={u.value || ""}
|
value={u.value || ""}
|
||||||
|
|
@ -2608,10 +2798,12 @@ function DbTableCombobox({
|
||||||
function FooterStatusEditor({
|
function FooterStatusEditor({
|
||||||
cell,
|
cell,
|
||||||
allColumnOptions,
|
allColumnOptions,
|
||||||
|
columnOptionGroups,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
allColumnOptions: { value: string; label: string }[];
|
allColumnOptions: { value: string; label: string }[];
|
||||||
|
columnOptionGroups: ColumnOptionGroup[];
|
||||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||||
}) {
|
}) {
|
||||||
const footerStatusMap = cell.footerStatusMap || [];
|
const footerStatusMap = cell.footerStatusMap || [];
|
||||||
|
|
@ -2644,7 +2836,7 @@ function FooterStatusEditor({
|
||||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="상태 컬럼" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
<SelectItem value="__none__" className="text-[10px]">없음</SelectItem>
|
||||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2680,10 +2872,12 @@ function FooterStatusEditor({
|
||||||
function FieldConfigEditor({
|
function FieldConfigEditor({
|
||||||
cell,
|
cell,
|
||||||
allColumnOptions,
|
allColumnOptions,
|
||||||
|
columnOptionGroups,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
allColumnOptions: { value: string; label: string }[];
|
allColumnOptions: { value: string; label: string }[];
|
||||||
|
columnOptionGroups: ColumnOptionGroup[];
|
||||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||||
}) {
|
}) {
|
||||||
const valueType = cell.valueType || "column";
|
const valueType = cell.valueType || "column";
|
||||||
|
|
@ -2706,7 +2900,7 @@ function FieldConfigEditor({
|
||||||
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
|
<Select value={cell.formulaLeft || ""} onValueChange={(v) => onUpdate({ formulaLeft: v })}>
|
||||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="좌항" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
|
<Select value={cell.formulaOperator || "+"} onValueChange={(v) => onUpdate({ formulaOperator: v as "+" | "-" | "*" | "/" })}>
|
||||||
|
|
@ -2726,7 +2920,7 @@ function FieldConfigEditor({
|
||||||
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
|
<Select value={cell.formulaRight || ""} onValueChange={(v) => onUpdate({ formulaRight: v })}>
|
||||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="우항" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allColumnOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>)}
|
{renderColumnOptionGroups(columnOptionGroups)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2741,16 +2935,62 @@ function FieldConfigEditor({
|
||||||
function TabActions({
|
function TabActions({
|
||||||
cfg,
|
cfg,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
columns,
|
||||||
}: {
|
}: {
|
||||||
cfg: PopCardListV2Config;
|
cfg: PopCardListV2Config;
|
||||||
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
|
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
|
||||||
|
columns: ColumnInfo[];
|
||||||
}) {
|
}) {
|
||||||
|
const designerCtx = usePopDesignerContext();
|
||||||
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
|
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
|
||||||
const clickAction = cfg.cardClickAction || "none";
|
const clickAction = cfg.cardClickAction || "none";
|
||||||
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
|
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
|
||||||
|
|
||||||
|
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
|
||||||
|
const processTableName = timelineCell?.timelineSource?.processTable || "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processTableName) { setProcessColumns([]); return; }
|
||||||
|
fetchTableColumns(processTableName)
|
||||||
|
.then(setProcessColumns)
|
||||||
|
.catch(() => setProcessColumns([]));
|
||||||
|
}, [processTableName]);
|
||||||
|
|
||||||
|
const ownerColumnGroups: ColumnOptionGroup[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
groupLabel: `메인 (${cfg.dataSource?.tableName || "테이블"})`,
|
||||||
|
options: columns.map((c) => ({ value: c.name, label: c.name })),
|
||||||
|
},
|
||||||
|
...(processColumns.length > 0
|
||||||
|
? [{
|
||||||
|
groupLabel: `공정 (${processTableName})`,
|
||||||
|
options: processColumns.map((c) => ({ value: `__process_${c.name}`, label: c.name })),
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
], [columns, processColumns, processTableName, cfg.dataSource?.tableName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* 소유자 우선 정렬 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소유자 우선 정렬</Label>
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={cfg.ownerSortColumn || "__none__"}
|
||||||
|
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-[10px]">사용 안 함</SelectItem>
|
||||||
|
{renderColumnOptionGroups(ownerColumnGroups)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
||||||
|
선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 카드 선택 시 */}
|
{/* 카드 선택 시 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">카드 선택 시 동작</Label>
|
<Label className="text-xs">카드 선택 시 동작</Label>
|
||||||
|
|
@ -2775,15 +3015,52 @@ function TabActions({
|
||||||
</div>
|
</div>
|
||||||
{clickAction === "modal-open" && (
|
{clickAction === "modal-open" && (
|
||||||
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
|
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
|
||||||
<div className="flex items-center gap-1">
|
{/* 모달 캔버스 (디자이너 모드) */}
|
||||||
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP 화면 ID</span>
|
{designerCtx && (
|
||||||
<Input
|
<div>
|
||||||
value={modalConfig.screenId || ""}
|
{modalConfig.screenId?.startsWith("modal-") ? (
|
||||||
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
|
<Button
|
||||||
placeholder="화면 ID (예: 4481)"
|
variant="outline"
|
||||||
className="h-7 flex-1 text-[10px]"
|
size="sm"
|
||||||
/>
|
className="h-7 w-full text-[10px]"
|
||||||
</div>
|
onClick={() => designerCtx.navigateToCanvas(modalConfig.screenId)}
|
||||||
|
>
|
||||||
|
모달 캔버스 열기
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
const selectedId = designerCtx.selectedComponentId;
|
||||||
|
if (!selectedId) return;
|
||||||
|
const modalId = designerCtx.createModalCanvas(
|
||||||
|
selectedId,
|
||||||
|
modalConfig.modalTitle || "카드 상세"
|
||||||
|
);
|
||||||
|
onUpdate({
|
||||||
|
cardClickModalConfig: { ...modalConfig, screenId: modalId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
모달 캔버스 생성
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 뷰어 모드 또는 직접 입력 폴백 */}
|
||||||
|
{!designerCtx && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 ID</span>
|
||||||
|
<Input
|
||||||
|
value={modalConfig.screenId || ""}
|
||||||
|
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
|
||||||
|
placeholder="모달 ID"
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 제목</span>
|
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 제목</span>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export interface CellRendererProps {
|
||||||
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||||
packageEntries?: PackageEntry[];
|
packageEntries?: PackageEntry[];
|
||||||
inputUnit?: string;
|
inputUnit?: string;
|
||||||
|
currentUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 메인 디스패치 =====
|
// ===== 메인 디스패치 =====
|
||||||
|
|
@ -592,7 +593,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
|
||||||
|
|
||||||
// ===== 11. action-buttons =====
|
// ===== 11. action-buttons =====
|
||||||
|
|
||||||
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
|
function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId?: string): "visible" | "disabled" | "hidden" {
|
||||||
const cond = btn.showCondition;
|
const cond = btn.showCondition;
|
||||||
if (!cond || cond.type === "always") return "visible";
|
if (!cond || cond.type === "always") return "visible";
|
||||||
|
|
||||||
|
|
@ -603,6 +604,9 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
|
||||||
matched = subStatus !== undefined && String(subStatus) === cond.value;
|
matched = subStatus !== undefined && String(subStatus) === cond.value;
|
||||||
} else if (cond.type === "column-value" && cond.column) {
|
} else if (cond.type === "column-value" && cond.column) {
|
||||||
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
|
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
|
||||||
|
} else if (cond.type === "owner-match" && cond.column) {
|
||||||
|
const ownerValue = String(row[cond.column] ?? "");
|
||||||
|
matched = !!currentUserId && ownerValue === currentUserId;
|
||||||
} else {
|
} else {
|
||||||
return "visible";
|
return "visible";
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +615,7 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" |
|
||||||
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
|
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
|
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) {
|
||||||
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
||||||
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
||||||
const currentProcessId = currentProcess?.processId;
|
const currentProcessId = currentProcess?.processId;
|
||||||
|
|
@ -619,7 +623,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }
|
||||||
if (cell.actionButtons && cell.actionButtons.length > 0) {
|
if (cell.actionButtons && cell.actionButtons.length > 0) {
|
||||||
const evaluated = cell.actionButtons.map((btn) => ({
|
const evaluated = cell.actionButtons.map((btn) => ({
|
||||||
btn,
|
btn,
|
||||||
state: evaluateShowCondition(btn, row),
|
state: evaluateShowCondition(btn, row, currentUserId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const activeBtn = evaluated.find((e) => e.state === "visible");
|
const activeBtn = evaluated.find((e) => e.state === "visible");
|
||||||
|
|
|
||||||
|
|
@ -295,8 +295,8 @@ function BasicSettingsTab({
|
||||||
const recommendation = useMemo(() => {
|
const recommendation = useMemo(() => {
|
||||||
if (!currentMode) return null;
|
if (!currentMode) return null;
|
||||||
const cols = GRID_BREAKPOINTS[currentMode].columns;
|
const cols = GRID_BREAKPOINTS[currentMode].columns;
|
||||||
if (cols >= 8) return { rows: 3, cols: 2 };
|
if (cols >= 25) return { rows: 3, cols: 2 };
|
||||||
if (cols >= 6) return { rows: 3, cols: 1 };
|
if (cols >= 18) return { rows: 3, cols: 1 };
|
||||||
return { rows: 2, cols: 1 };
|
return { rows: 2, cols: 1 };
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
|
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
|
||||||
import type {
|
import type {
|
||||||
PopDataConnection,
|
PopDataConnection,
|
||||||
PopComponentDefinitionV5,
|
PopComponentDefinition,
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -99,7 +99,7 @@ function parseScanResult(
|
||||||
function getConnectedFields(
|
function getConnectedFields(
|
||||||
componentId?: string,
|
componentId?: string,
|
||||||
connections?: PopDataConnection[],
|
connections?: PopDataConnection[],
|
||||||
allComponents?: PopComponentDefinitionV5[],
|
allComponents?: PopComponentDefinition[],
|
||||||
): ConnectedFieldInfo[] {
|
): ConnectedFieldInfo[] {
|
||||||
if (!componentId || !connections || !allComponents) return [];
|
if (!componentId || !connections || !allComponents) return [];
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ const PARSE_MODE_LABELS: Record<string, string> = {
|
||||||
interface PopScannerConfigPanelProps {
|
interface PopScannerConfigPanelProps {
|
||||||
config: PopScannerConfig;
|
config: PopScannerConfig;
|
||||||
onUpdate: (config: PopScannerConfig) => void;
|
onUpdate: (config: PopScannerConfig) => void;
|
||||||
allComponents?: PopComponentDefinitionV5[];
|
allComponents?: PopComponentDefinition[];
|
||||||
connections?: PopDataConnection[];
|
connections?: PopDataConnection[];
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const DEFAULT_CONFIG: PopSearchConfig = {
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: PopSearchConfig | undefined;
|
config: PopSearchConfig | undefined;
|
||||||
onUpdate: (config: PopSearchConfig) => void;
|
onUpdate: (config: PopSearchConfig) => void;
|
||||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
|
||||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,7 @@ export function PopSearchConfigPanel({ config, onUpdate, allComponents, connecti
|
||||||
interface StepProps {
|
interface StepProps {
|
||||||
cfg: PopSearchConfig;
|
cfg: PopSearchConfig;
|
||||||
update: (partial: Partial<PopSearchConfig>) => void;
|
update: (partial: Partial<PopSearchConfig>) => void;
|
||||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
|
||||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +268,7 @@ interface FilterConnectionSectionProps {
|
||||||
update: (partial: Partial<PopSearchConfig>) => void;
|
update: (partial: Partial<PopSearchConfig>) => void;
|
||||||
showFieldName: boolean;
|
showFieldName: boolean;
|
||||||
fixedFilterMode?: SearchFilterMode;
|
fixedFilterMode?: SearchFilterMode;
|
||||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
|
||||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +284,7 @@ interface ConnectedComponentInfo {
|
||||||
function getConnectedComponentInfo(
|
function getConnectedComponentInfo(
|
||||||
componentId?: string,
|
componentId?: string,
|
||||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
|
||||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[],
|
||||||
): ConnectedComponentInfo {
|
): ConnectedComponentInfo {
|
||||||
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
|
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
|
||||||
if (!componentId || !connections || !allComponents) return empty;
|
if (!componentId || !connections || !allComponents) return empty;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: StatusBarConfig | undefined;
|
config: StatusBarConfig | undefined;
|
||||||
onUpdate: (config: StatusBarConfig) => void;
|
onUpdate: (config: StatusBarConfig) => void;
|
||||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
|
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
|
||||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,832 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import type { PopWorkDetailConfig } from "../types";
|
||||||
|
import type { TimelineProcessStep } from "../types";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface WorkResultRow {
|
||||||
|
id: string;
|
||||||
|
work_order_process_id: string;
|
||||||
|
source_work_item_id: string;
|
||||||
|
source_detail_id: string;
|
||||||
|
work_phase: string;
|
||||||
|
item_title: string;
|
||||||
|
item_sort_order: string;
|
||||||
|
detail_type: string;
|
||||||
|
detail_label: string;
|
||||||
|
detail_sort_order: string;
|
||||||
|
spec_value: string | null;
|
||||||
|
lower_limit: string | null;
|
||||||
|
upper_limit: string | null;
|
||||||
|
input_type: string | null;
|
||||||
|
result_value: string | null;
|
||||||
|
status: string;
|
||||||
|
is_passed: string | null;
|
||||||
|
recorded_by: string | null;
|
||||||
|
recorded_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkGroup {
|
||||||
|
phase: string;
|
||||||
|
title: string;
|
||||||
|
itemId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkPhase = "PRE" | "IN" | "POST";
|
||||||
|
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
|
||||||
|
|
||||||
|
interface ProcessTimerData {
|
||||||
|
started_at: string | null;
|
||||||
|
paused_at: string | null;
|
||||||
|
total_paused_time: string | null;
|
||||||
|
status: string;
|
||||||
|
good_qty: string | null;
|
||||||
|
defect_qty: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PopWorkDetailComponentProps {
|
||||||
|
config?: PopWorkDetailConfig;
|
||||||
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
|
currentRowSpan?: number;
|
||||||
|
currentColSpan?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export function PopWorkDetailComponent({
|
||||||
|
config,
|
||||||
|
screenId,
|
||||||
|
componentId,
|
||||||
|
}: PopWorkDetailComponentProps) {
|
||||||
|
const { getSharedData } = usePopEvent(screenId || "default");
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const cfg: PopWorkDetailConfig = {
|
||||||
|
showTimer: config?.showTimer ?? true,
|
||||||
|
showQuantityInput: config?.showQuantityInput ?? true,
|
||||||
|
phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// parentRow에서 현재 공정 정보 추출
|
||||||
|
const parentRow = getSharedData<RowData>("parentRow");
|
||||||
|
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
|
const currentProcess = processFlow?.find((p) => p.isCurrent);
|
||||||
|
const workOrderProcessId = currentProcess?.processId
|
||||||
|
? String(currentProcess.processId)
|
||||||
|
: undefined;
|
||||||
|
const processName = currentProcess?.processName ?? "공정 상세";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 상태
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const [allResults, setAllResults] = useState<WorkResultRow[]>([]);
|
||||||
|
const [processData, setProcessData] = useState<ProcessTimerData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
const [tick, setTick] = useState(Date.now());
|
||||||
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 수량 입력 로컬 상태
|
||||||
|
const [goodQty, setGoodQty] = useState("");
|
||||||
|
const [defectQty, setDefectQty] = useState("");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D-FE1: 데이터 로드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!workOrderProcessId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const [resultRes, processRes] = await Promise.all([
|
||||||
|
dataApi.getTableData("process_work_result", {
|
||||||
|
size: 500,
|
||||||
|
filters: { work_order_process_id: workOrderProcessId },
|
||||||
|
}),
|
||||||
|
dataApi.getTableData("work_order_process", {
|
||||||
|
size: 1,
|
||||||
|
filters: { id: workOrderProcessId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]);
|
||||||
|
|
||||||
|
const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null;
|
||||||
|
setProcessData(proc);
|
||||||
|
if (proc) {
|
||||||
|
setGoodQty(proc.good_qty ?? "");
|
||||||
|
setDefectQty(proc.defect_qty ?? "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("데이터를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [workOrderProcessId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D-FE2: 좌측 사이드바 - 작업항목 그룹핑
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const groups = useMemo<WorkGroup[]>(() => {
|
||||||
|
const map = new Map<string, WorkGroup>();
|
||||||
|
for (const row of allResults) {
|
||||||
|
const key = row.source_work_item_id;
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, {
|
||||||
|
phase: row.work_phase,
|
||||||
|
title: row.item_title,
|
||||||
|
itemId: key,
|
||||||
|
sortOrder: parseInt(row.item_sort_order || "0", 10),
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const g = map.get(key)!;
|
||||||
|
g.total++;
|
||||||
|
if (row.status === "completed") g.completed++;
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort(
|
||||||
|
(a, b) =>
|
||||||
|
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
|
||||||
|
a.sortOrder - b.sortOrder
|
||||||
|
);
|
||||||
|
}, [allResults]);
|
||||||
|
|
||||||
|
// phase별로 그룹핑
|
||||||
|
const groupsByPhase = useMemo(() => {
|
||||||
|
const result: Record<string, WorkGroup[]> = {};
|
||||||
|
for (const g of groups) {
|
||||||
|
if (!result[g.phase]) result[g.phase] = [];
|
||||||
|
result[g.phase].push(g);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
// 첫 그룹 자동 선택
|
||||||
|
useEffect(() => {
|
||||||
|
if (groups.length > 0 && !selectedGroupId) {
|
||||||
|
setSelectedGroupId(groups[0].itemId);
|
||||||
|
}
|
||||||
|
}, [groups, selectedGroupId]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D-FE3: 우측 체크리스트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const currentItems = useMemo(
|
||||||
|
() =>
|
||||||
|
allResults
|
||||||
|
.filter((r) => r.source_work_item_id === selectedGroupId)
|
||||||
|
.sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)),
|
||||||
|
[allResults, selectedGroupId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveResultValue = useCallback(
|
||||||
|
async (
|
||||||
|
rowId: string,
|
||||||
|
resultValue: string,
|
||||||
|
isPassed: string | null,
|
||||||
|
newStatus: string
|
||||||
|
) => {
|
||||||
|
setSavingIds((prev) => new Set(prev).add(rowId));
|
||||||
|
try {
|
||||||
|
await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks: [
|
||||||
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] },
|
||||||
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] },
|
||||||
|
...(isPassed !== null
|
||||||
|
? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }]
|
||||||
|
: []),
|
||||||
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] },
|
||||||
|
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] },
|
||||||
|
],
|
||||||
|
data: { items: [{ id: rowId }], fieldValues: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllResults((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === rowId
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
result_value: resultValue,
|
||||||
|
status: newStatus,
|
||||||
|
is_passed: isPassed,
|
||||||
|
recorded_by: user?.userId ?? null,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: r
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSavingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(rowId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user?.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D-FE4: 타이머
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cfg.showTimer || !processData?.started_at) return;
|
||||||
|
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [cfg.showTimer, processData?.started_at]);
|
||||||
|
|
||||||
|
const elapsedMs = useMemo(() => {
|
||||||
|
if (!processData?.started_at) return 0;
|
||||||
|
const now = tick;
|
||||||
|
const totalMs = now - new Date(processData.started_at).getTime();
|
||||||
|
const pausedSec = parseInt(processData.total_paused_time || "0", 10);
|
||||||
|
const currentPauseMs = processData.paused_at
|
||||||
|
? now - new Date(processData.paused_at).getTime()
|
||||||
|
: 0;
|
||||||
|
return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs);
|
||||||
|
}, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]);
|
||||||
|
|
||||||
|
const formattedTime = useMemo(() => {
|
||||||
|
const totalSec = Math.floor(elapsedMs / 1000);
|
||||||
|
const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
|
||||||
|
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
|
||||||
|
const s = String(totalSec % 60).padStart(2, "0");
|
||||||
|
return `${h}:${m}:${s}`;
|
||||||
|
}, [elapsedMs]);
|
||||||
|
|
||||||
|
const isPaused = !!processData?.paused_at;
|
||||||
|
const isStarted = !!processData?.started_at;
|
||||||
|
|
||||||
|
const handleTimerAction = useCallback(
|
||||||
|
async (action: "start" | "pause" | "resume") => {
|
||||||
|
if (!workOrderProcessId) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post("/api/pop/production/timer", {
|
||||||
|
workOrderProcessId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
// 타이머 상태 새로고침
|
||||||
|
const res = await dataApi.getTableData("work_order_process", {
|
||||||
|
size: 1,
|
||||||
|
filters: { id: workOrderProcessId },
|
||||||
|
});
|
||||||
|
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
|
||||||
|
if (proc) setProcessData(proc);
|
||||||
|
} catch {
|
||||||
|
toast.error("타이머 제어에 실패했습니다.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workOrderProcessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// D-FE5: 수량 등록 + 완료
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const handleQuantityRegister = useCallback(async () => {
|
||||||
|
if (!workOrderProcessId) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks: [
|
||||||
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] },
|
||||||
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] },
|
||||||
|
],
|
||||||
|
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||||||
|
});
|
||||||
|
toast.success("수량이 등록되었습니다.");
|
||||||
|
} catch {
|
||||||
|
toast.error("수량 등록에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}, [workOrderProcessId, goodQty, defectQty]);
|
||||||
|
|
||||||
|
const handleProcessComplete = useCallback(async () => {
|
||||||
|
if (!workOrderProcessId) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks: [
|
||||||
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] },
|
||||||
|
{ type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] },
|
||||||
|
],
|
||||||
|
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||||||
|
});
|
||||||
|
toast.success("공정이 완료되었습니다.");
|
||||||
|
setProcessData((prev) =>
|
||||||
|
prev ? { ...prev, status: "completed" } : prev
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("공정 완료 처리에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}, [workOrderProcessId]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 안전 장치
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
if (!parentRow) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||||||
|
카드를 선택해주세요
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workOrderProcessId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||||||
|
공정 정보를 찾을 수 없습니다
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allResults.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="mr-2 h-4 w-4" />
|
||||||
|
작업기준이 등록되지 않았습니다
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProcessCompleted = processData?.status === "completed";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 렌더링
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<h3 className="text-sm font-semibold">{processName}</h3>
|
||||||
|
{cfg.showTimer && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Timer className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-sm font-medium tabular-nums">
|
||||||
|
{formattedTime}
|
||||||
|
</span>
|
||||||
|
{!isProcessCompleted && (
|
||||||
|
<>
|
||||||
|
{!isStarted && (
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("start")}>
|
||||||
|
<Play className="mr-1 h-3 w-3" />
|
||||||
|
시작
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isStarted && !isPaused && (
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("pause")}>
|
||||||
|
<Pause className="mr-1 h-3 w-3" />
|
||||||
|
정지
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isStarted && isPaused && (
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("resume")}>
|
||||||
|
<Play className="mr-1 h-3 w-3" />
|
||||||
|
재개
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문: 좌측 사이드바 + 우측 체크리스트 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측 사이드바 */}
|
||||||
|
<div className="w-40 shrink-0 overflow-y-auto border-r bg-muted/30">
|
||||||
|
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => {
|
||||||
|
const phaseGroups = groupsByPhase[phase];
|
||||||
|
if (!phaseGroups || phaseGroups.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={phase}>
|
||||||
|
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||||
|
{cfg.phaseLabels[phase] ?? phase}
|
||||||
|
</div>
|
||||||
|
{phaseGroups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.itemId}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col px-3 py-1.5 text-left transition-colors",
|
||||||
|
selectedGroupId === g.itemId
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted/60"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedGroupId(g.itemId)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium leading-tight">
|
||||||
|
{g.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{g.completed}/{g.total} 완료
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 체크리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{selectedGroupId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{currentItems.map((item) => (
|
||||||
|
<ChecklistItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
saving={savingIds.has(item.id)}
|
||||||
|
disabled={isProcessCompleted}
|
||||||
|
onSave={saveResultValue}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단: 수량 입력 + 완료 */}
|
||||||
|
{cfg.showQuantityInput && (
|
||||||
|
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||||||
|
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">양품</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-7 w-20 text-xs"
|
||||||
|
value={goodQty}
|
||||||
|
onChange={(e) => setGoodQty(e.target.value)}
|
||||||
|
disabled={isProcessCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">불량</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-7 w-20 text-xs"
|
||||||
|
value={defectQty}
|
||||||
|
onChange={(e) => setDefectQty(e.target.value)}
|
||||||
|
disabled={isProcessCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleQuantityRegister}
|
||||||
|
disabled={isProcessCompleted}
|
||||||
|
>
|
||||||
|
수량 등록
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{!isProcessCompleted && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleProcessComplete}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
공정 완료
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isProcessCompleted && (
|
||||||
|
<Badge variant="outline" className="text-xs text-green-600">
|
||||||
|
완료됨
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 체크리스트 개별 항목
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ChecklistItemProps {
|
||||||
|
item: WorkResultRow;
|
||||||
|
saving: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (
|
||||||
|
rowId: string,
|
||||||
|
resultValue: string,
|
||||||
|
isPassed: string | null,
|
||||||
|
newStatus: string
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
|
||||||
|
const isSaving = saving;
|
||||||
|
const isDisabled = disabled || isSaving;
|
||||||
|
|
||||||
|
switch (item.detail_type) {
|
||||||
|
case "check":
|
||||||
|
return <CheckItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||||
|
case "inspect":
|
||||||
|
return <InspectItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||||
|
case "input":
|
||||||
|
return <InputItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||||
|
case "procedure":
|
||||||
|
return <ProcedureItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||||
|
case "material":
|
||||||
|
return <MaterialItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||||
|
알 수 없는 유형: {item.detail_type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== check: 체크박스 =====
|
||||||
|
|
||||||
|
function CheckItem({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
item: WorkResultRow;
|
||||||
|
disabled: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: ChecklistItemProps["onSave"];
|
||||||
|
}) {
|
||||||
|
const checked = item.result_value === "Y";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded border px-3 py-2",
|
||||||
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const val = v ? "Y" : "N";
|
||||||
|
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs">{item.detail_label}</span>
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
{item.status === "completed" && !saving && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-green-600">
|
||||||
|
완료
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== inspect: 측정값 입력 (범위 판정) =====
|
||||||
|
|
||||||
|
function InspectItem({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
item: WorkResultRow;
|
||||||
|
disabled: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: ChecklistItemProps["onSave"];
|
||||||
|
}) {
|
||||||
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||||
|
const lower = parseFloat(item.lower_limit ?? "");
|
||||||
|
const upper = parseFloat(item.upper_limit ?? "");
|
||||||
|
const hasRange = !isNaN(lower) && !isNaN(upper);
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (!inputVal || disabled) return;
|
||||||
|
const numVal = parseFloat(inputVal);
|
||||||
|
let passed: string | null = null;
|
||||||
|
if (hasRange) {
|
||||||
|
passed = numVal >= lower && numVal <= upper ? "Y" : "N";
|
||||||
|
}
|
||||||
|
onSave(item.id, inputVal, passed, "completed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPassed = item.is_passed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-3 py-2",
|
||||||
|
isPassed === "Y" && "bg-green-50 border-green-200",
|
||||||
|
isPassed === "N" && "bg-red-50 border-red-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">{item.detail_label}</span>
|
||||||
|
{hasRange && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
기준: {item.lower_limit} ~ {item.upper_limit}
|
||||||
|
{item.spec_value ? ` (표준: ${item.spec_value})` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="h-7 w-28 text-xs"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="측정값 입력"
|
||||||
|
/>
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
{isPassed === "Y" && !saving && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-green-600">합격</Badge>
|
||||||
|
)}
|
||||||
|
{isPassed === "N" && !saving && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-red-600">불합격</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== input: 자유 입력 =====
|
||||||
|
|
||||||
|
function InputItem({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
item: WorkResultRow;
|
||||||
|
disabled: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: ChecklistItemProps["onSave"];
|
||||||
|
}) {
|
||||||
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||||
|
const inputType = item.input_type === "number" ? "number" : "text";
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (!inputVal || disabled) return;
|
||||||
|
onSave(item.id, inputVal, null, "completed");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-3 py-2",
|
||||||
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="값 입력"
|
||||||
|
/>
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== procedure: 절차 확인 (읽기 전용 + 체크) =====
|
||||||
|
|
||||||
|
function ProcedureItem({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
item: WorkResultRow;
|
||||||
|
disabled: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: ChecklistItemProps["onSave"];
|
||||||
|
}) {
|
||||||
|
const checked = item.result_value === "Y";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-3 py-2",
|
||||||
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-xs text-muted-foreground">
|
||||||
|
{item.spec_value || item.detail_label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">확인</span>
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== material: 자재/LOT 입력 =====
|
||||||
|
|
||||||
|
function MaterialItem({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
item: WorkResultRow;
|
||||||
|
disabled: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: ChecklistItemProps["onSave"];
|
||||||
|
}) {
|
||||||
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (!inputVal || disabled) return;
|
||||||
|
onSave(item.id, inputVal, null, "completed");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-3 py-2",
|
||||||
|
item.status === "completed" && "bg-green-50 border-green-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="LOT 번호 입력"
|
||||||
|
/>
|
||||||
|
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { PopWorkDetailConfig } from "../types";
|
||||||
|
|
||||||
|
interface PopWorkDetailConfigPanelProps {
|
||||||
|
config?: PopWorkDetailConfig;
|
||||||
|
onChange?: (config: PopWorkDetailConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PHASE_LABELS: Record<string, string> = {
|
||||||
|
PRE: "작업 전",
|
||||||
|
IN: "작업 중",
|
||||||
|
POST: "작업 후",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PopWorkDetailConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: PopWorkDetailConfigPanelProps) {
|
||||||
|
const cfg: PopWorkDetailConfig = {
|
||||||
|
showTimer: config?.showTimer ?? true,
|
||||||
|
showQuantityInput: config?.showQuantityInput ?? true,
|
||||||
|
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (partial: Partial<PopWorkDetailConfig>) => {
|
||||||
|
onChange?.({ ...cfg, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">타이머 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={cfg.showTimer}
|
||||||
|
onCheckedChange={(v) => update({ showTimer: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">수량 입력 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={cfg.showQuantityInput}
|
||||||
|
onCheckedChange={(v) => update({ showQuantityInput: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">단계 라벨</Label>
|
||||||
|
{(["PRE", "IN", "POST"] as const).map((phase) => (
|
||||||
|
<div key={phase} className="flex items-center gap-2">
|
||||||
|
<span className="w-12 text-xs font-medium text-muted-foreground">
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({
|
||||||
|
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClipboardCheck } from "lucide-react";
|
||||||
|
import type { PopWorkDetailConfig } from "../types";
|
||||||
|
|
||||||
|
interface PopWorkDetailPreviewProps {
|
||||||
|
config?: PopWorkDetailConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) {
|
||||||
|
const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" };
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||||
|
<ClipboardCheck className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
작업 상세
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Object.values(labels).map((l) => (
|
||||||
|
<span
|
||||||
|
key={l}
|
||||||
|
className="rounded bg-muted/60 px-1.5 py-0.5 text-[8px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||||
|
import { PopWorkDetailComponent } from "./PopWorkDetailComponent";
|
||||||
|
import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig";
|
||||||
|
import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview";
|
||||||
|
import type { PopWorkDetailConfig } from "../types";
|
||||||
|
|
||||||
|
const defaultConfig: PopWorkDetailConfig = {
|
||||||
|
showTimer: true,
|
||||||
|
showQuantityInput: true,
|
||||||
|
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
||||||
|
};
|
||||||
|
|
||||||
|
PopComponentRegistry.registerComponent({
|
||||||
|
id: "pop-work-detail",
|
||||||
|
name: "작업 상세",
|
||||||
|
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
|
||||||
|
category: "display",
|
||||||
|
icon: "ClipboardCheck",
|
||||||
|
component: PopWorkDetailComponent,
|
||||||
|
configPanel: PopWorkDetailConfigPanel,
|
||||||
|
preview: PopWorkDetailPreviewComponent,
|
||||||
|
defaultProps: defaultConfig,
|
||||||
|
connectionMeta: {
|
||||||
|
sendable: [
|
||||||
|
{
|
||||||
|
key: "process_completed",
|
||||||
|
label: "공정 완료",
|
||||||
|
type: "event",
|
||||||
|
category: "event",
|
||||||
|
description: "공정 작업 전체 완료 이벤트",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
receivable: [],
|
||||||
|
},
|
||||||
|
touchOptimized: true,
|
||||||
|
supportedDevices: ["mobile", "tablet"],
|
||||||
|
});
|
||||||
|
|
@ -851,7 +851,8 @@ export interface CardCellDefinitionV2 {
|
||||||
export interface ActionButtonUpdate {
|
export interface ActionButtonUpdate {
|
||||||
column: string;
|
column: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
valueType: "static" | "currentUser" | "currentTime" | "columnRef" | "userInput";
|
||||||
|
operationType?: "assign" | "add" | "subtract";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 액션 버튼 클릭 시 동작 모드
|
// 액션 버튼 클릭 시 동작 모드
|
||||||
|
|
@ -881,34 +882,49 @@ export interface SelectModeConfig {
|
||||||
export interface SelectModeButtonConfig {
|
export interface SelectModeButtonConfig {
|
||||||
label: string;
|
label: string;
|
||||||
variant: ButtonVariant;
|
variant: ButtonVariant;
|
||||||
clickMode: "status-change" | "modal-open" | "cancel-select";
|
clickMode: "status-change" | "modal-open" | "cancel-select" | "quantity-input";
|
||||||
targetTable?: string;
|
targetTable?: string;
|
||||||
updates?: ActionButtonUpdate[];
|
updates?: ActionButtonUpdate[];
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
modalScreenId?: string;
|
modalScreenId?: string;
|
||||||
|
quantityInput?: QuantityInputConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 버튼 중심 구조 (신규) =====
|
// ===== 버튼 중심 구조 (신규) =====
|
||||||
|
|
||||||
export interface ActionButtonShowCondition {
|
export interface ActionButtonShowCondition {
|
||||||
type: "timeline-status" | "column-value" | "always";
|
type: "timeline-status" | "column-value" | "always" | "owner-match";
|
||||||
value?: string;
|
value?: string;
|
||||||
column?: string;
|
column?: string;
|
||||||
unmatchBehavior?: "hidden" | "disabled";
|
unmatchBehavior?: "hidden" | "disabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionButtonClickAction {
|
export interface ActionButtonClickAction {
|
||||||
type: "immediate" | "select-mode" | "modal-open";
|
type: "immediate" | "select-mode" | "modal-open" | "quantity-input";
|
||||||
targetTable?: string;
|
targetTable?: string;
|
||||||
updates?: ActionButtonUpdate[];
|
updates?: ActionButtonUpdate[];
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
selectModeButtons?: SelectModeButtonConfig[];
|
selectModeButtons?: SelectModeButtonConfig[];
|
||||||
modalScreenId?: string;
|
modalScreenId?: string;
|
||||||
// 외부 테이블 조인 설정 (DB 직접 선택 시)
|
|
||||||
joinConfig?: {
|
joinConfig?: {
|
||||||
sourceColumn: string; // 메인 테이블의 FK 컬럼
|
sourceColumn: string;
|
||||||
targetColumn: string; // 외부 테이블의 매칭 컬럼
|
targetColumn: string;
|
||||||
};
|
};
|
||||||
|
quantityInput?: QuantityInputConfig;
|
||||||
|
preCondition?: ActionPreCondition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuantityInputConfig {
|
||||||
|
maxColumn?: string;
|
||||||
|
currentColumn?: string;
|
||||||
|
unit?: string;
|
||||||
|
enablePackage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionPreCondition {
|
||||||
|
column: string;
|
||||||
|
expectedValue: string;
|
||||||
|
failMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionButtonDef {
|
export interface ActionButtonDef {
|
||||||
|
|
@ -976,6 +992,7 @@ export interface PopCardListV2Config {
|
||||||
cartAction?: CardCartActionConfig;
|
cartAction?: CardCartActionConfig;
|
||||||
cartListMode?: CartListModeConfig;
|
cartListMode?: CartListModeConfig;
|
||||||
saveMapping?: CardListSaveMapping;
|
saveMapping?: CardListSaveMapping;
|
||||||
|
ownerSortColumn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
|
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
|
||||||
|
|
@ -983,3 +1000,14 @@ export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
|
||||||
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
|
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
|
||||||
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
|
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
|
||||||
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
|
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
|
||||||
|
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// pop-work-detail 전용 타입
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
export interface PopWorkDetailConfig {
|
||||||
|
showTimer: boolean;
|
||||||
|
showQuantityInput: boolean;
|
||||||
|
phaseLabels: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ export type ButtonActionType =
|
||||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||||
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||||
| "approval"; // 결재 요청
|
| "approval" // 결재 요청
|
||||||
|
| "apiCall"; // 범용 API 호출 (생산계획 자동 스케줄 등)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -286,6 +287,18 @@ export interface ButtonActionConfig {
|
||||||
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
eventName: string; // 발송할 이벤트 이름 (V2_EVENTS 키)
|
||||||
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
|
eventPayload?: Record<string, any>; // 이벤트 페이로드 (requestId는 자동 생성)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 범용 API 호출 관련 (apiCall 액션용)
|
||||||
|
apiCallConfig?: {
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
endpoint: string; // 예: "/api/production/generate-schedule"
|
||||||
|
payloadMapping?: Record<string, string>; // formData 필드 → API body 필드 매핑
|
||||||
|
staticPayload?: Record<string, any>; // 고정 페이로드 값
|
||||||
|
useSelectedRows?: boolean; // true면 선택된 행 데이터를 body에 포함
|
||||||
|
selectedRowsKey?: string; // 선택된 행 데이터의 key (기본: "items")
|
||||||
|
refreshAfterSuccess?: boolean; // 성공 후 테이블 새로고침 (기본: true)
|
||||||
|
confirmMessage?: string; // 실행 전 확인 메시지
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -457,6 +470,9 @@ export class ButtonActionExecutor {
|
||||||
case "event":
|
case "event":
|
||||||
return await this.handleEvent(config, context);
|
return await this.handleEvent(config, context);
|
||||||
|
|
||||||
|
case "apiCall":
|
||||||
|
return await this.handleApiCall(config, context);
|
||||||
|
|
||||||
case "approval":
|
case "approval":
|
||||||
return this.handleApproval(config, context);
|
return this.handleApproval(config, context);
|
||||||
|
|
||||||
|
|
@ -7681,6 +7697,97 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범용 API 호출 (생산계획 자동 스케줄 등)
|
||||||
|
*/
|
||||||
|
private static async handleApiCall(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { apiCallConfig } = config;
|
||||||
|
|
||||||
|
if (!apiCallConfig?.endpoint) {
|
||||||
|
toast.error("API 엔드포인트가 설정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확인 메시지
|
||||||
|
if (apiCallConfig.confirmMessage) {
|
||||||
|
const confirmed = window.confirm(apiCallConfig.confirmMessage);
|
||||||
|
if (!confirmed) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이로드 구성
|
||||||
|
let payload: Record<string, any> = { ...(apiCallConfig.staticPayload || {}) };
|
||||||
|
|
||||||
|
// formData에서 매핑
|
||||||
|
if (apiCallConfig.payloadMapping && context.formData) {
|
||||||
|
for (const [formField, apiField] of Object.entries(apiCallConfig.payloadMapping)) {
|
||||||
|
if (context.formData[formField] !== undefined) {
|
||||||
|
payload[apiField] = context.formData[formField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 행 데이터 포함
|
||||||
|
if (apiCallConfig.useSelectedRows && context.selectedRowsData) {
|
||||||
|
const key = apiCallConfig.selectedRowsKey || "items";
|
||||||
|
payload[key] = context.selectedRowsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[handleApiCall] API 호출:", {
|
||||||
|
method: apiCallConfig.method,
|
||||||
|
endpoint: apiCallConfig.endpoint,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
let response: any;
|
||||||
|
|
||||||
|
switch (apiCallConfig.method) {
|
||||||
|
case "GET":
|
||||||
|
response = await apiClient.get(apiCallConfig.endpoint, { params: payload });
|
||||||
|
break;
|
||||||
|
case "POST":
|
||||||
|
response = await apiClient.post(apiCallConfig.endpoint, payload);
|
||||||
|
break;
|
||||||
|
case "PUT":
|
||||||
|
response = await apiClient.put(apiCallConfig.endpoint, payload);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
response = await apiClient.delete(apiCallConfig.endpoint, { data: payload });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = response?.data;
|
||||||
|
|
||||||
|
if (result?.success === false) {
|
||||||
|
toast.error(result.message || "API 호출에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지
|
||||||
|
if (config.successMessage) {
|
||||||
|
toast.success(config.successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 새로고침
|
||||||
|
if (apiCallConfig.refreshAfterSuccess !== false) {
|
||||||
|
const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core");
|
||||||
|
v2EventBus.emitSync(V2_EVENTS.TABLE_REFRESH, {
|
||||||
|
tableName: context.tableName,
|
||||||
|
target: "all",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[handleApiCall] API 호출 오류:", error);
|
||||||
|
const msg = error?.response?.data?.message || error?.message || "API 호출 중 오류가 발생했습니다.";
|
||||||
|
toast.error(msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 결재 요청 모달 열기
|
* 결재 요청 모달 열기
|
||||||
*/
|
*/
|
||||||
|
|
@ -7843,4 +7950,8 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||||
approval: {
|
approval: {
|
||||||
type: "approval",
|
type: "approval",
|
||||||
},
|
},
|
||||||
|
apiCall: {
|
||||||
|
type: "apiCall",
|
||||||
|
successMessage: "처리되었습니다.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"@tiptap/core": "^2.27.1",
|
"@tiptap/core": "^2.27.1",
|
||||||
"@tiptap/extension-placeholder": "^2.27.1",
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
"@tiptap/pm": "^2.27.1",
|
"@tiptap/pm": "^2.27.1",
|
||||||
|
|
@ -3756,6 +3757,23 @@
|
||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
|
@ -3769,6 +3787,16 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "2.27.1",
|
"version": "2.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"@tiptap/core": "^2.27.1",
|
"@tiptap/core": "^2.27.1",
|
||||||
"@tiptap/extension-placeholder": "^2.27.1",
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
"@tiptap/pm": "^2.27.1",
|
"@tiptap/pm": "^2.27.1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue