Merge branch 'ksh-v2-work' into main
POP 디자이너 GRID-V6 + BLOCK DETAIL Phase 2~3 + 낙관적 잠금 기능을 main에 통합한다. 주요 병합 내용: - GRID-V6 정사각형 블록 그리드 시스템 (842ac27d) - POP 그리드 명칭 통일 + Dead Code 제거 (320100c4) - BLOCK DETAIL Phase 2: 생산 공정 백엔드 API (create-work-processes, timer) - BLOCK DETAIL Phase 3: pop-work-detail 컴포넌트 + 모달 캔버스 시스템 - 낙관적 잠금 + 소유자 기반 액션 제어 + 디자이너 설정 UI - LOCK-OWNER 카드 비활성화 UI 보완 충돌 해결: 0건 (자동 병합 완료)
This commit is contained in:
commit
224338d75f
|
|
@ -125,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"; // 공차중계 운전자 관리
|
||||||
|
|
@ -260,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);
|
||||||
|
|
|
||||||
|
|
@ -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 || "타이머 처리 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
if (!component) return prev;
|
||||||
|
|
||||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
if (currentMode === "tablet_landscape") {
|
||||||
if (currentMode === "tablet_landscape") {
|
const newLayout = {
|
||||||
const newLayout = {
|
...prev,
|
||||||
...layout,
|
components: {
|
||||||
components: {
|
...prev.components,
|
||||||
...layout.components,
|
[componentId]: { ...component, position: newPosition },
|
||||||
[componentId]: {
|
|
||||||
...component,
|
|
||||||
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];
|
||||||
|
if (!component) return prev;
|
||||||
|
|
||||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
if (currentMode === "tablet_landscape") {
|
||||||
if (currentMode === "tablet_landscape") {
|
return {
|
||||||
const newLayout = {
|
...prev,
|
||||||
...layout,
|
components: {
|
||||||
components: {
|
...prev.components,
|
||||||
...layout.components,
|
[componentId]: { ...component, position: newPosition },
|
||||||
[componentId]: {
|
|
||||||
...component,
|
|
||||||
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);
|
||||||
};
|
|
||||||
|
|
||||||
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
if (currentMode === "tablet_landscape") {
|
||||||
if (currentMode === "tablet_landscape") {
|
const newLayout = {
|
||||||
const newLayout = {
|
...prev,
|
||||||
...layout,
|
components: {
|
||||||
components: {
|
...prev.components,
|
||||||
...layout.components,
|
[componentId]: { ...component, position: newPosition },
|
||||||
[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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
for (const rowKey of sortedRows) {
|
||||||
// 검토 필요 판별
|
const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col);
|
||||||
// ========================================
|
let currentCol = 1;
|
||||||
|
let maxRowSpanInLine = 0;
|
||||||
|
|
||||||
/**
|
for (const comp of group) {
|
||||||
* 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인
|
const pos = comp.position;
|
||||||
*
|
const isMainContent = pos.colSpan >= designColumns * 0.5;
|
||||||
* 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칸 모드는 기본 모드이므로 검토 불필요
|
let scaledSpan = isMainContent
|
||||||
if (targetColumns === 12) {
|
? targetColumns
|
||||||
return false;
|
: 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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue