Merge branch 'ycshin-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-16 14:51:51 +09:00
commit ec3cb8155f
45 changed files with 4231 additions and 1315 deletions

View File

@ -0,0 +1,318 @@
/**
* (company_7)
*
* :
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1 (ROLLBACK)
* npx ts-node scripts/btn-bulk-update-company7.ts --run # (COMMIT)
* npx ts-node scripts/btn-bulk-update-company7.ts --backup #
* npx ts-node scripts/btn-bulk-update-company7.ts --restore #
*/
import { Pool } from "pg";
// ── 배포 DB 연결 ──
const pool = new Pool({
connectionString:
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
});
const COMPANY_CODE = "COMPANY_7";
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
const actionIconMap: Record<string, string> = {
save: "Check",
delete: "Trash2",
edit: "Pencil",
navigate: "ArrowRight",
modal: "Maximize2",
transferData: "SendHorizontal",
excel_download: "Download",
excel_upload: "Upload",
quickInsert: "Zap",
control: "Settings",
barcode_scan: "ScanLine",
operation_control: "Truck",
event: "Send",
copy: "Copy",
};
const FALLBACK_ICON = "SquareMousePointer";
function getIconForAction(actionType?: string): string {
if (actionType && actionIconMap[actionType]) {
return actionIconMap[actionType];
}
return FALLBACK_ICON;
}
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
function isTopLevelButton(comp: any): boolean {
return (
comp.url?.includes("v2-button-primary") ||
comp.overrides?.type === "v2-button-primary"
);
}
function isTabChildButton(comp: any): boolean {
return comp.componentType === "v2-button-primary";
}
function isButtonComponent(comp: any): boolean {
return isTopLevelButton(comp) || isTabChildButton(comp);
}
// ── 탭 위젯인지 판별 ──
function isTabsWidget(comp: any): boolean {
return (
comp.url?.includes("v2-tabs-widget") ||
comp.overrides?.type === "v2-tabs-widget"
);
}
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
function applyButtonStyle(config: any, actionType: string | undefined) {
const iconName = getIconForAction(actionType);
config.displayMode = "icon-text";
config.icon = {
name: iconName,
type: "lucide",
size: "보통",
...(config.icon?.color ? { color: config.icon.color } : {}),
};
config.iconTextPosition = "right";
config.iconGap = 6;
if (!config.style) config.style = {};
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
config.style.borderRadius = "8px";
config.style.labelColor = "#FFFFFF";
config.style.fontSize = "12px";
config.style.fontWeight = "normal";
config.style.labelTextAlign = "left";
if (actionType === "delete") {
config.style.backgroundColor = "#F04544";
} else if (actionType === "excel_upload" || actionType === "excel_download") {
config.style.backgroundColor = "#212121";
} else {
config.style.backgroundColor = "#3B83F6";
}
}
function updateButtonStyle(comp: any): boolean {
if (isTopLevelButton(comp)) {
const overrides = comp.overrides || {};
const actionType = overrides.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(overrides, actionType);
comp.overrides = overrides;
return true;
}
if (isTabChildButton(comp)) {
const config = comp.componentConfig || {};
const actionType = config.action?.type;
if (!comp.size) comp.size = {};
comp.size.height = 40;
applyButtonStyle(config, actionType);
comp.componentConfig = config;
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
if (!comp.style) comp.style = {};
comp.style.borderRadius = "8px";
comp.style.labelColor = "#FFFFFF";
comp.style.fontSize = "12px";
comp.style.fontWeight = "normal";
comp.style.labelTextAlign = "left";
comp.style.backgroundColor = config.style.backgroundColor;
return true;
}
return false;
}
// ── 백업 테이블 생성 ──
async function createBackup() {
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
const exists = await pool.query(
`SELECT to_regclass($1) AS tbl`,
[BACKUP_TABLE],
);
if (exists.rows[0].tbl) {
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
return;
}
await pool.query(
`CREATE TABLE ${BACKUP_TABLE} AS
SELECT * FROM screen_layouts_v2
WHERE company_code = $1`,
[COMPANY_CODE],
);
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
}
// ── 백업에서 원복 ──
async function restoreFromBackup() {
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
const result = await pool.query(
`UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data,
updated_at = backup.updated_at
FROM ${BACKUP_TABLE} AS backup
WHERE target.screen_id = backup.screen_id
AND target.company_code = backup.company_code
AND target.layer_id = backup.layer_id`,
);
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
}
// ── 메인: 버튼 일괄 변경 ──
async function updateButtons(testMode: boolean) {
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
// company_7 레코드 조회
const rows = await pool.query(
`SELECT screen_id, layer_id, company_code, layout_data
FROM screen_layouts_v2
WHERE company_code = $1
ORDER BY screen_id, layer_id`,
[COMPANY_CODE],
);
console.log(`대상 레코드 수: ${rows.rowCount}`);
if (!rows.rowCount) {
console.log("변경할 레코드가 없습니다.");
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
let totalUpdated = 0;
let totalButtons = 0;
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
for (const row of targetRows) {
const layoutData = row.layout_data;
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
continue;
}
let buttonsInRow = 0;
for (const comp of layoutData.components) {
// 최상위 버튼 처리
if (updateButtonStyle(comp)) {
buttonsInRow++;
}
// 탭 위젯 내부 버튼 처리
if (isTabsWidget(comp)) {
const tabs = comp.overrides?.tabs || [];
for (const tab of tabs) {
const tabComps = tab.components || [];
for (const tabComp of tabComps) {
if (updateButtonStyle(tabComp)) {
buttonsInRow++;
}
}
}
}
}
if (buttonsInRow > 0) {
await client.query(
`UPDATE screen_layouts_v2
SET layout_data = $1, updated_at = NOW()
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
);
totalUpdated++;
totalButtons += buttonsInRow;
console.log(
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
);
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
if (testMode) {
const sampleBtn = layoutData.components.find(isButtonComponent);
if (sampleBtn) {
console.log("\n--- 변경 후 샘플 버튼 ---");
console.log(JSON.stringify(sampleBtn, null, 2));
}
}
}
}
console.log(`\n--- 결과 ---`);
console.log(`변경된 레코드: ${totalUpdated}`);
console.log(`변경된 버튼: ${totalButtons}`);
if (testMode) {
await client.query("ROLLBACK");
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
} else {
await client.query("COMMIT");
console.log("\nCOMMIT 완료.");
}
} catch (err) {
await client.query("ROLLBACK");
console.error("\n에러 발생. ROLLBACK 완료.", err);
throw err;
} finally {
client.release();
}
}
// ── CLI 진입점 ──
async function main() {
const arg = process.argv[2];
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
console.log("사용법:");
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
console.log(" --run : 전체 실행 (COMMIT)");
console.log(" --backup : 백업 테이블 생성");
console.log(" --restore : 백업에서 원복");
process.exit(1);
}
try {
if (arg === "--backup") {
await createBackup();
} else if (arg === "--restore") {
await restoreFromBackup();
} else if (arg === "--test") {
await createBackup();
await updateButtons(true);
} else if (arg === "--run") {
await createBackup();
await updateButtons(false);
}
} catch (err) {
console.error("스크립트 실행 실패:", err);
process.exit(1);
} finally {
await pool.end();
}
}
main();

View File

@ -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);

View File

@ -314,13 +314,14 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { ruleId } = req.params; const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) const { formData, manualInputValue } = req.body;
try { try {
const previewCode = await numberingRuleService.previewCode( const previewCode = await numberingRuleService.previewCode(
ruleId, ruleId,
companyCode, companyCode,
formData formData,
manualInputValue
); );
return res.json({ success: true, data: { generatedCode: previewCode } }); return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) { } catch (error: any) {

View File

@ -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 || "타이머 처리 중 오류가 발생했습니다.",
});
}
};

View File

@ -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,

View File

@ -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;

View File

@ -39,8 +39,10 @@ function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globa
result += val; result += val;
if (idx < partValues.length - 1) { if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
if (val || !result.endsWith(sep)) {
result += sep; result += sep;
} }
}
}); });
return result; return result;
} }
@ -74,16 +76,22 @@ class NumberingRuleService {
*/ */
private async buildPrefixKey( private async buildPrefixKey(
rule: NumberingRuleConfig, rule: NumberingRuleConfig,
formData?: Record<string, any> formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> { ): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = []; const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) { for (const part of sortedParts) {
if (part.partType === "sequence") continue; if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로) const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue; continue;
} }
@ -1078,22 +1086,30 @@ class NumberingRuleService {
* @param ruleId ID * @param ruleId ID
* @param companyCode * @param companyCode
* @param formData ( ) * @param formData ( )
* @param manualInputValue ( )
*/ */
async previewCode( async previewCode(
ruleId: string, ruleId: string,
companyCode: string, companyCode: string,
formData?: Record<string, any> formData?: Record<string, any>,
manualInputValue?: string
): Promise<string> { ): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode); const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다"); if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번 조회 // 수동 파트가 있는데 입력값이 없으면 레거시 공용 시퀀스 조회를 건너뜀
const prefixKey = await this.buildPrefixKey(rule, formData); const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
const skipSequenceLookup = hasManualPart && !manualInputValue;
const manualValues = manualInputValue ? [manualInputValue] : undefined;
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
const pool = getPool(); const pool = getPool();
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); const currentSeq = skipSequenceLookup
? 0
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
logger.info("미리보기: prefix_key 기반 순번 조회", { logger.info("미리보기: prefix_key 기반 순번 조회", {
ruleId, prefixKey, currentSeq, ruleId, prefixKey, currentSeq, skipSequenceLookup,
}); });
const parts = await Promise.all(rule.parts const parts = await Promise.all(rule.parts
@ -1108,7 +1124,8 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
const nextSequence = currentSeq + 1; const startFrom = autoConfig.startFrom || 1;
const nextSequence = currentSeq + startFrom;
return String(nextSequence).padStart(length, "0"); return String(nextSequence).padStart(length, "0");
} }
@ -1150,110 +1167,8 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT"; return autoConfig.textValue || "TEXT";
} }
case "category": { case "category":
// 카테고리 기반 코드 생성 return this.resolveCategoryFormat(autoConfig, formData);
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", {
categoryKey,
hasFormData: !!formData,
});
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length,
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", {
columnName,
formDataKeys: Object.keys(formData),
});
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel,
})),
});
return "";
}
case "reference": { case "reference": {
const refColumn = autoConfig.referenceColumnName; const refColumn = autoConfig.referenceColumnName;
@ -1302,11 +1217,29 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode); const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다"); if (!rule) throw new Error("규칙을 찾을 수 없습니다");
// prefix_key 기반 순번: 순번 이외 파트 조합으로 prefix 생성 // 1단계: 수동 값 추출 (buildPrefixKey 전에 수행해야 prefix_key에 포함 가능)
const prefixKey = await this.buildPrefixKey(rule, formData); const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용 (수동 파트 1개인 경우만)
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
logger.info("수동 값 추출 폴백: userInputCode 전체 사용", { userInputCode });
}
}
// 2단계: prefix_key 빌드 (수동 값 포함)
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 // 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
let allocatedSequence = 0; let allocatedSequence = 0;
if (hasSequence) { if (hasSequence) {
allocatedSequence = await this.incrementSequenceForPrefix( allocatedSequence = await this.incrementSequenceForPrefix(
@ -1320,136 +1253,15 @@ class NumberingRuleService {
} }
logger.info("allocateCode: prefix_key 기반 순번 할당", { logger.info("allocateCode: prefix_key 기반 순번 할당", {
ruleId, prefixKey, allocatedSequence, ruleId, prefixKey, allocatedSequence, extractedManualValues,
}); });
// 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출
const manualParts = rule.parts.filter(
(p: any) => p.generationMethod === "manual"
);
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category": {
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
if (!catKey2 || !formData) {
return "CATEGORY";
}
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selVal2) {
return "CATEGORY";
}
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedManualValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedManualValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`
);
}
let manualPartIndex = 0; let manualPartIndex = 0;
const parts = await Promise.all(rule.parts const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order) .sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => { .map(async (part: any) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
const manualValue = const manualValue = extractedManualValues[manualPartIndex] || "";
extractedManualValues[manualPartIndex] ||
part.manualConfig?.value ||
"";
manualPartIndex++; manualPartIndex++;
return manualValue; return manualValue;
} }
@ -1459,7 +1271,9 @@ class NumberingRuleService {
switch (part.partType) { switch (part.partType) {
case "sequence": { case "sequence": {
const length = autoConfig.sequenceLength || 3; const length = autoConfig.sequenceLength || 3;
return String(allocatedSequence).padStart(length, "0"); const startFrom = autoConfig.startFrom || 1;
const actualSequence = allocatedSequence + startFrom - 1;
return String(actualSequence).padStart(length, "0");
} }
case "number": { case "number": {
@ -1496,65 +1310,14 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT"; return autoConfig.textValue || "TEXT";
} }
case "category": { case "category":
const categoryKey = autoConfig.categoryKey; return this.resolveCategoryFormat(autoConfig, formData);
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
return "";
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) {
return "";
}
const selectedValueStr = String(selectedValue);
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
}
} catch { /* ignore */ }
}
if (allocMapping) {
return allocMapping.format || "";
}
return "";
}
case "reference": { case "reference": {
const refColumn = autoConfig.referenceColumnName; const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) { if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]); return String(formData[refColumn]);
} }
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return ""; return "";
} }
@ -1593,6 +1356,139 @@ class NumberingRuleService {
return this.allocateCode(ruleId, companyCode); return this.allocateCode(ruleId, companyCode);
} }
/**
*
* 릿 ("____")
*/
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
const extractedValues: string[] = [];
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
return "X".repeat(length);
}
case "text":
return autoConfig.textValue || "";
case "date":
return "DATEPART";
case "category":
return this.resolveCategoryFormat(autoConfig, formData);
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "";
}
default:
return "";
}
}));
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
const templateParts = previewTemplate.split("____");
if (templateParts.length > 1) {
let remainingCode = userInputCode;
for (let i = 0; i < templateParts.length - 1; i++) {
const prefix = templateParts[i];
const suffix = templateParts[i + 1];
if (prefix && remainingCode.startsWith(prefix)) {
remainingCode = remainingCode.slice(prefix.length);
}
if (suffix) {
const suffixStart = suffix.replace(/X+|DATEPART/g, "");
const manualEndIndex = suffixStart
? remainingCode.indexOf(suffixStart)
: remainingCode.length;
if (manualEndIndex > 0) {
extractedValues.push(
remainingCode.slice(0, manualEndIndex)
);
remainingCode = remainingCode.slice(manualEndIndex);
}
} else {
extractedValues.push(remainingCode);
}
}
}
logger.info(
`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedValues)}`
);
return extractedValues;
}
/**
* format
* categoryKey + formData로 , format
*/
private async resolveCategoryFormat(
autoConfig: Record<string, any>,
formData?: Record<string, any>
): Promise<string> {
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) return "";
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
if (!selectedValue) return "";
const selectedValueStr = String(selectedValue);
let mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑 못 찾으면 category_values에서 valueCode → valueId 역변환
if (!mapping) {
try {
const pool = getPool();
const [tableName, colName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const result = await pool.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[tableName, colName, selectedValueStr]
);
if (result.rows.length > 0) {
const resolvedId = result.rows[0].value_id;
const resolvedLabel = result.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
}
} catch { /* ignore */ }
}
return mapping?.format || "";
}
private formatDate(date: Date, format: string): string { private formatDate(date: Date, format: string): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");

View File

@ -323,7 +323,7 @@ interface ButtonComponentConfig {
| 파일 | 내용 | | 파일 | 내용 |
|------|------| |------|------|
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 | | `frontend/lib/button-icon-map.tsx` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
--- ---
@ -338,3 +338,52 @@ interface ButtonComponentConfig {
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능 - 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장 - lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화 - lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화
- **동적 아이콘 로딩**: `iconMap`에 명시적으로 import되지 않은 lucide 아이콘도 `getLucideIcon()` 호출 시 `lucide-react`의 전체 아이콘(`icons`)에서 자동 조회 후 캐싱 → 화면 관리에서 선택한 모든 lucide 아이콘이 실제 화면에서도 렌더링됨
- **커스텀 아이콘 전역 관리 (미구현)**: 커스텀 아이콘을 버튼별(`componentConfig`)이 아닌 시스템 전역(`custom_icon_registry` 테이블)으로 관리하여, 한번 추가한 커스텀 아이콘이 모든 화면의 모든 버튼에서 사용 가능하도록 확장 예정
---
## [미구현] 커스텀 아이콘 전역 관리
### 현재 문제
- 커스텀 아이콘이 `componentConfig.customIcons`에 저장 → **해당 버튼에서만** 보임
- 저장1 버튼에 추가한 커스텀 아이콘이 저장2 버튼, 다른 화면에서는 안 보임
- 같은 아이콘을 쓰려면 매번 검색해서 다시 추가해야 함
### 변경 후 동작
- 커스텀 아이콘을 **회사(company_code) 단위 전역**으로 관리
- 어떤 화면의 어떤 버튼에서든 커스텀 아이콘 추가 → 모든 화면의 모든 버튼에서 커스텀란에 표시
- 버튼 액션 종류와 무관하게 모든 커스텀 아이콘이 노출
### DB 테이블 (신규)
```sql
CREATE TABLE custom_icon_registry (
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_code VARCHAR(500) NOT NULL,
icon_name VARCHAR(500) NOT NULL,
icon_type VARCHAR(500) DEFAULT 'lucide', -- 'lucide' | 'svg'
svg_data TEXT, -- SVG일 경우 원본 데이터
created_date TIMESTAMP DEFAULT now(),
updated_date TIMESTAMP DEFAULT now(),
writer VARCHAR(500)
);
CREATE INDEX idx_custom_icon_registry_company ON custom_icon_registry(company_code);
```
### 백엔드 API (신규)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/custom-icons` | 커스텀 아이콘 목록 조회 (company_code 필터) |
| POST | `/api/custom-icons` | 커스텀 아이콘 추가 |
| DELETE | `/api/custom-icons/:id` | 커스텀 아이콘 삭제 |
### 프론트엔드 변경
- `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 API 호출로 변경
- 기존 `componentConfig.customIcons` 데이터는 하위 호환으로 병합 표시 (점진적 마이그레이션)
- `componentConfig.customSvgIcons`도 동일하게 전역 테이블로 이관

View File

@ -145,8 +145,24 @@
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능 - **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수 - **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링 - **구현**: `lucide-react``icons` 객체에서 `Object.keys()`로 전체 이름 목록을 가져오고, CommandInput으로 필터링
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가 - **주의**: `allLucideIcons``button-icon-map.tsx`에서 re-export하여 import를 중앙화
### 18. 커스텀 아이콘 전역 관리 (미구현)
- **결정**: 커스텀 아이콘을 버튼별(`componentConfig`) → 시스템 전역(`custom_icon_registry` 테이블)으로 변경
- **근거**: 현재는 버튼 A에서 추가한 커스텀 아이콘이 버튼 B, 다른 화면에서 안 보여 매번 재등록 필요. 아이콘은 시각적 자원이므로 액션이나 화면에 종속될 이유가 없음
- **범위 검토**: 버튼별 < 화면 단위 < **시스템 전역(채택)** 같은 아이콘을 여러 화면에서 재사용하는 ERP 특성에 시스템 전역이 가장 적합
- **저장**: `custom_icon_registry` 테이블 (company_code 멀티테넌시), lucide 이름 또는 SVG 데이터 저장
- **하위 호환**: 기존 `componentConfig.customIcons` 데이터는 병합 표시 후 점진적 마이그레이션
### 19. 동적 아이콘 로딩 (getLucideIcon fallback)
- **결정**: `getLucideIcon(name)``iconMap`에 없는 아이콘을 `lucide-react``icons` 전체 객체에서 동적으로 조회 후 캐싱
- **근거**: 화면 관리에서 커스텀 lucide 아이콘을 선택하면 `componentConfig.customIcons`에 이름만 저장됨. 디자이너 세션에서는 `addToIconMap()`으로 런타임에 등록되지만, 실제 화면(뷰어) 로드 시에는 `iconMap`에 해당 아이콘이 없어 렌더링 실패. `icons` fallback을 추가하면 **어떤 lucide 아이콘이든 이름만으로 자동 렌더링**
- **구현**: `button-icon-map.tsx``import { icons as allLucideIcons } from "lucide-react"` 추가, `getLucideIcon()`에서 `iconMap` miss 시 `allLucideIcons[name]` 조회 후 `iconMap`에 캐싱
- **번들 영향**: `icons` 전체 객체 import로 번들 크기 증가 (~100-200KB). ERP 애플리케이션 특성상 수용 가능한 수준이며, 관리자가 선택한 모든 아이콘이 실제 화면에서 동작하는 것이 더 중요
- **대안 검토**: 뷰어 로드 시 `customIcons`를 순회하여 개별 등록 → 기각 (모든 뷰어 컴포넌트에 로직 추가 필요, 누락 위험)
--- ---
@ -159,7 +175,7 @@
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) | | 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) | | 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) | | 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 | | 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.tsx` | 액션별 추천 아이콘 + 동적 렌더링 유틸 + allLucideIcons fallback |
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 | | 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
--- ---
@ -169,17 +185,21 @@
### lucide-react 아이콘 동적 렌더링 ### lucide-react 아이콘 동적 렌더링
```typescript ```typescript
// button-icon-map.ts // button-icon-map.tsx
import { Check, Save, Trash2, Pencil, ... } from "lucide-react"; import { Check, Save, ..., icons as allLucideIcons, type LucideIcon } from "lucide-react";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = { // 추천 아이콘은 명시적 import, 나머지는 동적 조회
Check, Save, Trash2, Pencil, ... const iconMap: Record<string, LucideIcon> = { Check, Save, ... };
};
export function renderButtonIcon(name: string, size: string | number) { export function getLucideIcon(name: string): LucideIcon | undefined {
const IconComponent = iconMap[name]; if (iconMap[name]) return iconMap[name];
if (!IconComponent) return null; // iconMap에 없으면 lucide-react 전체에서 동적 조회 후 캐싱
return <IconComponent style={getIconSizeStyle(size)} />; const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
} }
``` ```

View File

@ -125,12 +125,30 @@
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인 - [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인 - [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
### 6단계: 정리 ### 6단계: 동적 아이콘 로딩 (뷰어 렌더링 누락 수정)
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러) - [x] `button-icon-map.tsx``icons as allLucideIcons` import 추가
- [x] `getLucideIcon()``iconMap` miss 시 `allLucideIcons` fallback 조회 + 캐싱
- [x] `allLucideIcons``button-icon-map.tsx`에서 re-export (import 중앙화)
- [x] `ButtonConfigPanel.tsx``lucide-react` 직접 import 제거, `button-icon-map`에서 import로 통합
- [x] 화면 관리에서 선택한 커스텀 lucide 아이콘이 실제 화면(뷰어)에서도 렌더링됨 확인
### 7단계: 정리
- [x] TypeScript 컴파일 에러 없음 확인
- [x] 불필요한 import 없음 확인 - [x] 불필요한 import 없음 확인
- [x] 문서 3개 최신화 (동적 로딩 반영)
- [x] 이 체크리스트 완료 표시 업데이트 - [x] 이 체크리스트 완료 표시 업데이트
### 8단계: 커스텀 아이콘 전역 관리 (미구현)
- [ ] `custom_icon_registry` 테이블 마이그레이션 SQL 작성 및 실행 (개발섭 + 본섭)
- [ ] 백엔드 API 구현 (GET/POST/DELETE `/api/custom-icons`)
- [ ] 프론트엔드 API 클라이언트 함수 추가 (`lib/api/`)
- [ ] `ButtonConfigPanel` — 커스텀 아이콘 조회/추가/삭제를 전역 API로 변경
- [ ] 기존 `componentConfig.customIcons` 하위 호환 병합 처리
- [ ] 검증: 화면 A에서 추가한 커스텀 아이콘이 화면 B에서도 보이는지 확인
--- ---
## 변경 이력 ## 변경 이력
@ -156,3 +174,6 @@
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) | | 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 | | 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 | | 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |
| 2026-03-13 | 동적 아이콘 로딩 — `getLucideIcon()` fallback으로 `allLucideIcons` 조회+캐싱, import 중앙화 |
| 2026-03-13 | 문서 3개 최신화 (계획서 설계 원칙, 맥락노트 결정사항 #18, 체크리스트 6-7단계) |
| 2026-03-13 | 커스텀 아이콘 전역 관리 계획 추가 (8단계, 미구현) — DB 테이블 + API + 프론트 변경 예정 |

View File

@ -0,0 +1,171 @@
# BTN - 버튼 UI 스타일 기준정보
## 1. 스타일 기준
### 공통 스타일
| 항목 | 값 |
|---|---|
| 높이 | 40px |
| 표시모드 | 아이콘 + 텍스트 (icon-text) |
| 아이콘 | 액션별 첫 번째 기본 아이콘 (자동 선택) |
| 아이콘 크기 비율 | 보통 |
| 아이콘-텍스트 간격 | 6px |
| 텍스트 위치 | 오른쪽 (아이콘 왼쪽, 텍스트 오른쪽) |
| 테두리 모서리 | 8px |
| 테두리 색상/두께 | 없음 (투명, borderWidth: 0) |
| 텍스트 색상 | #FFFFFF (흰색) |
| 텍스트 크기 | 12px |
| 텍스트 굵기 | normal (보통) |
| 텍스트 정렬 | 왼쪽 |
### 배경색 (액션별)
| 액션 타입 | 배경색 | 비고 |
|---|---|---|
| `delete` | `#F04544` | 빨간색 |
| `excel_download`, `excel_upload`, `multi_table_excel_upload` | `#212121` | 검정색 |
| 그 외 모든 액션 | `#3B83F6` | 파란색 (기본값) |
배경색은 디자이너에서 액션을 변경하면 자동으로 바뀐다.
### 너비 (텍스트 글자수별)
| 글자수 | 너비 |
|---|---|
| 6글자 이하 | 140px |
| 7글자 이상 | 160px |
### 액션별 기본 아이콘
디자이너에서 표시모드를 "아이콘" 또는 "아이콘+텍스트"로 변경하면 액션에 맞는 첫 번째 아이콘이 자동 선택된다.
소스: `frontend/lib/button-icon-map.tsx` > `actionIconMap`
| action.type | 기본 아이콘 |
|---|---|
| `save` | Check |
| `delete` | Trash2 |
| `edit` | Pencil |
| `navigate` | ArrowRight |
| `modal` | Maximize2 |
| `transferData` | SendHorizontal |
| `excel_download` | Download |
| `excel_upload` | Upload |
| `quickInsert` | Zap |
| `control` | Settings |
| `barcode_scan` | ScanLine |
| `operation_control` | Truck |
| `event` | Send |
| `copy` | Copy |
| (그 외/없음) | SquareMousePointer |
---
## 2. 코드 반영 현황
### 컴포넌트 기본값 (신규 버튼 생성 시 적용)
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/index.ts` | defaultConfig, defaultSize (140x40) |
| `frontend/lib/registry/components/v2-button-primary/config.ts` | ButtonPrimaryDefaultConfig |
### 액션 변경 시 배경색 자동 변경
| 파일 | 내용 |
|---|---|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 액션 변경 시 배경색/텍스트색 자동 설정 |
### 렌더링 배경색 우선순위
| 파일 | 내용 |
|---|---|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 배경색 결정 우선순위 개선 |
배경색 결정 순서:
1. `webTypeConfig.backgroundColor`
2. `componentConfig.backgroundColor`
3. `component.style.backgroundColor`
4. `componentConfig.style.backgroundColor`
5. `component.style.labelColor` (레거시 호환)
6. 액션별 기본 배경색 (`#F04544` / `#212121` / `#3B83F6`)
### 미반영 (추후 작업)
- split-panel 내부 버튼의 코드 기본값 (split-panel 컴포넌트가 자체 생성하는 버튼)
---
## 3. DB 데이터 매핑 (layout_data JSON)
버튼은 `layout_data.components[]` 배열 안에 `url``v2-button-primary`인 컴포넌트로 저장된다.
| 항목 | JSON 위치 | 값 |
|---|---|---|
| 높이 | `size.height` | `40` |
| 너비 | `size.width` | `140` 또는 `160` |
| 표시모드 | `overrides.displayMode` | `"icon-text"` |
| 아이콘 이름 | `overrides.icon.name` | 액션별 영문 이름 |
| 아이콘 타입 | `overrides.icon.type` | `"lucide"` |
| 아이콘 크기 | `overrides.icon.size` | `"보통"` |
| 텍스트 위치 | `overrides.iconTextPosition` | `"right"` |
| 아이콘-텍스트 간격 | `overrides.iconGap` | `6` |
| 테두리 모서리 | `overrides.style.borderRadius` | `"8px"` |
| 텍스트 색상 | `overrides.style.labelColor` | `"#FFFFFF"` |
| 텍스트 크기 | `overrides.style.fontSize` | `"12px"` |
| 텍스트 굵기 | `overrides.style.fontWeight` | `"normal"` |
| 텍스트 정렬 | `overrides.style.labelTextAlign` | `"left"` |
| 배경색 | `overrides.style.backgroundColor` | 액션별 색상 |
버튼이 위치하는 구조별 경로:
- 일반 버튼: `layout_data.components[]`
- 탭 위젯 내부: `layout_data.components[].overrides.tabs[].components[]`
- split-panel 내부: `layout_data.components[].overrides.rightPanel.components[]`
---
## 4. 탑씰(COMPANY_7) 일괄 변경 작업 기록
### 대상
- **회사**: 탑씰 (company_code = 'COMPANY_7')
- **테이블**: screen_layouts_v2 (배포서버)
- **스크립트**: `backend-node/scripts/btn-bulk-update-company7.ts`
- **백업 테이블**: `screen_layouts_v2_backup_company7`
### 작업 이력
| 날짜 | 작업 내용 | 비고 |
|---|---|---|
| 2026-03-13 | 백업 테이블 생성 | |
| 2026-03-13 | 전체 버튼 공통 스타일 일괄 적용 | 높이, 아이콘, 텍스트 스타일, 배경색, 모서리 |
| 2026-03-13 | 탭 위젯 내부 버튼 스타일 보정 | componentConfig + root style 양쪽 적용 |
| 2026-03-13 | fontWeight "400" → "normal" 보정 | |
| 2026-03-13 | overrides.style.width 제거 | size.width와 충돌 방지 |
| 2026-03-13 | save 액션 55개에 "저장" 텍스트 명시 | |
| 2026-03-13 | "엑셀다운로드" → "Excel" 텍스트 통일 | |
| 2026-03-13 | Excel 버튼 배경색 #212121 통일 | |
| 2026-03-13 | 전체 버튼 너비 140px 통일 | |
| 2026-03-13 | 7글자 이상 버튼 너비 160px 재조정 | |
| 2026-03-13 | split-panel 내부 버튼 스타일 적용 | BOM관리 등 7개 버튼 |
### 스킵 항목
- `transferData` 액션의 텍스트 없는 버튼 1개 (screen=5976)
### 알려진 이슈
- **반응형 너비 불일치**: 디자이너에서 설정한 `size.width`가 실제 화면(`ResponsiveGridRenderer`)에서 반영되지 않을 수 있음. 버튼 wrapper에 `width` 속성이 누락되어 flex shrink-to-fit 동작으로 너비가 줄어드는 현상. 세로(height)는 정상 반영됨.
### 원복 (필요 시)
```sql
UPDATE screen_layouts_v2 AS target
SET layout_data = backup.layout_data
FROM screen_layouts_v2_backup_company7 AS backup
WHERE target.layout_id = backup.layout_id;
```
### 백업 테이블 정리
```sql
DROP TABLE screen_layouts_v2_backup_company7;
```

View File

@ -0,0 +1,420 @@
# [계획서] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [맥락노트](./MPN[맥락]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
## 개요
기준정보 - 품목 정보 등록 모달에서 품번(`item_number`) 채번의 세 가지 문제를 해결합니다.
1. **BULK1 덮어쓰기 문제**: 사용자가 "ㅁㅁㅁ"을 입력해도 수동 값 추출이 실패하여 DB 숨은 값 `manualConfig.value = "BULK1"`로 덮어씌워짐
2. **순번 공유 문제**: `buildPrefixKey`가 수동 파트를 건너뛰어 모든 접두어가 같은 시퀀스 카운터를 공유함
3. **연속 구분자(--) 문제**: 카테고리가 비었을 때 `joinPartsWithSeparators`가 빈 파트에도 구분자를 붙여 `--` 발생 + 템플릿 불일치로 수동 값 추출 실패 → `userInputCode` 전체(구분자 포함)가 수동 값이 됨
---
## 현재 동작
### 채번 규칙 구성 (옵션설정 > 코드설정)
```
규칙1(카테고리/재질, 자동) → "-" → 규칙2(문자, 직접입력) → "-" → 규칙3(순번, 자동, 3자리, 시작=5)
```
### 실제 저장 흐름 (사용자가 "ㅁㅁㅁ" 입력 시)
1. 모달 열림 → `_numberingRuleId` 설정됨 (TextInputComponent L117-128)
2. 사용자가 "ㅁㅁㅁ" 입력 → `formData.item_number = "ㅁㅁㅁ"`
3. 저장 클릭 → `buttonActions.ts``_numberingRuleId` 확인 → `allocateCode(ruleId, "ㅁㅁㅁ", formData)` 호출
4. 백엔드: 템플릿 기반 수동 값 추출 시도 → **실패** (입력 "ㅁㅁㅁ"이 템플릿 "CATEGORY-____-XXX"와 불일치)
5. 폴백: `manualConfig.value = "BULK1"` 사용 → **사용자 입력 "ㅁㅁㅁ" 완전 무시됨**
6. `buildPrefixKey`가 수동 파트를 건너뜀 → prefix_key에 접두어 미포함 → 공유 카운터 사용
7. 결과: **-BULK1-015** (사용자가 뭘 입력하든 항상 BULK1, 항상 공유 카운터)
### 문제 1: 순번 공유 (buildPrefixKey)
**위치**: `numberingRuleService.ts` L85-88
```typescript
if (part.generationMethod === "manual") {
// 수동 입력 파트는 prefix에서 제외 (값이 매번 달라질 수 있으므로)
continue; // ← 접두어별 순번 분리를 막는 원인
}
```
`continue` 때문에 수동 입력값이 prefix_key에 포함되지 않습니다.
"ㅁㅁㅁ", "ㅇㅇㅇ", "BULK1" 전부 **같은 시퀀스 카운터를 공유**합니다.
### 문제 2: BULK1 덮어쓰기 (추출 실패 + manualConfig.value 폴백)
**발생 흐름**:
1. 사용자가 "ㅁㅁㅁ" 입력 → `userInputCode = "ㅁㅁㅁ"` 으로 `allocateCode` 호출
2. `allocateCode` 내부에서 **prefix_key를 먼저 빌드** (L1306) → 수동 값 추출은 그 이후 (L1332-1442)
3. 템플릿 기반 수동 값 추출 시도 (L1411-1436):
```
템플릿: "카테고리값-____-XXX" (카테고리값-수동입력위치-순번)
사용자 입력: "ㅁㅁㅁ"
```
4. "ㅁㅁㅁ"은 "카테고리값-"으로 시작하지 않음 → `startsWith` 불일치 → **추출 실패**`extractedManualValues = []`
5. 코드 조합 단계 (L1448-1454)에서 폴백 체인 동작:
```typescript
const manualValue =
extractedManualValues[0] || // undefined (추출 실패)
part.manualConfig?.value || // "BULK1" (DB 숨은 값) ← 여기서 덮어씌워짐
"";
```
6. 결과: `-BULK1-015` (사용자 입력 "ㅁㅁㅁ"이 완전히 무시됨)
**DB 숨은 값 원인**:
- DB `numbering_rule_parts.manual_config` 컬럼에 `{"value": "BULK1", "placeholder": "..."}` 저장됨
- `ManualConfigPanel.tsx`에는 `placeholder` 입력란만 있고 **`value` 입력란이 없음**
- 플레이스홀더 수정 시 `{ ...config, placeholder: ... }` 스프레드로 기존 `value: "BULK1"`이 계속 보존됨
### 문제 3: 연속 구분자(--) 문제
**발생 흐름**:
1. 카테고리 미선택 → 카테고리 파트 값 = `""` (빈 문자열)
2. `joinPartsWithSeparators`가 빈 파트에도 구분자 `-`를 추가 → 연속 빈 파트 시 `--` 발생
3. 사용자 입력 필드에 `-제발-015` 형태로 표시 (선행 `-`)
4. `extractManualValuesFromInput`에서 템플릿이 `CATEGORY-____-XXX`로 생성됨 (실제 값 `""` 대신 플레이스홀더 `"CATEGORY"` 사용)
5. 입력 `-제발-015``CATEGORY-`로 시작하지 않음 → 추출 실패
6. 폴백: `userInputCode` 전체 `-제발-015`가 수동 값이 됨
7. 코드 조합: `""` + `-` + `-제발-015` + `-` + `003` = `--제발-015-003`
### 정상 동작 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| `_numberingRuleId` 유지 | 정상 | 사용자 입력해도 allocateCode가 호출됨 |
| 시퀀스 증가 | 정상 | 순번이 증가하고 있음 (015 등) |
| 코드 조합 | 정상 | 구분자, 파트 순서 등 올바르게 결합됨 |
### 비정상 확인된 부분
| 항목 | 상태 | 근거 |
|------|------|------|
| 수동 값 추출 | **실패** | 사용자 입력 "ㅁㅁㅁ"이 템플릿과 불일치 → 추출 실패 → BULK1 폴백 |
| prefix_key 분리 | **실패** | `buildPrefixKey`가 수동 파트 skip → 모든 접두어가 같은 시퀀스 공유 |
| 연속 구분자 | **실패** | 빈 파트에 구분자 추가 + 템플릿 플레이스홀더 불일치 → `--` 발생 |
---
## 변경 후 동작
### prefix_key에 수동 파트 값 포함
```
현재: prefix_key = 카테고리값만 (수동 파트 무시)
변경: prefix_key = 카테고리값 + "|" + 수동입력값
```
### allocateCode 실행 순서 변경
```
현재: buildPrefixKey → 시퀀스 할당 → 수동 값 추출 → 코드 조합
변경: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### 순번 동작
```
"ㅁㅁㅁ" 첫 등록 → prefix_key="카테고리|ㅁㅁㅁ", sequence=1 → -ㅁㅁㅁ-001
"ㅁㅁㅁ" 두번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=2 → -ㅁㅁㅁ-002
"ㅇㅇㅇ" 첫 등록 → prefix_key="카테고리|ㅇㅇㅇ", sequence=1 → -ㅇㅇㅇ-001
"ㅁㅁㅁ" 세번째 → prefix_key="카테고리|ㅁㅁㅁ", sequence=3 → -ㅁㅁㅁ-003
```
### BULK1 폴백 제거 (코드 + DB 이중 조치)
```
코드: 폴백 체인에서 manualConfig.value 제거 → extractedManualValues만 사용
DB: manual_config에서 "value": "BULK1" 키 제거 → 유령 기본값 정리
```
### 연속 구분자 방지 + 템플릿 정합성 복원
```
joinPartsWithSeparators: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
extractManualValuesFromInput: 카테고리/참조 빈 값 시 "" 반환 (플레이스홀더 "CATEGORY"/"REF" 대신)
→ 템플릿이 실제 코드 구조와 일치 → 추출 성공 → -- 방지
```
---
## 시각적 예시
| 사용자 입력 | 현재 동작 | 원인 | 변경 후 동작 |
|------------|----------|------|-------------|
| `ㅁㅁㅁ` (첫번째) | `-BULK1-015` | 추출 실패 → BULK1 폴백 + 공유 카운터 | `카테고리값-ㅁㅁㅁ-001` |
| `ㅁㅁㅁ` (두번째) | `-BULK1-016` | 동일 | `카테고리값-ㅁㅁㅁ-002` |
| `ㅇㅇㅇ` (첫번째) | `-BULK1-017` | 동일 | `카테고리값-ㅇㅇㅇ-001` |
| (입력 안 함) | `-BULK1-018` | manualConfig.value 폴백 | 에러 반환 (수동 파트 필수 입력) |
| 카테고리 비었을 때 | `--제발-015-003` | 빈 파트 구분자 중복 + 템플릿 불일치 | `-제발-001` |
---
## 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant BA as buttonActions.ts
participant API as allocateNumberingCode API
participant NRS as numberingRuleService
participant DB as numbering_rule_sequences
User->>BA: 저장 클릭 (item_number = "ㅁㅁㅁ")
BA->>API: allocateCode(ruleId, "ㅁㅁㅁ", formData)
API->>NRS: allocateCode()
Note over NRS: 1단계: 수동 값 추출 (buildPrefixKey 전에 수행)
NRS->>NRS: extractManualValuesFromInput("ㅁㅁㅁ")
Note over NRS: 템플릿 파싱 실패 → 폴백: userInputCode 전체 사용
NRS->>NRS: extractedManualValues = ["ㅁㅁㅁ"]
Note over NRS: 2단계: prefix_key 빌드 (수동 값 포함)
NRS->>NRS: buildPrefixKey(rule, formData, ["ㅁㅁㅁ"])
Note over NRS: prefix_key = "카테고리값|ㅁㅁㅁ"
Note over NRS: 3단계: 시퀀스 할당
NRS->>DB: UPSERT sequences (prefix_key="카테고리값|ㅁㅁㅁ")
DB-->>NRS: current_sequence = 1
Note over NRS: 4단계: 코드 조합
NRS->>NRS: 카테고리값 + "-" + "ㅁㅁㅁ" + "-" + "001"
NRS-->>API: "카테고리값-ㅁㅁㅁ-001"
API-->>BA: generatedCode
BA->>BA: formData.item_number = "카테고리값-ㅁㅁㅁ-001"
```
---
## 변경 대상 파일
| 파일 | 변경 내용 | 규모 |
|------|----------|------|
| `backend-node/src/services/numberingRuleService.ts` | `buildPrefixKey``manualValues` 파라미터 추가, `allocateCode`에서 수동 값 추출 순서 변경 + 폴백 체인 정리, `extractManualValuesFromInput` 헬퍼 분리, `joinPartsWithSeparators` 연속 구분자 방지, 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경, `previewCode``manualInputValue` 파라미터 추가 + `startFrom` 적용 | ~80줄 |
| `backend-node/src/controllers/numberingRuleController.ts` | preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가 | ~2줄 |
| `frontend/lib/api/numberingRule.ts` | `previewNumberingCode``manualInputValue` 파라미터 추가 | ~3줄 |
| `frontend/components/v2/V2Input.tsx` | 수동 입력값 변경 시 디바운스(300ms) preview API 호출 + suffix(순번) 실시간 갱신 | ~35줄 |
| `db/migrations/1053_remove_bulk1_manual_config_value.sql` | `numbering_rule_parts.manual_config`에서 `value: "BULK1"` 제거 | SQL 1건 |
### buildPrefixKey 호출부 영향 분석
| 호출부 | 위치 | `manualValues` 전달 | 영향 |
|--------|------|---------------------|------|
| `previewCode` | L1091 | `manualInputValue` 전달 시 포함 | 접두어별 정확한 순번 조회 |
| `allocateCode` | L1332 | 전달 | prefix_key에 수동 값 포함됨 |
### 멀티테넌시 체크
| 항목 | 상태 | 근거 |
|------|------|------|
| `buildPrefixKey` | 영향 없음 | 시그니처만 확장, company_code 관련 변경 없음 |
| `allocateCode` | 이미 준수 | L1302에서 `companyCode`로 규칙 조회, L1313에서 시퀀스 할당 시 `companyCode` 전달 |
| `joinPartsWithSeparators` | 영향 없음 | 순수 문자열 조합 함수, company_code 무관 |
| DB 마이그레이션 | 해당 없음 | JSONB 내부 값 정리, company_code 무관 |
---
## 코드 설계
### 1. `joinPartsWithSeparators` 수정 - 연속 구분자 방지
**위치**: L36-48
**변경**: 빈 파트 뒤에 이미 구분자가 있으면 중복 추가하지 않음
```typescript
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
if (val || !result.endsWith(sep)) {
result += sep;
}
}
});
return result;
}
```
### 2. `buildPrefixKey` 수정 - 수동 파트 값을 prefix에 포함
**위치**: L75-88
**변경**: 세 번째 파라미터 `manualValues` 추가. 전달되면 prefix_key에 포함.
```typescript
private async buildPrefixKey(
rule: NumberingRuleConfig,
formData?: Record<string, any>,
manualValues?: string[]
): Promise<string> {
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const prefixParts: string[] = [];
let manualIndex = 0;
for (const part of sortedParts) {
if (part.partType === "sequence") continue;
if (part.generationMethod === "manual") {
const manualValue = manualValues?.[manualIndex] || "";
manualIndex++;
if (manualValue) {
prefixParts.push(manualValue);
}
continue;
}
// ... 나머지 기존 로직 (text, date, category, reference 등) 그대로 유지 ...
}
return prefixParts.join("|");
}
```
**하위 호환성**: `manualValues`는 optional. `previewCode`(L1091)는 전달하지 않으므로 동작 변화 없음.
### 3. `allocateCode` 수정 - 수동 값 추출 순서 변경 + 폴백 정리
**위치**: L1290-1584
**핵심 변경 2가지**:
(A) 기존에는 `buildPrefixKey`(L1306) → 수동 값 추출(L1332) 순서였으나, **수동 값 추출 → `buildPrefixKey`** 순서로 변경.
(B) 코드 조합 단계(L1448-1454)에서 `manualConfig.value` 폴백 제거.
```typescript
async allocateCode(ruleId, companyCode, formData?, userInputCode?) {
// ... 규칙 조회 ...
// 1단계: 수동 파트 값 추출 (buildPrefixKey 호출 전에 수행)
const manualParts = rule.parts.filter(p => p.generationMethod === "manual");
let extractedManualValues: string[] = [];
if (manualParts.length > 0 && userInputCode) {
extractedManualValues = await this.extractManualValuesFromInput(
rule, userInputCode, formData
);
// 폴백: 추출 실패 시 userInputCode 전체를 수동 값으로 사용
if (extractedManualValues.length === 0 && manualParts.length === 1) {
extractedManualValues = [userInputCode];
}
}
// 2단계: 수동 값을 포함하여 prefix_key 빌드
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
// 3단계: 시퀀스 할당 (기존 로직 그대로)
// 4단계: 코드 조합 (manualConfig.value 폴백 제거)
// 기존: extractedManualValues[i] || part.manualConfig?.value || ""
// 변경: extractedManualValues[i] || ""
}
```
### 4. `extractManualValuesFromInput` 헬퍼 분리 + 템플릿 정합성 복원
기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출.
로직 자체는 변경 없음, 위치만 이동.
카테고리/참조 파트의 빈 값 처리를 실제 코드 생성과 일치시킴.
```typescript
private async extractManualValuesFromInput(
rule: NumberingRuleConfig,
userInputCode: string,
formData?: Record<string, any>
): Promise<string[]> {
// 기존 L1332-1442의 로직을 그대로 이동
// 변경: 카테고리/참조 빈 값 시 "CATEGORY"/"REF" 대신 "" 반환
// → 템플릿이 실제 코드 구조와 일치 → 추출 성공률 향상
}
```
### 5. DB 마이그레이션 - BULK1 유령 기본값 제거
**파일**: `db/migrations/1053_remove_bulk1_manual_config_value.sql`
`numbering_rule_parts.manual_config` 컬럼에서 `value` 키를 제거합니다.
```sql
-- manual_config에서 "value" 키 제거 (BULK1 유령 기본값 정리)
UPDATE numbering_rule_parts
SET manual_config = manual_config - 'value'
WHERE generation_method = 'manual'
AND manual_config ? 'value'
AND manual_config->>'value' = 'BULK1';
```
> PostgreSQL JSONB 연산자 `-`를 사용하여 특정 키만 제거.
> `manual_config`의 나머지 필드(`placeholder` 등)는 유지됨.
> "BULK1" 값을 가진 레코드만 대상으로 하여 안전성 확보.
---
## 설계 원칙
- **변경 범위 최소화**: `numberingRuleService.ts` 코드 변경 + DB 마이그레이션 1건
- **이중 조치**: 코드에서 `manualConfig.value` 폴백 제거 + DB에서 유령 값 정리
- `buildPrefixKey``manualValues`는 optional → 기존 호출부(`previewCode` 등)에 영향 없음
- `allocateCode` 내부 로직 순서만 변경 (추출 → prefix_key 빌드), 새 로직 추가 아님
- 수동 값 추출 로직은 기존 코드를 헬퍼로 분리할 뿐, 로직 자체는 변경 없음
- DB 마이그레이션은 "BULK1" 값만 정확히 타겟팅하여 부작용 방지
- `TextInputComponent.tsx` 변경 불필요 (현재 동작이 올바름)
- 프론트엔드 변경 없음 → 프론트엔드 테스트 불필요
- `joinPartsWithSeparators`는 연속 구분자만 방지, 기존 구분자 구조 유지
- 템플릿 카테고리/참조 빈 값을 실제 코드와 일치시켜 추출 성공률 향상
---
## 실시간 순번 미리보기 (추가 기능)
### 배경
품목 등록 모달에서 수동 입력 세그먼트 우측에 표시되는 순번(suffix)이 입력값과 무관하게 고정되어 있었음. 사용자가 "ㅇㅇ"을 입력하면 해당 접두어로 이미 몇 개가 등록되었는지에 따라 순번이 달라져야 함.
### 목표 동작
```
모달 열림 : -[입력하시오]-005 (startFrom=5 기반 기본 순번)
"ㅇㅇ" 입력 : -[ㅇㅇ]-005 (기존 "ㅇㅇ" 등록 0건)
저장 후 재입력 "ㅇㅇ": -[ㅇㅇ]-006 (기존 "ㅇㅇ" 등록 1건)
```
### 아키텍처
```mermaid
sequenceDiagram
participant User as 사용자
participant V2 as V2Input
participant API as previewNumberingCode
participant BE as numberingRuleService.previewCode
participant DB as numbering_rule_sequences
User->>V2: 수동 입력 "ㅇㅇ"
Note over V2: 디바운스 300ms
V2->>API: preview(ruleId, formData, "ㅇㅇ")
API->>BE: previewCode(ruleId, companyCode, formData, "ㅇㅇ")
BE->>BE: buildPrefixKey(rule, formData, ["ㅇㅇ"])
Note over BE: prefix_key = "카테고리|ㅇㅇ"
BE->>DB: getSequenceForPrefix(prefix_key)
DB-->>BE: currentSeq = 0
Note over BE: nextSequence = 0 + startFrom(5) = 5
BE-->>API: "-____-005"
API-->>V2: generatedCode
V2->>V2: suffix = "-005" 갱신
Note over V2: 화면 표시: -[ㅇㅇ]-005
```
### 변경 내용
1. **백엔드 컨트롤러**: preview 엔드포인트가 `req.body.manualInputValue` 수신
2. **백엔드 서비스**: `previewCode``manualInputValue`를 받아 `buildPrefixKey`에 전달 → 접두어별 정확한 시퀀스 조회
3. **백엔드 서비스**: 수동 파트가 있는데 `manualInputValue`가 없는 초기 상태 → 레거시 공용 시퀀스 조회 건너뜀, `currentSeq = 0` 사용 → `startFrom` 기본값 표시
4. **프론트엔드 API**: `previewNumberingCode``manualInputValue` 파라미터 추가
5. **V2Input**: `manualInputValue` 변경 시 디바운스(300ms) preview API 재호출 → `numberingTemplateRef` 갱신 → suffix 실시간 업데이트
6. **V2Input**: 카테고리 변경 시 초기 useEffect에서도 현재 `manualInputValue`를 preview에 전달 → 카테고리 변경/삭제 시 순번 즉시 반영
7. **코드 정리**: 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼로 통합 (약 100줄 감소)

View File

@ -0,0 +1,161 @@
# [맥락노트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [체크리스트](./MPN[체크]-품번-수동접두어채번.md)
---
## 왜 이 작업을 하는가
- 기준정보 - 품목정보 등록 모달에서 품번 인풋에 사용자가 값을 입력해도 무시되고 "BULK1"로 저장됨
- 서로 다른 접두어("ㅁㅁㅁ", "ㅇㅇㅇ")를 입력해도 전부 같은 시퀀스 카운터를 공유함
- 카테고리 미선택 시 `--제발-015-003` 처럼 연속 구분자가 발생함
- 사용자 입력이 반영되고, 접두어별로 독립된 순번이 부여되어야 함
---
## 핵심 결정 사항과 근거
### 1. 수동 값 추출을 buildPrefixKey 전으로 이동
- **결정**: `allocateCode` 내부에서 수동 값 추출 → buildPrefixKey 순서로 변경
- **근거**: 기존에는 buildPrefixKey(L1306)가 먼저 실행된 후 수동 값 추출(L1332)이 진행됨. 수동 값이 prefix_key에 포함되려면 추출이 먼저 되어야 함
- **대안 검토**: buildPrefixKey 내부에서 직접 추출 → 기각 (역할 분리 위반, previewCode 호출에도 영향)
### 2. buildPrefixKey에 수동 파트 값 포함
- **결정**: `manualValues` optional 파라미터 추가, 전달되면 prefix_key에 포함
- **근거**: 기존 `continue`(L85-87)로 수동 파트가 prefix_key에서 제외되어 모든 접두어가 같은 시퀀스를 공유함
- **하위호환**: optional 파라미터이므로 `previewCode`(L1091) 등 기존 호출부는 영향 없음
### 3. 템플릿 파싱 실패 시 userInputCode 전체를 수동 값으로 사용
- **결정**: 수동 파트가 1개이고 템플릿 기반 추출이 실패하면 `userInputCode` 전체를 수동 값으로 사용
- **근거**: 사용자가 "ㅁㅁㅁ"처럼 접두어 부분만 입력하면 템플릿 "카테고리값-____-XXX"와 불일치. `startsWith` 조건 실패로 추출이 안 됨. 이 경우 입력 전체가 수동 값임
- **제한**: 수동 파트가 2개 이상이면 이 폴백 불가 (어디서 분리할지 알 수 없음)
### 4. 코드 조합에서 manualConfig.value 폴백 제거
- **결정**: `extractedManualValues[i] || part.manualConfig?.value || ""``extractedManualValues[i] || ""`
- **근거**: `manualConfig.value`는 UI에서 입력/편집할 수 없는 유령 필드. `ManualConfigPanel.tsx``value` 입력란이 없어 DB에 한번 저장되면 스프레드 연산자로 계속 보존됨
- **이중 조치**: 코드에서 폴백 제거 + DB 마이그레이션으로 기존 "BULK1" 값 정리
### 5. DB 마이그레이션은 BULK1만 타겟팅
- **결정**: `manual_config->>'value' = 'BULK1'` 조건으로 한정
- **근거**: 다른 value가 의도적으로 설정된 경우가 있을 수 있음. 확인된 문제("BULK1")만 정리하여 부작용 방지
- **대안 검토**: 전체 `manual_config.value` 키 제거 → 보류 (운영 판단 필요)
### 6. extractManualValuesFromInput 헬퍼 분리
- **결정**: 기존 `allocateCode` 내부의 수동 값 추출 로직(L1332-1442)을 별도 private 메서드로 추출
- **근거**: 추출 로직이 약 110줄로 `allocateCode`가 과도하게 비대함. 헬퍼로 분리하면 순서 변경도 자연스러움
- **원칙**: 로직 자체는 변경 없음, 위치만 이동 (구조적 변경과 행위적 변경 분리)
### 7. 프론트엔드 변경 불필요
- **결정**: 프론트엔드 코드 수정 없음
- **근거**: `_numberingRuleId`가 사용자 입력 시에도 유지되고 있음 확인. `buttonActions.ts`가 정상적으로 `allocateCode`를 호출함. 문제는 백엔드 로직에만 있음
### 8. joinPartsWithSeparators 연속 구분자 방지
- **결정**: 빈 파트 뒤에 이미 같은 구분자가 있으면 중복 추가하지 않음
- **근거**: 카테고리가 비면 파트 값 `""` + 구분자 `-`가 반복되어 `--` 발생. 구분자 구조(`-ㅁㅁㅁ-001`)는 유지하되 연속(`--`)만 방지
- **조건**: `if (val || !result.endsWith(sep))` — 값이 있으면 항상 추가, 값이 없으면 이미 같은 구분자로 끝나면 스킵
### 9. 템플릿 카테고리/참조 플레이스홀더를 실제값으로 변경
- **결정**: `extractManualValuesFromInput` 내부의 카테고리/참조 빈 값 반환을 `"CATEGORY"`/`"REF"` → `""`로 변경
- **근거**: 실제 코드 생성에서 빈 카테고리는 `""`인데 템플릿에서 `"CATEGORY"`를 쓰면 구조 불일치로 추출 실패. 로그로 확인: `userInputCode=-제발-015, previewTemplate=CATEGORY-____-XXX, extractedManualValues=[]`
- **카테고리 있을 때**: `catMapping2?.format` 반환은 수정 전후 동일하여 영향 없음
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `backend-node/src/services/numberingRuleService.ts` | joinPartsWithSeparators(L36), buildPrefixKey(L75), extractManualValuesFromInput(신규), allocateCode(L1296) |
| 신규 생성 | `db/migrations/1053_remove_bulk1_manual_config_value.sql` | BULK1 유령 값 정리 마이그레이션 |
| 변경 없음 | `frontend/components/screen/widgets/TextInputComponent.tsx` | _numberingRuleId 유지 확인 완료 |
| 변경 없음 | `frontend/lib/registry/components/numbering-rule/config.ts` | 채번 설정 레지스트리 |
| 변경 없음 | `frontend/components/screen/config-panels/NumberConfigPanel.tsx` | 채번 규칙 설정 패널 |
| 참고 | `backend-node/src/controllers/numberingRuleController.ts` | allocateNumberingCode 컨트롤러 |
---
## 기술 참고
### allocateCode 실행 순서 (변경 전 → 후)
```
변경 전: buildPrefixKey(L1306) → 시퀀스 할당 → 수동 값 추출(L1332) → 코드 조합
변경 후: 수동 값 추출 → buildPrefixKey(수동 값 포함) → 시퀀스 할당 → 코드 조합
```
### prefix_key 구성 (변경 전 → 후)
```
변경 전: "카테고리값" (수동 파트 무시, 모든 접두어가 같은 키)
변경 후: "카테고리값|ㅁㅁㅁ" (수동 파트 포함, 접두어별 독립 키)
```
### 폴백 체인 (변경 전 → 후)
```
변경 전: extractedManualValues[i] || manualConfig.value || ""
변경 후: extractedManualValues[i] || ""
```
### joinPartsWithSeparators 연속 구분자 방지 (변경 전 → 후)
```
변경 전: "" + "-" + "" + "-" + "ㅁㅁㅁ" → "--ㅁㅁㅁ"
변경 후: "" + "-" (이미 "-"로 끝남, 스킵) + "ㅁㅁㅁ" → "-ㅁㅁㅁ"
```
### 템플릿 정합성 (변경 전 → 후)
```
변경 전: 카테고리 비었을 때 템플릿 = "CATEGORY-____-XXX" / 입력 = "-제발-015" → 불일치 → 추출 실패
변경 후: 카테고리 비었을 때 템플릿 = "-____-XXX" / 입력 = "-제발-015" → 일치 → 추출 성공
```
### 10. 실시간 순번 미리보기 구현 방식
- **결정**: V2Input에서 `manualInputValue` 변경 시 디바운스(300ms)로 preview API를 재호출하여 suffix(순번)를 갱신
- **근거**: 기존 preview API는 `manualInputValue` 없이 호출되어 모든 접두어가 같은 기본 순번을 표시함. 접두어별 정확한 순번을 보여주려면 preview 시점에도 수동 값을 전달하여 해당 prefix_key의 시퀀스를 조회해야 함
- **대안 검토**: 프론트엔드에서 카운트 API를 별도 호출 → 기각 (기존 `previewCode` 흐름 재사용이 프로젝트 관행에 부합)
- **디바운스 300ms**: 사용자 타이핑 중 과도한 API 호출 방지. 프로젝트 기존 패턴(검색 디바운스 등)과 동일
### 11. previewCode에 manualInputValue 전달
- **결정**: `previewCode` 시그니처에 `manualInputValue?: string` 추가, `buildPrefixKey``[manualInputValue]`로 전달
- **근거**: `buildPrefixKey`가 이미 `manualValues` optional 파라미터를 지원하므로 자연스럽게 확장 가능. 순번 조회 시 접두어별 독립 시퀀스를 정확히 반영함
- **하위호환**: optional 파라미터이므로 기존 호출(`formData`만 전달)에 영향 없음
### 12. 초기 상태에서 레거시 시퀀스 조회 방지
- **결정**: `previewCode`에서 수동 파트가 있는데 `manualInputValue`가 없으면 시퀀스 조회를 건너뛰고 `currentSeq = 0` 사용
- **근거**: 수정 전에는 모든 할당이 수동 파트 없는 공용 prefix_key를 사용했으므로 레거시 시퀀스가 누적되어 있음(예: 16). 모달 초기 상태에서 이 공용 키를 조회하면 `-016`이 표시됨. 아직 어떤 접두어인지 모르는 상태이므로 `startFrom` 기본값을 보여주는 것이 정확함
- **`currentSeq = 0` + `startFrom`**: `nextSequence = 0 + startFrom(5) = 5``-005` 표시. 사용자가 입력하면 디바운스 preview가 해당 접두어의 실제 시퀀스를 조회
### 13. 카테고리 변경 시 수동 입력값 포함하여 순번 재조회
- **결정**: 초기 useEffect(카테고리 변경 트리거)에서 `previewNumberingCode` 호출 시 현재 `manualInputValue`도 함께 전달
- **근거**: 카테고리를 바꾸거나 삭제하면 prefix_key가 달라지므로 순번도 달라져야 함. 기존에는 입력값 변경과 카테고리 변경이 별도 트리거여서 카테고리 변경 시 수동 값이 누락됨
- **빈 입력값 처리**: `manualInputValue || undefined`로 처리하여 빈 문자열일 때는 기존처럼 `skipSequenceLookup` 작동
### 14. 카테고리 해석 로직 resolveCategoryFormat 헬퍼 통합
- **결정**: `previewCode`, `allocateCode`, `extractManualValuesFromInput` 3곳에 복붙된 카테고리 매핑 해석 로직을 `resolveCategoryFormat` private 메서드로 추출
- **근거**: 동일 로직 약 50줄이 3곳에 복사되어 있었음 (변수명만 pool2/ct2/cc2 등으로 다름). 한 곳을 수정하면 나머지도 동일하게 수정해야 하는 유지보수 위험
- **원칙**: 구조적 변경만 수행 (로직 변경 없음)
### BULK1이 DB에 남아있는 이유
```
ManualConfigPanel.tsx: placeholder 입력란만 존재 (value 입력란 없음)
플레이스홀더 수정 시: { ...existingConfig, placeholder: newValue }
→ 기존 config에 value: "BULK1"이 있으면 스프레드로 계속 보존됨
→ UI에서 제거 불가능한 유령 값
```

View File

@ -0,0 +1,100 @@
# [체크리스트] 품번 수동 접두어 채번 - 접두어별 독립 순번 생성
> 관련 문서: [계획서](./MPN[계획]-품번-수동접두어채번.md) | [맥락노트](./MPN[맥락]-품번-수동접두어채번.md)
---
## 공정 상태
- 전체 진행률: **100%** (전체 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 구조적 변경 (행위 변경 없음)
- [x] `numberingRuleService.ts`에서 수동 값 추출 로직을 `extractManualValuesFromInput` private 메서드로 분리
- [x] 기존 `allocateCode` 내부에서 분리한 메서드 호출로 교체
- [x] 기존 동작과 동일한지 확인 (구조적 변경만, 행위 변경 없음)
### 2단계: buildPrefixKey 수정
- [x] `buildPrefixKey` 시그니처에 `manualValues?: string[]` 파라미터 추가
- [x] 수동 파트 처리 로직 변경: `continue``manualValues`에서 값 꺼내 `prefixParts`에 추가
- [x] `previewCode` 호출부에 영향 없음 확인 (optional 파라미터)
### 3단계: allocateCode 순서 변경 + 폴백 정리
- [x] 수동 값 추출 로직을 `buildPrefixKey` 호출 전으로 이동
- [x] 수동 파트 1개 + 추출 실패 시 `userInputCode` 전체를 수동 값으로 사용하는 폴백 추가
- [x] `buildPrefixKey` 호출 시 `extractedManualValues`를 세 번째 인자로 전달
- [x] 코드 조합 단계에서 `part.manualConfig?.value` 폴백 제거
### 4단계: DB 마이그레이션
- [x] `db/migrations/1053_remove_bulk1_manual_config_value.sql` 작성
- [x] `manual_config->>'value' = 'BULK1'` 조건으로 JSONB에서 `value` 키 제거
- [x] 마이그레이션 실행 (9건 정리 완료)
### 5단계: 연속 구분자(--) 방지
- [x] `joinPartsWithSeparators`에서 빈 파트 뒤 연속 구분자 방지 로직 추가
- [x] `extractManualValuesFromInput`에서 카테고리/참조 빈 값 시 `""` 반환 (템플릿 정합성)
### 6단계: 검증
- [x] 카테고리 선택 + 수동입력 "ㅁㅁㅁ" → 카테고리값-ㅁㅁㅁ-001 생성 확인
- [x] 카테고리 미선택 + 수동입력 "ㅁㅁㅁ" → -ㅁㅁㅁ-001 생성 확인 (-- 아님)
- [x] 같은 접두어 "ㅁㅁㅁ" 재등록 → -ㅁㅁㅁ-002 순번 증가 확인
- [x] 다른 접두어 "ㅇㅇㅇ" 등록 → -ㅇㅇㅇ-001 독립 시퀀스 확인
- [x] 수동 파트 없는 채번 규칙 동작 영향 없음 확인
- [x] previewCode (미리보기) 동작 영향 없음 확인
- [x] BULK1이 더 이상 생성되지 않음 확인
### 7단계: 실시간 순번 미리보기
- [x] 백엔드 컨트롤러: preview 엔드포인트에 `manualInputValue` body 파라미터 수신 추가
- [x] 백엔드 서비스: `previewCode``manualInputValue` 파라미터 추가, `buildPrefixKey`에 전달
- [x] 프론트엔드 API: `previewNumberingCode``manualInputValue` 파라미터 추가
- [x] V2Input: `manualInputValue` 변경 시 디바운스(300ms) preview API 호출 + suffix 갱신
- [x] 백엔드 서비스: 초기 상태(수동 입력 없음) 시 레거시 공용 시퀀스 조회 건너뜀 → startFrom 기본값 표시
- [x] V2Input: 카테고리 변경 시 초기 useEffect에서도 `manualInputValue` 전달 → 순번 즉시 반영
- [x] 린트 에러 없음 확인
### 8단계: 코드 정리
- [x] 카테고리 해석 로직 3곳 중복 → `resolveCategoryFormat` 헬퍼 추출 (약 100줄 감소)
- [x] 임시 변수명 정리 (pool2/ct2/cc2 등 복붙 흔적 제거)
- [x] 린트 에러 없음 확인
### 9단계: 정리
- [x] 계획서/맥락노트/체크리스트 최신화
---
## 알려진 이슈 (보류)
| 이슈 | 설명 | 상태 |
|------|------|------|
| 저장 실패 시 순번 갭 | allocateCode와 saveFormData가 별도 트랜잭션이라 저장 실패해도 순번 소비됨 | 보류 |
| 유령 데이터 | 중복 품명으로 간헐적 저장 성공 + 리스트 미노출 | 보류 |
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-11 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-11 | 1-4단계 구현 완료 |
| 2026-03-11 | 5단계 추가 구현 (연속 구분자 방지 + 템플릿 정합성 복원) |
| 2026-03-11 | 계맥체 최신화 완료. 문제 4-5 보류 |
| 2026-03-12 | 7단계 실시간 순번 미리보기 구현 완료 (백엔드/프론트엔드 4파일) |
| 2026-03-12 | 계맥체 최신화 완료 |
| 2026-03-12 | 초기 상태 레거시 시퀀스 조회 방지 수정 + 계맥체 반영 |
| 2026-03-12 | 카테고리 변경 시 수동 입력값 포함 순번 재조회 수정 |
| 2026-03-12 | resolveCategoryFormat 헬퍼 추출 코드 정리 + 계맥체 최신화 |
| 2026-03-12 | 6단계 검증 완료. 전체 완료 |

View File

@ -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

View File

@ -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;
} }

View File

@ -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 = {
...layout, ...prev,
components: { components: {
...layout.components, ...prev.components,
[componentId]: { [componentId]: { ...component, position: newPosition },
...component,
position: newPosition,
},
}, },
}; };
setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
setHasChanges(true); return newLayout;
} else { } else {
// 다른 모드인 경우: 오버라이드에 저장 const currentHidden = prev.overrides?.[currentMode]?.hidden || [];
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 const newHidden = currentHidden.filter(id => id !== componentId);
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
const isHidden = currentHidden.includes(componentId);
const newHidden = isHidden
? currentHidden.filter(id => id !== componentId)
: currentHidden;
const newLayout = { const newLayout = {
...layout, ...prev,
overrides: { overrides: {
...layout.overrides, ...prev.overrides,
[currentMode]: { [currentMode]: {
...layout.overrides?.[currentMode], ...prev.overrides?.[currentMode],
positions: { positions: {
...layout.overrides?.[currentMode]?.positions, ...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition, [componentId]: newPosition,
}, },
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
hidden: newHidden.length > 0 ? newHidden : undefined, hidden: newHidden.length > 0 ? newHidden : undefined,
}, },
}, },
}; };
setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
setHasChanges(true); 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 },
}, },
[layout, saveToHistory, currentMode] };
} 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);
},
[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") {
const newLayout = { return {
...layout, ...prev,
components: { components: {
...layout.components, ...prev.components,
[componentId]: { [componentId]: { ...component, position: newPosition },
...component,
position: newPosition,
},
}, },
}; };
setLayout(newLayout);
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
// 현재는 간단히 매번 저장 (최적화 가능)
setHasChanges(true);
} else { } else {
// 다른 모드인 경우: 오버라이드에 저장 return {
const newLayout = { ...prev,
...layout,
overrides: { overrides: {
...layout.overrides, ...prev.overrides,
[currentMode]: { [currentMode]: {
...layout.overrides?.[currentMode], ...prev.overrides?.[currentMode],
positions: { positions: {
...layout.overrides?.[currentMode]?.positions, ...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition, [componentId]: newPosition,
}, },
}, },
}, },
}; };
setLayout(newLayout);
setHasChanges(true);
} }
} 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 },
}, },
[layout, currentMode] };
} else {
return {
...m,
overrides: {
...m.overrides,
[currentMode]: {
...m.overrides?.[currentMode],
positions: {
...m.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
}
}),
};
}
});
setHasChanges(true);
},
[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,
const newPosition = {
...component.position,
rowSpan: newRowSpan, rowSpan: newRowSpan,
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
}; });
if (activeCanvasId === "main") {
const component = prev.components[componentId];
if (!component) return prev;
const newPosition = buildPosition(component);
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") { if (currentMode === "tablet_landscape") {
const newLayout = { const newLayout = {
...layout, ...prev,
components: { components: {
...layout.components, ...prev.components,
[componentId]: { [componentId]: { ...component, position: newPosition },
...component,
position: newPosition,
},
}, },
}; };
setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
setHasChanges(true); return newLayout;
} else { } else {
// 다른 모드인 경우: 오버라이드에 저장
const newLayout = { const newLayout = {
...layout, ...prev,
overrides: { overrides: {
...layout.overrides, ...prev.overrides,
[currentMode]: { [currentMode]: {
...layout.overrides?.[currentMode], ...prev.overrides?.[currentMode],
positions: { positions: {
...layout.overrides?.[currentMode]?.positions, ...prev.overrides?.[currentMode]?.positions,
[componentId]: newPosition, [componentId]: newPosition,
}, },
}, },
}, },
}; };
setLayout(newLayout);
saveToHistory(newLayout); saveToHistory(newLayout);
setHasChanges(true); 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 },
}, },
[layout, currentMode, saveToHistory] };
} else {
return {
...m,
overrides: {
...m.overrides,
[currentMode]: {
...m.overrides?.[currentMode],
positions: {
...m.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
}
}),
};
saveToHistory(newLayout);
return newLayout;
}
});
setHasChanges(true);
},
[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 || [];
// 이미 숨겨져 있으면 무시 // 이미 숨겨져 있으면 무시

View File

@ -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,

View File

@ -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) => {

View File

@ -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: "공정별 체크리스트/검사/실적 상세 작업 화면",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -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[];
} }

View File

@ -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,

View File

@ -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;

View File

@ -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({
if (hasOverride) { id: comp.id,
return false; position: {
col: currentCol,
row: outputRow,
colSpan: scaledSpan,
rowSpan: scaledRowSpan,
},
});
maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan);
currentCol += scaledSpan;
} }
// 오버라이드 없으면 → 검토 필요 outputRow += Math.max(1, maxRowSpanInLine);
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;
} }
// 오버라이드가 있으면 오버라이드 위치로 판단 return resolveOverlaps(placed, targetColumns);
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);
} }

View File

@ -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,
};
}

View File

@ -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 isFull = fullscreen || (() => {
const detectedMode = currentMode || detectGridMode(viewportWidth); const detectedMode = currentMode || detectGridMode(viewportWidth);
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
const isFull = modalWidth >= viewportWidth; return modalWidth >= viewportWidth;
const rendererWidth = isFull ? viewportWidth : modalWidth - 32; })();
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>

View File

@ -764,7 +764,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달) // 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current; const currentFormData = formDataRef.current;
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData); const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData, manualInputValue || undefined);
if (previewResponse.success && previewResponse.data?.generatedCode) { if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode; const generatedCode = previewResponse.data.generatedCode;
@ -852,6 +852,49 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}; };
}, [columnName, manualInputValue, propsInputType, config.inputType, config.type]); }, [columnName, manualInputValue, propsInputType, config.inputType, config.type]);
// 수동 입력값 변경 시 디바운스로 순번 미리보기 갱신
useEffect(() => {
const inputType = propsInputType || config.inputType || config.type || "text";
if (inputType !== "numbering") return;
if (!numberingTemplateRef.current?.includes("____")) return;
const ruleId = numberingRuleIdRef.current;
if (!ruleId) return;
// 사용자가 한 번도 입력하지 않은 초기 상태면 스킵
if (!userEditedNumberingRef.current) return;
const debounceTimer = setTimeout(async () => {
try {
const currentFormData = formDataRef.current;
const resp = await previewNumberingCode(ruleId, currentFormData, manualInputValue || undefined);
if (resp.success && resp.data?.generatedCode) {
const newTemplate = resp.data.generatedCode;
if (newTemplate.includes("____")) {
numberingTemplateRef.current = newTemplate;
const parts = newTemplate.split("____");
const prefix = parts[0] || "";
const suffix = parts.length > 1 ? parts.slice(1).join("") : "";
const combined = prefix + manualInputValue + suffix;
setAutoGeneratedValue(combined);
onChange?.(combined);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, combined);
}
}
}
} catch {
/* 미리보기 실패 시 기존 suffix 유지 */
}
}, 300);
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 실제 표시할 값 (자동생성 값 또는 props value) // 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value; const displayValue = autoGeneratedValue ?? value;

View File

@ -105,6 +105,7 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
export async function previewNumberingCode( export async function previewNumberingCode(
ruleId: string, ruleId: string,
formData?: Record<string, unknown>, formData?: Record<string, unknown>,
manualInputValue?: string,
): Promise<ApiResponse<{ generatedCode: string }>> { ): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사 // ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") { if (!ruleId || ruleId === "undefined" || ruleId === "null") {
@ -114,6 +115,7 @@ export async function previewNumberingCode(
try { try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, { const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
formData: formData || {}, formData: formData || {},
manualInputValue,
}); });
if (!response.data) { if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" }; return { success: false, error: "서버 응답이 비어있습니다" };

View File

@ -19,11 +19,12 @@ import {
Send, Radio, Megaphone, Podcast, BellRing, Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard, Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer, SquareMousePointer,
icons as allLucideIcons,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import) // 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘은 명시적 import, 나머지는 동적 조회)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const iconMap: Record<string, LucideIcon> = { export const iconMap: Record<string, LucideIcon> = {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck, Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
@ -109,15 +110,27 @@ export function getIconSizeStyle(size: string | number): React.CSSProperties {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 아이콘 조회 / 동적 등록 // 아이콘 조회 / 동적 등록
// iconMap에 없으면 lucide-react 전체 아이콘에서 동적 조회 후 캐싱
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function getLucideIcon(name: string): LucideIcon | undefined { export function getLucideIcon(name: string): LucideIcon | undefined {
return iconMap[name]; if (iconMap[name]) return iconMap[name];
const found = allLucideIcons[name as keyof typeof allLucideIcons];
if (found) {
iconMap[name] = found;
return found;
}
return undefined;
} }
export function addToIconMap(name: string, component: LucideIcon): void { export function addToIconMap(name: string, component: LucideIcon): void {
iconMap[name] = component; iconMap[name] = component;
} }
// ButtonConfigPanel 등에서 전체 아이콘 검색용으로 사용
export { allLucideIcons };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SVG 정화 // SVG 정화
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -502,15 +502,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (component.style?.backgroundColor) { if (component.style?.backgroundColor) {
return component.style.backgroundColor; return component.style.backgroundColor;
} }
// 4순위: style.labelColor (레거시) // 4순위: componentConfig.style.backgroundColor
if (componentConfig.style?.backgroundColor) {
return componentConfig.style.backgroundColor;
}
// 5순위: style.labelColor (레거시 호환)
if (component.style?.labelColor) { if (component.style?.labelColor) {
return component.style.labelColor; return component.style.labelColor;
} }
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑 // 6순위: 액션별 기본 배경색
if (isDeleteAction()) { const excelActions = ["excel_download", "excel_upload", "multi_table_excel_upload"];
return "#ef4444"; // 빨간색 (Tailwind red-500) const actionType = typeof componentConfig.action === "string"
} ? componentConfig.action
return "#3b82f6"; // 파란색 (Tailwind blue-500) : componentConfig.action?.type || "";
if (actionType === "delete") return "#F04544";
if (excelActions.includes(actionType)) return "#212121";
return "#3B83F6";
}; };
const getButtonTextColor = () => { const getButtonTextColor = () => {

View File

@ -6,16 +6,29 @@ import { ButtonPrimaryConfig } from "./types";
* ButtonPrimary * ButtonPrimary
*/ */
export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = { export const ButtonPrimaryDefaultConfig: ButtonPrimaryConfig = {
text: "버튼", text: "저장",
actionType: "button", actionType: "button",
variant: "primary", variant: "default",
size: "md",
// 공통 기본값
disabled: false, disabled: false,
required: false, required: false,
readonly: false, readonly: false,
variant: "default", displayMode: "icon-text",
size: "md", icon: {
name: "Check",
type: "lucide",
size: "보통",
},
iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
}; };
/** /**

View File

@ -26,8 +26,24 @@ export const V2ButtonPrimaryDefinition = createComponentDefinition({
successMessage: "저장되었습니다.", successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.", errorMessage: "저장 중 오류가 발생했습니다.",
}, },
displayMode: "icon-text",
icon: {
name: "Check",
type: "lucide",
size: "보통",
}, },
defaultSize: { width: 120, height: 40 }, iconTextPosition: "right",
iconGap: 6,
style: {
borderRadius: "8px",
labelColor: "#FFFFFF",
fontSize: "12px",
fontWeight: "normal",
labelTextAlign: "left",
backgroundColor: "#3B83F6",
},
},
defaultSize: { width: 100, height: 40 },
configPanel: V2ButtonConfigPanel, configPanel: V2ButtonConfigPanel,
icon: "MousePointer", icon: "MousePointer",
tags: ["버튼", "액션", "클릭"], tags: ["버튼", "액션", "클릭"],

View File

@ -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";

View File

@ -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,7 +1074,11 @@ 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) => {
const locked = !!ownerSortColumn
&& !!String(row[ownerSortColumn] ?? "")
&& String(row[ownerSortColumn] ?? "") !== (currentUserId ?? "");
return (
<CardV2 <CardV2
key={`card-${index}`} key={`card-${index}`}
row={row} row={row}
@ -1041,8 +1105,11 @@ export function PopCardListV2Component({
onToggleRowSelect={() => toggleRowSelection(row)} onToggleRowSelect={() => toggleRowSelection(row)}
onEnterSelectMode={enterSelectMode} onEnterSelectMode={enterSelectMode}
onOpenPopModal={openPopModal} 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) {
if ((err as any)?.response?.status === 409) {
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
onRefresh?.();
} else {
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); 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>
); );
} }

View File

@ -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">
{/* 모달 캔버스 (디자이너 모드) */}
{designerCtx && (
<div>
{modalConfig.screenId?.startsWith("modal-") ? (
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
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"> <div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP ID</span> <span className="w-16 shrink-0 text-[9px] text-muted-foreground"> ID</span>
<Input <Input
value={modalConfig.screenId || ""} value={modalConfig.screenId || ""}
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })} onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
placeholder="화면 ID (예: 4481)" placeholder="모달 ID"
className="h-7 flex-1 text-[10px]" className="h-7 flex-1 text-[10px]"
/> />
</div> </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

View File

@ -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");

View File

@ -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]);

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"],
});

View File

@ -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>;
}