Compare commits

..

No commits in common. "cd290ad02ca8f6b03a81e6e67bb3c1bd1c1f3cd9" and "420d9c141cf3b7b8e0ba6951954e7dde9c7c70d6" have entirely different histories.

78 changed files with 26 additions and 23652 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2563,280 +2563,3 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res
} }
}; };
// ============================================================
// POP 전용 화면 그룹 API
// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회
// ============================================================
// POP 화면 그룹 목록 조회 (카테고리 트리용)
export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query;
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시)
if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 검색어 필터링
if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
params.push(`%${searchTerm}%`);
paramIndex++;
}
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
'screen_id', sgs.screen_id,
'screen_name', sd.screen_name,
'screen_role', sgs.screen_role,
'display_order', sgs.display_order,
'is_default', sgs.is_default,
'table_name', sd.table_name
) ORDER BY sgs.display_order
) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id
) as screens
FROM screen_groups sg
${whereClause}
ORDER BY sg.display_order ASC, sg.hierarchy_path ASC
`;
const result = await pool.query(dataQuery, params);
logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length });
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("POP 화면 그룹 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 생성 (hierarchy_path 자동 설정)
export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "";
const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body;
if (!group_name || !group_code) {
return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." });
}
// 회사 코드 결정
const effectiveCompanyCode = target_company_code || userCompanyCode;
if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) {
return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." });
}
// hierarchy_path 계산 - POP 하위로 설정
let hierarchyPath = "POP";
if (parent_group_id) {
// 부모 그룹의 hierarchy_path 조회
const parentResult = await pool.query(
`SELECT hierarchy_path FROM screen_groups WHERE id = $1`,
[parent_group_id]
);
if (parentResult.rows.length > 0) {
hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`;
}
} else {
// 최상위 POP 카테고리
hierarchyPath = `POP/${group_code}`;
}
// 중복 체크
const duplicateCheck = await pool.query(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[group_code, effectiveCompanyCode]
);
if (duplicateCheck.rows.length > 0) {
return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." });
}
// 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, description, icon, display_order,
parent_group_id, hierarchy_path, company_code, writer, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y')
RETURNING *
`;
const insertParams = [
group_name,
group_code,
description || null,
icon || null,
display_order || 0,
parent_group_id || null,
hierarchyPath,
effectiveCompanyCode,
userId,
];
const result = await pool.query(insertQuery, insertParams);
logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode });
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 생성 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 수정
export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
const { group_name, description, icon, display_order, is_active } = req.body;
// 기존 그룹 확인
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
const checkParams: any[] = [id];
if (companyCode !== "*") {
checkQuery += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
// POP 그룹인지 확인
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." });
}
// 업데이트
const updateQuery = `
UPDATE screen_groups
SET group_name = COALESCE($1, group_name),
description = COALESCE($2, description),
icon = COALESCE($3, icon),
display_order = COALESCE($4, display_order),
is_active = COALESCE($5, is_active),
updated_date = NOW()
WHERE id = $6
RETURNING *
`;
const updateParams = [group_name, description, icon, display_order, is_active, id];
const result = await pool.query(updateQuery, updateParams);
logger.info("POP 화면 그룹 수정", { groupId: id, companyCode });
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 수정 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message });
}
};
// POP 화면 그룹 삭제
export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
// 기존 그룹 확인
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
const checkParams: any[] = [id];
if (companyCode !== "*") {
checkQuery += ` AND company_code = $2`;
checkParams.push(companyCode);
}
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
// POP 그룹인지 확인
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." });
}
// 하위 그룹 확인
const childCheck = await pool.query(
`SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`,
[id]
);
if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
}
// 연결된 화면 확인
const screenCheck = await pool.query(
`SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`,
[id]
);
if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
}
// 삭제
await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]);
logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode });
res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." });
} catch (error: any) {
logger.error("POP 화면 그룹 삭제 실패:", error);
res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message });
}
};
// POP 루트 그룹 확보 (없으면 자동 생성)
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
// POP 루트 그룹 확인
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1
`;
const existing = await pool.query(checkQuery, [companyCode]);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
}
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, hierarchy_path, company_code,
description, display_order, is_active, writer
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
RETURNING *
`;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) {
logger.error("POP 루트 그룹 확보 실패:", error);
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
}
};

View File

@ -732,90 +732,6 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
} }
}; };
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================
// POP 레이아웃 조회
export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode, userType } = req.user as any;
const layout = await screenManagementService.getLayoutPop(
parseInt(screenId),
companyCode,
userType
);
res.json({ success: true, data: layout });
} catch (error) {
console.error("POP 레이아웃 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." });
}
};
// POP 레이아웃 저장
export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode, userId } = req.user as any;
const layoutData = req.body;
await screenManagementService.saveLayoutPop(
parseInt(screenId),
layoutData,
companyCode,
userId
);
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
} catch (error) {
console.error("POP 레이아웃 저장 실패:", error);
res
.status(500)
.json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." });
}
};
// POP 레이아웃 삭제
export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteLayoutPop(
parseInt(screenId),
companyCode
);
res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." });
} catch (error) {
console.error("POP 레이아웃 삭제 실패:", error);
res
.status(500)
.json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." });
}
};
// POP 레이아웃 존재하는 화면 ID 목록 조회
export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => {
try {
const { companyCode } = req.user as any;
const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode);
res.json({
success: true,
data: screenIds,
count: screenIds.length
});
} catch (error) {
console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." });
}
};
// 화면 코드 자동 생성 // 화면 코드 자동 생성
export const generateScreenCode = async ( export const generateScreenCode = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,

View File

@ -36,12 +36,6 @@ import {
syncMenuToScreenGroupsController, syncMenuToScreenGroupsController,
getSyncStatusController, getSyncStatusController,
syncAllCompaniesController, syncAllCompaniesController,
// POP 전용 화면 그룹
getPopScreenGroups,
createPopScreenGroup,
updatePopScreenGroup,
deletePopScreenGroup,
ensurePopRootGroup,
} from "../controllers/screenGroupController"; } from "../controllers/screenGroupController";
const router = Router(); const router = Router();
@ -112,15 +106,6 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만) // 전체 회사 동기화 (최고 관리자만)
router.post("/sync/all", syncAllCompaniesController); router.post("/sync/all", syncAllCompaniesController);
// ============================================================
// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%')
// ============================================================
router.get("/pop/groups", getPopScreenGroups);
router.post("/pop/groups", createPopScreenGroup);
router.put("/pop/groups/:id", updatePopScreenGroup);
router.delete("/pop/groups/:id", deletePopScreenGroup);
router.post("/pop/ensure-root", ensurePopRootGroup);
export default router; export default router;

View File

@ -26,10 +26,6 @@ import {
getLayoutV1, getLayoutV1,
getLayoutV2, getLayoutV2,
saveLayoutV2, saveLayoutV2,
getLayoutPop,
saveLayoutPop,
deleteLayoutPop,
getScreenIdsWithPopLayout,
generateScreenCode, generateScreenCode,
generateMultipleScreenCodes, generateMultipleScreenCodes,
assignScreenToMenu, assignScreenToMenu,
@ -88,12 +84,6 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides) router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장 router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// POP 레이아웃 관리 (모바일/태블릿)
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제
router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록
// 메뉴-화면 할당 관리 // 메뉴-화면 할당 관리
router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
router.get("/menus/:menuObjid/screens", getScreensByMenu); router.get("/menus/:menuObjid/screens", getScreensByMenu);

View File

@ -5168,322 +5168,6 @@ export class ScreenManagementService {
console.log(`V2 레이아웃 저장 완료`); console.log(`V2 레이아웃 저장 완료`);
} }
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
// ========================================
/**
* POP v1 v2 ()
* - sections 4 layouts + sections/components
*/
private migratePopV1ToV2(v1Data: any): any {
console.log("POP v1 → v2 마이그레이션 시작");
// 기본 v2 구조
const v2Data: any = {
version: "pop-2.0",
layouts: {
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
},
sections: {},
components: {},
dataFlow: {
sectionConnections: [],
},
settings: {
touchTargetMin: 48,
mode: "normal",
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
},
metadata: v1Data.metadata,
};
// v1 섹션 배열 처리
const sections = v1Data.sections || [];
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
for (const section of sections) {
// 섹션 정의 생성
v2Data.sections[section.id] = {
id: section.id,
label: section.label,
componentIds: (section.components || []).map((c: any) => c.id),
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
style: section.style,
};
// 섹션 위치 복사 (4모드 모두 동일)
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
for (const mode of modeKeys) {
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
}
// 컴포넌트별 처리
for (const comp of section.components || []) {
// 컴포넌트 정의 생성
v2Data.components[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
dataBinding: comp.dataBinding,
style: comp.style,
config: comp.config,
};
// 컴포넌트 위치 복사 (4모드 모두 동일)
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
for (const mode of modeKeys) {
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
}
}
}
const sectionCount = Object.keys(v2Data.sections).length;
const componentCount = Object.keys(v2Data.components).length;
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return v2Data;
}
/**
* POP
* - screen_layouts_pop 1
* - v1 v2로
*/
async getLayoutPop(
screenId: number,
companyCode: string,
userType?: string,
): Promise<any | null> {
console.log(`=== POP 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN";
// 권한 확인
const screens = await query<{
company_code: string | null;
table_name: string | null;
}>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
}
let layout: { layout_data: any } | null = null;
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드로 레이아웃 조회
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, existingScreen.company_code],
);
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1
ORDER BY updated_at DESC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = '*'`,
[screenId],
);
}
}
if (!layout) {
console.log(`POP 레이아웃 없음: screen_id=${screenId}`);
return null;
}
const layoutData = layout.layout_data;
// v1 → v2 자동 마이그레이션
if (layoutData && layoutData.version === "pop-1.0") {
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2(layoutData);
}
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
}
// v2 레이아웃 그대로 반환
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
return layoutData;
}
/**
* POP
* - screen_layouts_pop 1
* - v3 (version: "pop-3.0", )
* - v2/v1
*/
async saveLayoutPop(
screenId: number,
layoutData: any,
companyCode: string,
userId?: string,
): Promise<void> {
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// v5 그리드 레이아웃만 지원
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`컴포넌트: ${componentCount}`);
// v5 형식 검증
if (layoutData.version && layoutData.version !== "pop-5.0") {
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
}
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
}
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
const targetCompanyCode = companyCode === "*"
? (existingScreen.company_code || "*")
: companyCode;
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
// v5 그리드 레이아웃으로 저장 (단일 버전)
const dataToSave = {
...layoutData,
version: "pop-5.0",
};
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
[screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null],
);
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`);
}
/**
* POP ID
* - B: POP
*/
async getScreenIdsWithPopLayout(
companyCode: string,
): Promise<number[]> {
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
console.log(`회사 코드: ${companyCode}`);
let result: { screen_id: number }[];
if (companyCode === "*") {
// 최고 관리자: 모든 POP 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
[],
);
} else {
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code = $1 OR company_code = '*'`,
[companyCode],
);
}
const screenIds = result.map((r) => r.screen_id);
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}`);
return screenIds;
}
/**
* POP
*/
async deleteLayoutPop(
screenId: number,
companyCode: string,
): Promise<boolean> {
console.log(`=== POP 레이아웃 삭제 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다.");
}
const result = await query(
`DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
console.log(`POP 레이아웃 삭제 완료`);
return true;
}
} }
// 서비스 인스턴스 export // 서비스 인스턴스 export

View File

@ -1,390 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
RefreshCw,
Search,
Smartphone,
Eye,
Settings,
LayoutGrid,
GitBranch,
} from "lucide-react";
import { PopDesigner } from "@/components/pop/designer";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
PopCategoryTree,
PopScreenPreview,
PopScreenFlowView,
PopScreenSettingModal,
} from "@/components/pop/management";
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
// ============================================================
// 타입 정의
// ============================================================
type Step = "list" | "design";
type DevicePreview = "mobile" | "tablet";
type RightPanelView = "preview" | "flow";
// ============================================================
// 메인 컴포넌트
// ============================================================
export default function PopScreenManagementPage() {
const searchParams = useSearchParams();
// 단계 및 화면 상태
const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [selectedGroup, setSelectedGroup] = useState<PopScreenGroup | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
// 화면 데이터
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
// POP 레이아웃 존재 화면 ID
const [popLayoutScreenIds, setPopLayoutScreenIds] = useState<Set<number>>(new Set());
// UI 상태
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
// ============================================================
// 데이터 로드
// ============================================================
const loadScreens = useCallback(async () => {
try {
setLoading(true);
const [result, popScreenIds] = await Promise.all([
screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }),
screenApi.getScreenIdsWithPopLayout(),
]);
if (result.data && result.data.length > 0) {
setScreens(result.data);
}
setPopLayoutScreenIds(new Set(popScreenIds));
} catch (error) {
console.error("POP 화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadScreens();
}, [loadScreens]);
// 화면 목록 새로고침 이벤트 리스너
useEffect(() => {
const handleScreenListRefresh = () => {
console.log("POP 화면 목록 새로고침 이벤트 수신");
loadScreens();
};
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
return () => {
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
};
}, [loadScreens]);
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
useEffect(() => {
const openDesignerId = searchParams.get("openDesigner");
if (openDesignerId && screens.length > 0) {
const screenId = parseInt(openDesignerId, 10);
const targetScreen = screens.find((s) => s.screenId === screenId);
if (targetScreen) {
setSelectedScreen(targetScreen);
setCurrentStep("design");
setStepHistory(["list", "design"]);
}
}
}, [searchParams, screens]);
// ============================================================
// 핸들러
// ============================================================
const goToNextStep = (nextStep: Step) => {
setStepHistory((prev) => [...prev, nextStep]);
setCurrentStep(nextStep);
};
const goToStep = (step: Step) => {
setCurrentStep(step);
const stepIndex = stepHistory.findIndex((s) => s === step);
if (stepIndex !== -1) {
setStepHistory(stepHistory.slice(0, stepIndex + 1));
}
};
// 화면 선택
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setSelectedGroup(null);
};
// 그룹 선택
const handleGroupSelect = (group: PopScreenGroup | null) => {
setSelectedGroup(group);
// 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지)
};
// 화면 디자인 모드 진입
const handleDesignScreen = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
goToNextStep("design");
};
// POP 화면 미리보기 (새 탭에서 열기)
const handlePreviewScreen = (screen: ScreenDefinition) => {
const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`;
window.open(previewUrl, "_blank", "width=800,height=900");
};
// 화면 설정 모달 열기
const handleOpenSettings = () => {
if (selectedScreen) {
setIsSettingModalOpen(true);
}
};
// ============================================================
// 필터링된 데이터
// ============================================================
// POP 레이아웃이 있는 화면만 필터링
const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId));
// 검색어 필터링
const filteredScreens = popScreens.filter((screen) => {
if (!searchTerm) return true;
return (
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
);
});
const popScreenCount = popLayoutScreenIds.size;
// ============================================================
// 디자인 모드
// ============================================================
const isDesignMode = currentStep === "design";
if (isDesignMode && selectedScreen) {
return (
<div className="fixed inset-0 z-50 bg-background">
<PopDesigner
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
onScreenUpdate={(updatedFields) => {
setSelectedScreen({
...selectedScreen,
...updatedFields,
});
}}
/>
</div>
);
}
// ============================================================
// 목록 모드 렌더링
// ============================================================
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="shrink-0 border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">POP </h1>
<Badge variant="secondary" className="text-xs">
/릿
</Badge>
</div>
<p className="text-sm text-muted-foreground">
POP /릿
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
POP
</Button>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{popScreenCount === 0 ? (
// POP 화면이 없을 때 빈 상태 표시
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Smartphone className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">POP </h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
POP .
<br />
"새 POP 화면" /릿 .
</p>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
POP
</Button>
</div>
) : (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="POP 화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">POP </span>
<Badge variant="outline" className="text-xs">
{popScreenCount}
</Badge>
</div>
</div>
{/* 카테고리 트리 */}
<PopCategoryTree
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
onGroupSelect={handleGroupSelect}
searchTerm={searchTerm}
/>
</div>
{/* 오른쪽: 미리보기 / 화면 흐름 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 오른쪽 패널 헤더 */}
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
<TabsList className="h-8">
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
<LayoutGrid className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
</Tabs>
{selectedScreen && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => handlePreviewScreen(selectedScreen)}
>
<Eye className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleOpenSettings}
>
<Settings className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="default"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => handleDesignScreen(selectedScreen)}
>
</Button>
</div>
)}
</div>
{/* 오른쪽 패널 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{rightPanelView === "preview" ? (
<PopScreenPreview screen={selectedScreen} className="h-full" />
) : (
<PopScreenFlowView screen={selectedScreen} className="h-full" />
)}
</div>
</div>
</div>
)}
{/* 화면 생성 모달 */}
<CreateScreenModal
open={isCreateOpen}
onOpenChange={(open) => {
setIsCreateOpen(open);
if (!open) loadScreens();
}}
onCreated={() => {
setIsCreateOpen(false);
loadScreens();
}}
isPop={true}
/>
{/* 화면 설정 모달 */}
<PopScreenSettingModal
open={isSettingModalOpen}
onOpenChange={setIsSettingModalOpen}
screen={selectedScreen}
onSave={(updatedFields) => {
if (selectedScreen) {
setSelectedScreen({ ...selectedScreen, ...updatedFields });
}
loadScreens();
}}
/>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}

View File

@ -1,340 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import {
PopLayoutDataV5,
GridMode,
isV5Layout,
createEmptyPopLayoutV5,
GAP_PRESETS,
GRID_BREAKPOINTS,
detectGridMode,
} from "@/components/pop/designer/types/pop-layout";
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
import {
useResponsiveModeWithOverride,
type DeviceType,
} from "@/hooks/useDeviceOrientation";
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
mobile: {
landscape: { width: 600, label: "모바일 가로" },
portrait: { width: 375, label: "모바일 세로" },
},
tablet: {
landscape: { width: 1024, label: "태블릿 가로" },
portrait: { width: 820, label: "태블릿 세로" },
},
};
// 모드 키 변환
const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => {
if (device === "tablet") {
return isLandscape ? "tablet_landscape" : "tablet_portrait";
}
return isLandscape ? "mobile_landscape" : "mobile_portrait";
};
// ========================================
// 메인 컴포넌트 (v5 그리드 시스템 전용)
// ========================================
function PopScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const isPreviewMode = searchParams.get("preview") === "true";
// 반응형 모드 감지 (화면 크기에 따라 tablet/mobile, landscape/portrait 자동 전환)
// 프리뷰 모드에서는 수동 전환 가능
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
isPreviewMode ? "tablet" : undefined,
isPreviewMode ? true : undefined
);
// 현재 모드 정보
const deviceType = mode.device;
const isLandscape = mode.isLandscape;
const { user } = useAuth();
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
// 모드 결정:
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(viewportWidth);
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
// 화면 및 POP 레이아웃 로드
useEffect(() => {
const loadScreen = async () => {
try {
setLoading(true);
setError(null);
const screenData = await screenApi.getScreen(screenId);
setScreen(screenData);
try {
const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isV5Layout(popLayout)) {
// v5 레이아웃 로드
setLayout(popLayout);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyPopLayoutV5());
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyPopLayoutV5());
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
setLayout(createEmptyPopLayoutV5());
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다.");
toast.error("화면을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
if (screenId) {
loadScreen();
}
}, [screenId]);
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
const hasComponents = Object.keys(layout.components).length > 0;
if (loading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
<div className="text-center">
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
<p className="mt-4 text-gray-600">POP ...</p>
</div>
</div>
);
}
if (error || !screen) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
<div className="text-center max-w-md p-6">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<span className="text-2xl">!</span>
</div>
<h2 className="mb-2 text-xl font-bold text-gray-800"> </h2>
<p className="mb-4 text-gray-600">{error || "요청하신 POP 화면이 존재하지 않습니다."}</p>
<Button onClick={() => router.back()} variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
<ActiveTabProvider>
<TableOptionsProvider>
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
{/* 상단 툴바 (프리뷰 모드에서만) */}
{isPreviewMode && (
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
<div className="flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => window.close()}>
<ArrowLeft className="h-4 w-4 mr-1" />
</Button>
<span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-gray-400">
({currentModeKey.replace("_", " ")})
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<Button
variant={deviceType === "mobile" ? "default" : "ghost"}
size="sm"
onClick={() => setDevice("mobile")}
className="gap-1"
>
<Smartphone className="h-4 w-4" />
</Button>
<Button
variant={deviceType === "tablet" ? "default" : "ghost"}
size="sm"
onClick={() => setDevice("tablet")}
className="gap-1"
>
<Tablet className="h-4 w-4" />
릿
</Button>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
<Button
variant={isLandscape ? "default" : "ghost"}
size="sm"
onClick={() => setOrientation(true)}
className="gap-1"
>
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant={!isLandscape ? "default" : "ghost"}
size="sm"
onClick={() => setOrientation(false)}
className="gap-1"
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
{/* 자동 감지 모드 버튼 */}
<Button
variant={isAutoDetect ? "default" : "outline"}
size="sm"
onClick={() => {
setDevice(undefined);
setOrientation(undefined);
}}
className="gap-1"
>
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
</div>
)}
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
style={isPreviewMode ? {
width: currentDevice.width,
maxHeight: "80vh",
flexShrink: 0,
} : undefined}
>
{/* v5 그리드 렌더러 */}
{hasComponents ? (
<div
className="mx-auto min-h-full"
style={{ maxWidth: 1366 }}
>
{(() => {
// Gap 프리셋 계산
const currentGapPreset = layout.settings.gapPreset || "medium";
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 (
<PopRenderer
layout={layout}
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
/>
);
})()}
</div>
) : (
// 빈 화면
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<Smartphone className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">
</h3>
<p className="text-sm text-gray-500 max-w-xs">
POP .
</p>
</div>
)}
</div>
</div>
</div>
</TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider>
);
}
// Provider 래퍼
export default function PopScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<PopScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}

View File

@ -1,971 +0,0 @@
"use client";
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopComponentType,
PopGridPosition,
GridMode,
GapPreset,
GAP_PRESETS,
GRID_BREAKPOINTS,
DEFAULT_COMPONENT_GRID_SIZE,
} from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
import { useDrag } from "react-dnd";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toast } from "sonner";
import PopRenderer from "./renderers/PopRenderer";
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
import { DND_ITEM_TYPES } from "./constants";
/**
*
* @param relX X ( )
* @param relY Y ( )
*/
function calcGridPosition(
relX: number,
relY: number,
canvasWidth: number,
columns: number,
rowHeight: number,
gap: number,
padding: number
): { col: number; row: number } {
// 패딩 제외한 좌표
const x = relX - padding;
const y = relY - padding;
// 사용 가능한 너비 (패딩과 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 row = Math.max(1, Math.floor(y / rowStride) + 1);
return { col, row };
}
// 드래그 아이템 타입 정의
interface DragItemComponent {
type: typeof DND_ITEM_TYPES.COMPONENT;
componentType: PopComponentType;
}
interface DragItemMoveComponent {
componentId: string;
originalPosition: PopGridPosition;
}
// ========================================
// 프리셋 해상도 (4개 모드) - 너비만 정의
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
] as const;
type ViewportPreset = GridMode;
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// 캔버스 세로 자동 확장 설정
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
// ========================================
// Props
// ========================================
interface PopCanvasProps {
layout: PopLayoutDataV5;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onDeleteComponent: (componentId: string) => void;
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
onResizeEnd?: (componentId: string) => void;
onHideComponent?: (componentId: string) => void;
onUnhideComponent?: (componentId: string) => void;
onLockLayout?: () => void;
onResetOverride?: (mode: GridMode) => void;
onChangeGapPreset?: (preset: GapPreset) => void;
}
// ========================================
// PopCanvas: 그리드 캔버스
// ========================================
export default function PopCanvas({
layout,
selectedComponentId,
currentMode,
onModeChange,
onSelectComponent,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
onMoveComponent,
onResizeComponent,
onResizeEnd,
onHideComponent,
onUnhideComponent,
onLockLayout,
onResetOverride,
onChangeGapPreset,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 너비
const [customWidth, setCustomWidth] = useState(1024);
// 그리드 가이드 표시 여부
const [showGridGuide, setShowGridGuide] = useState(true);
// 패닝 상태
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode];
// Gap 프리셋 적용
const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
// 숨김 컴포넌트 ID 목록
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
// 최대 row + rowSpan 찾기
const maxRowEnd = visibleComps.reduce((max, comp) => {
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position;
const rowEnd = pos.row + pos.rowSpan;
return Math.max(max, rowEnd);
}, 1);
// 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height);
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
// 그리드 라벨 계산 (동적 행 수)
const gridLabels = useMemo(() => {
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
// 동적 행 수 계산
const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap));
const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1);
return { columnLabels, rowLabels };
}, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]);
// 줌 컨트롤
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
const handleZoomFit = () => setCanvasScale(1.0);
// 모드 변경
const handleViewportChange = (mode: GridMode) => {
onModeChange(mode);
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
setCustomWidth(presetData.width);
// customHeight는 dynamicCanvasHeight로 자동 계산됨
};
// 패닝
const handlePanStart = (e: React.MouseEvent) => {
const isMiddleButton = e.button === 1;
if (isMiddleButton || isSpacePressed) {
setIsPanning(true);
setPanStart({ x: e.clientX, y: e.clientY });
e.preventDefault();
}
};
const handlePanMove = (e: React.MouseEvent) => {
if (!isPanning || !containerRef.current) return;
const deltaX = e.clientX - panStart.x;
const deltaY = e.clientY - panStart.y;
containerRef.current.scrollLeft -= deltaX;
containerRef.current.scrollTop -= deltaY;
setPanStart({ x: e.clientX, y: e.clientY });
};
const handlePanEnd = () => setIsPanning(false);
// Ctrl + 휠로 줌 조정
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
}
};
// Space 키 감지
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [isSpacePressed]);
// 통합 드롭 핸들러 (팔레트에서 추가 + 컴포넌트 이동)
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: [DND_ITEM_TYPES.COMPONENT, DND_ITEM_TYPES.MOVE_COMPONENT],
drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => {
if (!canvasRef.current) return;
const canvasRect = canvasRef.current.getBoundingClientRect();
const itemType = monitor.getItemType();
// 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준
if (itemType === DND_ITEM_TYPES.COMPONENT) {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 내 상대 좌표 (스케일 보정)
// canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
// 그리드 좌표 계산
const gridPos = calcGridPosition(
relX,
relY,
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemComponent;
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType];
const candidatePosition: PopGridPosition = {
col: gridPos.col,
row: gridPos.row,
colSpan: defaultSize.colSpan,
rowSpan: defaultSize.rowSpan,
};
// 현재 모드에서의 유효 위치들로 중첩 검사
const effectivePositions = getAllEffectivePositions(layout, currentMode);
const existingPositions = Array.from(effectivePositions.values());
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePosition, pos)
);
let finalPosition: PopGridPosition;
if (hasOverlap) {
finalPosition = findNextEmptyPosition(
existingPositions,
defaultSize.colSpan,
defaultSize.rowSpan,
breakpoint.columns
);
toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다.");
} else {
finalPosition = candidatePosition;
}
onDropComponent(dragItem.componentType, finalPosition);
}
// 기존 컴포넌트 이동 - 마우스 위치 기준
if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 내 상대 좌표 (스케일 보정)
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
const gridPos = calcGridPosition(
relX,
relY,
customWidth,
breakpoint.columns,
breakpoint.rowHeight,
adjustedGap,
adjustedPadding
);
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
// 현재 모드에서의 유효 위치들 가져오기
const effectivePositions = getAllEffectivePositions(layout, currentMode);
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
const componentData = layout.components[dragItem.componentId];
if (!currentEffectivePos && !componentData) return;
const sourcePosition = currentEffectivePos || componentData.position;
// colSpan이 현재 모드의 columns를 초과하면 제한
const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns);
// 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정
let adjustedCol = gridPos.col;
if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) {
adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1);
}
const newPosition: PopGridPosition = {
col: adjustedCol,
row: gridPos.row,
colSpan: adjustedColSpan,
rowSpan: sourcePosition.rowSpan,
};
// 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크
const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => {
if (id === dragItem.componentId) return false; // 자기 자신 제외
return isOverlapping(newPosition, pos);
});
if (hasOverlap) {
toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)");
return;
}
// 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨)
onMoveComponent?.(dragItem.componentId, newPosition);
// 숨김 패널에서 드래그한 경우 안내 메시지
if (dragItem.fromHidden) {
toast.info("컴포넌트가 다시 표시됩니다");
}
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
);
drop(canvasRef);
// 빈 상태 체크
const isEmpty = Object.keys(layout.components).length === 0;
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
const hiddenComponents = useMemo(() => {
return hiddenComponentIds
.map(id => layout.components[id])
.filter(Boolean);
}, [hiddenComponentIds, layout.components]);
// 표시되는 컴포넌트 목록 (숨김 제외)
const visibleComponents = useMemo(() => {
return Object.values(layout.components).filter(
comp => !hiddenComponentIds.includes(comp.id)
);
}, [layout.components, hiddenComponentIds]);
// 검토 필요 컴포넌트 목록
const reviewComponents = useMemo(() => {
return visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
}, [visibleComponents, layout.overrides, currentMode]);
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
// 12칸 모드가 아닐 때만 패널 표시
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
const hasGridComponents = Object.keys(layout.components).length > 0;
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
const showRightPanel = showReviewPanel || showHiddenPanel;
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 컨트롤 */}
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
{/* 모드 프리셋 버튼 */}
<div className="flex gap-1">
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = currentMode === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
return (
<Button
key={preset.id}
variant={isActive ? "default" : "outline"}
size="sm"
onClick={() => handleViewportChange(preset.id as GridMode)}
className={cn(
"h-8 gap-1 text-xs",
isActive && "shadow-sm"
)}
>
<Icon className="h-3 w-3" />
{preset.shortLabel}
{isDefault && " (기본)"}
</Button>
);
})}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */}
{currentMode !== DEFAULT_PRESET && (
<>
<Button
variant="outline"
size="sm"
onClick={onLockLayout}
className="h-8 gap-1 text-xs"
>
<Lock className="h-3 w-3" />
</Button>
{layout.overrides?.[currentMode] && (
<Button
variant="ghost"
size="sm"
onClick={() => onResetOverride?.(currentMode)}
className="h-8 gap-1 text-xs"
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</>
)}
<div className="h-4 w-px bg-gray-300" />
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {Math.round(dynamicCanvasHeight)}
</div>
<div className="h-4 w-px bg-gray-300" />
{/* Gap 프리셋 선택 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">:</span>
<Select
value={currentGapPreset}
onValueChange={(value) => onChangeGapPreset?.(value as GapPreset)}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
<SelectItem key={preset} value={preset} className="text-xs">
{GAP_PRESETS[preset].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
{/* 줌 컨트롤 */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={handleZoomOut}
disabled={canvasScale <= 0.3}
className="h-7 w-7"
>
<ZoomOut className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleZoomFit}
className="h-7 w-7"
>
<Maximize2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleZoomIn}
disabled={canvasScale >= 1.5}
className="h-7 w-7"
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
<div className="h-4 w-px bg-gray-300" />
{/* 그리드 가이드 토글 */}
<Button
variant={showGridGuide ? "default" : "outline"}
size="sm"
onClick={() => setShowGridGuide(!showGridGuide)}
className="h-8 text-xs"
>
{showGridGuide ? "ON" : "OFF"}
</Button>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
isSpacePressed && "cursor-grab",
isPanning && "cursor-grabbing"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
style={{
width: showRightPanel
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
: `${customWidth + 32}px`,
minHeight: `${dynamicCanvasHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
>
{/* 그리드 + 라벨 영역 */}
<div className="relative">
{/* 그리드 라벨 영역 */}
{showGridGuide && (
<>
{/* 열 라벨 (상단) */}
<div
className="flex absolute top-0 left-8"
style={{
gap: `${adjustedGap}px`,
paddingLeft: `${adjustedPadding}px`,
}}
>
{gridLabels.columnLabels.map((num) => (
<div
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${adjustedPadding * 2}px - ${adjustedGap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${adjustedGap}px`,
paddingTop: `${adjustedPadding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
<div
key={`row-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: "24px",
height: `${breakpoint.rowHeight}px`,
}}
>
{num}
</div>
))}
</div>
</>
)}
{/* 디바이스 스크린 */}
<div
ref={canvasRef}
className={cn(
"relative rounded-lg border-2 bg-white shadow-xl overflow-visible",
canDrop && isOver && "ring-4 ring-primary/20"
)}
style={{
width: `${customWidth}px`,
minHeight: `${dynamicCanvasHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
>
{isEmpty ? (
// 빈 상태
<div className="flex h-full items-center justify-center p-8">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-500">
</div>
<div className="text-xs text-gray-400">
{breakpoint.label} - {breakpoint.columns}
</div>
</div>
</div>
) : (
// 그리드 렌더러
<PopRenderer
layout={layout}
viewportWidth={customWidth}
currentMode={currentMode}
isDesignMode={true}
showGridGuide={showGridGuide}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onBackgroundClick={() => onSelectComponent(null)}
onComponentMove={onMoveComponent}
onComponentResize={onResizeComponent}
onComponentResizeEnd={onResizeEnd}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
/>
)}
</div>
</div>
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
{showRightPanel && (
<div
className="flex flex-col gap-3"
style={{ marginTop: "32px" }}
>
{/* 검토 필요 패널 */}
{showReviewPanel && (
<ReviewPanel
components={reviewComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
/>
)}
{/* 숨김 컴포넌트 패널 */}
{showHiddenPanel && (
<HiddenPanel
components={hiddenComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onHideComponent={onHideComponent}
/>
)}
</div>
)}
</div>
</div>
{/* 하단 정보 */}
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
<div className="text-xs text-muted-foreground">
{breakpoint.label} - {breakpoint.columns} ( : {breakpoint.rowHeight}px)
</div>
<div className="text-xs text-muted-foreground">
Space + 드래그: 패닝 | Ctrl + :
</div>
</div>
</div>
);
}
// ========================================
// 검토 필요 영역 (오른쪽 패널)
// ========================================
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-blue-300 bg-blue-50/50"
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-blue-200 bg-blue-100/50 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-blue-600" />
<span className="text-xs font-semibold text-blue-700">
({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-blue-200 px-3 py-2 bg-blue-50/80 rounded-b-lg">
<p className="text-[10px] text-blue-600 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-blue-500 bg-blue-100 shadow-sm"
: "border-blue-200 bg-white hover:border-blue-400 hover:bg-blue-50"
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<span className="text-xs font-medium text-blue-800 line-clamp-1">
{component.label || component.id}
</span>
<span className="text-[10px] text-blue-600 bg-blue-50 rounded px-1.5 py-0.5 self-start">
</span>
</div>
);
}
// ========================================
// 숨김 컴포넌트 영역 (오른쪽 패널)
// ========================================
interface HiddenPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onHideComponent?: (componentId: string) => void;
}
function HiddenPanel({
components,
selectedComponentId,
onSelectComponent,
onHideComponent,
}: HiddenPanelProps) {
// 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.MOVE_COMPONENT,
drop: (item: { componentId: string; fromHidden?: boolean }) => {
// 이미 숨김 패널에서 온 아이템은 무시
if (item.fromHidden) return;
// 숨김 처리
onHideComponent?.(item.componentId);
toast.info("컴포넌트가 숨김 처리되었습니다");
},
canDrop: (item: { componentId: string; fromHidden?: boolean }) => {
// 숨김 패널에서 온 아이템은 드롭 불가
return !item.fromHidden;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onHideComponent]
);
return (
<div
ref={drop}
className={cn(
"flex flex-col rounded-lg border-2 border-dashed bg-gray-100/50 transition-colors",
isOver && canDrop
? "border-gray-600 bg-gray-200/70"
: "border-gray-400"
)}
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-gray-300 bg-gray-200/50 px-3 py-2 rounded-t-lg">
<EyeOff className="h-4 w-4 text-gray-600" />
<span className="text-xs font-semibold text-gray-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<HiddenItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-gray-300 px-3 py-2 bg-gray-100/80 rounded-b-lg">
<p className="text-[10px] text-gray-600 leading-tight">
</p>
</div>
</div>
);
}
// ========================================
// 숨김 컴포넌트 아이템 (드래그 가능)
// ========================================
interface HiddenItemProps {
component: PopComponentDefinitionV5;
isSelected: boolean;
onSelect: () => void;
}
function HiddenItem({
component,
isSelected,
onSelect,
}: HiddenItemProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.MOVE_COMPONENT,
item: {
componentId: component.id,
originalPosition: component.position,
fromHidden: true, // 숨김 패널에서 왔음을 표시
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[component.id, component.position]
);
return (
<div
ref={drag}
className={cn(
"rounded-md border-2 bg-white p-2 cursor-move transition-all opacity-60",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-400 hover:border-gray-500",
isDragging && "opacity-30"
)}
onClick={onSelect}
>
{/* 컴포넌트 이름 */}
<div className="flex items-center gap-1 text-xs font-medium text-gray-600 truncate">
<EyeOff className="h-3 w-3" />
{component.label || component.type}
</div>
{/* 원본 위치 정보 */}
<div className="text-[10px] text-gray-500 mt-1">
: {component.position.col}, {component.position.row}
</div>
</div>
);
}

View File

@ -1,661 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { toast } from "sonner";
// POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저)
import "@/lib/registry/pop-components";
import PopCanvas from "./PopCanvas";
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
import ComponentPalette from "./panels/ComponentPalette";
import {
PopLayoutDataV5,
PopComponentType,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GapPreset,
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
GRID_BREAKPOINTS,
} from "./types/pop-layout";
import { getAllEffectivePositions } from "./utils/gridUtils";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
// ========================================
// Props
// ========================================
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
// ========================================
// 메인 컴포넌트 (v5 그리드 시스템 전용)
// ========================================
export default function PopDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
}: PopDesignerProps) {
// ========================================
// 레이아웃 상태
// ========================================
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 히스토리
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// UI 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [idCounter, setIdCounter] = useState(1);
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// 선택된 컴포넌트
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
? layout.components[selectedComponentId] || null
: null;
// ========================================
// 히스토리 관리
// ========================================
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
// 최대 50개 유지
if (newHistory.length > 50) {
newHistory.shift();
return newHistory;
}
return newHistory;
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const previousLayout = history[newIndex];
if (previousLayout) {
setLayout(JSON.parse(JSON.stringify(previousLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("실행 취소됨");
}
}
}, [historyIndex, history]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const nextLayout = history[newIndex];
if (nextLayout) {
setLayout(JSON.parse(JSON.stringify(nextLayout)));
setHistoryIndex(newIndex);
setHasChanges(true);
toast.success("다시 실행됨");
}
}
}, [historyIndex, history]);
const canUndo = historyIndex > 0;
const canRedo = historyIndex < history.length - 1;
// ========================================
// 레이아웃 로드
// ========================================
useEffect(() => {
const loadLayout = async () => {
if (!selectedScreen?.screenId) return;
setIsLoading(true);
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) {
loadedLayout.settings.gapPreset = "medium";
}
setLayout(loadedLayout);
setHistory([loadedLayout]);
setHistoryIndex(0);
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
const existingIds = Object.keys(loadedLayout.components);
const maxId = existingIds.reduce((max, id) => {
const match = id.match(/comp_(\d+)/);
if (match) {
const num = parseInt(match[1], 10);
return num > max ? num : max;
}
return max;
}, 0);
setIdCounter(maxId + 1);
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
} else {
// 새 화면 또는 빈 레이아웃
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
console.log("새 POP 화면 생성 (v5 그리드)");
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("레이아웃을 불러오는데 실패했습니다");
const emptyLayout = createEmptyPopLayoutV5();
setLayout(emptyLayout);
setHistory([emptyLayout]);
setHistoryIndex(0);
} finally {
setIsLoading(false);
}
};
loadLayout();
}, [selectedScreen?.screenId]);
// ========================================
// 저장
// ========================================
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
setIsSaving(true);
try {
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
toast.success("저장되었습니다");
setHasChanges(false);
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장에 실패했습니다");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// ========================================
// 컴포넌트 핸들러
// ========================================
const handleDropComponent = useCallback(
(type: PopComponentType, position: PopGridPosition) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(componentId);
setHasChanges(true);
},
[idCounter, layout, saveToHistory]
);
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
const existingComponent = layout.components[componentId];
if (!existingComponent) return;
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...existingComponent,
...updates,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
},
[layout, saveToHistory]
);
const handleDeleteComponent = useCallback(
(componentId: string) => {
const newComponents = { ...layout.components };
delete newComponents[componentId];
const newLayout = {
...layout,
components: newComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponentId(null);
setHasChanges(true);
},
[layout, saveToHistory]
);
const handleMoveComponent = useCallback(
(componentId: string, newPosition: PopGridPosition) => {
const component = layout.components[componentId];
if (!component) return;
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") {
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...component,
position: newPosition,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
} else {
// 다른 모드인 경우: 오버라이드에 저장
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
const isHidden = currentHidden.includes(componentId);
const newHidden = isHidden
? currentHidden.filter(id => id !== componentId)
: currentHidden;
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: {
...layout.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
hidden: newHidden.length > 0 ? newHidden : undefined,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}
},
[layout, saveToHistory, currentMode]
);
const handleResizeComponent = useCallback(
(componentId: string, newPosition: PopGridPosition) => {
const component = layout.components[componentId];
if (!component) return;
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
if (currentMode === "tablet_landscape") {
const newLayout = {
...layout,
components: {
...layout.components,
[componentId]: {
...component,
position: newPosition,
},
},
};
setLayout(newLayout);
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
// 현재는 간단히 매번 저장 (최적화 가능)
setHasChanges(true);
} else {
// 다른 모드인 경우: 오버라이드에 저장
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: {
...layout.overrides?.[currentMode]?.positions,
[componentId]: newPosition,
},
},
},
};
setLayout(newLayout);
setHasChanges(true);
}
},
[layout, currentMode]
);
const handleResizeEnd = useCallback(
(componentId: string) => {
// 리사이즈 완료 시 현재 레이아웃을 히스토리에 저장
saveToHistory(layout);
},
[layout, saveToHistory]
);
// ========================================
// Gap 프리셋 관리
// ========================================
const handleChangeGapPreset = useCallback((preset: GapPreset) => {
const newLayout = {
...layout,
settings: {
...layout.settings,
gapPreset: preset,
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}, [layout, saveToHistory]);
// ========================================
// 모드별 오버라이드 관리
// ========================================
const handleLockLayout = useCallback(() => {
// 현재 화면에 보이는 유효 위치들을 저장 (오버라이드 또는 자동 재배치 위치)
const effectivePositions = getAllEffectivePositions(layout, currentMode);
const positionsToSave: Record<string, PopGridPosition> = {};
effectivePositions.forEach((position, componentId) => {
positionsToSave[componentId] = position;
});
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
positions: positionsToSave,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
toast.success("현재 배치가 고정되었습니다");
}, [layout, currentMode, saveToHistory]);
const handleResetOverride = useCallback((mode: GridMode) => {
const newOverrides = { ...layout.overrides };
delete newOverrides[mode];
const newLayout = {
...layout,
overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
toast.success("자동 배치로 되돌렸습니다");
}, [layout, saveToHistory]);
// ========================================
// 숨김 관리
// ========================================
const handleHideComponent = useCallback((componentId: string) => {
// 12칸 모드에서는 숨기기 불가
if (currentMode === "tablet_landscape") return;
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
// 이미 숨겨져 있으면 무시
if (currentHidden.includes(componentId)) return;
const newHidden = [...currentHidden, componentId];
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
hidden: newHidden,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
setSelectedComponentId(null);
}, [layout, currentMode, saveToHistory]);
const handleUnhideComponent = useCallback((componentId: string) => {
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
// 숨겨져 있지 않으면 무시
if (!currentHidden.includes(componentId)) return;
const newHidden = currentHidden.filter(id => id !== componentId);
const newLayout = {
...layout,
overrides: {
...layout.overrides,
[currentMode]: {
...layout.overrides?.[currentMode],
hidden: newHidden.length > 0 ? newHidden : undefined,
},
},
};
setLayout(newLayout);
saveToHistory(newLayout);
setHasChanges(true);
}, [layout, currentMode, saveToHistory]);
// ========================================
// 뒤로가기
// ========================================
const handleBack = useCallback(() => {
if (hasChanges) {
if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
onBackToList();
}
} else {
onBackToList();
}
}, [hasChanges, onBackToList]);
// ========================================
// 단축키 처리
// ========================================
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
const key = e.key.toLowerCase();
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Delete / Backspace: 컴포넌트 삭제
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
if (selectedComponentId) {
handleDeleteComponent(selectedComponentId);
}
}
// Ctrl+Z: Undo
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
e.preventDefault();
if (canUndo) undo();
return;
}
// Ctrl+Shift+Z or Ctrl+Y: Redo
if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
e.preventDefault();
if (canRedo) redo();
return;
}
// Ctrl+S: 저장
if (isCtrlOrCmd && key === "s") {
e.preventDefault();
handleSave();
return;
}
// H키: 선택된 컴포넌트 숨김 (12칸 모드가 아닐 때만)
if (key === "h" && !isCtrlOrCmd && selectedComponentId) {
e.preventDefault();
handleHideComponent(selectedComponentId);
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedComponentId, handleDeleteComponent, handleHideComponent, canUndo, canRedo, undo, redo, handleSave]);
// ========================================
// 로딩
// ========================================
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
// ========================================
// 렌더링
// ========================================
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-screen flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
{/* 왼쪽: 뒤로가기 + 화면명 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
<p className="text-xs text-muted-foreground">
(v5)
</p>
</div>
</div>
{/* 오른쪽: Undo/Redo + 저장 */}
<div className="flex items-center gap-2">
{/* Undo/Redo 버튼 */}
<div className="flex items-center gap-1 border-r pr-2 mr-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={undo}
disabled={!canUndo}
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={redo}
disabled={!canRedo}
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
{/* 저장 버튼 */}
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
<Save className="mr-1 h-4 w-4" />
{isSaving ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽: 컴포넌트 팔레트 */}
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
<ComponentPalette />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 중앙: 캔버스 */}
<ResizablePanel defaultSize={65}>
<PopCanvas
layout={layout}
selectedComponentId={selectedComponentId}
currentMode={currentMode}
onModeChange={setCurrentMode}
onSelectComponent={setSelectedComponentId}
onDropComponent={handleDropComponent}
onUpdateComponent={handleUpdateComponent}
onDeleteComponent={handleDeleteComponent}
onMoveComponent={handleMoveComponent}
onResizeComponent={handleResizeComponent}
onResizeEnd={handleResizeEnd}
onHideComponent={handleHideComponent}
onUnhideComponent={handleUnhideComponent}
onLockLayout={handleLockLayout}
onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 속성 패널 */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
<ComponentEditorPanel
component={selectedComponent}
currentMode={currentMode}
onUpdateComponent={
selectedComponentId
? (updates) => handleUpdateComponent(selectedComponentId, updates)
: undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</DndProvider>
);
}

View File

@ -1,14 +0,0 @@
/**
* DnD(Drag and Drop)
*/
// DnD 아이템 타입
export const DND_ITEM_TYPES = {
/** 팔레트에서 새 컴포넌트 드래그 */
COMPONENT: "POP_COMPONENT",
/** 캔버스 내 기존 컴포넌트 이동 */
MOVE_COMPONENT: "POP_MOVE_COMPONENT",
} as const;
// 타입 추출
export type DndItemType = typeof DND_ITEM_TYPES[keyof typeof DND_ITEM_TYPES];

View File

@ -1 +0,0 @@
export * from "./dnd";

View File

@ -1,31 +0,0 @@
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
// 타입
export * from "./types";
// 메인 디자이너
export { default as PopDesigner } from "./PopDesigner";
// 캔버스
export { default as PopCanvas } from "./PopCanvas";
// 패널
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
// 렌더러
export { default as PopRenderer } from "./renderers/PopRenderer";
// 유틸리티
export * from "./utils/gridUtils";
// 핵심 타입 재export (편의)
export type {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopComponentType,
PopGridPosition,
GridMode,
PopGridConfig,
PopDataBinding,
PopDataFlow,
} from "./types/pop-layout";

View File

@ -1,438 +0,0 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
PopComponentType,
} from "../types/pop-layout";
import {
Settings,
Database,
Eye,
Grid3x3,
MoveHorizontal,
MoveVertical,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
// ========================================
// Props
// ========================================
interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV5 | null;
/** 현재 모드 */
currentMode: GridMode;
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// ========================================
export default function ComponentEditorPanel({
component,
currentMode,
onUpdateComponent,
className,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 선택된 컴포넌트 없음
if (!component) {
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
// 기본 모드 여부
const isDefaultMode = currentMode === "tablet_landscape";
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component.label || COMPONENT_TYPE_LABELS[component.type]}
</h3>
<p className="text-xs text-muted-foreground">{component.type}</p>
{!isDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
(릿 )
</p>
)}
</div>
{/* 탭 */}
<Tabs defaultValue="position" className="flex flex-1 flex-col">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 위치 탭 */}
<TabsContent value="position" className="flex-1 overflow-auto p-4">
<PositionForm
component={component}
currentMode={currentMode}
isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<VisibilityForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 위치 편집 폼
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
const { position } = component;
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
// 범위 체크
let clampedValue = Math.max(1, value);
if (field === "col" || field === "colSpan") {
clampedValue = Math.min(columns, clampedValue);
}
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
clampedValue = columns - position.col + 1;
}
onUpdate?.({
position: {
...position,
[field]: clampedValue,
},
});
};
return (
<div className="space-y-6">
{/* 그리드 정보 */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700 mb-1">
: {GRID_BREAKPOINTS[currentMode].label}
</p>
<p className="text-xs text-muted-foreground">
{columns} ×
</p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(Col)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.col}
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(Row)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.row}
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~)
</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(ColSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.colSpan}
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
<p className="text-xs text-muted-foreground">
{Math.round((position.colSpan / columns) * 100)}%
</p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(RowSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.rowSpan}
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p className="text-xs text-amber-800">
(릿 ) .
.
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
// config 업데이트 핸들러
const handleConfigUpdate = (newConfig: any) => {
onUpdate?.({ config: newConfig });
};
return (
<div className="space-y-4">
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
</div>
{/* 컴포넌트 타입별 설정 패널 */}
{ConfigPanel ? (
<ConfigPanel
config={component.config || {}}
onUpdate={handleConfigUpdate}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs text-muted-foreground">
{component.type}
</p>
</div>
)}
</div>
);
}
// ========================================
// 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
onUpdate?.({
visibility: {
...component.visibility,
[mode]: visible,
},
});
};
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{modes.map((mode) => {
const isVisible = component.visibility?.[mode.key] !== false;
return (
<div key={mode.key} className="flex items-center gap-2">
<Checkbox
id={`visibility-${mode.key}`}
checked={isVisible}
onCheckedChange={(checked) =>
handleVisibilityChange(mode.key, checked === true)
}
/>
<label
htmlFor={`visibility-${mode.key}`}
className="text-xs cursor-pointer"
>
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs text-blue-800">
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 text-center">
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-gray-700"> </p>
<p className="text-xs text-muted-foreground mt-1">
Phase 4
</p>
</div>
</div>
);
}

View File

@ -1,98 +0,0 @@
"use client";
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
interface PaletteItem {
type: PopComponentType;
label: string;
icon: React.ElementType;
description: string;
}
const PALETTE_ITEMS: PaletteItem[] = [
{
type: "pop-sample",
label: "샘플 박스",
icon: Square,
description: "크기 조정 테스트용",
},
{
type: "pop-text",
label: "텍스트",
icon: FileText,
description: "텍스트, 시간, 이미지 표시",
},
];
// 드래그 가능한 컴포넌트 아이템
function DraggablePaletteItem({ item }: { item: PaletteItem }) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.COMPONENT,
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[item.type]
);
const Icon = item.icon;
return (
<div
ref={drag}
className={cn(
"flex cursor-grab items-center gap-3 rounded-md border bg-white p-3",
"transition-all hover:border-primary hover:shadow-sm",
isDragging && "opacity-50 cursor-grabbing"
)}
>
<div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-muted-foreground truncate">
{item.description}
</div>
</div>
</div>
);
}
// 컴포넌트 팔레트 패널
export default function ComponentPalette() {
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 헤더 */}
<div className="border-b bg-white px-4 py-3">
<h3 className="text-sm font-semibold"></h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{PALETTE_ITEMS.map((item) => (
<DraggablePaletteItem key={item.type} item={item} />
))}
</div>
</div>
{/* 하단 안내 */}
<div className="border-t bg-white px-4 py-3">
<p className="text-xs text-muted-foreground">
Tip: 캔버스의
</p>
</div>
</div>
);
}

View File

@ -1,3 +0,0 @@
// POP 디자이너 패널 export (v5 그리드 시스템)
export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
export { default as ComponentPalette } from "./ComponentPalette";

View File

@ -1,564 +0,0 @@
"use client";
import React, { useMemo } from "react";
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { DND_ITEM_TYPES } from "../constants";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
GridBreakpoint,
detectGridMode,
PopComponentType,
} from "../types/pop-layout";
import {
convertAndResolvePositions,
isOverlapping,
getAllEffectivePositions,
} from "../utils/gridUtils";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
// ========================================
// Props
// ========================================
interface PopRendererProps {
/** v5 레이아웃 데이터 */
layout: PopLayoutDataV5;
/** 현재 뷰포트 너비 */
viewportWidth: number;
/** 현재 모드 (자동 감지 또는 수동 지정) */
currentMode?: GridMode;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 그리드 가이드 표시 여부 */
showGridGuide?: boolean;
/** 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 컴포넌트 클릭 */
onComponentClick?: (componentId: string) => void;
/** 배경 클릭 */
onBackgroundClick?: () => void;
/** 컴포넌트 이동 */
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
/** 컴포넌트 크기 조정 */
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
onComponentResizeEnd?: (componentId: string) => void;
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
overrideGap?: number;
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
overridePadding?: number;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-sample": "샘플",
};
// ========================================
// PopRenderer: v5 그리드 렌더러
// ========================================
export default function PopRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
showGridGuide = true,
selectedComponentId,
onComponentClick,
onBackgroundClick,
onComponentMove,
onComponentResize,
onComponentResizeEnd,
overrideGap,
overridePadding,
className,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
// 숨김 컴포넌트 ID 목록
const hiddenIds = overrides?.[mode]?.hidden || [];
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
const dynamicRowCount = useMemo(() => {
const visibleComps = Object.values(components).filter(
comp => !hiddenIds.includes(comp.id)
);
const maxRowEnd = visibleComps.reduce((max, comp) => {
const override = overrides?.[mode]?.positions?.[comp.id];
const pos = override ? { ...comp.position, ...override } : comp.position;
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]);
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${finalGap}px`,
padding: `${finalPadding}px`,
minHeight: "100%",
backgroundColor: "#ffffff",
position: "relative",
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
// 그리드 가이드 셀 생성 (동적 행 수)
const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return [];
const cells = [];
for (let row = 1; row <= dynamicRowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({
id: `cell-${col}-${row}`,
col,
row
});
}
}
return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
const modeVisibility = comp.visibility[mode];
return modeVisibility !== false;
};
// 자동 재배치된 위치 계산 (오버라이드 없을 때)
const autoResolvedPositions = useMemo(() => {
const componentsArray = Object.entries(components).map(([id, comp]) => ({
id,
position: comp.position,
}));
return convertAndResolvePositions(componentsArray, mode);
}, [components, mode]);
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용 또는 자동 재배치
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
// 1순위: 오버라이드가 있으면 사용
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
// 2순위: 자동 재배치된 위치 사용
const autoResolved = autoResolvedPositions.find(p => p.id === comp.id);
if (autoResolved) {
return autoResolved.position;
}
// 3순위: 원본 위치 (12칸 모드)
return comp.position;
};
// 오버라이드 숨김 체크
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
};
// 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용)
const effectivePositionsMap = useMemo(() =>
getAllEffectivePositions(layout, mode),
[layout, mode]
);
return (
<div
className={cn("relative min-h-full w-full", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* 그리드 가이드 셀 (실제 DOM) */}
{gridCells.map(cell => (
<div
key={cell.id}
className="pointer-events-none border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
{/* v5.1: 자동 줄바꿈으로 모든 컴포넌트가 그리드 안에 배치됨 */}
{Object.values(components).map((comp) => {
// visibility 체크
if (!isVisible(comp)) return null;
// 오버라이드 숨김 체크
if (isHiddenByOverride(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
const isSelected = selectedComponentId === comp.id;
// 디자인 모드에서는 드래그 가능한 컴포넌트, 뷰어 모드에서는 일반 컴포넌트
if (isDesignMode) {
return (
<DraggableComponent
key={comp.id}
component={comp}
position={position}
positionStyle={positionStyle}
isSelected={isSelected}
isDesignMode={isDesignMode}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={effectivePositionsMap}
effectiveGap={finalGap}
effectivePadding={finalPadding}
onComponentClick={onComponentClick}
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
/>
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링
return (
<div
key={comp.id}
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
style={positionStyle}
>
<ComponentContent
component={comp}
effectivePosition={position}
isDesignMode={false}
isSelected={false}
/>
</div>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
interface DraggableComponentProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
positionStyle: React.CSSProperties;
isSelected: boolean;
isDesignMode: boolean;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
effectiveGap: number;
effectivePadding: number;
onComponentClick?: (componentId: string) => void;
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResizeEnd?: (componentId: string) => void;
}
function DraggableComponent({
component,
position,
positionStyle,
isSelected,
isDesignMode,
breakpoint,
viewportWidth,
allEffectivePositions,
effectiveGap,
effectivePadding,
onComponentClick,
onComponentMove,
onComponentResize,
onComponentResizeEnd,
}: DraggableComponentProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_ITEM_TYPES.MOVE_COMPONENT,
item: {
componentId: component.id,
originalPosition: position
},
canDrag: isDesignMode,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[component.id, position, isDesignMode]
);
return (
<div
ref={isDesignMode ? drag : null}
className={cn(
"relative rounded-lg border-2 transition-all overflow-hidden z-10 bg-white",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-move hover:border-gray-300 hover:shadow-sm",
isDragging && "opacity-50"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(component.id);
}}
>
<ComponentContent
component={component}
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
{isDesignMode && isSelected && onComponentResize && (
<ResizeHandles
component={component}
position={position}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={allEffectivePositions}
effectiveGap={effectiveGap}
effectivePadding={effectivePadding}
onResize={onComponentResize}
onResizeEnd={onComponentResizeEnd}
/>
)}
</div>
);
}
// ========================================
// 리사이즈 핸들
// ========================================
interface ResizeHandlesProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
effectiveGap: number;
effectivePadding: number;
onResize: (componentId: string, newPosition: PopGridPosition) => void;
onResizeEnd?: (componentId: string) => void;
}
function ResizeHandles({
component,
position,
breakpoint,
viewportWidth,
allEffectivePositions,
effectiveGap,
effectivePadding,
onResize,
onResizeEnd,
}: ResizeHandlesProps) {
const handleMouseDown = (direction: 'right' | 'bottom' | 'corner') => (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startColSpan = position.colSpan;
const startRowSpan = position.rowSpan;
// 그리드 셀 크기 동적 계산 (Gap 프리셋 적용된 값 사용)
// 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1)
const availableWidth = viewportWidth - effectivePadding * 2 - effectiveGap * (breakpoint.columns - 1);
const cellWidth = availableWidth / breakpoint.columns + effectiveGap; // 셀 너비 + gap 단위
const cellHeight = breakpoint.rowHeight + effectiveGap;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newColSpan = startColSpan;
let newRowSpan = startRowSpan;
if (direction === 'right' || direction === 'corner') {
const colDelta = Math.round(deltaX / cellWidth);
newColSpan = Math.max(1, startColSpan + colDelta);
// 최대 칸 수 제한
newColSpan = Math.min(newColSpan, breakpoint.columns - position.col + 1);
}
if (direction === 'bottom' || direction === 'corner') {
const rowDelta = Math.round(deltaY / cellHeight);
newRowSpan = Math.max(1, startRowSpan + rowDelta);
}
// 변경사항이 있으면 업데이트
if (newColSpan !== position.colSpan || newRowSpan !== position.rowSpan) {
const newPosition: PopGridPosition = {
...position,
colSpan: newColSpan,
rowSpan: newRowSpan,
};
// 유효 위치 기반 겹침 검사 (다른 컴포넌트와)
const hasOverlap = Array.from(allEffectivePositions.entries()).some(
([id, pos]) => {
if (id === component.id) return false; // 자기 자신 제외
return isOverlapping(newPosition, pos);
}
);
// 겹치지 않을 때만 리사이즈 적용
if (!hasOverlap) {
onResize(component.id, newPosition);
}
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// 리사이즈 완료 알림 (히스토리 저장용)
onResizeEnd?.(component.id);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<>
{/* 오른쪽 핸들 (가로 크기) */}
<div
className="absolute top-0 bottom-0 w-2 cursor-ew-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('right')}
style={{ right: '-4px' }}
/>
{/* 아래쪽 핸들 (세로 크기) */}
<div
className="absolute left-0 right-0 h-2 cursor-ns-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('bottom')}
style={{ bottom: '-4px' }}
/>
{/* 오른쪽 아래 모서리 (가로+세로) */}
<div
className="absolute h-3 w-3 cursor-nwse-resize bg-primary hover:bg-primary/80 transition-colors rounded-sm"
onMouseDown={handleMouseDown('corner')}
style={{ right: '-6px', bottom: '-6px' }}
/>
</>
);
}
// ========================================
// 컴포넌트 내용 렌더링
// ========================================
interface ComponentContentProps {
component: PopComponentDefinitionV5;
effectivePosition: PopGridPosition;
isDesignMode: boolean;
isSelected: boolean;
}
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const PreviewComponent = registeredComp?.preview;
// 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div
className={cn(
"flex h-5 shrink-0 items-center border-b px-2",
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
)}
>
<span className={cn(
"text-[10px] font-medium truncate",
isSelected ? "text-primary" : "text-gray-600"
)}>
{component.label || typeLabel}
</span>
</div>
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
<div className="flex flex-1 items-center justify-center overflow-hidden">
{PreviewComponent ? (
<PreviewComponent config={component.config} />
) : (
<span className="text-xs text-gray-400 p-2">
{typeLabel}
</span>
)}
</div>
{/* 위치 정보 표시 (유효 위치 사용) */}
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
{effectivePosition.col},{effectivePosition.row}
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
</div>
</div>
);
}
// 실제 모드: 컴포넌트 렌더링
return renderActualComponent(component);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
// 샘플 박스 렌더링
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
</div>
);
}

View File

@ -1,4 +0,0 @@
// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
export { default as PopRenderer } from "./PopRenderer";

View File

@ -1,2 +0,0 @@
// POP 디자이너 타입 export
export * from "./pop-layout";

View File

@ -1,395 +0,0 @@
// POP 디자이너 레이아웃 타입 정의
// v5.0: CSS Grid 기반 그리드 시스템
// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전
// ========================================
// 공통 타입
// ========================================
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
/**
*
*/
export interface PopDataFlow {
connections: PopDataConnection[];
}
export interface PopDataConnection {
id: string;
sourceComponent: string;
sourceField: string;
targetComponent: string;
targetField: string;
transformType?: "direct" | "calculate" | "lookup";
}
/**
*
*/
export interface PopDataBinding {
entityField?: string;
defaultValue?: any;
format?: string;
validation?: {
required?: boolean;
min?: number;
max?: number;
pattern?: string;
};
}
/**
*
*/
export interface PopStylePreset {
theme?: "default" | "primary" | "success" | "warning" | "danger";
size?: "sm" | "md" | "lg";
variant?: "solid" | "outline" | "ghost";
}
/**
*
*/
export interface PopComponentConfig {
// 필드 설정
inputType?: "text" | "number" | "date" | "select" | "barcode";
placeholder?: string;
readonly?: boolean;
// 버튼 설정
action?: "submit" | "scan" | "navigate" | "custom";
targetScreen?: string;
// 리스트 설정
columns?: { field: string; label: string; width?: number }[];
selectable?: boolean;
// 인디케이터 설정
indicatorType?: "status" | "progress" | "count";
// 스캐너 설정
scanType?: "barcode" | "qr" | "both";
autoSubmit?: boolean;
}
/**
*
*/
export interface PopLayoutMetadata {
createdAt?: string;
updatedAt?: string;
author?: string;
description?: string;
tags?: string[];
}
// ========================================
// v5 그리드 기반 레이아웃
// ========================================
// 핵심: CSS Grid로 정확한 위치 지정
// - 열/행 좌표로 배치 (col, row)
// - 칸 단위 크기 (colSpan, rowSpan)
// - Material Design 브레이크포인트 기반
/**
* (4)
*/
export type GridMode =
| "mobile_portrait" // 4칸
| "mobile_landscape" // 6칸
| "tablet_portrait" // 8칸
| "tablet_landscape"; // 12칸 (기본)
/**
*
*/
export interface GridBreakpoint {
minWidth?: number;
maxWidth?: number;
columns: number;
rowHeight: number;
gap: number;
padding: number;
label: string;
}
/**
*
* (768px, 1024px) +
*/
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
mobile_portrait: {
maxWidth: 479,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
label: "모바일 세로 (4칸)",
},
// 스마트폰 가로 + 소형 태블릿
mobile_landscape: {
minWidth: 480,
maxWidth: 767,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
label: "모바일 가로 (6칸)",
},
// 태블릿 세로 (iPad Mini ~ iPad Pro)
tablet_portrait: {
minWidth: 768,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
label: "태블릿 세로 (8칸)",
},
// 태블릿 가로 + 데스크톱 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
label: "태블릿 가로 (12칸)",
},
} as const;
/**
*
*/
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
/**
*
* GRID_BREAKPOINTS와
*/
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 480) return "mobile_portrait";
if (viewportWidth < 768) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
/**
* v5 ( )
*/
export interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV5>;
// 데이터 흐름
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV5;
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (위치 변경용)
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
}
/**
*
*/
export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격 (px)
gap: number; // 기본 8px
// 패딩 (px)
padding: number; // 기본 16px
}
/**
* (/ )
*/
export interface PopGridPosition {
col: number; // 시작 열 (1부터, 최대 12)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
}
/**
* v5
*/
export interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
position: PopGridPosition;
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
/**
* Gap
*/
export type GapPreset = "narrow" | "medium" | "wide";
/**
* Gap
*/
export interface GapPresetConfig {
multiplier: number;
label: string;
}
/**
* Gap
*/
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
narrow: { multiplier: 0.5, label: "좁게" },
medium: { multiplier: 1.0, label: "보통" },
wide: { multiplier: 1.5, label: "넓게" },
};
/**
* v5
*/
export interface PopGlobalSettingsV5 {
// 터치 최소 크기 (px)
touchTargetMin: number; // 기본 48
// 모드
mode: "normal" | "industrial";
// Gap 프리셋
gapPreset: GapPreset; // 기본 "medium"
}
/**
* v5
*/
export interface PopModeOverrideV5 {
// 컴포넌트별 위치 오버라이드
positions?: Record<string, Partial<PopGridPosition>>;
// 컴포넌트별 숨김
hidden?: string[];
}
// ========================================
// v5 유틸리티 함수
// ========================================
/**
* v5
*/
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: 8,
padding: 16,
},
components: {},
dataFlow: { connections: [] },
settings: {
touchTargetMin: 48,
mode: "normal",
gapPreset: "medium",
},
});
/**
* v5
*/
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
return layout?.version === "pop-5.0";
};
/**
* ( )
*/
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-sample": { colSpan: 2, rowSpan: 1 },
"pop-text": { colSpan: 3, rowSpan: 1 },
};
/**
* v5
*/
export const createComponentDefinitionV5 = (
id: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopComponentDefinitionV5 => ({
id,
type,
label,
position,
});
/**
* v5
*/
export const addComponentToV5Layout = (
layout: PopLayoutDataV5,
componentId: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopLayoutDataV5 => {
const newLayout = { ...layout };
// 컴포넌트 정의 추가
newLayout.components = {
...newLayout.components,
[componentId]: createComponentDefinitionV5(componentId, type, position, label),
};
return newLayout;
};
// ========================================
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
// ========================================
// 기존 코드에서 import 오류 방지용
/** @deprecated v5에서는 PopLayoutDataV5 사용 */
export type PopLayoutData = PopLayoutDataV5;
/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */
export type PopComponentDefinition = PopComponentDefinitionV5;
/** @deprecated v5에서는 PopGridPosition 사용 */
export type GridPosition = PopGridPosition;

View File

@ -1,562 +0,0 @@
import {
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
GridBreakpoint,
GapPreset,
GAP_PRESETS,
PopLayoutDataV5,
PopComponentDefinitionV5,
} from "../types/pop-layout";
// ========================================
// Gap/Padding 조정
// ========================================
/**
* Gap breakpoint의 gap/padding
*
* @param base breakpoint
* @param preset Gap ("narrow" | "medium" | "wide")
* @returns breakpoint (gap, padding )
*/
export function getAdjustedBreakpoint(
base: GridBreakpoint,
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(
components: Array<{ id: string; position: PopGridPosition }>,
targetMode: GridMode
): Array<{ id: string; position: PopGridPosition }> {
// 엣지 케이스: 빈 배열
if (components.length === 0) {
return [];
}
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
const converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // 원본 col 보존
}));
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
// 3단계: 정상 컴포넌트의 최대 row 계산
const maxRow = normalComponents.length > 0
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
: 0;
// 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 adjusted = [
...normalComponents.map(c => ({ id: c.id, position: c.position })),
...wrappedComponents,
];
// 6단계: 겹침 해결 (아래로 밀기)
return resolveOverlaps(adjusted, targetColumns);
}
// ========================================
// 검토 필요 판별
// ========================================
/**
* "검토 필요"
*
* v5.1 :
* - 12 ( )
* - ( )
*
* @param currentMode
* @param hasOverride
* @returns true = , false =
*/
export function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드는 기본 모드이므로 검토 불필요
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) {
return false;
}
// 오버라이드 없으면 → 검토 필요
return true;
}
/**
* @deprecated v5.1 needsReview()
*
* isOutOfBounds는 "화면 밖" ,
* v5.1 .
* needsReview() "검토 필요" .
*/
export function isOutOfBounds(
originalPosition: PopGridPosition,
currentMode: GridMode,
overridePosition?: PopGridPosition | null
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드면 초과 불가
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 오버라이드 위치로 판단
if (overridePosition) {
return overridePosition.col > targetColumns;
}
// 오버라이드 없으면 원본 col로 판단
return originalPosition.col > targetColumns;
}
// ========================================
// 겹침 감지 및 해결
// ========================================
/**
*
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침 체크
const aColEnd = a.col + a.colSpan - 1;
const bColEnd = b.col + b.colSpan - 1;
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
// 행 겹침 체크
const aRowEnd = a.row + a.rowSpan - 1;
const bRowEnd = b.row + b.rowSpan - 1;
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
return colOverlap && rowOverlap;
}
/**
* ( )
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 열이 범위를 초과하면 조정
if (col + colSpan - 1 > columns) {
colSpan = columns - col + 1;
}
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
// ========================================
// 좌표 변환
// ========================================
/**
*
*
* 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(
existingPositions: PopGridPosition[],
colSpan: number,
rowSpan: number,
columns: number
): PopGridPosition {
let row = 1;
let col = 1;
const maxAttempts = 1000;
let attempts = 0;
while (attempts < maxAttempts) {
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
// 범위 체크
if (col + colSpan - 1 > columns) {
col = 1;
row++;
continue;
}
// 겹침 체크
const hasOverlap = existingPositions.some(pos =>
isOverlapping(candidatePos, pos)
);
if (!hasOverlap) {
return candidatePos;
}
// 다음 위치로 이동
col++;
if (col + colSpan - 1 > columns) {
col = 1;
row++;
}
attempts++;
}
// 실패 시 마지막 행에 배치
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.
*
* @param componentId ID
* @param layout
* @param mode
* @param autoResolvedPositions ()
*/
export function getEffectiveComponentPosition(
componentId: string,
layout: PopLayoutDataV5,
mode: GridMode,
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
): PopGridPosition | null {
const component = layout.components[componentId];
if (!component) return null;
// 1순위: 오버라이드가 있으면 사용
const override = layout.overrides?.[mode]?.positions?.[componentId];
if (override) {
return { ...component.position, ...override };
}
// 2순위: 자동 재배치된 위치 사용
if (autoResolvedPositions) {
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
if (autoResolved) {
return autoResolved.position;
}
} else {
// 자동 재배치 직접 계산
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
id,
position: comp.position,
}));
const resolved = convertAndResolvePositions(componentsArray, mode);
const autoResolved = resolved.find(p => p.id === componentId);
if (autoResolved) {
return autoResolved.position;
}
}
// 3순위: 원본 위치 (12칸 모드)
return component.position;
}
/**
* .
* .
*
* v5.1: 자동
* "화면 밖" .
*/
export function getAllEffectivePositions(
layout: PopLayoutDataV5,
mode: GridMode
): Map<string, PopGridPosition> {
const result = new Map<string, PopGridPosition>();
// 숨김 처리된 컴포넌트 ID 목록
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
// 자동 재배치 위치 미리 계산
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
id,
position: comp.position,
}));
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
// 각 컴포넌트의 유효 위치 계산
Object.keys(layout.components).forEach(componentId => {
// 숨김 처리된 컴포넌트는 제외
if (hiddenIds.includes(componentId)) {
return;
}
const position = getEffectiveComponentPosition(
componentId,
layout,
mode,
autoResolvedPositions
);
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
// 따라서 추가 필터링 불필요
if (position) {
result.set(componentId, position);
}
});
return result;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,347 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import {
ReactFlow,
Node,
Edge,
Position,
MarkerType,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { cn } from "@/lib/utils";
import { Monitor, Layers, ArrowRight, Loader2 } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
// ============================================================
// 타입 정의
// ============================================================
interface PopScreenFlowViewProps {
screen: ScreenDefinition | null;
className?: string;
onSubScreenSelect?: (subScreenId: string) => void;
}
interface PopLayoutData {
version?: string;
sections?: any[];
mainScreen?: {
id: string;
name: string;
};
subScreens?: SubScreen[];
flow?: FlowConnection[];
}
interface SubScreen {
id: string;
name: string;
type: "modal" | "drawer" | "fullscreen";
triggerFrom?: string; // 어느 화면/버튼에서 트리거되는지
}
interface FlowConnection {
from: string;
to: string;
trigger?: string;
label?: string;
}
// ============================================================
// 커스텀 노드 컴포넌트
// ============================================================
interface ScreenNodeData {
label: string;
type: "main" | "modal" | "drawer" | "fullscreen";
isMain?: boolean;
}
function ScreenNode({ data }: { data: ScreenNodeData }) {
const isMain = data.type === "main" || data.isMain;
return (
<div
className={cn(
"px-4 py-3 rounded-lg border-2 shadow-sm min-w-[140px] text-center transition-colors",
isMain
? "bg-primary/10 border-primary text-primary"
: "bg-background border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2 mb-1">
{isMain ? (
<Monitor className="h-4 w-4" />
) : (
<Layers className="h-4 w-4" />
)}
<span className="text-xs text-muted-foreground">
{isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"}
</span>
</div>
<div className="font-medium text-sm">{data.label}</div>
</div>
);
}
const nodeTypes = {
screenNode: ScreenNode,
};
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) {
const [loading, setLoading] = useState(false);
const [layoutData, setLayoutData] = useState<PopLayoutData | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// 레이아웃 데이터 로드
useEffect(() => {
if (!screen) {
setLayoutData(null);
setNodes([]);
setEdges([]);
return;
}
const loadLayout = async () => {
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
if (layout && layout.version === "pop-1.0") {
setLayoutData(layout);
} else {
setLayoutData(null);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
setLayoutData(null);
} finally {
setLoading(false);
}
};
loadLayout();
}, [screen]);
// 레이아웃 데이터에서 노드/엣지 생성
useEffect(() => {
if (!layoutData || !screen) {
return;
}
const newNodes: Node[] = [];
const newEdges: Edge[] = [];
// 메인 화면 노드
const mainNodeId = "main";
newNodes.push({
id: mainNodeId,
type: "screenNode",
position: { x: 50, y: 100 },
data: {
label: screen.screenName,
type: "main",
isMain: true,
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
});
// 하위 화면 노드들
const subScreens = layoutData.subScreens || [];
const horizontalGap = 200;
const verticalGap = 100;
subScreens.forEach((subScreen, index) => {
// 세로로 나열, 여러 개일 경우 열 분리
const col = Math.floor(index / 3);
const row = index % 3;
newNodes.push({
id: subScreen.id,
type: "screenNode",
position: {
x: 300 + col * horizontalGap,
y: 50 + row * verticalGap,
},
data: {
label: subScreen.name,
type: subScreen.type || "modal",
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
});
});
// 플로우 연결 (flow 배열 또는 triggerFrom 기반)
const flows = layoutData.flow || [];
if (flows.length > 0) {
// 명시적 flow 배열 사용
flows.forEach((flow, index) => {
newEdges.push({
id: `edge-${index}`,
source: flow.from,
target: flow.to,
type: "smoothstep",
animated: true,
label: flow.label || flow.trigger,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#888",
},
style: { stroke: "#888", strokeWidth: 2 },
});
});
} else {
// triggerFrom 기반으로 엣지 생성 (기본: 메인 → 서브)
subScreens.forEach((subScreen, index) => {
const sourceId = subScreen.triggerFrom || mainNodeId;
newEdges.push({
id: `edge-${index}`,
source: sourceId,
target: subScreen.id,
type: "smoothstep",
animated: true,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#888",
},
style: { stroke: "#888", strokeWidth: 2 },
});
});
}
setNodes(newNodes);
setEdges(newEdges);
}, [layoutData, screen, setNodes, setEdges]);
// 노드 클릭 핸들러
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
if (node.id !== "main" && onSubScreenSelect) {
onSubScreenSelect(node.id);
}
},
[onSubScreenSelect]
);
// 레이아웃 또는 하위 화면이 없는 경우
const hasSubScreens = layoutData?.subScreens && layoutData.subScreens.length > 0;
if (!screen) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm"> .</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
if (!layoutData) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm">POP .</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"> </h3>
<span className="text-xs text-muted-foreground">
{screen.screenName}
</span>
</div>
{!hasSubScreens && (
<span className="text-xs text-muted-foreground">
</span>
)}
</div>
<div className="flex-1">
{hasSubScreens ? (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.5}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background color="#ddd" gap={16} />
<Controls showInteractive={false} />
<MiniMap
nodeColor={(node) => (node.data?.isMain ? "#3b82f6" : "#9ca3af")}
maskColor="rgba(0, 0, 0, 0.1)"
className="!bg-muted/50"
/>
</ReactFlow>
) : (
// 하위 화면이 없으면 간단한 단일 노드 표시
<div className="h-full flex items-center justify-center bg-muted/10">
<div className="text-center">
<div className="inline-flex items-center justify-center px-6 py-4 rounded-lg border-2 border-primary bg-primary/10">
<Monitor className="h-5 w-5 mr-2 text-primary" />
<span className="font-medium text-primary">{screen.screenName}</span>
</div>
<p className="text-xs text-muted-foreground mt-4">
() .
<br />
.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,195 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
// ============================================================
// 타입 정의
// ============================================================
type DeviceType = "mobile" | "tablet";
interface PopScreenPreviewProps {
screen: ScreenDefinition | null;
className?: string;
}
// 디바이스 프레임 크기
// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트
const DEVICE_SIZES = {
mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로)
tablet: { width: 1024, height: 768 }, // iPad 기준 (가로)
};
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
const [deviceType, setDeviceType] = useState<DeviceType>("tablet");
const [loading, setLoading] = useState(false);
const [hasLayout, setHasLayout] = useState(false);
const [key, setKey] = useState(0); // iframe 새로고침용
// 레이아웃 존재 여부 확인
useEffect(() => {
if (!screen) {
setHasLayout(false);
return;
}
const checkLayout = async () => {
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
// v2 레이아웃: sections는 객체 (Record<string, PopSectionDefinition>)
// v1 레이아웃: sections는 배열
if (layout) {
const isV2 = layout.version === "pop-2.0";
const hasSections = isV2
? layout.sections && Object.keys(layout.sections).length > 0
: layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0;
setHasLayout(hasSections);
} else {
setHasLayout(false);
}
} catch {
setHasLayout(false);
} finally {
setLoading(false);
}
};
checkLayout();
}, [screen]);
// 미리보기 URL
const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null;
// 새 탭에서 열기
const openInNewTab = () => {
if (previewUrl) {
const size = DEVICE_SIZES[deviceType];
window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`);
}
};
// iframe 새로고침
const refreshPreview = () => {
setKey((prev) => prev + 1);
};
const deviceSize = DEVICE_SIZES[deviceType];
// 미리보기 컨테이너에 맞게 스케일 조정
const scale = deviceType === "tablet" ? 0.5 : 0.6;
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
{/* 헤더 */}
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"></h3>
{screen && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{screen.screenName}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 디바이스 선택 */}
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
<TabsList className="h-8">
<TabsTrigger value="mobile" className="h-7 px-3 gap-1.5" title="모바일 (375x667)">
<Smartphone className="h-3.5 w-3.5" />
<span className="text-xs"></span>
</TabsTrigger>
<TabsTrigger value="tablet" className="h-7 px-3 gap-1.5" title="태블릿 (1024x768 가로)">
<Tablet className="h-3.5 w-3.5" />
<span className="text-xs">릿</span>
</TabsTrigger>
</TabsList>
</Tabs>
{screen && hasLayout && (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshPreview}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={openInNewTab}>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
{/* 미리보기 영역 */}
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
{!screen ? (
// 화면 미선택
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm"> .</p>
</div>
) : loading ? (
// 로딩 중
<div className="text-center text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />
<p className="text-sm"> ...</p>
</div>
) : !hasLayout ? (
// 레이아웃 없음
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
{deviceType === "mobile" ? (
<Smartphone className="h-8 w-8" />
) : (
<Tablet className="h-8 w-8" />
)}
</div>
<p className="text-sm mb-2">POP .</p>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (
// 디바이스 프레임 + iframe (심플한 테두리)
<div
className="relative border-2 border-gray-300 rounded-lg shadow-lg overflow-hidden"
style={{
width: deviceSize.width * scale,
height: deviceSize.height * scale,
}}
>
<iframe
key={key}
src={previewUrl || ""}
className="w-full h-full border-0"
style={{
width: deviceSize.width,
height: deviceSize.height,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
title="POP Screen Preview"
/>
</div>
)}
</div>
</div>
);
}

View File

@ -1,442 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText,
Layers,
GitBranch,
Plus,
Trash2,
GripVertical,
Loader2,
Save,
} from "lucide-react";
import { toast } from "sonner";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
import { PopScreenFlowView } from "./PopScreenFlowView";
// ============================================================
// 타입 정의
// ============================================================
interface PopScreenSettingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
screen: ScreenDefinition | null;
onSave?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
interface SubScreenItem {
id: string;
name: string;
type: "modal" | "drawer" | "fullscreen";
triggerFrom?: string;
}
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenSettingModal({
open,
onOpenChange,
screen,
onSave,
}: PopScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// 개요 탭 상태
const [screenName, setScreenName] = useState("");
const [screenDescription, setScreenDescription] = useState("");
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
const [screenIcon, setScreenIcon] = useState("");
// 하위 화면 탭 상태
const [subScreens, setSubScreens] = useState<SubScreenItem[]>([]);
// 카테고리 목록
const [categories, setCategories] = useState<PopScreenGroup[]>([]);
// 초기 데이터 로드
useEffect(() => {
if (!open || !screen) return;
// 화면 정보 설정
setScreenName(screen.screenName || "");
setScreenDescription(screen.description || "");
setScreenIcon("");
setSelectedCategoryId("");
// 카테고리 목록 로드
loadCategories();
// 레이아웃에서 하위 화면 정보 로드
loadLayoutData();
}, [open, screen]);
const loadCategories = async () => {
try {
const data = await getPopScreenGroups();
setCategories(data.filter((g) => g.hierarchy_path?.startsWith("POP/")));
} catch (error) {
console.error("카테고리 로드 실패:", error);
}
};
const loadLayoutData = async () => {
if (!screen) return;
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
if (layout && layout.subScreens) {
setSubScreens(
layout.subScreens.map((sub: any) => ({
id: sub.id || `sub-${Date.now()}`,
name: sub.name || "",
type: sub.type || "modal",
triggerFrom: sub.triggerFrom || "main",
}))
);
} else {
setSubScreens([]);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
setSubScreens([]);
} finally {
setLoading(false);
}
};
// 하위 화면 추가
const addSubScreen = () => {
const newSubScreen: SubScreenItem = {
id: `sub-${Date.now()}`,
name: `새 모달 ${subScreens.length + 1}`,
type: "modal",
triggerFrom: "main",
};
setSubScreens([...subScreens, newSubScreen]);
};
// 하위 화면 삭제
const removeSubScreen = (id: string) => {
setSubScreens(subScreens.filter((s) => s.id !== id));
};
// 하위 화면 업데이트
const updateSubScreen = (id: string, field: keyof SubScreenItem, value: string) => {
setSubScreens(
subScreens.map((s) => (s.id === id ? { ...s, [field]: value } : s))
);
};
// 저장
const handleSave = async () => {
if (!screen) return;
try {
setSaving(true);
// 화면 기본 정보 업데이트
const screenUpdate: Partial<ScreenDefinition> = {
screenName,
description: screenDescription,
};
// 레이아웃에 하위 화면 정보 저장
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
const updatedLayout = {
...currentLayout,
version: "pop-1.0",
subScreens: subScreens,
// flow 배열 자동 생성 (메인 → 각 서브)
flow: subScreens.map((sub) => ({
from: sub.triggerFrom || "main",
to: sub.id,
})),
};
await screenApi.saveLayoutPop(screen.screenId, updatedLayout);
toast.success("화면 설정이 저장되었습니다.");
onSave?.(screenUpdate);
onOpenChange(false);
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
if (!screen) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0 shrink-0">
<DialogTitle className="text-base sm:text-lg">POP </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{screen.screenName} ({screen.screenCode})
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
<TabsTrigger
value="overview"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<FileText className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="subscreens"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<Layers className="h-4 w-4 mr-2" />
{subScreens.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{subScreens.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value="flow"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<GitBranch className="h-4 w-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4 max-w-[500px]">
<div>
<Label htmlFor="screenName" className="text-xs sm:text-sm">
*
</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="화면 이름"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="category" className="text-xs sm:text-sm">
</Label>
<Select value={selectedCategoryId} onValueChange={setSelectedCategoryId}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.group_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={screenDescription}
onChange={(e) => setScreenDescription(e.target.value)}
placeholder="화면에 대한 설명"
rows={3}
className="text-xs sm:text-sm resize-none"
/>
</div>
<div>
<Label htmlFor="icon" className="text-xs sm:text-sm">
</Label>
<Input
id="icon"
value={screenIcon}
onChange={(e) => setScreenIcon(e.target.value)}
placeholder="lucide 아이콘 이름 (예: Package)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground mt-1">
lucide-react .
</p>
</div>
</div>
)}
</TabsContent>
{/* 하위 화면 탭 */}
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
, .
</p>
<Button size="sm" onClick={addSubScreen}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<ScrollArea className="h-[300px]">
{subScreens.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm"> .</p>
<Button variant="link" className="text-xs" onClick={addSubScreen}>
</Button>
</div>
) : (
<div className="space-y-3">
{subScreens.map((subScreen, index) => (
<div
key={subScreen.id}
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
>
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Input
value={subScreen.name}
onChange={(e) =>
updateSubScreen(subScreen.id, "name", e.target.value)
}
placeholder="화면 이름"
className="h-8 text-xs flex-1"
/>
<Select
value={subScreen.type}
onValueChange={(v) =>
updateSubScreen(subScreen.id, "type", v)
}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"></SelectItem>
<SelectItem value="drawer"></SelectItem>
<SelectItem value="fullscreen"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">
:
</span>
<Select
value={subScreen.triggerFrom || "main"}
onValueChange={(v) =>
updateSubScreen(subScreen.id, "triggerFrom", v)
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main"> </SelectItem>
{subScreens
.filter((s) => s.id !== subScreen.id)
.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeSubScreen(subScreen.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</TabsContent>
{/* 화면 흐름 탭 */}
<TabsContent value="flow" className="flex-1 m-0 overflow-hidden">
<PopScreenFlowView screen={screen} className="h-full" />
</TabsContent>
</Tabs>
{/* 푸터 */}
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,8 +0,0 @@
/**
* POP
*/
export { PopCategoryTree } from "./PopCategoryTree";
export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView";
export { PopScreenSettingModal } from "./PopScreenSettingModal";

View File

@ -26,10 +26,9 @@ interface CreateScreenModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onCreated?: (screen: ScreenDefinition) => void; onCreated?: (screen: ScreenDefinition) => void;
isPop?: boolean; // POP 화면 생성 모드
} }
export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop = false }: CreateScreenModalProps) { export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const [screenName, setScreenName] = useState(""); const [screenName, setScreenName] = useState("");
@ -247,19 +246,6 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop
const created = await screenApi.createScreen(createData); const created = await screenApi.createScreen(createData);
// POP 모드일 경우 빈 POP 레이아웃 자동 생성
if (isPop && created.screenId) {
try {
await screenApi.saveLayoutPop(created.screenId, {
version: "2.0",
components: [],
});
} catch (popError) {
console.error("POP 레이아웃 생성 실패:", popError);
// POP 레이아웃 생성 실패해도 화면 생성은 성공으로 처리
}
}
// 날짜 필드 보정 // 날짜 필드 보정
const mapped: ScreenDefinition = { const mapped: ScreenDefinition = {
...created, ...created,
@ -292,7 +278,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{isPop ? "새 POP 화면 생성" : "새 화면 생성"}</DialogTitle> <DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">

View File

@ -132,9 +132,6 @@ interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null; selectedScreen: ScreenDefinition | null;
onBackToList: () => void; onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void; onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
// POP 모드 지원
isPop?: boolean;
defaultDevicePreview?: "mobile" | "tablet";
} }
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
@ -161,15 +158,7 @@ const panelConfigs: PanelConfig[] = [
}, },
]; ];
export default function ScreenDesigner({ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
selectedScreen,
onBackToList,
onScreenUpdate,
isPop = false,
defaultDevicePreview = "tablet"
}: ScreenDesignerProps) {
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
const [layout, setLayout] = useState<LayoutData>({ const [layout, setLayout] = useState<LayoutData>({
components: [], components: [],
gridSettings: { gridSettings: {
@ -1458,15 +1447,9 @@ export default function ScreenDesigner({
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
} }
// V2/POP API 사용 여부에 따라 분기 // V2 API 사용 여부에 따라 분기
let response: any; let response: any;
if (USE_POP_API) { if (USE_V2_API) {
// POP 모드: screen_layouts_pop 테이블 사용
const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId);
response = popResponse ? convertV2ToLegacy(popResponse) : null;
console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트");
} else if (USE_V2_API) {
// 데스크톱 V2 모드: screen_layouts_v2 테이블 사용
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인 // 🐛 디버깅: API 응답에서 fieldMapping.id 확인
@ -1995,13 +1978,10 @@ export default function ScreenDesigner({
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
// 분할 패널 디버그 로그 (주석 처리) // 분할 패널 디버그 로그 (주석 처리)
// V2/POP API 사용 여부에 따라 분기 // V2 API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution); if (USE_V2_API) {
if (USE_POP_API) { // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
// POP 모드: screen_layouts_pop 테이블에 저장 const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 데스크톱 V2 모드: screen_layouts_v2 테이블에 저장
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
} else { } else {
@ -2026,18 +2006,6 @@ export default function ScreenDesigner({
} }
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
// POP 미리보기 핸들러 (새 창에서 열기)
const handlePopPreview = useCallback(() => {
if (!selectedScreen?.screenId) {
toast.error("화면 정보가 없습니다.");
return;
}
const deviceType = defaultDevicePreview || "tablet";
const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`;
window.open(previewUrl, "_blank", "width=800,height=900");
}, [selectedScreen, defaultDevicePreview]);
// 다국어 자동 생성 핸들러 // 다국어 자동 생성 핸들러
const handleGenerateMultilang = useCallback(async () => { const handleGenerateMultilang = useCallback(async () => {
if (!selectedScreen?.screenId) { if (!selectedScreen?.screenId) {
@ -2116,10 +2084,8 @@ export default function ScreenDesigner({
// 자동 저장 (매핑 정보가 손실되지 않도록) // 자동 저장 (매핑 정보가 손실되지 않도록)
try { try {
const v2Layout = convertLegacyToV2(updatedLayout); if (USE_V2_API) {
if (USE_POP_API) { const v2Layout = convertLegacyToV2(updatedLayout);
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else { } else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
@ -5495,11 +5461,9 @@ export default function ScreenDesigner({
gridSettings: layoutWithResolution.gridSettings, gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution, screenResolution: layoutWithResolution.screenResolution,
}); });
// V2/POP API 사용 여부에 따라 분기 // V2 API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution); if (USE_V2_API) {
if (USE_POP_API) { const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else { } else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
@ -5757,7 +5721,6 @@ export default function ScreenDesigner({
onBack={onBackToList} onBack={onBackToList}
onSave={handleSave} onSave={handleSave}
isSaving={isSaving} isSaving={isSaving}
onPreview={isPop ? handlePopPreview : undefined}
onResolutionChange={setScreenResolution} onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings} gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings} onGridSettingsChange={updateGridSettings}

View File

@ -61,7 +61,6 @@ interface ScreenRelationFlowProps {
screen: ScreenDefinition | null; screen: ScreenDefinition | null;
selectedGroup?: { id: number; name: string; company_code?: string } | null; selectedGroup?: { id: number; name: string; company_code?: string } | null;
initialFocusedScreenId?: number | null; initialFocusedScreenId?: number | null;
isPop?: boolean;
} }
// 노드 타입 (Record<string, unknown> 확장) // 노드 타입 (Record<string, unknown> 확장)
@ -70,7 +69,7 @@ type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
type AllNodeType = ScreenNodeType | TableNodeType; type AllNodeType = ScreenNodeType | TableNodeType;
// 내부 컴포넌트 (useReactFlow 사용 가능) // 내부 컴포넌트 (useReactFlow 사용 가능)
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId, isPop = false }: ScreenRelationFlowProps) { function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]); const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -2353,7 +2352,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
fieldMappings={settingModalNode.existingConfig?.fieldMappings} fieldMappings={settingModalNode.existingConfig?.fieldMappings}
componentCount={0} componentCount={0}
onSaveSuccess={handleRefreshVisualization} onSaveSuccess={handleRefreshVisualization}
isPop={isPop}
/> />
)} )}

View File

@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
fieldMappings?: FieldMappingInfo[]; fieldMappings?: FieldMappingInfo[];
componentCount?: number; componentCount?: number;
onSaveSuccess?: () => void; onSaveSuccess?: () => void;
isPop?: boolean; // POP 화면 여부
} }
// 검색 가능한 Select 컴포넌트 // 검색 가능한 Select 컴포넌트
@ -240,7 +239,6 @@ export function ScreenSettingModal({
fieldMappings = [], fieldMappings = [],
componentCount = 0, componentCount = 0,
onSaveSuccess, onSaveSuccess,
isPop = false,
}: ScreenSettingModalProps) { }: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview"); const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -521,7 +519,6 @@ export function ScreenSettingModal({
iframeKey={iframeKey} iframeKey={iframeKey}
canvasWidth={canvasSize.width} canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height} canvasHeight={canvasSize.height}
isPop={isPop}
/> />
</div> </div>
</div> </div>
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
iframeKey?: number; // iframe 새로고침용 키 iframeKey?: number; // iframe 새로고침용 키
canvasWidth?: number; // 화면 캔버스 너비 canvasWidth?: number; // 화면 캔버스 너비
canvasHeight?: number; // 화면 캔버스 높이 canvasHeight?: number; // 화면 캔버스 높이
isPop?: boolean; // POP 화면 여부
} }
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) { function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
if (companyCode) { if (companyCode) {
params.set("company_code", companyCode); params.set("company_code", companyCode);
} }
// POP 화면일 경우 디바이스 타입 추가
if (isPop) {
params.set("device", "tablet");
}
// POP 화면과 데스크톱 화면 경로 분기
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
return `${baseUrl}${screenPath}?${params.toString()}`; return `${baseUrl}/screens/${screenId}?${params.toString()}`;
} }
return `${screenPath}?${params.toString()}`; return `/screens/${screenId}?${params.toString()}`;
}, [screenId, companyCode, isPop]); }, [screenId, companyCode]);
const handleIframeLoad = () => { const handleIframeLoad = () => {
setLoading(false); setLoading(false);

View File

@ -450,8 +450,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
{onPreview && ( {onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2"> <Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Eye className="h-4 w-4" /> <Smartphone className="h-4 w-4" />
<span>POP </span> <span> </span>
</Button> </Button>
)} )}
{onGenerateMultilang && ( {onGenerateMultilang && (

View File

@ -1,45 +0,0 @@
"use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -1,211 +0,0 @@
"use client";
import { useState, useEffect, useMemo } from "react";
// ========================================
// 타입 정의
// ========================================
export type DeviceType = "mobile" | "tablet";
export type OrientationType = "landscape" | "portrait";
export interface ResponsiveMode {
device: DeviceType;
orientation: OrientationType;
isLandscape: boolean;
modeKey: "tablet_landscape" | "tablet_portrait" | "mobile_landscape" | "mobile_portrait";
}
// ========================================
// 브레이크포인트 (화면 너비 기준)
// GRID_BREAKPOINTS와 일치해야 함!
// ========================================
const BREAKPOINTS = {
// mobile_portrait: ~479px (4칸)
// mobile_landscape: 480~767px (6칸)
// tablet_portrait: 768~1023px (8칸)
// tablet_landscape: 1024px~ (12칸)
TABLET_MIN: 768, // 768px 이상이면 tablet
};
/**
*
*
* - 4
* - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
* - resize orientation
*
* @returns ResponsiveMode
*/
export function useResponsiveMode(): ResponsiveMode {
const [mode, setMode] = useState<ResponsiveMode>({
device: "tablet",
orientation: "landscape",
isLandscape: true,
modeKey: "tablet_landscape",
});
useEffect(() => {
if (typeof window === "undefined") return;
const detectMode = (): ResponsiveMode => {
const width = window.innerWidth;
const height = window.innerHeight;
// 디바이스 타입 결정 (화면 너비 기준)
const device: DeviceType = width >= BREAKPOINTS.TABLET_MIN ? "tablet" : "mobile";
// 방향 결정 (가로/세로 비율)
const isLandscape = width > height;
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
// 모드 키 생성
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
return { device, orientation, isLandscape, modeKey };
};
// 초기값 설정
setMode(detectMode());
const handleChange = () => {
setTimeout(() => {
setMode(detectMode());
}, 100);
};
// 이벤트 리스너 등록
window.addEventListener("resize", handleChange);
window.addEventListener("orientationchange", handleChange);
// matchMedia로 orientation 변경 감지
const landscapeQuery = window.matchMedia("(orientation: landscape)");
if (landscapeQuery.addEventListener) {
landscapeQuery.addEventListener("change", handleChange);
}
return () => {
window.removeEventListener("resize", handleChange);
window.removeEventListener("orientationchange", handleChange);
if (landscapeQuery.removeEventListener) {
landscapeQuery.removeEventListener("change", handleChange);
}
};
}, []);
return mode;
}
/**
* (orientation)
*
* - /
* - window.matchMedia와 orientationchange
* - SSR (typeof window !== 'undefined')
*
* @returns isLandscape - true: , false:
*/
export function useDeviceOrientation(): boolean {
const [isLandscape, setIsLandscape] = useState<boolean>(false);
useEffect(() => {
if (typeof window === "undefined") return;
const detectOrientation = (): boolean => {
if (window.matchMedia) {
const landscapeQuery = window.matchMedia("(orientation: landscape)");
return landscapeQuery.matches;
}
return window.innerWidth > window.innerHeight;
};
setIsLandscape(detectOrientation());
const handleOrientationChange = () => {
setTimeout(() => {
setIsLandscape(detectOrientation());
}, 100);
};
const landscapeQuery = window.matchMedia("(orientation: landscape)");
if (landscapeQuery.addEventListener) {
landscapeQuery.addEventListener("change", handleOrientationChange);
} else if (landscapeQuery.addListener) {
landscapeQuery.addListener(handleOrientationChange);
}
window.addEventListener("orientationchange", handleOrientationChange);
window.addEventListener("resize", handleOrientationChange);
return () => {
if (landscapeQuery.removeEventListener) {
landscapeQuery.removeEventListener("change", handleOrientationChange);
} else if (landscapeQuery.removeListener) {
landscapeQuery.removeListener(handleOrientationChange);
}
window.removeEventListener("orientationchange", handleOrientationChange);
window.removeEventListener("resize", handleOrientationChange);
};
}, []);
return isLandscape;
}
/**
*
*
*
* @param initialOverride - (undefined면 )
* @returns [isLandscape, setIsLandscape, isAutoDetect]
*/
export function useDeviceOrientationWithOverride(
initialOverride?: boolean
): [boolean, (value: boolean | undefined) => void, boolean] {
const autoDetectedIsLandscape = useDeviceOrientation();
const [manualOverride, setManualOverride] = useState<boolean | undefined>(initialOverride);
const isLandscape = manualOverride !== undefined ? manualOverride : autoDetectedIsLandscape;
const isAutoDetect = manualOverride === undefined;
const setOrientation = (value: boolean | undefined) => {
setManualOverride(value);
};
return [isLandscape, setOrientation, isAutoDetect];
}
/**
* +
* /
*/
export function useResponsiveModeWithOverride(
initialDeviceOverride?: DeviceType,
initialOrientationOverride?: boolean
): {
mode: ResponsiveMode;
setDevice: (device: DeviceType | undefined) => void;
setOrientation: (isLandscape: boolean | undefined) => void;
isAutoDetect: boolean;
} {
const autoMode = useResponsiveMode();
const [deviceOverride, setDeviceOverride] = useState<DeviceType | undefined>(initialDeviceOverride);
const [orientationOverride, setOrientationOverride] = useState<boolean | undefined>(initialOrientationOverride);
const mode = useMemo((): ResponsiveMode => {
const device = deviceOverride ?? autoMode.device;
const isLandscape = orientationOverride ?? autoMode.isLandscape;
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
return { device, orientation, isLandscape, modeKey };
}, [autoMode, deviceOverride, orientationOverride]);
const isAutoDetect = deviceOverride === undefined && orientationOverride === undefined;
return {
mode,
setDevice: setDeviceOverride,
setOrientation: setOrientationOverride,
isAutoDetect,
};
}

View File

@ -1,207 +0,0 @@
"use client";
import { useState, useCallback, useRef } from "react";
// ========================================
// 레이아웃 히스토리 훅
// Undo/Redo 기능 제공
// ========================================
interface HistoryState<T> {
past: T[];
present: T;
future: T[];
}
interface UseLayoutHistoryReturn<T> {
// 현재 상태
state: T;
// 상태 설정 (히스토리에 기록)
setState: (newState: T | ((prev: T) => T)) => void;
// Undo
undo: () => void;
// Redo
redo: () => void;
// Undo 가능 여부
canUndo: boolean;
// Redo 가능 여부
canRedo: boolean;
// 히스토리 초기화 (새 레이아웃 로드 시)
reset: (initialState: T) => void;
// 히스토리 크기
historySize: number;
}
/**
*
* @param initialState
* @param maxHistory ( 50)
*/
export function useLayoutHistory<T>(
initialState: T,
maxHistory: number = 50
): UseLayoutHistoryReturn<T> {
const [history, setHistory] = useState<HistoryState<T>>({
past: [],
present: initialState,
future: [],
});
// 배치 업데이트를 위한 타이머
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingStateRef = useRef<T | null>(null);
/**
* ( )
*
*/
const setState = useCallback(
(newState: T | ((prev: T) => T)) => {
setHistory((prev) => {
const resolvedState =
typeof newState === "function"
? (newState as (prev: T) => T)(prev.present)
: newState;
// 같은 상태면 무시
if (JSON.stringify(resolvedState) === JSON.stringify(prev.present)) {
console.log("[History] 상태 동일, 무시");
return prev;
}
// 히스토리에 현재 상태 추가
const newPast = [...prev.past, prev.present];
// 최대 히스토리 개수 제한
if (newPast.length > maxHistory) {
newPast.shift();
}
console.log("[History] 상태 저장, past 크기:", newPast.length);
return {
past: newPast,
present: resolvedState,
future: [], // Redo 히스토리 초기화
};
});
},
[maxHistory]
);
/**
* ( )
*
*/
const setStateBatched = useCallback(
(newState: T | ((prev: T) => T), batchDelay: number = 300) => {
// 현재 상태 업데이트 (히스토리에는 바로 기록하지 않음)
setHistory((prev) => {
const resolvedState =
typeof newState === "function"
? (newState as (prev: T) => T)(prev.present)
: newState;
pendingStateRef.current = prev.present;
return {
...prev,
present: resolvedState,
};
});
// 배치 타이머 리셋
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
}
// 일정 시간 후 히스토리에 기록
batchTimerRef.current = setTimeout(() => {
if (pendingStateRef.current !== null) {
setHistory((prev) => {
const newPast = [...prev.past, pendingStateRef.current as T];
if (newPast.length > maxHistory) {
newPast.shift();
}
pendingStateRef.current = null;
return {
...prev,
past: newPast,
future: [],
};
});
}
}, batchDelay);
},
[maxHistory]
);
/**
* Undo -
*/
const undo = useCallback(() => {
console.log("[History] Undo 호출");
setHistory((prev) => {
console.log("[History] Undo 실행, past 크기:", prev.past.length);
if (prev.past.length === 0) {
console.log("[History] Undo 불가 - past 비어있음");
return prev;
}
const newPast = [...prev.past];
const previousState = newPast.pop()!;
console.log("[History] Undo 성공, 남은 past 크기:", newPast.length);
return {
past: newPast,
present: previousState,
future: [prev.present, ...prev.future],
};
});
}, []);
/**
* Redo -
*/
const redo = useCallback(() => {
setHistory((prev) => {
if (prev.future.length === 0) {
return prev;
}
const newFuture = [...prev.future];
const nextState = newFuture.shift()!;
return {
past: [...prev.past, prev.present],
present: nextState,
future: newFuture,
};
});
}, []);
/**
* ( )
*/
const reset = useCallback((initialState: T) => {
setHistory({
past: [],
present: initialState,
future: [],
});
}, []);
return {
state: history.present,
setState,
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
reset,
historySize: history.past.length,
};
}
export default useLayoutHistory;

View File

@ -1,182 +0,0 @@
/**
* POP API
* - hierarchy_path LIKE 'POP/%' POP
* - screen_groups와 ,
*/
import { apiClient } from "./client";
import { ScreenGroup, ScreenGroupScreen } from "./screenGroup";
// ============================================================
// POP 화면 그룹 타입 (ScreenGroup 재활용)
// ============================================================
export interface PopScreenGroup extends ScreenGroup {
// 추가 필드 필요시 여기에 정의
children?: PopScreenGroup[]; // 트리 구조용
}
export interface CreatePopScreenGroupRequest {
group_name: string;
group_code: string;
description?: string;
icon?: string;
display_order?: number;
parent_group_id?: number | null;
target_company_code?: string; // 최고관리자용
}
export interface UpdatePopScreenGroupRequest {
group_name?: string;
description?: string;
icon?: string;
display_order?: number;
is_active?: boolean;
}
// ============================================================
// API 함수
// ============================================================
/**
* POP
* - hierarchy_path가 'POP'
*/
export async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]> {
try {
const params = new URLSearchParams();
if (searchTerm) {
params.append("searchTerm", searchTerm);
}
const url = `/screen-groups/pop/groups${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiClient.get<{ success: boolean; data: PopScreenGroup[] }>(url);
if (response.data?.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("POP 화면 그룹 조회 실패:", error);
return [];
}
}
/**
* POP
*/
export async function createPopScreenGroup(
data: CreatePopScreenGroupRequest
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
"/screen-groups/pop/groups",
data
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 생성 실패:", error);
return { success: false, message: error.response?.data?.message || "생성에 실패했습니다." };
}
}
/**
* POP
*/
export async function updatePopScreenGroup(
id: number,
data: UpdatePopScreenGroupRequest
): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.put<{ success: boolean; data: PopScreenGroup; message: string }>(
`/screen-groups/pop/groups/${id}`,
data
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 수정 실패:", error);
return { success: false, message: error.response?.data?.message || "수정에 실패했습니다." };
}
}
/**
* POP
*/
export async function deletePopScreenGroup(
id: number
): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiClient.delete<{ success: boolean; message: string }>(
`/screen-groups/pop/groups/${id}`
);
return response.data;
} catch (error: any) {
console.error("POP 화면 그룹 삭제 실패:", error);
return { success: false, message: error.response?.data?.message || "삭제에 실패했습니다." };
}
}
/**
* POP ( )
*/
export async function ensurePopRootGroup(): Promise<{ success: boolean; data?: PopScreenGroup; message?: string }> {
try {
const response = await apiClient.post<{ success: boolean; data: PopScreenGroup; message: string }>(
"/screen-groups/pop/ensure-root"
);
return response.data;
} catch (error: any) {
console.error("POP 루트 그룹 확보 실패:", error);
return { success: false, message: error.response?.data?.message || "루트 그룹 확보에 실패했습니다." };
}
}
// ============================================================
// 유틸리티 함수
// ============================================================
/**
*
*/
export function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[] {
const groupMap = new Map<number, PopScreenGroup>();
const rootGroups: PopScreenGroup[] = [];
// 먼저 모든 그룹을 맵에 저장
groups.forEach((group) => {
groupMap.set(group.id, { ...group, children: [] });
});
// 트리 구조 생성
groups.forEach((group) => {
const node = groupMap.get(group.id)!;
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
// 부모가 있으면 부모의 children에 추가
const parent = groupMap.get(group.parent_group_id)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
// 부모가 없거나 POP 루트면 최상위에 추가
// hierarchy_path가 'POP'이거나 'POP/XXX' 형태인지 확인
if (group.hierarchy_path === "POP" ||
(group.hierarchy_path?.startsWith("POP/") &&
group.hierarchy_path.split("/").length === 2)) {
rootGroups.push(node);
}
}
});
// display_order로 정렬
const sortByOrder = (a: PopScreenGroup, b: PopScreenGroup) =>
(a.display_order || 0) - (b.display_order || 0);
rootGroups.sort(sortByOrder);
rootGroups.forEach((group) => {
if (group.children) {
group.children.sort(sortByOrder);
}
});
return rootGroups;
}

View File

@ -213,32 +213,6 @@ export const screenApi = {
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData); await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
}, },
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================
// POP 레이아웃 조회
getLayoutPop: async (screenId: number): Promise<any> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`);
return response.data.data;
},
// POP 레이아웃 저장
saveLayoutPop: async (screenId: number, layoutData: any): Promise<void> => {
await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData);
},
// POP 레이아웃 삭제
deleteLayoutPop: async (screenId: number): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}/layout-pop`);
},
// POP 레이아웃이 존재하는 화면 ID 목록 조회
getScreenIdsWithPopLayout: async (): Promise<number[]> => {
const response = await apiClient.get(`/screen-management/pop-layout-screen-ids`);
return response.data.data || [];
},
// 연결된 모달 화면 감지 // 연결된 모달 화면 감지
detectLinkedModals: async ( detectLinkedModals: async (
screenId: number, screenId: number,

View File

@ -1,268 +0,0 @@
"use client";
import React from "react";
/**
* POP
*/
export interface PopComponentDefinition {
id: string;
name: string;
description: string;
category: PopComponentCategory;
icon?: string;
component: React.ComponentType<any>;
configPanel?: React.ComponentType<any>;
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
defaultProps?: Record<string, any>;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;
supportedDevices?: ("mobile" | "tablet")[];
createdAt?: Date;
updatedAt?: Date;
}
/**
* POP
*/
export type PopComponentCategory =
| "display" // 데이터 표시 (카드, 리스트, 배지)
| "input" // 입력 (스캐너, 터치 입력)
| "action" // 액션 (버튼, 스와이프)
| "layout" // 레이아웃 (컨테이너, 그리드)
| "feedback"; // 피드백 (토스트, 로딩)
/**
* POP
*/
export interface PopComponentRegistryEvent {
type: "component_registered" | "component_unregistered";
data: PopComponentDefinition;
timestamp: Date;
}
/**
* POP
* /릿 , ,
*/
export class PopComponentRegistry {
private static components = new Map<string, PopComponentDefinition>();
private static eventListeners: Array<(event: PopComponentRegistryEvent) => void> = [];
/**
*
*/
static registerComponent(definition: PopComponentDefinition): void {
// 유효성 검사
if (!definition.id || !definition.name || !definition.component) {
throw new Error(
`POP 컴포넌트 등록 실패 (${definition.id || "unknown"}): 필수 필드 누락`
);
}
// 중복 등록 체크
if (this.components.has(definition.id)) {
console.warn(`[POP Registry] 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
}
// 타임스탬프 추가
const enhancedDefinition: PopComponentDefinition = {
...definition,
touchOptimized: definition.touchOptimized ?? true,
minTouchArea: definition.minTouchArea ?? 44,
supportedDevices: definition.supportedDevices ?? ["mobile", "tablet"],
createdAt: definition.createdAt || new Date(),
updatedAt: new Date(),
};
this.components.set(definition.id, enhancedDefinition);
// 이벤트 발생
this.emitEvent({
type: "component_registered",
data: enhancedDefinition,
timestamp: new Date(),
});
// 개발 모드에서만 로깅
if (process.env.NODE_ENV === "development") {
console.log(`[POP Registry] 컴포넌트 등록: ${definition.id}`);
}
}
/**
*
*/
static unregisterComponent(id: string): void {
const definition = this.components.get(id);
if (!definition) {
console.warn(`[POP Registry] 등록되지 않은 컴포넌트 해제 시도: ${id}`);
return;
}
this.components.delete(id);
// 이벤트 발생
this.emitEvent({
type: "component_unregistered",
data: definition,
timestamp: new Date(),
});
console.log(`[POP Registry] 컴포넌트 해제: ${id}`);
}
/**
*
*/
static getComponent(id: string): PopComponentDefinition | undefined {
return this.components.get(id);
}
/**
* URL로
*/
static getComponentByUrl(url: string): PopComponentDefinition | undefined {
// "@/lib/registry/pop-components/pop-card-list" → "pop-card-list"
const parts = url.split("/");
const componentId = parts[parts.length - 1];
return this.getComponent(componentId);
}
/**
*
*/
static getAllComponents(): PopComponentDefinition[] {
return Array.from(this.components.values()).sort((a, b) => {
// 카테고리별 정렬, 그 다음 이름순
if (a.category !== b.category) {
return a.category.localeCompare(b.category);
}
return a.name.localeCompare(b.name);
});
}
/**
*
*/
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[] {
return Array.from(this.components.values())
.filter((def) => def.category === category)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
*
*/
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[] {
return Array.from(this.components.values())
.filter((def) => def.supportedDevices?.includes(device))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
*
*/
static searchComponents(query: string): PopComponentDefinition[] {
const lowerQuery = query.toLowerCase();
return Array.from(this.components.values()).filter(
(def) =>
def.id.toLowerCase().includes(lowerQuery) ||
def.name.toLowerCase().includes(lowerQuery) ||
def.description?.toLowerCase().includes(lowerQuery)
);
}
/**
*
*/
static getComponentCount(): number {
return this.components.size;
}
/**
*
*/
static getStatsByCategory(): Record<PopComponentCategory, number> {
const stats: Record<PopComponentCategory, number> = {
display: 0,
input: 0,
action: 0,
layout: 0,
feedback: 0,
};
for (const def of this.components.values()) {
stats[def.category]++;
}
return stats;
}
/**
*
*/
static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void {
this.eventListeners.push(callback);
}
/**
*
*/
static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void {
const index = this.eventListeners.indexOf(callback);
if (index > -1) {
this.eventListeners.splice(index, 1);
}
}
/**
*
*/
private static emitEvent(event: PopComponentRegistryEvent): void {
for (const listener of this.eventListeners) {
try {
listener(event);
} catch (error) {
console.error("[POP Registry] 이벤트 리스너 오류:", error);
}
}
}
/**
* ()
*/
static clear(): void {
this.components.clear();
console.log("[POP Registry] 레지스트리 초기화됨");
}
/**
*
*/
static hasComponent(id: string): boolean {
return this.components.has(id);
}
/**
*
*/
static debug(): void {
console.group("[POP Registry] 등록된 컴포넌트");
console.log(`${this.components.size}개 컴포넌트`);
console.table(
Array.from(this.components.values()).map((c) => ({
id: c.id,
name: c.name,
category: c.category,
touchOptimized: c.touchOptimized,
devices: c.supportedDevices?.join(", "),
}))
);
console.groupEnd();
}
}
// 기본 export
export default PopComponentRegistry;

View File

@ -1,20 +0,0 @@
"use client";
/**
* POP
*
* []
* - import하면 registerComponent()
* - : import "./pop-text" pop-text.tsx PopComponentRegistry.registerComponent()
*/
// 공통 타입 re-export (외부에서 필요 시 사용 가능)
export * from "./types";
// POP 컴포넌트 등록
import "./pop-text";
// 향후 추가될 컴포넌트들:
// import "./pop-field";
// import "./pop-button";
// import "./pop-list";

View File

@ -1,831 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import {
FontSize,
FontWeight,
TextAlign,
ObjectFit,
VerticalAlign,
FONT_SIZE_LABELS,
FONT_WEIGHT_LABELS,
OBJECT_FIT_LABELS,
FONT_SIZE_CLASSES,
FONT_WEIGHT_CLASSES,
TEXT_ALIGN_CLASSES,
VERTICAL_ALIGN_LABELS,
VERTICAL_ALIGN_CLASSES,
JUSTIFY_CLASSES,
} from "./types";
// ========================================
// 타입 정의
// ========================================
export type PopTextType = "text" | "datetime" | "image" | "title";
// datetime 빌더 설정 타입
export interface DateTimeBuilderConfig {
// 날짜 요소
showYear?: boolean;
showMonth?: boolean;
showDay?: boolean;
showWeekday?: boolean;
// 시간 요소
showHour?: boolean;
showMinute?: boolean;
showSecond?: boolean;
// 표기 방식
useKorean?: boolean; // true: 한글 (02월 04일), false: 숫자 (02/04)
// 구분자
dateSeparator?: string; // "-", "/", "."
}
export interface PopTextConfig {
textType: PopTextType;
content?: string;
dateFormat?: string; // 기존 호환용 (deprecated)
dateTimeConfig?: DateTimeBuilderConfig; // 새로운 빌더 설정
isRealtime?: boolean;
imageUrl?: string;
objectFit?: ObjectFit;
imageScale?: number; // 이미지 크기 조정 (10-100%)
fontSize?: FontSize;
fontWeight?: FontWeight;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign; // 상하 정렬
}
const TEXT_TYPE_LABELS: Record<PopTextType, string> = {
text: "일반 텍스트",
datetime: "시간/날짜",
image: "이미지",
title: "제목",
};
// ========================================
// datetime 포맷 빌드 함수
// ========================================
function buildDateTimeFormat(config?: DateTimeBuilderConfig): string {
// 설정이 없으면 기본값 (시:분:초)
if (!config) return "HH:mm:ss";
const sep = config.dateSeparator || "-";
const parts: string[] = [];
// 날짜 부분 조합
const hasDateParts = config.showYear || config.showMonth || config.showDay;
if (hasDateParts) {
const dateParts: string[] = [];
if (config.showYear) dateParts.push(config.useKorean ? "yyyy년" : "yyyy");
if (config.showMonth) dateParts.push(config.useKorean ? "MM월" : "MM");
if (config.showDay) dateParts.push(config.useKorean ? "dd일" : "dd");
// 한글 모드: 공백으로 연결, 숫자 모드: 구분자로 연결
parts.push(config.useKorean ? dateParts.join(" ") : dateParts.join(sep));
}
// 요일
if (config.showWeekday) {
parts.push(config.useKorean ? "(EEEE)" : "(EEE)");
}
// 시간 부분 조합
const timeParts: string[] = [];
if (config.showHour) timeParts.push(config.useKorean ? "HH시" : "HH");
if (config.showMinute) timeParts.push(config.useKorean ? "mm분" : "mm");
if (config.showSecond) timeParts.push(config.useKorean ? "ss초" : "ss");
if (timeParts.length > 0) {
// 한글 모드: 공백으로 연결, 숫자 모드: 콜론으로 연결
parts.push(config.useKorean ? timeParts.join(" ") : timeParts.join(":"));
}
// 아무것도 선택 안 했으면 기본값
return parts.join(" ") || "HH:mm:ss";
}
// ========================================
// 메인 컴포넌트
// ========================================
interface PopTextComponentProps {
config?: PopTextConfig;
label?: string;
isDesignMode?: boolean;
}
export function PopTextComponent({
config,
label,
isDesignMode,
}: PopTextComponentProps) {
const textType = config?.textType || "text";
if (isDesignMode) {
return (
<div className="flex h-full w-full items-center justify-center">
<DesignModePreview config={config} label={label} />
</div>
);
}
// 실제 렌더링
switch (textType) {
case "datetime":
return <DateTimeDisplay config={config} />;
case "image":
return <ImageDisplay config={config} />;
case "title":
return <TitleDisplay config={config} label={label} />;
default:
return <TextDisplay config={config} label={label} />;
}
}
// 디자인 모드 미리보기 (실제 설정값 표시)
function DesignModePreview({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const textType = config?.textType || "text";
// 공통 정렬 래퍼 클래스 (상하좌우 정렬)
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
switch (textType) {
case "datetime":
// 실시간 시간 미리보기
return (
<div className={alignWrapperClass}>
<DateTimePreview config={config} />
</div>
);
case "image":
// 이미지 미리보기
if (!config?.imageUrl) {
return (
<div className="flex h-full w-full items-center justify-center border border-dashed border-gray-300 text-[10px] text-gray-400">
URL
</div>
);
}
// 이미지도 정렬 래퍼 적용
return (
<div className={alignWrapperClass}>
<img
src={config.imageUrl}
alt=""
style={{
objectFit: config.objectFit || "none",
width: `${config.imageScale || 100}%`,
height: `${config.imageScale || 100}%`,
}}
/>
</div>
);
case "title":
// 제목 미리보기
return (
<div className={alignWrapperClass}>
<span
className={cn(
"whitespace-pre-wrap",
FONT_SIZE_CLASSES[config?.fontSize || "lg"],
FONT_WEIGHT_CLASSES[config?.fontWeight || "bold"]
)}
>
{config?.content || label || "제목"}
</span>
</div>
);
default:
// 일반 텍스트 미리보기
return (
<div className={alignWrapperClass}>
<span
className={cn(
"whitespace-pre-wrap",
FONT_SIZE_CLASSES[config?.fontSize || "base"]
)}
>
{config?.content || label || "텍스트"}
</span>
</div>
);
}
}
// 디자인 모드용 시간 미리보기 (실시간)
function DateTimePreview({ config }: { config?: PopTextConfig }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
// 디자인 모드에서도 실시간 업데이트 (간격 늘림)
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig
? buildDateTimeFormat(config.dateTimeConfig)
: config?.dateFormat || "HH:mm:ss";
return (
<span
className={cn(
"font-mono text-gray-600",
FONT_SIZE_CLASSES[config?.fontSize || "base"]
)}
>
{format(now, dateFormat, { locale: ko })}
</span>
);
}
// 시간/날짜 (실시간 지원)
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
if (!config?.isRealtime) return;
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, [config?.isRealtime]);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig
? buildDateTimeFormat(config.dateTimeConfig)
: config?.dateFormat || "HH:mm:ss";
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span
className={cn("font-mono", FONT_SIZE_CLASSES[config?.fontSize || "base"])}
>
{format(now, dateFormat, { locale: ko })}
</span>
</div>
);
}
// 이미지
function ImageDisplay({ config }: { config?: PopTextConfig }) {
if (!config?.imageUrl) {
return (
<div className="flex h-full items-center justify-center border-2 border-dashed text-xs text-gray-400">
URL
</div>
);
}
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<img
src={config.imageUrl}
alt=""
style={{
objectFit: config.objectFit || "none",
width: `${config?.imageScale || 100}%`,
height: `${config?.imageScale || 100}%`,
}}
/>
</div>
);
}
// 제목
function TitleDisplay({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"];
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span className={cn("whitespace-pre-wrap", sizeClass, weightClass)}>
{config?.content || label || "제목"}
</span>
</div>
);
}
// 일반 텍스트
function TextDisplay({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span className={cn("whitespace-pre-wrap", sizeClass)}>
{config?.content || label || "텍스트"}
</span>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopTextConfigPanelProps {
config: PopTextConfig;
onUpdate: (config: PopTextConfig) => void;
}
export function PopTextConfigPanel({
config,
onUpdate,
}: PopTextConfigPanelProps) {
const textType = config?.textType || "text";
return (
<div className="space-y-4">
{/* 텍스트 타입 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={textType}
onValueChange={(v) =>
onUpdate({ ...config, textType: v as PopTextType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(TEXT_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 서브타입별 설정 */}
{textType === "text" && (
<>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Textarea
value={config?.content || ""}
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
placeholder="여러 줄 입력 가능"
rows={3}
className="text-xs resize-none"
/>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "datetime" && (
<>
{/* 포맷 빌더 UI */}
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
{/* 실시간 업데이트 */}
<div className="flex items-center gap-2">
<Switch
checked={config?.isRealtime ?? true}
onCheckedChange={(v) => onUpdate({ ...config, isRealtime: v })}
/>
<Label className="text-xs"> </Label>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "image" && (
<>
<div className="space-y-2">
<Label className="text-xs"> URL</Label>
<Input
value={config?.imageUrl || ""}
onChange={(e) =>
onUpdate({ ...config, imageUrl: e.target.value })
}
placeholder="https://..."
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config?.objectFit || "none"}
onValueChange={(v) =>
onUpdate({ ...config, objectFit: v as ObjectFit })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(OBJECT_FIT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">
: {config?.imageScale || 100}%
</Label>
<input
type="range"
min={10}
max={100}
step={10}
value={config?.imageScale || 100}
onChange={(e) =>
onUpdate({ ...config, imageScale: Number(e.target.value) })
}
className="w-full"
/>
</div>
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "title" && (
<>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={config?.content || ""}
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
placeholder="제목 입력"
className="h-8 text-xs"
/>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<FontWeightSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
</div>
);
}
// 공통: 글자 크기
function FontSizeSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config?.fontSize || "base"}
onValueChange={(v) => onUpdate({ ...config, fontSize: v as FontSize })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FONT_SIZE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 공통: 글자 굵기
function FontWeightSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config?.fontWeight || "normal"}
onValueChange={(v) =>
onUpdate({ ...config, fontWeight: v as FontWeight })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FONT_WEIGHT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// datetime 포맷 빌더 UI
function DateTimeFormatBuilder({ config, onUpdate }: PopTextConfigPanelProps) {
// 기본값 설정 (시:분:초)
const dtConfig: DateTimeBuilderConfig = config?.dateTimeConfig || {
showHour: true,
showMinute: true,
showSecond: true,
useKorean: false,
dateSeparator: "-",
};
// dateTimeConfig 업데이트 헬퍼
const updateDtConfig = (updates: Partial<DateTimeBuilderConfig>) => {
onUpdate({
...config,
dateTimeConfig: { ...dtConfig, ...updates },
});
};
// 날짜 요소가 하나라도 선택되었는지
const hasDateParts = dtConfig.showYear || dtConfig.showMonth || dtConfig.showDay;
// 미리보기용 포맷 생성
const previewFormat = buildDateTimeFormat(dtConfig);
const previewText = format(new Date(), previewFormat, { locale: ko });
return (
<div className="space-y-3">
{/* 날짜 요소 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showYear || false}
onCheckedChange={(checked) =>
updateDtConfig({ showYear: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showMonth || false}
onCheckedChange={(checked) =>
updateDtConfig({ showMonth: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showDay || false}
onCheckedChange={(checked) =>
updateDtConfig({ showDay: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showWeekday || false}
onCheckedChange={(checked) =>
updateDtConfig({ showWeekday: checked === true })
}
/>
</label>
</div>
</div>
{/* 시간 요소 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showHour || false}
onCheckedChange={(checked) =>
updateDtConfig({ showHour: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showMinute || false}
onCheckedChange={(checked) =>
updateDtConfig({ showMinute: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showSecond || false}
onCheckedChange={(checked) =>
updateDtConfig({ showSecond: checked === true })
}
/>
</label>
</div>
</div>
{/* 표기 방식 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<RadioGroup
value={dtConfig.useKorean ? "korean" : "number"}
onValueChange={(v) => updateDtConfig({ useKorean: v === "korean" })}
className="flex gap-4"
>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="number" />
(02/04)
</label>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="korean" />
(02 04)
</label>
</RadioGroup>
</div>
{/* 구분자 (숫자 모드 + 날짜 요소가 있을 때만) */}
{!dtConfig.useKorean && hasDateParts && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={dtConfig.dateSeparator || "-"}
onValueChange={(v) => updateDtConfig({ dateSeparator: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="-" className="text-xs">
- ()
</SelectItem>
<SelectItem value="/" className="text-xs">
/ ()
</SelectItem>
<SelectItem value="." className="text-xs">
. ()
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 미리보기 */}
<div className="rounded border bg-muted/50 p-2">
<span className="text-[10px] text-muted-foreground">: </span>
<span className="text-xs font-medium">{previewText}</span>
</div>
</div>
);
}
// 공통: 정렬 (좌우 + 상하)
function AlignmentSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-0">
<Label className="text-xs"></Label>
<div className="grid grid-cols-2 gap-2">
{/* 좌우 정렬 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">/</span>
<Select
value={config?.textAlign || "left"}
onValueChange={(v) =>
onUpdate({ ...config, textAlign: v as TextAlign })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="좌우" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="right" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 상하 정렬 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">/</span>
<Select
value={config?.verticalAlign || "center"}
onValueChange={(v) =>
onUpdate({ ...config, verticalAlign: v as VerticalAlign })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="상하" />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="bottom" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기 컴포넌트
// ========================================
function PopTextPreviewComponent({ config }: { config?: PopTextConfig }) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<DesignModePreview config={config} />
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-text",
name: "텍스트",
description: "텍스트, 시간, 이미지 표시",
category: "display",
icon: "FileText",
component: PopTextComponent,
configPanel: PopTextConfigPanel,
preview: PopTextPreviewComponent,
defaultProps: { textType: "text", fontSize: "base" },
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -1,88 +0,0 @@
/**
* POP
*/
// ===== 스타일 관련 공통 타입 =====
// ===== 폰트 사이즈, 폰트 굵기, 텍스트 정렬, 이미지 맞춤 방식 정의 =====
export type FontSize = "xs" | "sm" | "base" | "lg" | "xl";
export type FontWeight = "normal" | "medium" | "bold";
export type TextAlign = "left" | "center" | "right" | "justify";
export type ObjectFit = "contain" | "cover" | "fill" | "none";
// ===== 라벨 매핑 =====
export const FONT_SIZE_LABELS: Record<FontSize, string> = {
xs: "아주 작게",
sm: "작게",
base: "보통",
lg: "크게",
xl: "매우 크게",
};
export const FONT_WEIGHT_LABELS: Record<FontWeight, string> = {
normal: "보통",
medium: "중간",
bold: "굵게",
};
export const TEXT_ALIGN_LABELS: Record<TextAlign, string> = {
left: "왼쪽",
center: "가운데",
right: "오른쪽",
justify: "양쪽 정렬",
};
export const OBJECT_FIT_LABELS: Record<ObjectFit, string> = {
contain: "비율 유지",
cover: "채우기",
fill: "늘리기",
none: "원본 크기",
};
// ===== Tailwind 클래스 매핑 =====
// 작게는 Tailwind 기본, 크게는 base(16px) 기준 2배씩: 12px → 14px → 16px → 32px → 64px
export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-[32px]",
xl: "text-[64px]",
};
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
normal: "font-normal",
medium: "font-medium",
bold: "font-bold",
};
export const TEXT_ALIGN_CLASSES: Record<TextAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
justify: "text-justify",
};
// ===== 상하 정렬 =====
export type VerticalAlign = "top" | "center" | "bottom";
export const VERTICAL_ALIGN_LABELS: Record<VerticalAlign, string> = {
top: "위",
center: "가운데",
bottom: "아래",
};
export const VERTICAL_ALIGN_CLASSES: Record<VerticalAlign, string> = {
top: "items-start",
center: "items-center",
bottom: "items-end",
};
// 좌우 정렬 (justify용 - flex 컨테이너에서 사용)
export const JUSTIFY_CLASSES: Record<string, string> = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
};

View File

@ -1,231 +0,0 @@
/**
* POP
*
* POP(/릿) overrides
* - componentConfig.ts에서 import하여
* - POP overrides
*/
import { z } from "zod";
// ============================================
// 공통 요소 재사용 (componentConfig.ts에서 import)
// ============================================
export {
// 공통 스키마
customConfigSchema,
componentV2Schema,
layoutV2Schema,
// 공통 유틸리티 함수
deepMerge,
mergeComponentConfig,
extractCustomConfig,
isDeepEqual,
getComponentTypeFromUrl,
} from "./componentConfig";
// ============================================
// POP 전용 URL 생성 함수
// ============================================
export function getPopComponentUrl(componentType: string): string {
return `@/lib/registry/pop-components/${componentType}`;
}
// ============================================
// POP 전용 컴포넌트 기본값
// ============================================
// POP 카드 리스트 기본값
export const popCardListDefaults = {
displayMode: "card" as const,
cardStyle: "compact" as const,
showHeader: true,
showFooter: false,
pageSize: 10,
enablePullToRefresh: true,
enableInfiniteScroll: false,
cardColumns: 1,
gap: 8,
padding: 16,
// 터치 최적화
touchFeedback: true,
swipeActions: false,
};
// POP 터치 버튼 기본값
export const popTouchButtonDefaults = {
variant: "primary" as const,
size: "lg" as const,
text: "확인",
icon: null,
iconPosition: "left" as const,
fullWidth: true,
// 터치 최적화
minHeight: 48, // 최소 터치 영역 48px
hapticFeedback: true,
pressDelay: 0,
};
// POP 스캐너 입력 기본값
export const popScannerInputDefaults = {
placeholder: "바코드를 스캔하세요",
showKeyboard: false,
autoFocus: true,
autoSubmit: true,
submitDelay: 300,
// 스캐너 설정
scannerMode: "auto" as const,
beepOnScan: true,
vibrationOnScan: true,
clearOnSubmit: true,
};
// POP 상태 배지 기본값
export const popStatusBadgeDefaults = {
variant: "default" as const,
size: "md" as const,
text: "",
icon: null,
// 스타일
rounded: true,
pulse: false,
};
// ============================================
// POP 전용 overrides 스키마
// ============================================
// POP 카드 리스트 overrides 스키마
export const popCardListOverridesSchema = z
.object({
displayMode: z.enum(["card", "list", "grid"]).default("card"),
cardStyle: z.enum(["compact", "default", "expanded"]).default("compact"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(false),
pageSize: z.number().default(10),
enablePullToRefresh: z.boolean().default(true),
enableInfiniteScroll: z.boolean().default(false),
cardColumns: z.number().default(1),
gap: z.number().default(8),
padding: z.number().default(16),
touchFeedback: z.boolean().default(true),
swipeActions: z.boolean().default(false),
// 데이터 바인딩
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
titleField: z.string().optional(),
subtitleField: z.string().optional(),
statusField: z.string().optional(),
})
.passthrough();
// POP 터치 버튼 overrides 스키마
export const popTouchButtonOverridesSchema = z
.object({
variant: z.enum(["primary", "secondary", "success", "warning", "danger", "ghost"]).default("primary"),
size: z.enum(["sm", "md", "lg", "xl"]).default("lg"),
text: z.string().default("확인"),
icon: z.string().nullable().default(null),
iconPosition: z.enum(["left", "right", "top", "bottom"]).default("left"),
fullWidth: z.boolean().default(true),
minHeight: z.number().default(48),
hapticFeedback: z.boolean().default(true),
pressDelay: z.number().default(0),
// 액션
actionType: z.string().optional(),
actionParams: z.record(z.string(), z.any()).optional(),
})
.passthrough();
// POP 스캐너 입력 overrides 스키마
export const popScannerInputOverridesSchema = z
.object({
placeholder: z.string().default("바코드를 스캔하세요"),
showKeyboard: z.boolean().default(false),
autoFocus: z.boolean().default(true),
autoSubmit: z.boolean().default(true),
submitDelay: z.number().default(300),
scannerMode: z.enum(["auto", "camera", "external"]).default("auto"),
beepOnScan: z.boolean().default(true),
vibrationOnScan: z.boolean().default(true),
clearOnSubmit: z.boolean().default(true),
// 데이터 바인딩
tableName: z.string().optional(),
columnName: z.string().optional(),
})
.passthrough();
// POP 상태 배지 overrides 스키마
export const popStatusBadgeOverridesSchema = z
.object({
variant: z.enum(["default", "success", "warning", "danger", "info"]).default("default"),
size: z.enum(["sm", "md", "lg"]).default("md"),
text: z.string().default(""),
icon: z.string().nullable().default(null),
rounded: z.boolean().default(true),
pulse: z.boolean().default(false),
// 조건부 스타일
conditionField: z.string().optional(),
conditionMapping: z.record(z.string(), z.string()).optional(),
})
.passthrough();
// ============================================
// POP 컴포넌트 overrides 스키마 레지스트리
// ============================================
export const popComponentOverridesSchemaRegistry: Record<string, z.ZodTypeAny> = {
"pop-card-list": popCardListOverridesSchema,
"pop-touch-button": popTouchButtonOverridesSchema,
"pop-scanner-input": popScannerInputOverridesSchema,
"pop-status-badge": popStatusBadgeOverridesSchema,
};
// ============================================
// POP 컴포넌트 기본값 레지스트리
// ============================================
export const popComponentDefaultsRegistry: Record<string, Record<string, any>> = {
"pop-card-list": popCardListDefaults,
"pop-touch-button": popTouchButtonDefaults,
"pop-scanner-input": popScannerInputDefaults,
"pop-status-badge": popStatusBadgeDefaults,
};
// ============================================
// POP 기본값 조회 함수
// ============================================
export function getPopComponentDefaults(componentType: string): Record<string, any> {
return popComponentDefaultsRegistry[componentType] || {};
}
// ============================================
// POP URL로 기본값 조회
// ============================================
export function getPopDefaultsByUrl(componentUrl: string): Record<string, any> {
// "@/lib/registry/pop-components/pop-card-list" → "pop-card-list"
const parts = componentUrl.split("/");
const componentType = parts[parts.length - 1];
return getPopComponentDefaults(componentType);
}
// ============================================
// POP overrides 파싱 및 검증
// ============================================
export function parsePopOverridesByUrl(
componentUrl: string,
overrides: Record<string, any>,
): Record<string, any> {
const parts = componentUrl.split("/");
const componentType = parts[parts.length - 1];
const schema = popComponentOverridesSchemaRegistry[componentType];
if (!schema) {
// 스키마 없으면 그대로 반환
return overrides || {};
}
try {
return schema.parse(overrides || {});
} catch {
// 파싱 실패 시 기본값 반환
return getPopComponentDefaults(componentType);
}
}

View File

@ -48,7 +48,6 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react-grid-layout": "^1.3.6",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
"@xyflow/react": "^12.8.4", "@xyflow/react": "^12.8.4",
@ -77,7 +76,6 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-is": "^18.3.1", "react-is": "^18.3.1",
@ -6212,15 +6210,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-grid-layout": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz",
"integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-reconciler": { "node_modules/@types/react-reconciler": {
"version": "0.32.2", "version": "0.32.2",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz",
@ -9787,12 +9776,6 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@ -11120,6 +11103,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@ -11727,6 +11711,7 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@ -12157,6 +12142,7 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -12785,6 +12771,7 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -12796,6 +12783,7 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
@ -13195,38 +13183,6 @@
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz",
"integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.66.0", "version": "7.66.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
@ -13342,20 +13298,6 @@
} }
} }
}, },
"node_modules/react-resizable": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
"integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-resizable-panels": { "node_modules/react-resizable-panels": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
@ -13695,12 +13637,6 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",

View File

@ -57,7 +57,6 @@
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react-grid-layout": "^1.3.6",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
"@xyflow/react": "^12.8.4", "@xyflow/react": "^12.8.4",
@ -86,7 +85,6 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-is": "^18.3.1", "react-is": "^18.3.1",

View File

@ -1,286 +0,0 @@
# POP 화면 시스템 아키텍처
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
---
## 현재 버전: v5 (CSS Grid)
| 항목 | v5 (현재) |
|------|----------|
| 레이아웃 | CSS Grid |
| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) |
| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) |
| 칸 수 | 4/6/8/12칸 |
---
## 폴더 구조
```
frontend/
├── app/(pop)/ # Next.js App Router
│ ├── layout.tsx # POP 전용 레이아웃
│ └── pop/
│ ├── page.tsx # 대시보드
│ ├── screens/[screenId]/ # 화면 뷰어 (v5)
│ └── work/ # 작업 화면
├── components/pop/ # POP 컴포넌트
│ ├── designer/ # 디자이너 모듈 ★
│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장)
│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드)
│ │ ├── panels/
│ │ │ └── ComponentEditorPanel.tsx # 속성 편집
│ │ ├── renderers/
│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링
│ │ ├── types/
│ │ │ └── pop-layout.ts # v5 타입 정의
│ │ └── utils/
│ │ └── gridUtils.ts # 위치 계산
│ ├── management/ # 화면 관리
│ └── dashboard/ # 대시보드
└── lib/
├── api/screen.ts # 화면 API
└── registry/ # 컴포넌트 레지스트리
```
---
## 핵심 파일
### 1. PopDesigner.tsx (메인)
**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리
```typescript
// 상태 관리
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
// 핵심 함수
handleSave() // 레이아웃 저장
handleAddComponent() // 컴포넌트 추가
handleUpdateComponent() // 컴포넌트 수정
handleDeleteComponent() // 컴포넌트 삭제
handleUndo() / handleRedo() // 히스토리
```
### 2. PopCanvas.tsx (캔버스)
**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환
```typescript
// DnD 설정
const DND_ITEM_TYPES = { COMPONENT: "component" };
// 뷰포트 프리셋 (4개 모드) - height 제거됨 (세로 무한 스크롤)
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", width: 375, columns: 4 },
{ id: "mobile_landscape", width: 600, columns: 6 },
{ id: "tablet_portrait", width: 834, columns: 8 },
{ id: "tablet_landscape", width: 1024, columns: 12 },
];
// 세로 자동 확장
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
const dynamicCanvasHeight = useMemo(() => { ... }, []);
// 기능
- useDrop(): 팔레트에서 컴포넌트 드롭
- handleWheel(): 줌 (30%~150%)
- Space + 드래그: 패닝
```
### 3. PopRenderer.tsx (렌더러)
**역할**: CSS Grid 기반 레이아웃 렌더링
```typescript
// Props
interface PopRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode: GridMode;
isDesignMode: boolean;
selectedComponentId?: string | null;
onSelectComponent?: (id: string | null) => void;
}
// CSS Grid 스타일 생성
const gridStyle = useMemo(() => ({
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
gap: `${gap}px`,
padding: `${padding}px`,
}), [mode]);
// 위치 변환 (12칸 → 다른 모드)
const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
const ratio = GRID_BREAKPOINTS[targetMode].columns / 12;
return {
col: Math.max(1, Math.round(pos.col * ratio)),
colSpan: Math.max(1, Math.round(pos.colSpan * ratio)),
row: pos.row,
rowSpan: pos.rowSpan,
};
};
```
### 4. ComponentEditorPanel.tsx (속성 패널)
**역할**: 선택된 컴포넌트 속성 편집
```typescript
// 탭 구조
- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집)
- settings: label, type 등
- data: 데이터 바인딩 (미구현)
- visibility: 모드별 표시/숨김
```
### 5. pop-layout.ts (타입 정의)
**역할**: v5 타입 정의
```typescript
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 브레이크포인트 설정 (2026-02-06 재설계)
const GRID_BREAKPOINTS = {
mobile_portrait: { columns: 4, maxWidth: 479, gap: 8, padding: 12 },
mobile_landscape: { columns: 6, minWidth: 480, maxWidth: 767, gap: 8, padding: 16 },
tablet_portrait: { columns: 8, minWidth: 768, maxWidth: 1023, gap: 12, padding: 20 },
tablet_landscape: { columns: 12, minWidth: 1024, gap: 12, padding: 24 },
};
// 모드 감지 (순수 너비 기반)
function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 480) return "mobile_portrait";
if (viewportWidth < 768) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
// 레이아웃 데이터
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: PopLayoutMetadata;
gridConfig: PopGridConfig;
components: PopComponentDefinitionV5[];
globalSettings: PopGlobalSettingsV5;
}
// 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label: string;
gridPosition: PopGridPosition; // col, row, colSpan, rowSpan
config: PopComponentConfig;
visibility: Record<GridMode, boolean>;
modeOverrides?: Record<GridMode, PopModeOverrideV5>;
}
// 위치
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
}
```
### 6. gridUtils.ts (유틸리티)
**역할**: 그리드 위치 계산
```typescript
// 위치 변환
convertPositionToMode(pos, targetMode)
// 겹침 감지
isOverlapping(posA, posB)
// 빈 위치 찾기
findNextEmptyPosition(layout, mode)
// 마우스 → 그리드 좌표
mouseToGridPosition(mouseX, mouseY, canvasRect, mode)
```
---
## 데이터 흐름
```
[사용자 액션]
[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history)
[PopCanvas] ← DnD, 줌, 모드 전환
[PopRenderer] ← CSS Grid 렌더링
[컴포넌트 표시]
```
### 저장 흐름
```
[저장 버튼]
PopDesigner.handleSave()
screenApi.saveLayoutPop(screenId, layout)
[백엔드] screenManagementService.saveLayoutPop()
[DB] screen_layouts_pop 테이블
```
### 로드 흐름
```
[페이지 로드]
PopDesigner useEffect
screenApi.getLayoutPop(screenId)
isV5Layout(data) 체크
setLayout(data) 또는 createEmptyPopLayoutV5()
```
---
## API 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 |
| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 |
---
## 삭제된 레거시 (참고용)
| 파일 | 버전 | 이유 |
|------|------|------|
| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 |
| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 |
| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 |
| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 |
---
*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)*

File diff suppressed because it is too large Load Diff

View File

@ -1,646 +0,0 @@
# POP 파일 상세 목록
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
---
## 목차
1. [App Router 파일](#1-app-router-파일)
2. [Designer 파일](#2-designer-파일)
3. [Panels 파일](#3-panels-파일)
4. [Renderers 파일](#4-renderers-파일)
5. [Types 파일](#5-types-파일)
6. [Utils 파일](#6-utils-파일)
7. [Management 파일](#7-management-파일)
8. [Dashboard 파일](#8-dashboard-파일)
9. [Library 파일](#9-library-파일)
10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일)
---
## 1. App Router 파일
### `frontend/app/(pop)/layout.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 전용 레이아웃 래퍼 |
| 라우트 그룹 | `(pop)` - URL에 포함되지 않음 |
| 특징 | 데스크톱과 분리된 터치 최적화 환경 |
---
### `frontend/app/(pop)/pop/page.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 메인 대시보드 |
| 경로 | `/pop` |
| 사용 컴포넌트 | `PopDashboard` |
---
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 개별 POP 화면 뷰어 (v5 전용) |
| 경로 | `/pop/screens/:screenId` |
| 버전 | v5 그리드 시스템 전용 |
**핵심 코드 구조**:
```typescript
// v5 레이아웃 상태
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 레이아웃 로드
useEffect(() => {
const popLayout = await screenApi.getLayoutPop(screenId);
if (isV5Layout(popLayout)) {
setLayout(popLayout);
} else {
// 레거시 레이아웃은 빈 v5로 처리
setLayout(createEmptyPopLayoutV5());
}
}, [screenId]);
// v5 그리드 렌더링
{hasComponents ? (
<PopRenderer
layout={layout}
viewportWidth={viewportWidth}
currentMode={currentModeKey}
isDesignMode={false}
/>
) : (
// 빈 화면
)}
```
**제공 기능**:
- 반응형 모드 감지 (useResponsiveModeWithOverride)
- 프리뷰 모드 (`?preview=true`)
- 디바이스/방향 수동 전환 (프리뷰 모드)
- 4개 그리드 모드 지원
---
### `frontend/app/(pop)/pop/work/page.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 작업 화면 (샘플) |
| 경로 | `/pop/work` |
---
## 2. Designer 파일
### `frontend/components/pop/designer/PopDesigner.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 디자이너 메인 (v5 전용) |
| 의존성 | react-dnd, ResizablePanelGroup |
**핵심 Props**:
```typescript
interface PopDesignerProps {
selectedScreen: ScreenDefinition;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
}
```
**상태 관리**:
```typescript
// v5 레이아웃
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 그리드 모드 (4개)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
// UI 상태
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
```
**주요 핸들러**:
| 핸들러 | 역할 |
|--------|------|
| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) |
| `handleUpdateComponent` | 컴포넌트 속성 수정 |
| `handleDeleteComponent` | 컴포넌트 삭제 |
| `handleSave` | v5 레이아웃 저장 |
---
### `frontend/components/pop/designer/PopCanvas.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v5 CSS Grid 기반 캔버스 + 행/열 라벨 |
| 렌더링 | CSS Grid (4/6/8/12칸) |
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
| 라벨 | 열 라벨 (1~12), 행 라벨 (1~20) |
| 토글 | 그리드 ON/OFF 버튼 |
**핵심 Props**:
```typescript
interface PopCanvasProps {
layout: PopLayoutDataV5;
selectedComponentId: string | null;
currentMode: GridMode;
onModeChange: (mode: GridMode) => void;
onSelectComponent: (id: string | null) => void;
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
onDeleteComponent: (componentId: string) => void;
}
```
**뷰포트 프리셋** (v5.2 - height 제거됨, 세로 자동 확장):
```typescript
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", width: 375, columns: 4 },
{ id: "mobile_landscape", label: "모바일 가로", width: 600, columns: 6 },
{ id: "tablet_portrait", label: "태블릿 세로", width: 834, columns: 8 },
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, columns: 12 },
];
// 세로 자동 확장
const MIN_CANVAS_HEIGHT = 600;
const CANVAS_EXTRA_ROWS = 3;
const dynamicCanvasHeight = useMemo(() => { ... }, []);
```
**제공 기능**:
- 4개 모드 프리셋 전환
- 줌 컨트롤 (30% ~ 150%)
- 패닝 (Space + 드래그)
- 컴포넌트 드래그 앤 드롭
- 그리드 좌표 계산
---
### `frontend/components/pop/designer/index.ts`
```typescript
export { default as PopDesigner } from "./PopDesigner";
export { default as PopCanvas } from "./PopCanvas";
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
export { default as PopRenderer } from "./renderers/PopRenderer";
export * from "./types";
export * from "./utils/gridUtils";
```
---
## 3. Panels 파일
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v5 컴포넌트 편집 패널 |
| 위치 | 오른쪽 사이드바 |
**핵심 Props**:
```typescript
interface ComponentEditorPanelProps {
component: PopComponentDefinitionV5 | null;
currentMode: GridMode;
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
className?: string;
}
```
**3개 탭**:
| 탭 | 아이콘 | 내용 |
|----|--------|------|
| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) |
| `settings` | Settings | 라벨, 타입별 설정 |
| `data` | Database | 데이터 바인딩 (Phase 4) |
---
### `frontend/components/pop/designer/panels/index.ts`
```typescript
export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel";
```
---
## 4. Renderers 파일
### `frontend/components/pop/designer/renderers/PopRenderer.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | v5 레이아웃 CSS Grid 렌더러 + 격자 셀 |
| 입력 | PopLayoutDataV5, viewportWidth, currentMode, showGridGuide |
| 격자 | 동적 행 수 (컴포넌트 배치에 따라 자동 계산, CSS Grid 좌표계) |
**핵심 Props**:
```typescript
interface PopRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean;
selectedComponentId?: string | null;
showGridGuide?: boolean; // 격자 표시 여부
onComponentClick?: (componentId: string) => void;
onBackgroundClick?: () => void;
className?: string;
}
```
**격자 셀 렌더링**:
```typescript
// 동적 행 수 계산 (컴포넌트 배치 기반)
const gridCells = useMemo(() => {
const maxRowEnd = Object.values(components).reduce((max, comp) => {
const pos = getEffectivePosition(comp);
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
const rowCount = Math.max(10, maxRowEnd + 5);
const cells = [];
for (let row = 1; row <= rowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) {
cells.push({ id: `cell-${col}-${row}`, col, row });
}
}
return cells;
}, [components, overrides, mode, breakpoint.columns]);
// 컴포넌트와 동일한 CSS Grid 좌표계로 렌더링
{showGridGuide && gridCells.map(cell => (
<div
key={cell.id}
className="border border-dashed border-blue-300/40"
style={{ gridColumn: cell.col, gridRow: cell.row }}
/>
))}
```
**CSS Grid 스타일 생성**:
```typescript
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
};
```
**컴포넌트 위치 변환**:
```typescript
const convertPosition = (position: PopGridPosition): React.CSSProperties => ({
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
});
```
---
### `frontend/components/pop/designer/renderers/index.ts`
```typescript
export { default as PopRenderer, default } from "./PopRenderer";
```
---
## 5. Types 파일
### `frontend/components/pop/designer/types/pop-layout.ts`
| 항목 | 내용 |
|------|------|
| 역할 | POP 레이아웃 v5 타입 시스템 |
| 버전 | v5 전용 (레거시 제거됨) |
**핵심 타입**:
```typescript
// 그리드 모드
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
// 그리드 브레이크포인트
interface GridBreakpoint {
label: string;
columns: number;
minWidth: number;
maxWidth: number;
rowHeight: number;
gap: number;
padding: number;
}
// v5 레이아웃
interface PopLayoutDataV5 {
version: "pop-5.0";
gridConfig: PopGridConfig;
components: Record<string, PopComponentDefinitionV5>;
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV5;
metadata?: PopLayoutMetadata;
overrides?: Record<GridMode, PopModeOverrideV5>;
}
// 그리드 위치
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기
rowSpan: number; // 행 크기
}
// v5 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
position: PopGridPosition;
visibility?: { modes: GridMode[]; defaultVisible: boolean };
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
```
**주요 함수**:
| 함수 | 역할 |
|------|------|
| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 |
| `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 |
| `isV5Layout()` | v5 타입 가드 |
| `detectGridMode()` | 뷰포트 너비로 모드 감지 |
---
### `frontend/components/pop/designer/types/index.ts`
```typescript
export * from "./pop-layout";
```
---
## 5.5. Constants 파일 (신규)
### `frontend/components/pop/designer/constants/dnd.ts`
| 항목 | 내용 |
|------|------|
| 역할 | DnD(Drag and Drop) 관련 상수 |
| 생성일 | 2026-02-05 |
**핵심 상수**:
```typescript
export const DND_ITEM_TYPES = {
/** 팔레트에서 새 컴포넌트 드래그 */
COMPONENT: "POP_COMPONENT",
/** 캔버스 내 기존 컴포넌트 이동 */
MOVE_COMPONENT: "POP_MOVE_COMPONENT",
} as const;
```
**사용처**:
- `PopCanvas.tsx` - useDrop accept 타입
- `PopRenderer.tsx` - useDrag type
- `ComponentPalette.tsx` - useDrag type
---
### `frontend/components/pop/designer/constants/index.ts`
```typescript
export * from "./dnd";
```
---
## 6. Utils 파일
### `frontend/components/pop/designer/utils/gridUtils.ts`
| 항목 | 내용 |
|------|------|
| 역할 | 그리드 위치 계산 유틸리티 |
| 용도 | 좌표 변환, 겹침 감지, 자동 배치 |
**주요 함수**:
| 함수 | 역할 |
|------|------|
| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 |
| `isOverlapping()` | 두 위치 겹침 여부 확인 |
| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) |
| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 |
| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 |
| `isValidPosition()` | 위치 유효성 검사 |
| `clampPosition()` | 위치 범위 조정 |
| `findNextEmptyPosition()` | 다음 빈 위치 찾기 |
| `autoLayoutComponents()` | 자동 배치 |
---
## 7. Management 파일
### `frontend/components/pop/management/PopCategoryTree.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 카테고리 트리 |
| 기능 | 그룹 추가/수정/삭제, 화면 목록 |
---
### `frontend/components/pop/management/PopScreenSettingModal.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 설정 모달 |
| 기능 | 화면명, 설명, 그룹 설정 |
---
### `frontend/components/pop/management/PopScreenPreview.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 미리보기 |
| 기능 | 썸네일, 기본 정보 표시 |
---
### `frontend/components/pop/management/PopScreenFlowView.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | 화면 간 플로우 시각화 |
| 기능 | 화면 연결 관계 표시 |
---
### `frontend/components/pop/management/index.ts`
```typescript
export { PopCategoryTree } from "./PopCategoryTree";
export { PopScreenSettingModal } from "./PopScreenSettingModal";
export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView";
```
---
## 8. Dashboard 파일
### `frontend/components/pop/dashboard/PopDashboard.tsx`
| 항목 | 내용 |
|------|------|
| 역할 | POP 대시보드 메인 |
| 구성 | 헤더, KPI, 메뉴그리드, 공지, 푸터 |
---
### 기타 Dashboard 컴포넌트
| 파일 | 역할 |
|------|------|
| `DashboardHeader.tsx` | 상단 헤더 |
| `DashboardFooter.tsx` | 하단 푸터 |
| `MenuGrid.tsx` | 메뉴 그리드 |
| `KpiBar.tsx` | KPI 요약 바 |
| `NoticeBanner.tsx` | 공지 배너 |
| `NoticeList.tsx` | 공지 목록 |
| `ActivityList.tsx` | 최근 활동 목록 |
---
## 9. Library 파일
### `frontend/lib/api/popScreenGroup.ts`
| 항목 | 내용 |
|------|------|
| 역할 | POP 화면 그룹 API 클라이언트 |
**API 함수**:
```typescript
async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
async function createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
async function updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
async function deletePopScreenGroup(id: number): Promise<...>
async function ensurePopRootGroup(): Promise<...>
```
---
### `frontend/lib/registry/PopComponentRegistry.ts`
| 항목 | 내용 |
|------|------|
| 역할 | POP 컴포넌트 중앙 레지스트리 |
---
### `frontend/lib/schemas/popComponentConfig.ts`
| 항목 | 내용 |
|------|------|
| 역할 | POP 컴포넌트 설정 스키마 |
| 검증 | Zod 기반 |
---
## 10. 루트 컴포넌트 파일
### `frontend/components/pop/index.ts`
```typescript
export * from "./designer";
export * from "./management";
export * from "./dashboard";
// 개별 컴포넌트 export
```
---
### 기타 루트 레벨 컴포넌트
| 파일 | 역할 |
|------|------|
| `PopApp.tsx` | POP 앱 셸 |
| `PopHeader.tsx` | 공통 헤더 |
| `PopBottomNav.tsx` | 하단 네비게이션 |
| `PopStatusTabs.tsx` | 상태 탭 |
| `PopWorkCard.tsx` | 작업 카드 |
| `PopProductionPanel.tsx` | 생산 패널 |
| `PopSettingsModal.tsx` | 설정 모달 |
| `PopAcceptModal.tsx` | 수락 모달 |
| `PopProcessModal.tsx` | 프로세스 모달 |
| `PopEquipmentModal.tsx` | 설비 모달 |
---
## 파일 수 통계
| 폴더 | 파일 수 | 설명 |
|------|---------|------|
| `app/(pop)` | 4 | App Router 페이지 |
| `components/pop/designer` | 11 | 디자이너 모듈 (v5) - constants 포함 |
| `components/pop/management` | 5 | 관리 모듈 |
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
| `lib` | 3 | 라이브러리 |
| **총계** | **50** | |
---
## 삭제된 파일 (v5 통합으로 제거)
| 파일 | 이유 |
|------|------|
| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 |
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 |
| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 |
| `ComponentEditorPanelV4.tsx` | v4 편집 패널 |
| `PopPanel.tsx` | 레거시 팔레트 패널 |
| `test-v4/page.tsx` | v4 테스트 페이지 |
| `GridGuide.tsx` | SVG 기반 격자 가이드 (좌표 불일치로 삭제, CSS Grid 통합) |
---
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)*

View File

@ -1,120 +0,0 @@
# 기능별 색인
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
> **검색 팁**: Ctrl+F로 기능명, 키워드 검색
---
## 렌더링
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 |
| 격자 셀 렌더링 | PopRenderer.tsx | `gridCells` (useMemo) | 12x20 = 240개 DOM 셀 |
| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 |
| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS |
## 그리드 가이드
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 (동적 행 수) |
| 열 라벨 | PopCanvas.tsx | `gridLabels.columns` | 1~12 표시 |
| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 동적 계산 (dynamicCanvasHeight 기반) |
| 토글 | PopCanvas.tsx | `showGridGuide` 상태 | 격자 ON/OFF |
## 세로 자동 확장
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 동적 높이 | PopCanvas.tsx | `dynamicCanvasHeight` | 컴포넌트 배치 기반 자동 계산 |
| 최소 높이 | PopCanvas.tsx | `MIN_CANVAS_HEIGHT` | 600px 보장 |
| 여유 행 | PopCanvas.tsx | `CANVAS_EXTRA_ROWS` | 항상 3행 추가 |
| 격자 행 수 | PopRenderer.tsx | `gridCells` | maxRowEnd + 5 동적 계산 |
## 드래그 앤 드롭
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 |
| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 |
| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 |
| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 |
## 편집
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 |
| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 |
| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 |
| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 |
## 레이아웃 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 |
| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 |
## 상태 관리
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 상태 | PopDesigner.tsx | `useState<PopLayoutDataV5>` | 메인 레이아웃 |
| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo |
| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 |
| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 |
## 저장/로드
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 |
| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 |
| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 |
## 뷰포트/줌
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 (width만, height 제거) |
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 너비 기반 모드 판별 |
## 브레이크포인트
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 그리드 설정 | pop-layout.ts | `GRID_BREAKPOINTS` | 모드별 칸 수, gap, padding |
| 모드 감지 | pop-layout.ts | `detectGridMode()` | viewportWidth → GridMode |
| 훅 연동 | useDeviceOrientation.ts | `BREAKPOINTS.TABLET_MIN` | 768px (태블릿 경계) |
## 자동 줄바꿈/검토
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|------|------|--------------|------|
| 자동 배치 | gridUtils.ts | `convertAndResolvePositions()` | col > maxCol → 맨 아래 배치 |
| 검토 필요 판별 | gridUtils.ts | `needsReview()` | 오버라이드 없으면 true |
| 검토 패널 | PopCanvas.tsx | `ReviewPanel` | 검토 필요 컴포넌트 목록 |
---
## 파일별 주요 기능
| 파일 | 핵심 기능 |
|------|----------|
| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 |
| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 행/열 라벨, 격자 토글 |
| PopRenderer.tsx | CSS Grid 렌더링, 격자 셀, 위치 변환, 컴포넌트 표시 |
| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) |
| ComponentPalette.tsx | 컴포넌트 팔레트 (드래그 가능한 컴포넌트 목록) |
| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 |
| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 |
---
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -1,150 +0,0 @@
# POP 개발 계획
---
## 현재 상태 (2026-02-06)
**v5.2 그리드 시스템 완성 (브레이크포인트 재설계 + 세로 자동 확장)**
---
## 작업 순서
```
[Phase 1~3] [Phase 5] [Phase 4]
v4 Flexbox → v5 CSS Grid → 실제 컴포넌트 구현
완료 완료 (v5.2) 다음
```
---
## 완료된 Phase
### Phase 1~3: v4 Flexbox 시스템 (완료, 레거시 삭제됨)
v4 Flexbox 기반 시스템은 v5 CSS Grid로 완전히 대체되었습니다.
v4 관련 파일은 모두 삭제되었습니다.
- [x] v4 기본 구조, 렌더러, 디자이너 통합
- [x] Undo/Redo, 드래그 리사이즈, Flexbox 가로 배치
- [x] 비율 스케일링 시스템
- [x] 오버라이드 기능 (모드별 배치 고정)
- [x] 컴포넌트 표시/숨김, 줄바꿈
### Phase 5: v5 CSS Grid 시스템 (완료)
#### Phase 5.1: 타입 정의 (완료)
- [x] `PopLayoutDataV5` 인터페이스
- [x] `PopGridConfig`, `PopGridPosition` 타입
- [x] `GridMode`, `GRID_BREAKPOINTS` 상수
- [x] `createEmptyPopLayoutV5()`, `isV5Layout()`, `detectGridMode()`
#### Phase 5.2: 그리드 렌더러 (완료)
- [x] `PopRenderer.tsx` - CSS Grid 기반 렌더링
- [x] 격자 셀 렌더링 (CSS Grid 동일 좌표계)
- [x] 위치 변환 (12칸 -> 4/6/8칸)
#### Phase 5.3: 디자이너 UI (완료)
- [x] `PopCanvas.tsx` - 그리드 캔버스 + 행/열 라벨
- [x] 드래그 스냅 (칸에 맞춤)
- [x] `ComponentEditorPanel.tsx` - 위치 편집
#### Phase 5.4: 반응형 자동화 (완료)
- [x] 자동 변환 알고리즘 (12칸 -> 4칸)
- [x] 겹침 감지 및 재배치
- [x] 모드별 오버라이드 저장
#### v5.1 추가 기능 (완료)
- [x] 자동 줄바꿈 (col > maxCol -> 맨 아래 배치)
- [x] "검토 필요" 알림 시스템
- [x] Gap 프리셋 (좁게/보통/넓게)
- [x] 숨김 기능 (모드별)
#### v5.2 브레이크포인트 재설계 + 세로 자동 확장 (완료)
- [x] 기기 기반 브레이크포인트 (479/767/1023px)
- [x] 세로 자동 확장 (dynamicCanvasHeight)
- [x] 뷰어 반응형 일관성 (detectGridMode 사용)
- [x] VIEWPORT_PRESETS에서 height 제거
---
## 다음 작업
### Phase 4: 실제 컴포넌트 구현
현재 모든 컴포넌트는 `pop-sample` (샘플 박스)로 렌더링됩니다.
실제 컴포넌트를 구현하여 데이터 바인딩까지 연결해야 합니다.
**컴포넌트 구현 목록**:
- [ ] pop-field: 입력/표시 필드
- [ ] pop-button: 액션 버튼
- [ ] pop-list: 데이터 리스트 (카드 템플릿)
- [ ] pop-indicator: KPI/상태 표시
- [ ] pop-scanner: 바코드/QR 스캔
- [ ] pop-numpad: 숫자 입력 패드
**참고 문서**: [components-spec.md](./components-spec.md)
### 후속 작업
- [ ] 워크플로우 연동 (버튼 액션, 화면 전환)
- [ ] 데이터 바인딩 연결
- [ ] 실기기 테스트 (아이폰 SE, iPad Mini 등)
---
## 현재 구현 계획
> **용도**: 이 섹션은 "지금 바로 실행할 구체적 계획"입니다.
> 새 세션에서 이 섹션만 읽으면 코딩을 시작할 수 있어야 합니다.
> 완료되면 다음 기능의 계획으로 **교체**합니다.
### 대상: (계획 수립 전)
현재 구현 계획이 없습니다. 계획 수립 세션에서 다음 형식으로 작성해주세요:
```
### 대상: [기능명]
#### 구현 순서 (의존성 기반)
1. [ ] 파일명 - 변경 내용 요약
2. [ ] 파일명 - 변경 내용 요약
#### 파일별 변경 사항
| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 |
|---|------------|------|----------|---------|
#### 함정 경고
- (빠뜨리면 에러나는 것들)
#### 참조
- 관련 문서/파일 경로
```
---
## 브레이크포인트 (v5.2 현재)
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
---
## 관련 문서
| 문서 | 내용 |
|------|------|
| [STATUS.md](./STATUS.md) | 현재 진행 상태 |
| [SPEC.md](./SPEC.md) | 기술 스펙 |
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 코드 구조 |
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 설계 |
| [decisions/005](./decisions/005-breakpoint-redesign.md) | 브레이크포인트 재설계 ADR |
---
*최종 업데이트: 2026-02-06 (v5.2 완료, Phase 4 대기)*

View File

@ -1,254 +0,0 @@
# 문제-해결 색인
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등)
---
## 렌더링 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
| **그리드 가이드 셀 크기 불균일** | gridAutoRows → gridTemplateRows로 행 높이 강제 고정 | 2026-02-06 | gridAutoRows, gridTemplateRows, 셀 크기, CSS Grid |
| **컴포넌트 콘텐츠가 셀 경계 벗어남** | overflow-visible → overflow-hidden 변경 | 2026-02-06 | overflow, 셀 크기, 콘텐츠 |
## DnD (드래그앤드롭) 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
| **Expected drag drop context (뷰어)** | isDesignMode=false일 때 DraggableComponent 대신 일반 div 렌더링 | 2026-02-05 | DndProvider, useDrag, 뷰어, context |
| **컴포넌트 중첩(겹침)** | toast import 누락 → `sonner`에서 import | 2026-02-05 | 겹침, overlap, toast |
| **리사이즈 핸들 작동 안됨** | useDrop 2개 중복 → 단일 useDrop으로 통합 | 2026-02-05 | resize, 핸들, useDrop |
| **드래그 좌표 완전 틀림 (Row 92)** | 캔버스 scale 보정 누락 → `(offset - rect.left) / scale` | 2026-02-05 | scale, 좌표, transform |
| **DND 타입 상수 불일치** | 3개 파일에 중복 정의 → `constants/dnd.ts`로 통합 | 2026-02-05 | 상수, DND, 타입 |
| **컴포넌트 이동 안됨** | useDrop accept 타입 불일치 → 공통 상수 사용 | 2026-02-05 | 이동, useDrop, accept |
## 타입 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props |
| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 |
## 레이아웃 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| **화면 밖 컴포넌트 정보 손실** | 자동 줄바꿈 로직 추가 (col > maxCol → col=1, row=맨아래+1) | 2026-02-06 | 자동배치, 줄바꿈, 정보손실 |
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
## 브레이크포인트/반응형 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| **뷰어 반응형 모드 불일치** | detectGridMode() 사용으로 일관성 확보 | 2026-02-06 | 반응형, 뷰어, 모드 |
| **768~839px 모드 불일치** | TABLET_MIN 768로 변경, 브레이크포인트 재설계 | 2026-02-06 | 브레이크포인트, 768px |
| **useResponsiveMode vs GRID_BREAKPOINTS 불일치** | 뷰어에서 detectGridMode(viewportWidth) 사용 | 2026-02-06 | 훅, 상수, 일관성 |
## 저장/로드 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 |
| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 |
## UI/UX 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 |
| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 |
---
## 그리드 가이드 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| SVG 격자와 CSS Grid 좌표 불일치 | GridGuide.tsx 삭제, PopRenderer에서 CSS Grid 셀로 격자 렌더링 | 2026-02-05 | 격자, SVG, CSS Grid, 좌표 |
| 행/열 라벨 위치 오류 | PopCanvas에 absolute positioning 라벨 추가 | 2026-02-05 | 라벨, 행, 열, 정렬 |
| 격자선과 컴포넌트 불일치 | 동일한 CSS Grid 좌표계 사용 | 2026-02-05 | 통합, 정렬, 일체감 |
---
## 해결 완료 (이번 세션)
| 문제 | 상태 | 해결 방법 |
|------|------|----------|
| PopCanvas 타입 오류 | **해결** | 임시 타입 가드 추가 |
| 팔레트 UI 없음 | **해결** | ComponentPalette.tsx 신규 추가 |
| SVG 격자 좌표 불일치 | **해결** | CSS Grid 기반 통합 |
| 드래그 좌표 완전 틀림 | **해결** | scale 보정 + calcGridPosition 함수 |
| DND 타입 상수 불일치 | **해결** | constants/dnd.ts 통합 |
| 컴포넌트 이동 안됨 | **해결** | useDrop/useDrag 타입 통일 |
| 컴포넌트 중첩(겹침) | **해결** | toast import 추가 → 겹침 감지 로직 정상 작동 |
| 리사이즈 핸들 작동 안됨 | **해결** | useDrop 통합 (2개 → 1개) |
| 숨김 컴포넌트 드래그 안됨 | **해결** | handleMoveComponent에서 숨김 해제 + 위치 저장 단일 상태 업데이트 |
| 그리드 범위 초과 에러 | **해결** | adjustedCol 계산으로 드롭 위치 자동 조정 |
| Expected drag drop context (뷰어) | **해결** | isDesignMode=false일 때 일반 div 렌더링 |
| hiddenComponentIds 중복 정의 | **해결** | 중복 useMemo 제거 (라인 410-412) |
| 뷰어 반응형 모드 불일치 | **해결** | detectGridMode() 사용 |
| 그리드 가이드 셀 크기 불균일 | **해결** | gridTemplateRows로 행 높이 강제 고정 |
| Canvas vs Renderer 행 수 불일치 | **해결** | 숨김 필터 통일, 여유행 +3으로 통일 |
| 디버깅 console.log 잔존 | **해결** | reviewComponents 내 console.log 삭제 |
---
## 드래그 좌표 버그 상세 (2026-02-05)
### 증상
- 컴포넌트를 아래로 드래그 → 위로 올라감
- Row 92 같은 비정상 좌표
- 드래그 이동/리사이즈 전혀 작동 안됨
### 원인
```
캔버스: transform: scale(0.8)
getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px)
getClientOffset() → 뷰포트 기준 실제 마우스 좌표
이 둘을 그대로 계산하면 좌표 완전 틀림
```
### 해결
```typescript
// 스케일 보정된 상대 좌표 계산
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
// 실제 캔버스 크기로 그리드 계산
calcGridPosition(relX, relY, customWidth, ...);
```
### 교훈
> CSS `transform: scale()` 적용된 요소에서 좌표 계산 시,
> `getBoundingClientRect()`는 스케일 적용된 값을 반환하지만
> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요**
---
## Expected drag drop context 에러 상세 (2026-02-05 심야)
### 증상
```
Invariant Violation: Expected drag drop context
at useDrag (...)
at DraggableComponent (...)
```
뷰어 페이지(`/pop/viewer/[screenId]`)에서 POP 화면 조회 시 에러 발생
### 원인
```
PopRenderer의 DraggableComponent에서 useDrag 훅을 무조건 호출
→ 뷰어 페이지에는 DndProvider가 없음
→ React 훅은 조건부 호출 불가 (Rules of Hooks)
→ DndProvider 없이 useDrag 호출 시 context 에러
```
### 해결
```typescript
// PopRenderer.tsx - 컴포넌트 렌더링 부분
if (isDesignMode) {
return (
<DraggableComponent ... /> // useDrag 사용
);
}
// 뷰어 모드: 드래그 없는 일반 렌더링
return (
<div className="..." style={positionStyle}>
<ComponentContent ... />
</div>
);
```
### 교훈
> React DnD의 `useDrag`/`useDrop` 훅은 반드시 `DndProvider` 내부에서만 호출해야 함.
> 디자인 모드와 뷰어 모드를 분기할 때, 훅이 포함된 컴포넌트 자체를 조건부 렌더링해야 함.
> 훅 내부에서 `canDrag: false`로 설정해도 훅 자체는 호출되므로 context 에러 발생.
### 관련 파일
- `gridUtils.ts`: convertAndResolvePositions(), needsReview()
- `PopCanvas.tsx`: ReviewPanel, ReviewItem
- `PopRenderer.tsx`: 자동 배치 위치 렌더링
---
## 뷰어 반응형 모드 불일치 상세 (2026-02-06)
### 증상
```
- 아이폰 SE, iPad Pro 프리셋은 정상 작동
- 브라우저 수동 리사이즈 시 6칸 모드(mobile_landscape)가 적용 안 됨
- 768~839px 구간에서 8칸으로 표시됨 (예상: 6칸)
```
### 원인
```
useResponsiveMode 훅:
- deviceType: width/height 비율로 "mobile"/"tablet" 판정
- isLandscape: width > height로 판정
- BREAKPOINTS.TABLET_MIN = 840 (당시)
GRID_BREAKPOINTS:
- mobile_landscape: 600~839px (6칸)
- tablet_portrait: 840~1023px (8칸)
결과:
- 768px 화면 → useResponsiveMode: "tablet" (768 < 840이지만 비율 판정)
- 768px 화면 → GRID_BREAKPOINTS: "mobile_landscape" (6칸)
- → 모드 불일치!
```
### 해결
**1단계: 브레이크포인트 재설계**
```typescript
// 기존
mobile_landscape: { minWidth: 600, maxWidth: 839 }
tablet_portrait: { minWidth: 840, maxWidth: 1023 }
// 변경 후
mobile_landscape: { minWidth: 480, maxWidth: 767 }
tablet_portrait: { minWidth: 768, maxWidth: 1023 }
```
**2단계: 훅 연동**
```typescript
// useDeviceOrientation.ts
BREAKPOINTS.TABLET_MIN: 768 // was 840
```
**3단계: 뷰어 모드 감지 방식 변경**
```typescript
// page.tsx (뷰어)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
: detectGridMode(viewportWidth); // 일반: 너비 기반 (일관성 확보)
```
### 교훈
> 반응형 모드 판정은 **단일 소스(GRID_BREAKPOINTS)**를 기준으로 해야 함.
> 훅과 상수가 각각 다른 기준을 사용하면 구간별 불일치 발생.
> 뷰어에서는 `detectGridMode(viewportWidth)` 직접 사용으로 일관성 확보.
---
## 병합 관련
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| **ScreenDesigner.tsx 3건 충돌** (origin/main 병합) | 함수 시그니처: ksh-v2-work 유지(isPop/defaultDevicePreview), 저장 로직: 3단계 분기 유지+console.log 제거, 툴바 props: origin/main 채택 | 2026-02-09 | 병합, merge, ScreenDesigner, 충돌 |
| **usePanelState 중복 선언** (병합 시 발견) | 충돌 1 해결 과정에서 L175의 중복 usePanelState 제거, L215의 완전한 버전만 유지 | 2026-02-09 | usePanelState, 중복, 병합 |
| **툴바 JSX 들여쓰기 불일치** (병합 후 린트) | origin/main 코드가 ksh-v2-work와 2칸 들여쓰기 차이. 기능 영향 없음. 추후 포매팅 정리 권장 | 2026-02-09 | 들여쓰기, 포매팅, 린트, prettier |
---
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*

View File

@ -1,110 +0,0 @@
# POP 화면 시스템
> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요.
> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조
---
## 현재 상태
| 항목 | 값 |
|------|-----|
| 버전 | **v5.2** (브레이크포인트 재설계 + 세로 자동 확장) |
| 상태 | **반응형 시스템 완성** |
| 다음 | Phase 4 (실제 컴포넌트 구현) |
**마지막 업데이트**: 2026-02-06
---
## 마지막 대화 요약
> **v5.2.1 그리드 셀 크기 강제 고정**:
> - gridAutoRows → gridTemplateRows로 행 높이 강제 고정
> - "셀의 크기 = 컴포넌트의 크기" 원칙을 코드 수준에서 강제
> - Canvas/Renderer 간 행 수 계산 기준 통일 (숨김 필터, 여유행 +3)
>
> 다음: Phase 4 (실제 컴포넌트 구현)
---
## 빠른 경로
| 알고 싶은 것 | 문서 |
|--------------|------|
| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) |
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
| 그리드 가이드 설계 | [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) |
| 브레이크포인트 재설계 | [decisions/005-breakpoint-redesign.md](./decisions/005-breakpoint-redesign.md) |
| 자동 줄바꿈 시스템 | [decisions/006-auto-wrap-review-system.md](./decisions/006-auto-wrap-review-system.md) |
| 개발 계획/로드맵 | [PLAN.md](./PLAN.md) |
| 지금 바로 코딩할 계획 | [PLAN.md "현재 구현 계획"](./PLAN.md#현재-구현-계획) |
| 작업 프롬프트 | [WORKFLOW_PROMPTS.md](./WORKFLOW_PROMPTS.md) |
| 컴포넌트 상세 설계 | [components-spec.md](./components-spec.md) |
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
| 코드 어디 있어? | [FILES.md](./FILES.md) |
| 기능별 색인 | [INDEX.md](./INDEX.md) |
| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) |
---
## 핵심 파일
| 파일 | 역할 | 경로 |
|------|------|------|
| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
| 캔버스 | 그리드 캔버스 + DnD + 라벨 | `frontend/components/pop/designer/PopCanvas.tsx` |
| 렌더러 | CSS Grid 렌더링 + 격자 셀 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` |
| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` |
| 팔레트 | 컴포넌트 목록 | `frontend/components/pop/designer/panels/ComponentPalette.tsx` |
---
## 문서 구조
```
[Layer 1: 먼저 읽기]
README.md (지금 여기) → STATUS.md
[Layer 2: 필요시 읽기]
CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN
[Layer 3: 심화]
decisions/, sessions/, archive/
```
**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로.
---
## POP이란?
**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템
| 용도 | 경로 |
|------|------|
| 뷰어 | `/pop/screens/{screenId}` |
| 관리 | `/admin/screenMng/popScreenMngList` |
| API | `/api/screen-management/layout-pop/:screenId` |
---
## v5 그리드 시스템 (현재)
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
**세로 무한 스크롤**: 캔버스 높이 자동 확장 (컴포넌트 배치에 따라)
**그리드 가이드**: CSS Grid 기반 격자 셀 + 행/열 라벨 (ON/OFF 토글)
---
*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)*

View File

@ -1,574 +0,0 @@
# popdocs 사용 규칙
> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다.
> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요.
---
## 요청 유형 인식
### 키워드로 요청 유형 판별
| 유형 | 키워드 예시 | 행동 |
|------|------------|------|
| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 |
| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 |
| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 |
| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 |
| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 |
| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 |
### 요청 유형별 행동
```
[저장 요청]
"@popdocs 오늘 작업 저장해줘"
→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화
[조회 요청]
"@popdocs 이전에 DnD 문제 어떻게 해결했어?"
→ PROBLEMS.md 검색 → 관련 내용만 반환
[분석 요청]
"@popdocs v4랑 v5 뭐가 달라?"
→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성
[수정 요청]
"@popdocs STATUS 업데이트해줘"
→ STATUS.md 수정 → README.md 동기화
[요약 요청]
"@popdocs 이번 주 작업 요약해줘"
→ sessions/ 해당 기간 검색 → 요약 생성
[계획 저장]
"@popdocs 구현 계획 저장해줘"
→ PLAN.md "현재 구현 계획" 섹션 교체 → STATUS.md 동기화
[작업 시작]
"@popdocs 오늘 작업 시작하자"
→ README → STATUS → PLAN.md "현재 구현 계획" → 중단점 확인 → 작업 시작
```
---
## 컨텍스트 효율화 원칙
### Progressive Disclosure (점진적 공개)
**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로.
```
Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄)
Layer 2 (상세) → 필요한 문서만 선택적으로
Layer 3 (심화) → 코드 파일, archive/ (필요시만)
```
### Token as Currency (토큰은 자원)
| 원칙 | 설명 |
|------|------|
| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 |
| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 |
| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 |
| **테이블 > 산문** | 긴 설명 대신 표로 압축 |
| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 |
### Context Bloat 방지
```
❌ 잘못된 방법:
"모든 문서를 읽고 파악한 후 작업하겠습니다"
→ 1,300줄 이상 낭비
✅ 올바른 방법:
"README → STATUS → 필요한 섹션만"
→ 평균 50~100줄로 작업 가능
```
---
## 문서 구조 (3계층)
```
popdocs/
├── [Layer 1: 진입점] ─────────────────────────
│ ├── README.md ← 시작점 (현재 상태 요약)
│ ├── STATUS.md ← 진행 상태, 다음 작업
│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서)
├── [Layer 2: 상세 문서] ─────────────────────────
│ ├── CHANGELOG.md ← 변경 이력 (날짜별)
│ ├── PROBLEMS.md ← 문제-해결 색인
│ ├── INDEX.md ← 기능별 색인
│ ├── ARCHITECTURE.md ← 코드 구조
│ ├── FILES.md ← 파일 목록
│ ├── SPEC.md ← 기술 스펙
│ └── PLAN.md ← 계획
├── [Layer 3: 심화/기록] ─────────────────────────
│ ├── decisions/ ← ADR (결정 기록)
│ ├── sessions/ ← 날짜별 작업 기록
│ └── archive/ ← 보관 (레거시)
└── [외부 참조] ─────────────────────────
└── 실제 코드 → frontend/components/pop/designer/
```
---
## 조회 규칙 (읽기)
### 작업 시작 시
```
1. README.md 읽기 (~60줄)
└→ 현재 상태, 다음 작업 확인
2. STATUS.md 읽기 (~40줄)
└→ 상세 진행 상황, 중단점 확인
3. 필요한 문서만 선택적으로
```
### 요청별 조회 경로
| 사용자 요청 | 조회 경로 |
|-------------|----------|
| "지금 뭐 해야 해?" | README → STATUS |
| "구현 계획 보여줘" | PLAN.md "현재 구현 계획" 섹션 |
| "어제 뭐 했어?" | sessions/어제날짜.md |
| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) |
| "이 기능 어디있어?" | INDEX.md 또는 FILES.md |
| "왜 이렇게 결정했어?" | decisions/ |
| "전체 히스토리" | CHANGELOG.md (기간 한정) |
| "코드 구조 알려줘" | ARCHITECTURE.md |
| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG |
### 효율적 검색
```
# 전체 파일 읽지 말고 키워드 검색
PROBLEMS.md에서 "DnD" 검색 → 관련 행만
CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만
FILES.md에서 "렌더러" 검색 → 관련 파일만
```
---
## 저장 규칙 (쓰기)
### 저장 유형별 위치
| 요청 패턴 | 저장 위치 | 형식 |
|----------|----------|------|
| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 |
| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 |
| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 |
| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 |
| "현재 상태 업데이트" | STATUS.md | 상태 수정 |
| "기능 색인 추가해줘" | INDEX.md | 행 추가 |
| "구현 계획 저장해줘" | PLAN.md "현재 구현 계획" | 섹션 교체 |
### 저장 후 필수 동기화
```
저장 완료 후 항상:
1. STATUS.md 업데이트 (진행 상태, 다음 작업)
2. README.md "마지막 대화 요약" 업데이트 (1-2줄)
```
---
## 분석/비교 규칙
### 비교 요청 시
```
사용자: "@popdocs v4랑 v5 뭐가 달라?"
AI 행동:
1. decisions/003-v5-grid-system.md 확인 (있으면)
2. 없으면 CHANGELOG에서 관련 날짜 검색
3. 비교표 형식으로 응답
응답 형식:
| 항목 | v4 | v5 |
|------|----|----|
| 배치 | Flexbox | CSS Grid |
| ... | ... | ... |
```
### 분석 요청 시
```
사용자: "@popdocs 이번 달 작업 분석해줘"
AI 행동:
1. sessions/ 폴더에서 해당 기간 파일 목록
2. 각 파일의 "요약" 섹션만 추출
3. 종합 분석 제공
```
---
## 수정 규칙
### 문서 수정 요청 시
```
사용자: "@popdocs STATUS 업데이트해줘"
AI 행동:
1. STATUS.md 읽기
2. 변경 내용 적용
3. README.md 동기화 (마지막 대화 요약)
4. 변경 내용 사용자에게 확인
```
### 여러 문서 수정 시
```
수정 순서:
상세 문서 먼저 → STATUS.md → README.md
(역방향: 진입점이 항상 최신 상태 유지)
```
---
## 요약/보고서 규칙
### 요약 요청 시
```
사용자: "@popdocs 이번 주 요약해줘"
AI 행동:
1. sessions/ 해당 기간 파일 확인
2. 각 파일의 "요약" + "완료" 섹션 추출
3. 압축된 형식으로 응답
응답 형식:
## 이번 주 요약 (02-01 ~ 02-05)
| 날짜 | 주요 작업 |
|------|----------|
| 02-05 | v5 통합 완료 |
| 02-04 | ... |
```
### 보고서 생성 요청 시
```
사용자: "@popdocs 진행 보고서 만들어줘"
AI 행동:
1. STATUS.md (현재 상태)
2. CHANGELOG.md (최근 작업)
3. sessions/ (상세 기록)
4. 보고서 형식으로 조합
→ 새 파일 생성하지 말고 응답으로 제공
→ 필요시 사용자가 저장 요청
```
---
## 예외 처리
### 분류 불가 시
```
사용자: "@popdocs 이거 저장해줘" (유형 불명확)
AI 행동:
→ 사용자에게 질문: "어떤 유형으로 저장할까요?"
- 오늘 작업 기록 (sessions/)
- 문제-해결 기록 (PROBLEMS.md)
- 결정 사항 (decisions/)
- 변경 이력 (CHANGELOG.md)
```
### 복합 요청 시
```
사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘"
AI 행동 (순서):
1. PROBLEMS.md에 문제-해결 추가
2. decisions/에 ADR 생성 (필요시)
3. CHANGELOG.md에 섹션 추가
4. STATUS.md 업데이트
5. README.md 동기화
```
### 전체 조회 요청 시
```
사용자: "@popdocs 전체 히스토리 보여줘"
AI 행동:
→ "기간을 지정해주시면 더 정확히 찾아드릴게요."
- 예: "최근 1주일", "2월 작업", "v5 관련"
→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만
```
### 파일 없음 시
```
사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음)
AI 행동:
→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?"
```
### 키워드 검색 실패 시
```
사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음)
AI 행동:
→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?"
- CHANGELOG.md
- INDEX.md
- sessions/
```
---
## 동기화 규칙
### 항상 동기화해야 하는 쌍
| 변경 문서 | 동기화 대상 |
|----------|-----------|
| sessions/ 생성 | STATUS.md (최근 세션) |
| PROBLEMS.md 추가 | - |
| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md |
| CHANGELOG.md 추가 | STATUS.md (진행 상태) |
| STATUS.md 수정 | README.md (마지막 요약) |
| PLAN.md 구현 계획 수정 | STATUS.md (다음 작업) |
### 불일치 발견 시
```
README.md와 STATUS.md 내용이 다르면:
→ STATUS.md를 정본(正本)으로
→ README.md를 STATUS.md 기준으로 업데이트
```
---
## 정리 규칙
### 주기적 정리 (수동 요청 시)
| 대상 | 조건 | 조치 |
|------|------|------|
| sessions/ | 30일 이상 | archive/sessions/로 이동 |
| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 |
| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 |
### 정리 요청 패턴
```
사용자: "@popdocs 오래된 파일 정리해줘"
AI 행동:
1. sessions/ 30일 이상 파일 목록 제시
2. 사용자 확인 후 archive/로 이동
3. 강제 삭제하지 않음
```
---
## 템플릿
### 세션 기록 (sessions/YYYY-MM-DD.md)
```markdown
# YYYY-MM-DD 작업 기록
## 요약
(한 줄 요약 - 50자 이내)
## 완료
- [x] 작업1
- [x] 작업2
## 미완료
- [ ] 작업3 (이유: ...)
## 중단점
> (내일 이어서 할 때 바로 시작할 수 있는 정보)
## 대화 핵심
- 키워드1: 설명
- 키워드2: 설명
## 관련 링크
- CHANGELOG: #YYYY-MM-DD
- ADR: decisions/NNN (있으면)
```
### 문제-해결 (PROBLEMS.md 행 추가)
```markdown
| 문제 | 해결 | 날짜 | 키워드 |
|------|------|------|--------|
| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 |
```
### ADR (decisions/NNN-제목.md)
```markdown
# ADR-NNN: 제목
**날짜**: YYYY-MM-DD
**상태**: 채택됨
## 배경 (왜)
(2-3문장)
## 결정 (무엇)
(핵심 결정 사항)
## 대안
| 옵션 | 장점 | 단점 | 결과 |
|------|------|------|------|
## 교훈
- (배운 점)
```
### 구현 계획 (PLAN.md "현재 구현 계획" 교체)
```markdown
### 대상: [기능명]
#### 구현 순서 (의존성 기반)
1. [ ] 파일명 - 변경 내용 요약
2. [ ] 파일명 - 변경 내용 요약
#### 파일별 변경 사항
| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 |
|---|------------|------|----------|---------|
| 1 | path/file.tsx (신규) | 생성 | 설명 | 주의 |
| 2 | path/file.tsx (수정) | 수정 | 설명 | 주의 |
#### 함정 경고
- (빠뜨리면 에러나는 것들)
#### 참조
- 관련 문서/파일 경로
```
**라이프사이클**:
- 계획 수립 시: "현재 구현 계획" 섹션을 새 계획으로 **교체**
- 코딩 중: 완료 항목 `[ ]``[x]`
- 기능 완료 시: 다음 기능 계획으로 **교체** (이전 계획은 CHANGELOG에 기록됨)
- 항상 1개만 존재
### CHANGELOG 섹션
```markdown
## [YYYY-MM-DD] 제목
### 배경
(왜 - 2-3문장)
### 작업
- [x] 완료1
- [ ] 미완료 → STATUS.md로
### 다음
→ STATUS.md 참조
```
---
## 토큰 비용 가이드
| 문서 | 평균 줄 수 | 언제 읽나 |
|------|-----------|----------|
| README.md | ~60 | 항상 (진입점) |
| STATUS.md | ~40 | 항상 (진입점) |
| SAVE_RULES.md | ~350 | 저장/조회 요청 시 |
| sessions/날짜.md | ~30 | 해당 날짜만 |
| PROBLEMS.md | ~100 | 키워드 검색 |
| INDEX.md | ~80 | 키워드 검색 |
| CHANGELOG.md | ~600 | 날짜/키워드 검색 |
| ARCHITECTURE.md | ~300 | 구조 파악 시 |
| FILES.md | ~200 | 파일 찾을 때 |
**권장**: README + STATUS = ~100줄로 작업 시작
---
## 예시 워크플로우
### 1. 작업 시작
```
사용자: "@popdocs 오늘 작업 시작하자"
AI:
1. README.md 읽기 (60줄)
2. STATUS.md 읽기 (40줄)
3. 중단점 확인
4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?"
```
### 2. 자료 검색
```
사용자: "@popdocs DnD 관련 자료 찾아줘"
AI:
1. PROBLEMS.md에서 "DnD" 검색
2. INDEX.md에서 "DnD" 검색
3. 관련 내용 종합해서 응답
4. 전체 파일 읽지 않음
```
### 3. 비교 요청
```
사용자: "@popdocs v4랑 v5 비교해줘"
AI:
1. decisions/003-v5-grid-system.md 확인
2. "대안 검토" 섹션에서 비교표 추출
3. 필요시 CHANGELOG 보충
```
### 4. 작업 저장
```
사용자: "@popdocs 오늘 작업 저장해줘"
AI:
1. sessions/2026-02-05.md 생성
2. STATUS.md 업데이트
3. README.md "마지막 대화 요약" 업데이트
4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다."
```
### 5. 불명확한 요청
```
사용자: "@popdocs 이거 저장해줘"
AI:
"어떤 유형으로 저장할까요?
1. 오늘 작업 기록 (sessions/)
2. 문제-해결 (PROBLEMS.md)
3. 결정 사항 (decisions/)
4. 변경 이력 (CHANGELOG.md)"
```
---
*2025-2026 AI 컨텍스트 엔지니어링 기반*
*Progressive Disclosure, Agentic Context Engineering, Token as Currency*

View File

@ -1,236 +0,0 @@
# POP 기술 스펙
**버전: v5 (CSS Grid 기반)**
---
## v5 핵심 규칙
### 1. 그리드 시스템
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|------|----------|-------|----------|
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S (세로) |
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로, 작은 태블릿 |
| tablet_portrait | 768~1023px | 8칸 | iPad Mini ~ iPad Pro (세로) |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 (기본) |
> **브레이크포인트 기준**: 실제 기기 CSS 뷰포트 너비 기반 (2026-02-06 재설계)
### 2. 위치 지정
```typescript
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
}
```
### 3. 브레이크포인트 설정
```typescript
const GRID_BREAKPOINTS = {
mobile_portrait: {
columns: 4,
rowHeight: 48,
gap: 8,
padding: 12,
maxWidth: 479, // 아이폰 SE (375px) ~ 갤럭시 S (360px)
},
mobile_landscape: {
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
minWidth: 480,
maxWidth: 767, // 스마트폰 가로
},
tablet_portrait: {
columns: 8,
rowHeight: 52,
gap: 12,
padding: 20,
minWidth: 768, // iPad Mini 세로 (768px)
maxWidth: 1023,
},
tablet_landscape: {
columns: 12,
rowHeight: 56,
gap: 12,
padding: 24,
minWidth: 1024, // iPad Pro 11 가로 (1194px), 12.9 가로 (1366px)
},
};
```
### 4. 세로 자동 확장
```typescript
// 캔버스 높이 동적 계산
const MIN_CANVAS_HEIGHT = 600; // 최소 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
const dynamicCanvasHeight = useMemo(() => {
// 가장 아래 컴포넌트 위치 계산
const maxRowEnd = visibleComps.reduce((max, comp) => {
const rowEnd = pos.row + pos.rowSpan;
return Math.max(max, rowEnd);
}, 1);
// 여유 행 추가하여 높이 계산
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight + padding);
}, [layout.components, ...]);
```
**특징**:
- 디자이너: 세로 무한 확장 (컴포넌트 추가에 제한 없음)
- 뷰어: 터치 스크롤로 아래 컴포넌트 접근 가능
---
## 데이터 구조
### v5 레이아웃
```typescript
interface PopLayoutDataV5 {
version: "pop-5.0";
metadata: {
screenId: number;
createdAt: string;
updatedAt: string;
};
gridConfig: {
defaultMode: GridMode;
maxRows: number;
};
components: PopComponentDefinitionV5[];
globalSettings: {
backgroundColor: string;
padding: number;
};
}
```
### v5 컴포넌트
```typescript
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType; // "pop-label" | "pop-button" | ...
label: string;
gridPosition: PopGridPosition;
config: PopComponentConfig;
visibility: Record<GridMode, boolean>; // 모드별 표시/숨김
modeOverrides?: Record<GridMode, PopModeOverrideV5>; // 모드별 오버라이드
}
```
### 컴포넌트 타입
```typescript
type PopComponentType =
| "pop-label" // 텍스트 라벨
| "pop-button" // 버튼
| "pop-input" // 입력 필드
| "pop-select" // 선택 박스
| "pop-grid" // 데이터 그리드
| "pop-container"; // 컨테이너
```
---
## 크기 프리셋
### 터치 요소
| 요소 | 일반 | 산업용 |
|------|-----|-------|
| 버튼 높이 | 48px | 60px |
| 입력창 높이 | 48px | 56px |
| 터치 영역 | 48px | 60px |
### 폰트 (clamp)
| 용도 | 범위 | CSS |
|------|-----|-----|
| 본문 | 14-18px | `clamp(14px, 1.5vw, 18px)` |
| 제목 | 18-28px | `clamp(18px, 2.5vw, 28px)` |
### 간격
| 이름 | 값 | 용도 |
|------|---|-----|
| sm | 8px | 요소 내부 |
| md | 16px | 컴포넌트 간 |
| lg | 24px | 섹션 간 |
---
## 반응형 원칙
```
누르는 것 → 고정 (48px) - 버튼, 터치 영역
읽는 것 → 범위 (clamp) - 텍스트
담는 것 → 칸 (colSpan) - 컨테이너
```
---
## 위치 변환
12칸 기준으로 설계 → 다른 모드에서 자동 변환
```typescript
// 12칸 → 4칸 변환 예시
const ratio = 4 / 12; // = 0.333
original: { col: 1, colSpan: 6 } // 12칸에서 절반
converted: { col: 1, colSpan: 2 } // 4칸에서 절반
```
---
## Troubleshooting
### 컴포넌트가 얇게 보임
- **증상**: rowSpan이 적용 안됨
- **원인**: gridTemplateRows 고정 px
- **해결**: `1fr` 사용
### 모드 전환 안 됨
- **증상**: 화면 크기 변경해도 레이아웃 유지
- **해결**: `detectGridMode()` 사용
### 겹침 발생
- **증상**: 컴포넌트끼리 겹침
- **해결**: `resolveOverlaps()` 호출
---
## 타입 가드
```typescript
// v5 레이아웃 판별
function isV5Layout(data: any): data is PopLayoutDataV5 {
return data?.version === "pop-5.0";
}
// 사용 예시
if (isV5Layout(savedData)) {
setLayout(savedData);
} else {
setLayout(createEmptyPopLayoutV5());
}
```
---
*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)*
*파일 목록: [FILES.md](./FILES.md)*

View File

@ -1,126 +0,0 @@
# 현재 상태
> **마지막 업데이트**: 2026-02-06
> **담당**: POP 화면 디자이너
---
## 진행 상태
| 단계 | 상태 | 설명 |
|------|------|------|
| v5 타입 정의 | 완료 | `pop-layout.ts` |
| v5 렌더러 | 완료 | `PopRenderer.tsx` |
| v5 캔버스 | 완료 | `PopCanvas.tsx` |
| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` |
| v5 유틸리티 | 완료 | `gridUtils.ts` |
| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 |
| 문서 정리 | 완료 | popdocs v5 기준 재정비 |
| 컴포넌트 팔레트 | 완료 | `ComponentPalette.tsx` |
| 드래그앤드롭 | 완료 | 스케일 보정, DND 상수 통합 |
| 그리드 가이드 재설계 | 완료 | CSS Grid 기반 통합 |
| 모드별 오버라이드 | 완료 | 위치/크기 모드별 저장 |
| 화면 밖 컴포넌트 | 완료 | 오른쪽 패널 배치, 드래그로 복원 |
| 숨김 기능 | 완료 | 모드별 숨김/숨김해제 |
| 리사이즈 겹침 검사 | 완료 | 실시간 겹침 방지 |
| Gap 프리셋 | 완료 | 좁게/보통/넓게 간격 조정 |
| **자동 줄바꿈** | **완료** | col > maxCol → 맨 아래 배치 |
| **검토 필요 시스템** | **완료** | 오버라이드 없으면 검토 알림 |
| **브레이크포인트 재설계** | **완료** | 기기 기반 (479/767/1023px) |
| **세로 자동 확장** | **완료** | 캔버스 높이 동적 계산 |
| **그리드 셀 크기 강제 고정** | **완료** | gridTemplateRows로 행 높이 고정, overflow-hidden |
---
## 다음 작업 (우선순위)
1. **실제 컴포넌트 구현** (Phase 4)
- pop-label, pop-button 등 실제 렌더링
- 데이터 바인딩 연결
2. **워크플로우 연동**
- 버튼 액션 연결
- 화면 전환 로직
---
## 최근 주요 변경 (2026-02-06)
### 브레이크포인트 재설계
| 모드 | 변경 전 | 변경 후 | 근거 |
|------|--------|--------|------|
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 |
| mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 |
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 포함 |
| tablet_landscape | 1024px+ | 동일 | - |
### 세로 자동 확장
| 기능 | 설명 |
|------|------|
| 동적 캔버스 높이 | 컴포넌트 배치에 따라 자동 계산 |
| 최소 높이 | 600px 보장 |
| 여유 행 | 항상 3행 추가 |
| 뷰어 스크롤 | 터치 스크롤로 아래 컴포넌트 접근 |
### v5.1 자동 줄바꿈 시스템
| 기능 | 설명 |
|------|------|
| 자동 줄바꿈 | col > maxCol인 컴포넌트를 맨 아래에 자동 배치 |
| 정보 손실 방지 | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
| 검토 필요 알림 | 오버라이드 없으면 "검토 필요" 패널 표시 |
| 검토 완료 | 편집하면 오버라이드 저장, 검토 필요에서 제거 |
### 기존 기능 유지 (2026-02-05 심야)
| 기능 | 설명 |
|------|------|
| 모드별 재배치 | 4/6/8/12칸 모드별로 컴포넌트 위치/크기 개별 저장 |
| 자동 레이아웃 고정 | 드래그/리사이즈 시 자동으로 오버라이드 저장 |
| 원본으로 되돌리기 | 오버라이드 삭제하여 자동 재배치로 복원 |
| 숨김 기능 | 특정 모드에서 컴포넌트 의도적 숨김 (검토와 별개) |
---
## 알려진 문제
| 문제 | 상태 | 비고 |
|------|------|------|
| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 |
| SVG 격자 좌표 불일치 | 해결됨 | GridGuide 삭제, CSS Grid 통합 |
| 드래그 좌표 계산 오류 | 해결됨 | 스케일 보정 적용 |
| DND 타입 상수 불일치 | 해결됨 | constants/dnd.ts로 통합 |
| 숨김 컴포넌트 드래그 안됨 | 해결됨 | 상태 업데이트 순서 수정 |
| 그리드 범위 초과 에러 | 해결됨 | 드롭 위치 자동 조정 |
| Expected drag drop context | 해결됨 | 뷰어 모드에서 일반 div 렌더링 |
| Gap 프리셋 UI 안 보임 | 해결됨 | 그리드 라벨에 adjustedGap 적용 |
| 화면 밖 컴포넌트 정보 손실 | 해결됨 | 자동 줄바꿈으로 항상 그리드 안에 배치 |
| 뷰어 반응형 모드 불일치 | 해결됨 | detectGridMode() 사용으로 일관성 확보 |
| hiddenComponentIds 중복 정의 | 해결됨 | 중복 useMemo 제거 |
| 그리드 가이드 셀 크기 불균일 | 해결됨 | gridTemplateRows로 행 높이 강제 고정 |
| Canvas/Renderer 행 수 불일치 | 해결됨 | 숨김 필터 통일, 여유행 +3 |
| 디버깅 console.log 잔존 | 해결됨 | reviewComponents 내 삭제 |
---
## 최근 세션
| 날짜 | 요약 | 상세 |
|------|------|------|
| 2026-02-06 | 브레이크포인트 재설계, 세로 자동 확장, v5.1 자동 줄바꿈 | [sessions/2026-02-06.md](./sessions/2026-02-06.md) |
| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
| 2026-02-05 저녁 | v5 통합, 그리드 가이드 재설계 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
---
## 관련 결정
| ADR | 제목 | 날짜 |
|-----|------|------|
| 006 | v5.1 자동 줄바꿈 + 검토 필요 시스템 | 2026-02-06 |
| 005 | 브레이크포인트 재설계 (기기 기반) + 세로 자동 확장 | 2026-02-06 |
| 004 | 그리드 가이드 CSS Grid 통합 | 2026-02-05 |
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
| 001 | v4 제약조건 기반 | 2026-02-03 |
---
*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)*

View File

@ -1,302 +0,0 @@
# 워크플로우 프롬프트
> 각 작업 단계에서 AI에게 내리는 표준 프롬프트입니다.
> 상황에 맞는 프롬프트를 복사해서 사용하세요.
> `[괄호]` 안은 상황에 맞게 수정하세요.
---
## 한 번에 복사용
```
===== 토의 중 개념 학습 =====
지금 설명한 [개념명]을 우리 프로젝트 코드에서 실제 사용되는 예시로 보여줘.
해당 코드가 없으면 어떤 문제가 생기는지 한 문장으로.
===== 계획 =====
구현 계획서를 작성해줘.
포함할 것:
1. 파일별 변경 사항 (추가/수정/삭제할 코드)
2. 구현 순서 (의존성 기반)
사전 검증 (코딩 전에 반드시):
1. 새로 추가할 변수/함수/타입 각각에 대해 해당 파일에서 Grep으로 동일 이름 검색
2. 충돌 발견 시 "충돌: [이름] - [파일명] 라인 [X]에 기존 정의 있음" 보고
3. 충돌 있으면 해결 방안 제시 (이름 변경 or 기존 코드 재사용)
4. 계획서에 명시된 모든 함수/변수/타입을 리스트업하고 "어디서 정의, 어디서 사용" 매핑
5. 사용처는 있는데 정의가 누락된 항목이 있으면 보고
주의사항:
- 이 대화를 못 본 사람도 실행할 수 있을 정도로 구체적으로
- 빠뜨리면 에러날 만한 함정을 명시적으로 경고해줘
문서 정리:
- PLAN.md "현재 구현 계획" 섹션을 이 계획으로 교체해줘
- STATUS.md "다음 작업"도 동기화해줘
===== 계획 이해 (선택) =====
이 계획에서 가장 복잡한 변경 1개를 골라서,
왜 이렇게 해야 하는지 한 문장으로 설명해줘.
===== 코딩 =====
위 계획대로 코딩 진행해줘.
규칙:
1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어
2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인
3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해
4. 한 파일 완료할 때마다 린트 확인
각 파일 수정이 끝나면 이것만 알려줘:
- 충돌 검사 결과
- 추가한 import
- 정의한 함수/변수
- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장)
코딩 완료 후 자체 검증:
- 새로 추가한 모든 변수/함수가 정의되어 있는가?
- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가?
- import한 모든 것이 실제로 사용되는가?
- 사용하는 모든 것이 import되어 있는가?
- interface의 모든 props가 실제로 전달되는가?
이상 없으면 완료 보고, 이상 있으면 수정 후 보고.
문서 정리:
- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘
===== 새 세션 코딩 (다른 모델) =====
@popdocs/ 의 README → STATUS → PLAN.md "현재 구현 계획" 순서로 읽고,
계획대로 코딩을 진행해줘.
규칙:
1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어
2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인
3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해
4. 한 파일 완료할 때마다 린트 확인
각 파일 수정이 끝나면 이것만 알려줘:
- 충돌 검사 결과
- 추가한 import
- 정의한 함수/변수
- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장)
코딩 완료 후 자체 검증:
- 새로 추가한 모든 변수/함수가 정의되어 있는가?
- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가?
- import한 모든 것이 실제로 사용되는가?
- 사용하는 모든 것이 import되어 있는가?
- interface의 모든 props가 실제로 전달되는가?
이상 없으면 완료 보고, 이상 있으면 수정 후 보고.
문서 정리:
- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘
===== 검수 =====
수정한 파일들을 검수해줘.
검증 항목:
1. 린트 에러
2. 새로 추가한 변수/함수가 중복 정의되지 않았는지 Grep 확인
3. import한 것 중 사용 안 하는 것, 사용하는데 import 안 한 것
4. interface에 정의된 props와 실제 전달되는 props 일치 여부
문제 발견 시:
- 고치기 전에 해당 코드를 보여주고 어디가 잘못됐는지 표시해줘
- 내가 확인한 다음에 고쳐줘
문서 정리:
- 발견된 문제가 있으면 PROBLEMS.md에 추가할 내용을 미리 정리해둬
===== 수정 =====
발견된 문제를 수정해줘.
수정 전에 먼저:
1. 이 문제가 왜 발생했는지 원인 한 문장
2. 다음에 같은 실수를 방지하려면 코딩할 때 뭘 확인했어야 하는지 한 문장
그다음 수정 진행해.
문서 정리:
- 수정한 내용을 PROBLEMS.md 형식(문제 | 해결 | 날짜 | 키워드)으로 정리해둬
===== 기록 =====
작업 내용을 popdocs에 기록해줘.
업데이트 대상:
- sessions/오늘날짜.md 생성
- CHANGELOG.md 섹션 추가
- STATUS.md 진행상태 업데이트
- PLAN.md "현재 구현 계획"에서 완료 항목 최종 확인
- README.md "마지막 대화 요약" 동기화
- PROBLEMS.md에 발생한 문제-해결 추가 (있으면)
- INDEX.md에 새로 추가된 기능/함수 색인 추가 (있으면)
추가로 "이번 작업에서 배운 것" 섹션을 포함해줘:
- 새로 알게 된 기술 개념 (있으면)
- 발생했던 에러와 원인 패턴 (있으면)
- 다음에 비슷한 작업할 때 주의할 점 (있으면)
없으면 생략.
===== 동기화 확인 =====
popdocs 문서 간 동기화 상태를 확인해줘.
확인 항목:
1. README.md "마지막 대화 요약"이 STATUS.md와 일치하는지
2. STATUS.md "다음 작업"이 PLAN.md "현재 구현 계획"과 일치하는지
3. PLAN.md 체크박스 상태가 실제 코드 변경과 일치하는지
4. sessions/오늘날짜.md의 "완료" 항목이 CHANGELOG.md와 일치하는지
불일치 발견 시:
- 어떤 문서의 어떤 부분이 다른지 보여줘
- STATUS.md를 정본으로 맞춰줘
===== 주간 복습 (금요일) =====
이번 주 작업 기록을 보고:
1. 내가 "쉽게 설명해줘"라고 요청했던 개념 중 가장 중요한 3개
2. 발생했던 에러 중 다시 만날 가능성이 높은 패턴 2개
3. 각각을 한 문장 정의 + 우리 프로젝트에서 어디에 해당하는지
정리해줘.
참조할 문서:
- sessions/ 이번 주 파일들
- PROBLEMS.md 이번 주 항목들
- CHANGELOG.md 이번 주 섹션들
===== 병합 준비 (merge 전) =====
[source-branch]를 [target-branch]에 병합하려고 해.
병합 전 점검해줘:
1. 양쪽 브랜치의 최근 커밋 히스토리 비교 (git log --oneline --left-right [target]...[source])
2. 충돌 예상 파일 목록 (git merge --no-commit --no-ff [source] 후 git diff --name-only --diff-filter=U)
3. 충돌 예상 파일 중 규모가 큰 파일(500줄 이상) 식별 - 이 파일들은 특별 주의 대상
4. 양쪽에서 동시에 수정한 파일 목록 (git diff --name-only [target]...[source])
5. 삭제 vs 수정 충돌 가능성 (한쪽에서 삭제하고 다른 쪽에서 수정한 파일)
점검 후 위험도를 알려줘:
- 높음: 같은 함수/컴포넌트를 양쪽에서 구조적으로 변경한 경우
- 중간: 같은 파일이지만 다른 부분을 수정한 경우
- 낮음: 서로 다른 파일만 수정한 경우
충돌 예상 파일이 있으면 각 파일별로:
- 양쪽에서 무엇을 변경했는지 한 줄 요약
- 어떤 쪽을 기준으로 병합해야 하는지 판단 근거
===== 병합 실행 (merge 중) =====
병합을 진행해줘.
규칙:
1. diff3 형식으로 충돌 표시 (git config merge.conflictstyle diff3)
2. 충돌 파일 하나씩 순서대로 해결 - 의존성 낮은 파일부터
3. 각 충돌 파일 해결 전에 반드시:
- 공통 조상(base)을 확인하여 양쪽이 원래 코드에서 무엇을 변경했는지 파악
- 양쪽 변경의 의도를 모두 보존할 수 있는지 판단
- 한쪽만 선택해야 하면 그 이유를 명시
4. 충돌 마커(<<<<<<, ======, >>>>>>)가 모두 제거되었는지 확인
각 충돌 파일 해결 후 보고:
- 충돌 위치 (함수명/컴포넌트명)
- 해결 방식: "양쪽 통합" / "ours 선택" / "theirs 선택" / "새로 작성"
- 선택 이유 한 문장
===== 병합 후 시맨틱 검증 (merge 후 - 가장 중요) =====
텍스트 충돌은 해결했지만, Git이 감지 못하는 시맨틱 충돌을 점검해줘.
검증 항목:
1. 함수/변수 이름 변경 충돌: 한쪽에서 rename한 함수를 다른 쪽에서 기존 이름으로 호출하고 있지 않은지
2. 타입/인터페이스 변경 충돌: 타입 필드가 변경/삭제되었는데 다른 쪽에서 해당 필드를 사용하는 코드가 추가되지 않았는지
3. import 정합성: 병합 후 중복 import, 누락 import, 사용하지 않는 import 확인
4. 함수 시그니처 충돌: 매개변수가 변경되었는데 호출부가 기존 시그니처를 사용하지 않는지
5. 삭제된 코드 의존성: 한쪽에서 삭제한 함수/변수를 다른 쪽 새 코드가 참조하지 않는지
6. 전역 상태/설정 변경: 설정값이 바뀌었는데 기존 값 기반 로직이 추가되지 않았는지
검증 방법:
- TypeScript 타입 체크: npx tsc --noEmit
- 빌드 확인: npm run build
- 남은 충돌 마커: git diff --check
- 병합으로 변경된 전체 diff: git diff HEAD~1..HEAD
문제 발견 시:
- 파일명, 라인, 구체적인 문제를 보여줘
- 수정 방안을 제시하되, 내 확인 후에 수정해줘
===== 병합 후 빌드/테스트 검증 =====
병합 후 프로젝트가 정상 작동하는지 확인해줘.
순서:
1. 남은 충돌 마커 검색: 프로젝트 전체에서 <<<<<<, ======, >>>>>> 검색
2. TypeScript 컴파일: npx tsc --noEmit → 타입 에러 목록
3. 프론트엔드 빌드: npm run build → 빌드 에러 목록
4. 백엔드 빌드: npm run build (backend-node) → 빌드 에러 목록
5. 린트 체크: 변경된 파일들에 대해 린트 확인
에러 발견 시 각각에 대해:
- 에러 메시지 전문
- 원인이 병합 때문인지, 기존 코드 문제인지 구분
- 병합 때문이면 어떤 충돌 해결이 잘못되었는지 추적
===== 병합 완료 정리 =====
병합이 완료되었어. 정리해줘.
정리 항목:
1. 병합 요약: 어떤 브랜치에서 어떤 브랜치로, 총 충돌 파일 수, 해결 방식 통계
2. 주의가 필요한 변경사항: 시맨틱 충돌 위험이 있었던 부분 목록
3. 테스트가 필요한 기능: 병합으로 영향받은 기능 목록 (수동 테스트 대상)
4. 커밋 메시지 작성: 병합 내용을 요약한 적절한 커밋 메시지 제안
문서 정리:
- PROBLEMS.md에 병합 중 발견된 문제-해결 추가 (있으면)
- CHANGELOG.md에 병합 내용 기록
```
---
## popdocs 업데이트 시점 요약
| 단계 | 업데이트 대상 | 시점 |
|------|-------------|------|
| 계획 수립 | PLAN.md "현재 구현 계획", STATUS.md | 계획 확정 시 |
| 코딩 중 | PLAN.md 완료 체크 `[x]` | 각 파일 완료 시 |
| 검수 | PROBLEMS.md 내용 준비 | 문제 발견 시 |
| 수정 | PROBLEMS.md 내용 준비 | 수정 완료 시 |
| 병합 준비 | (응답으로 제공) | merge 시작 전 |
| 병합 실행 | (충돌 해결 중) | merge 진행 중 |
| 병합 시맨틱 검증 | (응답으로 제공) | 텍스트 충돌 해결 직후 |
| 병합 빌드 검증 | (응답으로 제공) | 시맨틱 검증 후 |
| 병합 완료 정리 | PROBLEMS.md, CHANGELOG.md | 병합 최종 완료 시 |
| 기록 | sessions/, CHANGELOG, STATUS, README, PROBLEMS, INDEX | 작업 완료 시 |
| 동기화 확인 | 전체 문서 간 불일치 점검 | 기록 직후 |
| 주간 복습 | (응답으로 제공, 파일 저장은 선택) | 금요일 |
---
## 세션 분리 가이드
```
[Opus 세션] 토의 + 계획
→ popdocs 업데이트 (PLAN.md, STATUS.md)
→ 세션 종료
[새 세션 - Sonnet/Opus] 코딩 + 검수 + 수정
→ "@popdocs/ 읽고 PLAN.md 계획대로 진행해"
→ 15건 이내로 완료
→ 세션 종료
[새 세션 - 아무 모델] 기록 + 동기화 확인
→ "기록" 프롬프트 → "동기화 확인" 프롬프트
[병합 세션 - Opus 권장] 브랜치 병합
→ "병합 준비" 프롬프트 → 위험도 파악
→ "병합 실행" 프롬프트 → 텍스트 충돌 해결
→ "병합 후 시맨틱 검증" 프롬프트 → 숨은 버그 점검
→ "병합 후 빌드/테스트 검증" 프롬프트 → 빌드 확인
→ "병합 완료 정리" 프롬프트 → 기록 및 커밋
```
**세션을 끊는 기준**:
- 작업이 15건 이내로 끝나면 한 세션에서 끝까지 (끊을 필요 없음)
- 대화가 15건을 넘어갈 것 같으면 세션 분리
- 완전히 다른 작업으로 전환할 때
---
*최종 업데이트: 2026-02-09*

View File

@ -1,227 +0,0 @@
# POP 레이아웃 canvasGrid.rows 버그 수정
## 문제점
### 1. 데이터 불일치
- **DB에 저장된 데이터**: `canvasGrid.rowHeight: 20` (고정 픽셀)
- **코드에서 기대하는 데이터**: `canvasGrid.rows: 24` (비율 기반)
- **결과**: `rows``undefined`로 인한 렌더링 오류
### 2. 타입 정의 불일치
- **PopCanvas.tsx 타입**: `{ columns: number; rowHeight: number; gap: number }`
- **실제 사용**: `canvasGrid.rows`로 계산
- **결과**: 타입 안정성 저하
### 3. 렌더링 오류
- **디자이너**: `rowHeight = resolution.height / undefined``NaN`
- **뷰어**: `gridTemplateRows: repeat(undefined, 1fr)` → CSS 무효
- **결과**: 섹션이 매우 작게 표시됨
---
## 수정 내용
### 1. ensureV2Layout 강화
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => {
let result: PopLayoutDataV2;
if (isV2Layout(data)) {
result = data;
} else if (isV1Layout(data)) {
result = migrateV1ToV2(data);
} else {
console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성");
result = createEmptyPopLayoutV2();
}
// ✅ canvasGrid.rows 보장 (구버전 데이터 호환)
if (!result.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
result.settings.canvasGrid = {
...result.settings.canvasGrid,
rows: DEFAULT_CANVAS_GRID.rows, // 24
};
}
return result;
};
```
**효과**: DB에서 로드한 구버전 데이터도 자동으로 `rows: 24` 보장
---
### 2. PopCanvas.tsx 타입 수정 및 fallback
**파일**: `frontend/components/pop/designer/PopCanvas.tsx`
**타입 정의 수정**:
```typescript
interface DeviceFrameProps {
canvasGrid: { columns: number; rows: number; gap: number }; // rowHeight → rows
// ...
}
```
**fallback 추가**:
```typescript
// ✅ rows가 없으면 24 사용
const rows = canvasGrid.rows || 24;
const rowHeight = Math.floor(resolution.height / rows);
```
**효과**:
- 타입 일관성 확보
- `NaN` 방지
---
### 3. PopLayoutRenderer.tsx fallback
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
```typescript
style={{
display: "grid",
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
// ✅ fallback 추가
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
gap: `${canvasGrid.gap}px`,
padding: `${canvasGrid.gap}px`,
}}
```
**효과**: 뷰어에서도 안전하게 렌더링
---
### 4. 백엔드 저장 로직 강화
**파일**: `backend-node/src/services/screenManagementService.ts`
```typescript
if (isV2) {
dataToSave = {
...layoutData,
version: "pop-2.0",
};
// ✅ canvasGrid.rows 검증 및 보정
if (dataToSave.settings?.canvasGrid) {
if (!dataToSave.settings.canvasGrid.rows) {
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
dataToSave.settings.canvasGrid.rows = 24;
}
// ✅ 구버전 rowHeight 필드 제거
if (dataToSave.settings.canvasGrid.rowHeight) {
console.warn("구버전 rowHeight 필드 제거");
delete dataToSave.settings.canvasGrid.rowHeight;
}
}
}
```
**효과**: 앞으로 저장되는 모든 데이터는 올바른 구조 보장
---
## 원칙 준수 여부
### 1. 데스크톱과 완전 분리 ✅
- POP 전용 파일만 수정
- 데스크톱 코드 0% 영향
### 2. 4모드 반응형 디자인 ✅
- 변경 없음
### 3. 비율 기반 그리드 시스템 ✅
- **오히려 원칙을 바로잡는 수정**
- 고정 픽셀(`rowHeight`) → 비율(`rows`) 강제
---
## 해결된 문제
| 문제 | 수정 전 | 수정 후 |
|------|---------|---------|
| 섹션 크기 | 매우 작게 표시 | 정상 크기 (24x24 그리드) |
| 디자이너 렌더링 | `NaN` 오류 | 정상 계산 |
| 뷰어 렌더링 | CSS 무효 | 비율 기반 렌더링 |
| 타입 안정성 | `rowHeight` vs `rows` 불일치 | `rows`로 통일 |
| 구버전 데이터 | 호환 불가 | 자동 보정 |
---
## 테스트 방법
### 1. 기존 화면 확인 (screen_id: 3884)
```bash
# 디자이너 접속
http://localhost:9771/screen-management/pop-designer/3884
# 저장 후 뷰어 확인
http://localhost:9771/pop/screens/3884
```
**기대 결과**:
- 섹션이 화면 전체 크기로 정상 표시
- 가로/세로 모드 전환 시 비율 유지
### 2. 새로운 화면 생성
- POP 디자이너에서 새 화면 생성
- 섹션 추가 및 배치
- 저장 후 DB 확인
**DB 확인**:
```sql
SELECT
screen_id,
layout_data->'settings'->'canvasGrid' as canvas_grid
FROM screen_layouts_pop
WHERE screen_id = 3884;
```
**기대 결과**:
```json
{
"gap": 4,
"rows": 24,
"columns": 24
}
```
---
## 추가 조치 사항
### 1. 기존 DB 데이터 마이그레이션 (선택)
만약 프론트엔드 자동 보정이 아닌 DB 마이그레이션을 원한다면:
```sql
UPDATE screen_layouts_pop
SET layout_data = jsonb_set(
jsonb_set(
layout_data,
'{settings,canvasGrid,rows}',
'24'
),
'{settings,canvasGrid}',
(layout_data->'settings'->'canvasGrid') - 'rowHeight'
)
WHERE layout_data->'settings'->'canvasGrid'->>'rows' IS NULL
AND layout_data->>'version' = 'pop-2.0';
```
### 2. 모드별 컴포넌트 위치 반대 문제
**별도 이슈**: `activeModeKey` 상태 관리 점검 필요
- DeviceFrame 클릭 시 모드 전환
- 저장 시 올바른 `modeKey` 전달 확인
---
## 결론
**원칙 준수**: 데스크톱 분리, 4모드 반응형 유지
**비율 기반 강제**: 고정 픽셀 제거
**하위 호환**: 구버전 데이터 자동 보정
**안정성 향상**: 타입 일관성 확보

View File

@ -1,389 +0,0 @@
# POP 컴포넌트 로드맵
## 큰 그림: 3단계 접근
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1단계: 기초 블록 2단계: 조합 블록 3단계: 완성 화면 │
│ ─────────────── ─────────────── ─────────────── │
│ │
│ [버튼] [입력창] [폼 그룹] [작업지시 화면] │
│ [아이콘] [라벨] → [카드] → [실적입력 화면] │
│ [뱃지] [로딩] [리스트] [모니터링 대시보드] │
│ [테이블] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 1단계: 기초 블록 (Primitive)
가장 작은 단위. 다른 곳에서 재사용됩니다.
### 필수 기초 블록
| 컴포넌트 | 역할 | 우선순위 |
|---------|------|---------|
| `PopButton` | 모든 버튼 | 1 |
| `PopInput` | 텍스트 입력 | 1 |
| `PopLabel` | 라벨/제목 | 1 |
| `PopIcon` | 아이콘 표시 | 1 |
| `PopBadge` | 상태 뱃지 | 2 |
| `PopLoading` | 로딩 스피너 | 2 |
| `PopDivider` | 구분선 | 3 |
### PopButton 예시
```typescript
interface PopButtonProps {
children: React.ReactNode;
variant: "primary" | "secondary" | "danger" | "success";
size: "sm" | "md" | "lg" | "xl";
disabled?: boolean;
loading?: boolean;
icon?: string;
fullWidth?: boolean;
onClick?: () => void;
}
// 사용
<PopButton variant="primary" size="lg">
작업 완료
</PopButton>
```
### PopInput 예시
```typescript
interface PopInputProps {
type: "text" | "number" | "date" | "time";
value: string | number;
onChange: (value: string | number) => void;
label?: string;
placeholder?: string;
required?: boolean;
error?: string;
size: "md" | "lg"; // POP은 lg 기본
}
// 사용
<PopInput
type="number"
label="수량"
size="lg"
value={qty}
onChange={setQty}
/>
```
---
## 2단계: 조합 블록 (Compound)
기초 블록을 조합한 중간 단위.
### 조합 블록 목록
| 컴포넌트 | 구성 | 용도 |
|---------|------|-----|
| `PopFormField` | Label + Input + Error | 폼 입력 그룹 |
| `PopCard` | Container + Header + Body | 정보 카드 |
| `PopListItem` | Container + Content + Action | 리스트 항목 |
| `PopNumberPad` | Grid + Buttons | 숫자 입력 |
| `PopStatusBox` | Icon + Label + Value | 상태 표시 |
### PopFormField 예시
```typescript
// 기초 블록 조합
function PopFormField({ label, required, error, children }) {
return (
<div className="pop-form-field">
<PopLabel required={required}>{label}</PopLabel>
{children}
{error && <span className="error">{error}</span>}
</div>
);
}
// 사용
<PopFormField label="품번" required error={errors.itemCode}>
<PopInput type="text" value={itemCode} onChange={setItemCode} />
</PopFormField>
```
### PopCard 예시
```typescript
function PopCard({ title, badge, children, onClick }) {
return (
<div className="pop-card" onClick={onClick}>
<div className="pop-card-header">
<PopLabel size="lg">{title}</PopLabel>
{badge && <PopBadge>{badge}</PopBadge>}
</div>
<div className="pop-card-body">
{children}
</div>
</div>
);
}
// 사용
<PopCard title="작업지시 #1234" badge="진행중">
<p>목표 수량: 100개</p>
<p>완료 수량: 45개</p>
</PopCard>
```
---
## 3단계: 복합 컴포넌트 (Complex)
비즈니스 로직이 포함된 완성형.
### 복합 컴포넌트 목록
| 컴포넌트 | 기능 | 데이터 |
|---------|------|-------|
| `PopDataTable` | 대량 데이터 표시/편집 | API 연동 |
| `PopCardList` | 카드 형태 리스트 | API 연동 |
| `PopBarcodeScanner` | 바코드/QR 스캔 | 카메라/외부장치 |
| `PopKpiGauge` | KPI 게이지 | 실시간 데이터 |
| `PopAlarmList` | 알람 목록 | 웹소켓 |
| `PopProcessFlow` | 공정 흐름도 | 공정 데이터 |
### PopDataTable 예시
```typescript
interface PopDataTableProps {
// 데이터
data: any[];
columns: Column[];
// 기능
selectable?: boolean;
editable?: boolean;
sortable?: boolean;
// 반응형 (자동)
responsiveColumns?: {
tablet: string[];
mobile: string[];
};
// 이벤트
onRowClick?: (row: any) => void;
onSelectionChange?: (selected: any[]) => void;
}
// 사용
<PopDataTable
data={workOrders}
columns={[
{ key: "orderNo", label: "지시번호" },
{ key: "itemName", label: "품명" },
{ key: "qty", label: "수량", align: "right" },
{ key: "status", label: "상태" },
]}
responsiveColumns={{
tablet: ["orderNo", "itemName", "qty", "status"],
mobile: ["orderNo", "qty"], // 모바일은 2개만
}}
onRowClick={(row) => openDetail(row.id)}
/>
```
---
## 개발 순서 제안
### Phase 1: 기초 (1-2주)
```
Week 1:
- PopButton (모든 버튼의 기반)
- PopInput (모든 입력의 기반)
- PopLabel
- PopIcon
Week 2:
- PopBadge
- PopLoading
- PopDivider
```
### Phase 2: 조합 (2-3주)
```
Week 3:
- PopFormField (폼의 기본 단위)
- PopCard (카드의 기본 단위)
Week 4:
- PopListItem
- PopStatusBox
- PopNumberPad
Week 5:
- PopModal
- PopToast
```
### Phase 3: 복합 (3-4주)
```
Week 6-7:
- PopDataTable (가장 복잡)
- PopCardList
Week 8-9:
- PopBarcodeScanner
- PopKpiGauge
- PopAlarmList
- PopProcessFlow
```
---
## 컴포넌트 설계 원칙
### 1. 크기는 외부에서 제어
```typescript
// 좋음: 크기를 props로 받음
<PopButton size="lg">확인</PopButton>
// 나쁨: 내부에서 크기 고정
<button style={{ height: "48px" }}>확인</button>
```
### 2. 최소 크기는 내부에서 보장
```typescript
// 컴포넌트 내부
const styles = {
minHeight: 48, // 터치 최소 크기 보장
minWidth: 80,
};
```
### 3. 반응형은 자동
```typescript
// 좋음: 화면 크기에 따라 자동 조절
<PopFormField label="이름">
<PopInput />
</PopFormField>
// 나쁨: 모드별로 다른 컴포넌트
{isMobile ? <MobileInput /> : <TabletInput />}
```
### 4. 데이터와 UI 분리
```typescript
// 좋음: 데이터 로직은 훅으로
const { data, loading, error } = useWorkOrders();
<PopDataTable data={data} loading={loading} />
// 나쁨: 컴포넌트 안에서 fetch
function PopDataTable() {
useEffect(() => {
fetch('/api/work-orders')...
}, []);
}
```
---
## 폴더 구조 제안
```
frontend/components/pop/
├── primitives/ # 1단계: 기초 블록
│ ├── PopButton.tsx
│ ├── PopInput.tsx
│ ├── PopLabel.tsx
│ ├── PopIcon.tsx
│ ├── PopBadge.tsx
│ ├── PopLoading.tsx
│ └── index.ts
├── compounds/ # 2단계: 조합 블록
│ ├── PopFormField.tsx
│ ├── PopCard.tsx
│ ├── PopListItem.tsx
│ ├── PopNumberPad.tsx
│ ├── PopStatusBox.tsx
│ └── index.ts
├── complex/ # 3단계: 복합 컴포넌트
│ ├── PopDataTable/
│ │ ├── PopDataTable.tsx
│ │ ├── PopTableHeader.tsx
│ │ ├── PopTableRow.tsx
│ │ └── index.ts
│ ├── PopCardList/
│ ├── PopBarcodeScanner/
│ └── index.ts
├── hooks/ # 공용 훅
│ ├── usePopTheme.ts
│ ├── useResponsiveSize.ts
│ └── useTouchFeedback.ts
└── styles/ # 공용 스타일
├── pop-variables.css
└── pop-base.css
```
---
## 스타일 변수
```css
/* pop-variables.css */
:root {
/* 터치 크기 */
--pop-touch-min: 48px;
--pop-touch-industrial: 60px;
/* 폰트 크기 */
--pop-font-body: clamp(14px, 1.5vw, 18px);
--pop-font-heading: clamp(18px, 2.5vw, 28px);
--pop-font-caption: clamp(12px, 1vw, 14px);
/* 간격 */
--pop-gap-sm: 8px;
--pop-gap-md: 16px;
--pop-gap-lg: 24px;
/* 색상 */
--pop-primary: #2563eb;
--pop-success: #16a34a;
--pop-warning: #f59e0b;
--pop-danger: #dc2626;
/* 고대비 (야외용) */
--pop-high-contrast-bg: #000000;
--pop-high-contrast-fg: #ffffff;
}
```
---
## 다음 단계
1. **기초 블록부터 시작**: PopButton, PopInput 먼저 만들기
2. **스토리북 설정**: 컴포넌트별 문서화
3. **테스트**: 터치 크기, 반응형 확인
4. **디자이너 연동**: v4 레이아웃 시스템과 통합
---
*최종 업데이트: 2026-02-03*

View File

@ -1,763 +0,0 @@
# POP 그리드 시스템 코딩 계획
> 작성일: 2026-02-05
> 상태: 코딩 준비 완료
---
## 작업 목록
```
Phase 5.1: 타입 정의 ─────────────────────────────
[ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등)
[ ] 2. 브레이크포인트 상수 정의
[ ] 3. v5 생성/변환 함수
Phase 5.2: 그리드 렌더러 ─────────────────────────
[ ] 4. PopGridRenderer.tsx 생성
[ ] 5. 위치 변환 로직 (12칸→4칸)
Phase 5.3: 디자이너 UI ───────────────────────────
[ ] 6. PopCanvasV5.tsx 생성
[ ] 7. 드래그 스냅 기능
[ ] 8. ComponentEditorPanelV5.tsx
Phase 5.4: 통합 ──────────────────────────────────
[ ] 9. 자동 변환 알고리즘
[ ] 10. PopDesigner.tsx 통합
```
---
## Phase 5.1: 타입 정의
### 작업 1: v5 타입 정의
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
**추가할 코드**:
```typescript
// ========================================
// v5.0 그리드 기반 레이아웃
// ========================================
// 핵심: CSS Grid로 정확한 위치 지정
// - 열/행 좌표로 배치 (col, row)
// - 칸 단위 크기 (colSpan, rowSpan)
/**
* v5 레이아웃 (그리드 기반)
*/
export interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV5>;
// 데이터 흐름 (기존과 동일)
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV5;
// 메타데이터
metadata?: PopLayoutMetadata;
// 모드별 오버라이드 (위치 변경용)
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
}
/**
* 그리드 설정
*/
export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격 (px)
gap: number; // 기본 8px
// 패딩 (px)
padding: number; // 기본 16px
}
/**
* v5 컴포넌트 정의
*/
export interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
position: PopGridPosition;
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
/**
* 그리드 위치
*/
export interface PopGridPosition {
col: number; // 시작 열 (1부터, 최대 12)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
}
/**
* v5 전역 설정
*/
export interface PopGlobalSettingsV5 {
// 터치 최소 크기 (px)
touchTargetMin: number; // 기본 48
// 모드
mode: "normal" | "industrial";
}
/**
* v5 모드별 오버라이드
*/
export interface PopModeOverrideV5 {
// 컴포넌트별 위치 오버라이드
positions?: Record<string, Partial<PopGridPosition>>;
// 컴포넌트별 숨김
hidden?: string[];
}
```
### 작업 2: 브레이크포인트 상수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// 그리드 브레이크포인트
// ========================================
export type GridMode =
| "mobile_portrait"
| "mobile_landscape"
| "tablet_portrait"
| "tablet_landscape";
export const GRID_BREAKPOINTS: Record<GridMode, {
minWidth?: number;
maxWidth?: number;
columns: number;
rowHeight: number;
gap: number;
padding: number;
label: string;
}> = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
label: "모바일 세로 (4칸)",
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
label: "모바일 가로 (6칸)",
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
label: "태블릿 세로 (8칸)",
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
label: "태블릿 가로 (12칸)",
},
};
// 기본 모드
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
// 뷰포트 너비로 모드 감지
export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 600) return "mobile_portrait";
if (viewportWidth < 840) return "mobile_landscape";
if (viewportWidth < 1024) return "tablet_portrait";
return "tablet_landscape";
}
```
### 작업 3: v5 생성/변환 함수
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// ========================================
// v5 유틸리티 함수
// ========================================
/**
* 빈 v5 레이아웃 생성
*/
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: 8,
padding: 16,
},
components: {},
dataFlow: { connections: [] },
settings: {
touchTargetMin: 48,
mode: "normal",
},
});
/**
* v5 레이아웃 여부 확인
*/
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
return layout?.version === "pop-5.0";
};
/**
* v5 컴포넌트 정의 생성
*/
export const createComponentDefinitionV5 = (
id: string,
type: PopComponentType,
position: PopGridPosition,
label?: string
): PopComponentDefinitionV5 => ({
id,
type,
label,
position,
});
/**
* 컴포넌트 타입별 기본 크기 (칸 단위)
*/
export const DEFAULT_COMPONENT_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-field": { colSpan: 3, rowSpan: 1 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-list": { colSpan: 12, rowSpan: 4 },
"pop-indicator": { colSpan: 3, rowSpan: 2 },
"pop-scanner": { colSpan: 6, rowSpan: 2 },
"pop-numpad": { colSpan: 4, rowSpan: 5 },
"pop-spacer": { colSpan: 1, rowSpan: 1 },
"pop-break": { colSpan: 12, rowSpan: 0 },
};
/**
* v4 → v5 마이그레이션
*/
export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => {
const componentsV4 = Object.values(layoutV4.components);
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
componentsV4.forEach((comp) => {
// 픽셀 → 칸 변환 (대략적)
const colSpan = comp.size.width === "fill"
? columns
: Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85)));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow += 1;
currentCol = 1;
}
componentsV5[comp.id] = {
id: comp.id,
type: comp.type,
label: comp.label,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
visibility: comp.visibility,
dataBinding: comp.dataBinding,
config: comp.config,
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: {
rowHeight: 48,
gap: layoutV4.settings.defaultGap,
padding: layoutV4.settings.defaultPadding,
},
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: {
touchTargetMin: layoutV4.settings.touchTargetMin,
mode: layoutV4.settings.mode,
},
};
};
```
---
## Phase 5.2: 그리드 렌더러
### 작업 4: PopGridRenderer.tsx
**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
```typescript
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV5,
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
detectGridMode,
} from "../types/pop-layout";
interface PopGridRendererProps {
layout: PopLayoutDataV5;
viewportWidth: number;
currentMode?: GridMode;
isDesignMode?: boolean;
selectedComponentId?: string | null;
onComponentClick?: (componentId: string) => void;
onBackgroundClick?: () => void;
className?: string;
}
export function PopGridRenderer({
layout,
viewportWidth,
currentMode,
isDesignMode = false,
selectedComponentId,
onComponentClick,
onBackgroundClick,
className,
}: PopGridRendererProps) {
const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정)
const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode];
// CSS Grid 스타일
const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
gridAutoRows: `${breakpoint.rowHeight}px`,
gap: `${breakpoint.gap}px`,
padding: `${breakpoint.padding}px`,
minHeight: "100%",
}), [breakpoint]);
// visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
if (!comp.visibility) return true;
return comp.visibility[mode] !== false;
};
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
const sourceColumns = 12; // 항상 12칸 기준으로 저장
const targetColumns = breakpoint.columns;
if (sourceColumns === targetColumns) {
return {
gridColumn: `${position.col} / span ${position.colSpan}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
// 비율 계산
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 + newColSpan - 1 > targetColumns) {
newColSpan = targetColumns - newCol + 1;
}
return {
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
};
// 오버라이드 적용
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
const override = overrides?.[mode]?.positions?.[comp.id];
if (override) {
return { ...comp.position, ...override };
}
return comp.position;
};
return (
<div
className={cn("relative min-h-full w-full bg-white", className)}
style={gridStyle}
onClick={(e) => {
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{Object.values(components).map((comp) => {
if (!isVisible(comp)) return null;
const position = getEffectivePosition(comp);
const positionStyle = convertPosition(position);
return (
<div
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden",
selectedComponentId === comp.id
? "border-primary ring-2 ring-primary/30 z-10"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
{/* 컴포넌트 내용 */}
<ComponentContent component={comp} isDesignMode={isDesignMode} />
</div>
);
})}
</div>
);
}
// 컴포넌트 내용 렌더링
function ComponentContent({
component,
isDesignMode
}: {
component: PopComponentDefinitionV5;
isDesignMode: boolean;
}) {
const typeLabels: Record<string, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
if (isDesignMode) {
return (
<div className="flex h-full w-full flex-col">
<div className="flex h-5 shrink-0 items-center border-b bg-gray-50 px-2">
<span className="text-[10px] font-medium text-gray-600">
{component.label || typeLabels[component.type] || component.type}
</span>
</div>
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">
{typeLabels[component.type]}
</span>
</div>
</div>
);
}
// 실제 컴포넌트 렌더링 (Phase 4에서 구현)
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-gray-500">
{component.label || typeLabels[component.type]}
</span>
</div>
);
}
export default PopGridRenderer;
```
### 작업 5: 위치 변환 유틸리티
**파일**: `frontend/components/pop/designer/utils/gridUtils.ts`
```typescript
import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout";
/**
* 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,
};
}
/**
* 두 위치가 겹치는지 확인
*/
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
// 열 겹침
const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col);
// 행 겹침
const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row);
return colOverlap && rowOverlap;
}
/**
* 겹침 해결 (아래로 밀기)
*/
export function resolveOverlaps(
positions: Array<{ id: string; position: PopGridPosition }>,
columns: number
): Array<{ id: string; position: PopGridPosition }> {
// row, col 순으로 정렬
const sorted = [...positions].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
sorted.forEach((item) => {
let { row, col, colSpan, rowSpan } = item.position;
// 기존 배치와 겹치면 아래로 이동
let attempts = 0;
while (attempts < 100) {
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
if (!hasOverlap) break;
row++;
attempts++;
}
resolved.push({
id: item.id,
position: { col, row, colSpan, rowSpan },
});
});
return resolved;
}
/**
* 마우스 좌표 → 그리드 좌표 변환
*/
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;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
/**
* 그리드 좌표 → 픽셀 좌표 변환
*/
export function gridToPixelPosition(
col: number,
row: 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,
height: rowHeight,
};
}
```
---
## Phase 5.3: 디자이너 UI
### 작업 6-7: PopCanvasV5.tsx
**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx`
핵심 기능:
- 그리드 배경 표시 (바둑판)
- 4개 모드 프리셋 버튼
- 드래그 앤 드롭 (칸에 스냅)
- 컴포넌트 리사이즈 (칸 단위)
### 작업 8: ComponentEditorPanelV5.tsx
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
핵심 기능:
- 위치 편집 (col, row 입력)
- 크기 편집 (colSpan, rowSpan 입력)
- visibility 체크박스
---
## Phase 5.4: 통합
### 작업 9: 자동 변환 알고리즘
이미 `gridUtils.ts`에 포함
### 작업 10: PopDesigner.tsx 통합
**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx`
변경 사항:
- v5 레이아웃 상태 추가
- v3/v4/v5 자동 판별
- 새 화면 → v5로 시작
- v4 → v5 마이그레이션 옵션
---
## 파일 목록
| 상태 | 파일 | 작업 |
|------|------|------|
| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 |
| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 |
| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 |
| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 |
| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 |
| 수정 | `PopDesigner.tsx` | v5 통합 |
---
## 시작 순서
```
1. pop-layout.ts에 v5 타입 추가 (작업 1-3)
2. PopGridRenderer.tsx 생성 (작업 4)
3. gridUtils.ts 생성 (작업 5)
4. PopCanvasV5.tsx 생성 (작업 6-7)
5. ComponentEditorPanelV5.tsx 생성 (작업 8)
6. PopDesigner.tsx 수정 (작업 9-10)
7. 테스트
```
---
*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*

View File

@ -1,329 +0,0 @@
# POP 화면 그리드 시스템 설계
> 작성일: 2026-02-05
> 상태: 계획 (Plan)
> 관련: Softr, Ant Design, Material Design 분석 기반
---
## 1. 목적
POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계
### 현재 문제
- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐
- 컴포넌트 크기 규칙 없음 → 디자인 불일치
- 반응형 규칙 부족 → 모드별 수동 조정 필요
### 목표
- 그리드 기반 배치로 일관성 확보
- 크기 프리셋으로 디자인 통일
- 자동 반응형으로 작업량 감소
---
## 2. 대상 디바이스
### 지원 범위
| 구분 | 크기 범위 | 기준 해상도 | 비고 |
|------|----------|-------------|------|
| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 |
| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 |
### 참고: 산업용 디바이스 해상도
| 디바이스 | 화면 크기 | 해상도 |
|----------|----------|--------|
| Zebra TC57 PDA | 5인치 | 720x1280 |
| Honeywell CT47 | 5.5인치 | 2160x1080 |
| Honeywell RT10A | 10.1인치 | 1920x1200 |
---
## 3. 그리드 시스템 설계
### 3.1 브레이크포인트 (Breakpoints)
Material Design 가이드라인 기반으로 4단계 정의:
| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 |
|------|------|----------|---------------|-------------|
| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** |
| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** |
| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** |
| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** |
### 3.2 기준 해상도
| 모드 | 기준 너비 | 기준 높이 | 비고 |
|------|----------|----------|------|
| 모바일 세로 | 375px | 667px | iPhone SE 기준 |
| 모바일 가로 | 667px | 375px | - |
| 태블릿 세로 | 768px | 1024px | iPad 기준 |
| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** |
### 3.3 그리드 구조
```
태블릿 가로 (12 columns)
┌──────────────────────────────────────────────────────────────┐
│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │
└──────────────────────────────────────────────────────────────┘
모바일 세로 (4 columns)
┌────────────────────────┐
│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│
│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │
└────────────────────────┘
```
### 3.4 마진/간격 규칙
8px 기반 간격 시스템 (Material Design 표준):
| 속성 | 태블릿 | 모바일 | 용도 |
|------|--------|--------|------|
| screenPadding | 24px | 16px | 화면 가장자리 여백 |
| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 |
| gapMd | 16px | 12px | 기본 간격 |
| gapLg | 24px | 16px | 섹션 간 간격 |
| rowGap | 16px | 12px | 줄 사이 간격 |
---
## 4. 컴포넌트 크기 시스템
### 4.1 열 단위 (Span) 크기
픽셀 대신 **열 단위(span)** 로 크기 지정:
| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) |
|------|--------------------|--------------------|---------------|
| XS | 1 span | 1 span | 1 span |
| S | 2 span | 2 span | 2 span |
| M | 3 span | 2 span | 2 span |
| L | 4 span | 4 span | 4 span (full) |
| XL | 6 span | 4 span | 4 span (full) |
| Full | 12 span | 8 span | 4 span |
### 4.2 높이 프리셋
| 프리셋 | 픽셀값 | 용도 |
|--------|--------|------|
| `xs` | 32px | 배지, 아이콘 버튼 |
| `sm` | 48px | 일반 버튼, 입력 필드 |
| `md` | 80px | 카드, 인디케이터 |
| `lg` | 120px | 큰 카드, 리스트 아이템 |
| `xl` | 200px | 대형 영역 |
| `auto` | 내용 기반 | 가변 높이 |
### 4.3 컴포넌트별 기본값
| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 |
|----------|------------|-------------|------|------|
| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 |
| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 |
| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 |
| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 |
| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 |
| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 |
| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 |
| pop-break | Full | Full | 0 | 줄바꿈 |
---
## 5. 반응형 규칙
### 5.1 자동 조정
설계자가 별도 설정하지 않아도 자동 적용:
```
태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄
태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄
모바일 (4col): [A:2] [B:2] → 두 줄
[C:2] [D:2]
```
### 5.2 수동 오버라이드
필요시 모드별 설정 가능:
```typescript
interface ResponsiveOverride {
// 크기 변경
span?: number;
height?: HeightPreset;
// 표시/숨김
hidden?: boolean;
// 내부 요소 숨김 (컴포넌트별)
hideElements?: string[];
}
```
### 5.3 표시/숨김 예시
```
태블릿: [제품명] [수량] [단가] [합계] [비고]
모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김
```
설정:
```typescript
{
id: "unit-price",
type: "pop-field",
visibility: {
mobile_portrait: false,
mobile_landscape: false
}
}
```
---
## 6. 데이터 구조 (제안)
### 6.1 레이아웃 데이터 (v5 제안)
```typescript
interface PopLayoutDataV5 {
version: "5.0";
// 그리드 설정 (전역)
gridConfig: {
tablet: { columns: 12; gap: 16; padding: 24 };
mobile: { columns: 4; gap: 8; padding: 16 };
};
// 컴포넌트 목록 (순서대로)
components: PopComponentV5[];
// 모드별 오버라이드 (선택)
modeOverrides?: {
[mode: string]: {
gridConfig?: Partial<GridConfig>;
componentOverrides?: Record<string, ResponsiveOverride>;
};
};
}
```
### 6.2 컴포넌트 데이터
```typescript
interface PopComponentV5 {
id: string;
type: PopComponentType;
// 크기 (span 단위)
size: {
span: number; // 기본 열 개수 (1~12)
height: HeightPreset; // xs, sm, md, lg, xl, auto
};
// 반응형 크기 (선택)
responsiveSize?: {
mobile?: { span?: number; height?: HeightPreset };
tablet_portrait?: { span?: number; height?: HeightPreset };
};
// 표시/숨김
visibility?: {
[mode: string]: boolean;
};
// 컴포넌트별 설정
config?: any;
// 데이터 바인딩
dataBinding?: any;
}
```
---
## 7. 현재 v4와의 관계
### 7.1 v4 유지 사항
- Flexbox 기반 렌더링
- 오버라이드 시스템
- visibility 속성
### 7.2 변경 사항
| v4 | v5 (제안) |
|----|-----------|
| `fixedWidth: number` | `span: 1~12` |
| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 |
| 자유 픽셀 | 열 단위 프리셋 |
### 7.3 마이그레이션 방향
```
v4 fixedWidth: 200px
v5 span: 3 (태블릿 기준 약 25%)
```
---
## 8. 구현 우선순위
### Phase 1: 프리셋만 적용 (최소 변경)
- [ ] 높이 프리셋 드롭다운
- [ ] 너비 프리셋 드롭다운 (XS~Full)
- [ ] 기존 Flexbox 렌더링 유지
### Phase 2: 그리드 시스템 도입
- [ ] 브레이크포인트 감지
- [ ] 그리드 칸 수 자동 변경
- [ ] span → 픽셀 자동 계산
### Phase 3: 반응형 자동화
- [ ] 모드별 자동 span 변환
- [ ] 줄바꿈 자동 처리
- [ ] 오버라이드 최소화
---
## 9. 참고 자료
### 분석 대상
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
### 핵심 원칙
1. **Flexbox는 도구**: 그리드 시스템 안에서 사용
2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능
3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요
---
## 10. FAQ
### Q1: 기존 v4 화면은 어떻게 되나요?
A: 하위 호환 유지. v4 화면은 v4로 계속 동작.
### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요?
A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심.
### Q3: 그리드 칸 수를 바꿀 수 있나요?
A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능.
### Q4: Flexbox와 Grid 중 뭘 쓰나요?
A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬.
---
*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.*
*최종 업데이트: 2026-02-05*

View File

@ -1,480 +0,0 @@
# POP 그리드 시스템 도입 계획
> 작성일: 2026-02-05
> 상태: 계획 승인, 구현 대기
---
## 개요
### 목표
현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여
4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현
### 핵심 변경점
| 항목 | v4 (현재) | v5 (그리드) |
|------|----------|-------------|
| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** |
| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** |
| 위치 지정 | 순서대로 자동 | **열/행 좌표** |
| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** |
---
## Phase 구조
```
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
1주 1주 1~2주 1주
```
---
## Phase 5.1: 그리드 타입 정의
### 목표
v5 레이아웃 데이터 구조 설계
### 작업 항목
- [ ] `PopLayoutDataV5` 인터페이스 정의
- [ ] `PopGridConfig` 인터페이스 (그리드 설정)
- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan)
- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기)
- [ ] 브레이크포인트 상수 정의
- [ ] `createEmptyPopLayoutV5()` 생성 함수
- [ ] `isV5Layout()` 타입 가드
### 데이터 구조 설계
```typescript
// v5 레이아웃
interface PopLayoutDataV5 {
version: "pop-5.0";
// 그리드 설정
gridConfig: PopGridConfig;
// 컴포넌트 목록
components: Record<string, PopComponentDefinitionV5>;
// 모드별 오버라이드
overrides?: {
mobile_portrait?: PopModeOverrideV5;
mobile_landscape?: PopModeOverrideV5;
tablet_portrait?: PopModeOverrideV5;
};
// 기존 호환
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV5;
}
// 그리드 설정
interface PopGridConfig {
// 모드별 칸 수
columns: {
tablet_landscape: 12; // 기본 (10~14인치)
tablet_portrait: 8; // 8~10인치 세로
mobile_landscape: 6; // 6~8인치 가로
mobile_portrait: 4; // 4~6인치 세로
};
// 행 높이 (px) - 1행의 기본 높이
rowHeight: number; // 기본 48px
// 간격
gap: number; // 기본 8px
padding: number; // 기본 16px
}
// 컴포넌트 정의
interface PopComponentDefinitionV5 {
id: string;
type: PopComponentType;
label?: string;
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준
position: {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 차지할 열 수 (1~12)
rowSpan: number; // 차지할 행 수 (1~)
};
// 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존 속성
dataBinding?: PopDataBinding;
config?: PopComponentConfig;
}
```
### 브레이크포인트 정의
```typescript
// 브레이크포인트 상수
const GRID_BREAKPOINTS = {
// 4~6인치 모바일 세로
mobile_portrait: {
maxWidth: 599,
columns: 4,
rowHeight: 40,
gap: 8,
padding: 12,
},
// 6~8인치 모바일 가로 / 작은 태블릿
mobile_landscape: {
minWidth: 600,
maxWidth: 839,
columns: 6,
rowHeight: 44,
gap: 8,
padding: 16,
},
// 8~10인치 태블릿 세로
tablet_portrait: {
minWidth: 840,
maxWidth: 1023,
columns: 8,
rowHeight: 48,
gap: 12,
padding: 16,
},
// 10~14인치 태블릿 가로 (기본)
tablet_landscape: {
minWidth: 1024,
columns: 12,
rowHeight: 48,
gap: 16,
padding: 24,
},
} as const;
```
### 산출물
- `frontend/components/pop/designer/types/pop-layout-v5.ts`
---
## Phase 5.2: 그리드 렌더러
### 목표
CSS Grid 기반 렌더러 구현
### 작업 항목
- [ ] `PopGridRenderer.tsx` 생성
- [ ] CSS Grid 스타일 계산 로직
- [ ] 브레이크포인트 감지 및 칸 수 자동 변경
- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row)
- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환)
- [ ] visibility 처리
- [ ] 기존 PopFlexRenderer와 공존
### 렌더링 로직
```typescript
// CSS Grid 스타일 생성
function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties {
const columns = config.columns[mode];
const { rowHeight, gap, padding } = config;
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridAutoRows: `${rowHeight}px`,
gap: `${gap}px`,
padding: `${padding}px`,
};
}
// 컴포넌트 위치 스타일
function calculatePositionStyle(
position: PopComponentPositionV5['position'],
sourceColumns: number, // 원본 모드 칸 수 (12)
targetColumns: number // 현재 모드 칸 수 (4)
): React.CSSProperties {
// 12칸 → 4칸 변환 예시
// col: 7, colSpan: 3 → col: 3, colSpan: 1
const ratio = targetColumns / sourceColumns;
const newCol = Math.max(1, Math.ceil(position.col * ratio));
const newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
return {
gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`,
gridRow: `${position.row} / span ${position.rowSpan}`,
};
}
```
### 산출물
- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
---
## Phase 5.3: 디자이너 UI
### 목표
그리드 기반 편집 UI 구현
### 작업 항목
- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스)
- [ ] 그리드 배경 표시 (바둑판 모양)
- [ ] 컴포넌트 드래그 배치 (칸에 스냅)
- [ ] 컴포넌트 리사이즈 (칸 단위)
- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan)
- [ ] 모드 전환 시 그리드 칸 수 변경 표시
- [ ] v4/v5 자동 판별 및 전환
### UI 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │
├─────────────────────────────────────────────────────────────────┤
│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │
├────────────┬────────────────────────────────────┬───────────────┤
│ │ 1 2 3 4 5 6 ... 12 │ │
│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │
│ │1│ A │ B │ │ 열: [1-12] │
│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │
│ 버튼 │2│ C │ │ 너비: [1-12]│
│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│
│ 인디케이터 │3│ D │ E │ │ │
│ ... │ └───────────┴───────────┘ │ 표시 설정 │
│ │ │ [x] 태블릿↔ │
│ │ (그리드 배경 표시) │ [x] 모바일↕ │
└────────────┴────────────────────────────────────┴───────────────┘
```
### 드래그 앤 드롭 로직
```typescript
// 마우스 위치 → 그리드 좌표 변환
function mouseToGridPosition(
mouseX: number,
mouseY: number,
gridConfig: PopGridConfig,
canvasRect: DOMRect
): { col: number; row: number } {
const { columns, rowHeight, gap, padding } = gridConfig;
// 캔버스 내 상대 위치
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// 칸 너비 계산
const totalGap = gap * (columns - 1);
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
// 그리드 좌표 계산
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
return { col, row };
}
```
### 산출물
- `frontend/components/pop/designer/PopCanvasV5.tsx`
- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
---
## Phase 5.4: 반응형 자동화
### 목표
모드 전환 시 자동 레이아웃 조정
### 작업 항목
- [ ] 12칸 → 4칸 자동 변환 알고리즘
- [ ] 겹침 감지 및 자동 재배치
- [ ] 모드별 오버라이드 저장
- [ ] "자동 배치" vs "수동 고정" 선택
- [ ] 변환 미리보기
### 자동 변환 알고리즘
```typescript
// 12칸 → 4칸 변환 전략
function convertLayoutToMode(
components: PopComponentDefinitionV5[],
sourceMode: 'tablet_landscape', // 12칸
targetMode: 'mobile_portrait' // 4칸
): PopComponentDefinitionV5[] {
const sourceColumns = 12;
const targetColumns = 4;
const ratio = targetColumns / sourceColumns; // 0.333
// 1. 각 컴포넌트 위치 변환
const converted = components.map(comp => {
const newCol = Math.max(1, Math.ceil(comp.position.col * ratio));
const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio));
return {
...comp,
position: {
...comp.position,
col: newCol,
colSpan: Math.min(newColSpan, targetColumns),
},
};
});
// 2. 겹침 감지 및 해결
return resolveOverlaps(converted, targetColumns);
}
// 겹침 해결 (아래로 밀기)
function resolveOverlaps(
components: PopComponentDefinitionV5[],
columns: number
): PopComponentDefinitionV5[] {
// 행 단위로 그리드 점유 상태 추적
const grid: boolean[][] = [];
// row 순서대로 처리
const sorted = [...components].sort((a, b) =>
a.position.row - b.position.row || a.position.col - b.position.col
);
return sorted.map(comp => {
let { row, col, colSpan, rowSpan } = comp.position;
// 배치 가능한 위치 찾기
while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) {
row++; // 아래로 이동
}
// 그리드에 표시
markOccupied(grid, row, col, colSpan, rowSpan);
return {
...comp,
position: { row, col, colSpan, rowSpan },
};
});
}
```
### 산출물
- `frontend/components/pop/designer/utils/gridLayoutUtils.ts`
---
## 마이그레이션 전략
### v4 → v5 변환
```typescript
function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 {
const componentsList = Object.values(layoutV4.components);
// Flexbox 순서 → Grid 위치 변환
let currentRow = 1;
let currentCol = 1;
const columns = 12;
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
componentsList.forEach((comp, index) => {
// 기본 크기 추정 (픽셀 → 칸)
const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85));
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
// 줄바꿈 체크
if (currentCol + colSpan - 1 > columns) {
currentRow++;
currentCol = 1;
}
componentsV5[comp.id] = {
...comp,
position: {
col: currentCol,
row: currentRow,
colSpan,
rowSpan,
},
};
currentCol += colSpan;
});
return {
version: "pop-5.0",
gridConfig: { /* 기본값 */ },
components: componentsV5,
dataFlow: layoutV4.dataFlow,
settings: { /* 변환 */ },
};
}
```
### 하위 호환
| 버전 | 처리 방식 |
|------|----------|
| v1~v2 | v3로 변환 후 v5로 |
| v3 | v5로 직접 변환 |
| v4 | v5로 직접 변환 |
| v5 | 그대로 사용 |
---
## 일정 (예상)
| Phase | 작업 | 예상 기간 |
|-------|------|----------|
| 5.1 | 타입 정의 | 2~3일 |
| 5.2 | 그리드 렌더러 | 3~5일 |
| 5.3 | 디자이너 UI | 5~7일 |
| 5.4 | 반응형 자동화 | 3~5일 |
| - | 테스트 및 버그 수정 | 2~3일 |
| **총** | | **약 2~3주** |
---
## 리스크 및 대응
| 리스크 | 영향 | 대응 |
|--------|------|------|
| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 |
| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 |
| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 |
| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 |
---
## 성공 기준
1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시
2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지
3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치
4. **하위 호환**: 기존 v4 화면이 정상 동작
---
## 관련 문서
- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세
- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙
---
*최종 업데이트: 2026-02-05*

View File

@ -1,518 +0,0 @@
# Phase 3 완료 요약
**날짜**: 2026-02-04
**상태**: 완료 ✅
**버전**: v4.0 Phase 3
---
## 🎯 달성 목표
Phase 2의 배치 고정 기능 이후, 다음 3가지 핵심 기능 추가:
1. ✅ **모드별 컴포넌트 표시/숨김** (visibility)
2. ✅ **강제 줄바꿈 컴포넌트** (pop-break)
3. ✅ **컴포넌트 오버라이드 병합** (모드별 설정 변경)
---
## 📦 구현 내용
### 1. 타입 정의
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
// pop-break 추가
export type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕
// visibility 속성 추가
export interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
size: PopSizeConstraintV4;
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// ...
}
// 기본 크기
defaultSizes["pop-break"] = {
width: "fill", // 100% 너비
height: "fixed",
fixedHeight: 0, // 높이 0
};
```
---
### 2. 렌더러 로직
**파일**: `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
#### visibility 체크
```typescript
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode];
return modeVisibility !== false; // undefined도 true로 취급
};
```
#### 컴포넌트 오버라이드 병합
```typescript
const getMergedComponent = (baseComponent: PopComponentDefinitionV4) => {
if (currentMode === "tablet_landscape") return baseComponent;
const override = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!override) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...override,
size: { ...baseComponent.size, ...override.size },
config: { ...baseComponent.config, ...override.config },
};
};
```
#### pop-break 렌더링
```typescript
if (mergedComponent.type === "pop-break") {
return (
<div
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
className={isDesignMode
? "h-4 border-2 border-dashed border-gray-300"
: "h-0"
}
>
{isDesignMode && <span>줄바꿈</span>}
</div>
);
}
```
---
### 3. 삭제 함수 개선
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
```typescript
export const removeComponentFromV4Layout = (
layout: PopLayoutDataV4,
componentId: string
): PopLayoutDataV4 => {
// 1. components에서 삭제
const { [componentId]: _, ...remainingComponents } = layout.components;
// 2. root.children에서 제거
const newRoot = removeChildFromContainer(layout.root, componentId);
// 3. 🆕 모든 오버라이드에서 제거
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
return {
...layout,
root: newRoot,
components: remainingComponents,
overrides: newOverrides,
};
};
```
#### 오버라이드 정리 로직
```typescript
function cleanupOverridesAfterDelete(
overrides: PopLayoutDataV4["overrides"],
componentId: string
) {
// 각 모드별로:
// 1. containers.root.children에서 componentId 제거
// 2. components[componentId] 제거
// 3. 빈 오버라이드 자동 삭제
}
```
---
### 4. 속성 패널 UI
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
#### "표시" 탭 추가
```typescript
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### VisibilityForm 컴포넌트
```typescript
function VisibilityForm({ component, onUpdate }) {
const modes = [
{ key: "tablet_landscape", label: "태블릿 가로" },
{ key: "tablet_portrait", label: "태블릿 세로" },
{ key: "mobile_landscape", label: "모바일 가로" },
{ key: "mobile_portrait", label: "모바일 세로" },
];
return (
<div>
{modes.map(({ key, label }) => (
<input
type="checkbox"
checked={component.visibility?.[key] !== false}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
))}
</div>
);
}
```
---
### 5. 팔레트 업데이트
**파일**: `frontend/components/pop/designer/panels/ComponentPaletteV4.tsx`
```typescript
const COMPONENT_PALETTE = [
// ... 기존 컴포넌트들
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
```
---
## 🎨 UI 변경사항
### 컴포넌트 팔레트
```
컴포넌트
├─ 필드
├─ 버튼
├─ 리스트
├─ 인디케이터
├─ 스캐너
├─ 숫자패드
├─ 스페이서
└─ 줄바꿈 🆕
```
### 속성 패널
```
┌─────────────────────┐
│ 탭: [크기][설정] │
│ [표시📍][데이터] │
├─────────────────────┤
│ 모드별 표시 설정 │
│ ☑ 태블릿 가로 │
│ ☑ 태블릿 세로 │
│ ☐ 모바일 가로 (숨김)│
│ ☑ 모바일 세로 │
├─────────────────────┤
│ 반응형 숨김 │
│ [500] px 이하 숨김 │
└─────────────────────┘
```
---
## 📖 사용 예시
### 예시 1: 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
visibility: {
tablet_landscape: false, // 태블릿: 숨김
tablet_portrait: false,
mobile_landscape: true, // 모바일: 표시
mobile_portrait: true,
},
}
```
**결과**:
- 태블릿 화면: "전화 걸기" 버튼 안 보임
- 모바일 화면: "전화 걸기" 버튼 보임
---
### 예시 2: 모드별 줄바꿈
```typescript
레이아웃: [A] [B] [줄바꿈] [C] [D]
줄바꿈 설정:
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김
mobile_portrait: true, // 모바일: 줄바꿈 표시
}
}
```
**결과**:
```
태블릿 가로 (1024px):
┌───────────────────────────┐
│ [A] [B] [C] [D] │ ← 한 줄
└───────────────────────────┘
모바일 세로 (375px):
┌─────────────────┐
│ [A] [B] │ ← 첫 줄
│ [C] [D] │ ← 둘째 줄 (줄바꿈 적용)
└─────────────────┘
```
---
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
```typescript
// 기본 (태블릿 가로)
{
id: "product-list",
type: "pop-list",
config: {
columns: 7, // 7개 컬럼
}
}
// 오버라이드 (모바일 세로)
overrides: {
mobile_portrait: {
components: {
"product-list": {
config: {
columns: 3, // 3개 컬럼
}
}
}
}
}
```
**결과**:
- 태블릿: 7개 컬럼 표시
- 모바일: 3개 컬럼 표시 (자동 병합)
---
## 🧪 테스트 시나리오
### ✅ 테스트 1: 줄바꿈 기본 동작
1. 팔레트에서 "줄바꿈" 드래그
2. 컴포넌트 사이에 드롭
3. 디자인 모드에서 점선 "줄바꿈" 표시 확인
4. 미리보기에서 줄바꿈이 안 보이는지 확인
### ✅ 테스트 2: 모드별 줄바꿈
1. 줄바꿈 컴포넌트 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로: 한 줄
4. 모바일 세로: 두 줄
### ✅ 테스트 3: 삭제 시 오버라이드 정리
1. 모바일 세로에서 배치 고정
2. 컴포넌트 삭제
3. 저장 후 로드
4. DB 확인: overrides에서도 제거되었는지
### ✅ 테스트 4: 컴포넌트 숨김
1. 컴포넌트 선택
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿: 컴포넌트 안 보임
4. 모바일: 컴포넌트 보임
### ✅ 테스트 5: 속성 패널 UI
1. 컴포넌트 선택
2. "표시" 탭 클릭
3. 4개 체크박스 확인
4. 체크 해제 시 "(숨김)" 표시
5. 저장 후 로드 → 상태 유지
---
## 📝 수정된 파일
### 코드 파일 (5개)
```
✅ frontend/components/pop/designer/types/pop-layout.ts
- PopComponentType 확장 (pop-break)
- PopComponentDefinitionV4.visibility 추가
- cleanupOverridesAfterDelete() 추가
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
- isComponentVisible() 추가
- getMergedComponent() 추가
- pop-break 렌더링 추가
- ContainerRenderer props 확장
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
- "표시" 탭 추가
- VisibilityForm 컴포넌트 추가
- COMPONENT_TYPE_LABELS 업데이트
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
- "줄바꿈" 컴포넌트 추가
✅ frontend/components/pop/designer/PopDesigner.tsx
- (기존 Phase 2 변경사항 유지)
```
### 문서 파일 (6개)
```
✅ popdocs/CHANGELOG.md
- Phase 3 완료 기록
✅ popdocs/PLAN.md
- Phase 3 체크 완료
- Phase 4 계획 추가
✅ popdocs/V4_UNIFIED_DESIGN_SPEC.md
- Phase 3 섹션 추가
✅ popdocs/components-spec.md
- pop-break 상세 스펙 추가
- Phase 3 업데이트 노트
✅ popdocs/README.md
- 현재 상태 업데이트
- Phase 3 요약 추가
✅ popdocs/decisions/002-phase3-visibility-break.md (신규)
- 상세 설계 문서
✅ popdocs/PHASE3_SUMMARY.md (신규)
- 이 문서
```
---
## 🎓 핵심 개념
### Flexbox 줄바꿈 원리
```css
/* 컨테이너 */
.container {
display: flex;
flex-direction: row;
flex-wrap: wrap; /* 필수 */
}
/* pop-break */
.pop-break {
flex-basis: 100%; /* 전체 너비 차지 → 다음 요소는 새 줄로 */
height: 0; /* 실제로는 안 보임 */
}
```
### visibility vs hideBelow
| 속성 | 제어 방식 | 용도 |
|------|----------|------|
| `visibility` | 모드별 명시적 | 특정 모드에서만 표시 (예: 모바일 전용) |
| `hideBelow` | 픽셀 기반 자동 | 화면 너비에 따라 자동 숨김 (예: 500px 이하) |
**예시**:
```typescript
{
visibility: {
tablet_landscape: false, // 태블릿 가로: 무조건 숨김
},
hideBelow: 500, // 500px 이하: 자동 숨김 (다른 모드에서도)
}
```
---
## 🚀 다음 단계
### Phase 4: 실제 컴포넌트 구현
```
우선순위:
1. pop-field (입력/표시 필드)
2. pop-button (액션 버튼)
3. pop-list (데이터 리스트)
4. pop-indicator (KPI 표시)
5. pop-scanner (바코드/QR)
6. pop-numpad (숫자 입력)
```
### 추가 개선 사항
```
1. 컴포넌트 오버라이드 UI
- 리스트 컬럼 수 조정 UI
- 버튼 스타일 변경 UI
- 필드 표시 형식 변경 UI
2. "모든 모드에 적용" 기능
- 한 번에 모든 모드 체크/해제
3. 오버라이드 비교 뷰
- 기본값 vs 오버라이드 차이 시각화
```
---
## ✨ 주요 성과
1. ✅ **모드별 컴포넌트 제어**: visibility 속성으로 유연한 표시/숨김
2. ✅ **Flexbox 줄바꿈 해결**: pop-break 컴포넌트로 업계 표준 달성
3. ✅ **확장 가능한 구조**: 컴포넌트 오버라이드 병합으로 추후 기능 추가 용이
4. ✅ **데이터 일관성**: 삭제 시 오버라이드 자동 정리로 데이터 무결성 유지
5. ✅ **직관적인 UI**: 체크박스 기반 visibility 제어
---
## 📚 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
- [PLAN.md](./PLAN.md) - 로드맵
---
*Phase 3 완료 - 2026-02-04*
*다음: Phase 4 (실제 컴포넌트 구현)*

View File

@ -1,658 +0,0 @@
# POP 화면 시스템 구현 계획서
## 개요
Vexplor 서비스 내에서 POP(Point of Production) 화면을 구성할 수 있는 시스템을 구현합니다.
기존 Vexplor와 충돌 없이 별도 공간에서 개발하되, 장기적으로 통합 가능하도록 동일한 서비스 로직을 사용합니다.
---
## 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **충돌 방지** | POP 전용 공간에서 개발 |
| **통합 준비** | 기본 서비스 로직은 Vexplor와 동일 |
| **데이터 공유** | 같은 DB, 같은 데이터 소스 사용 |
---
## 아키텍처 개요
```
┌─────────────────────────────────────────────────────────────────────┐
│ [데이터베이스] │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ screen_ │ │ screen_layouts_ │ │ screen_layouts_ │ │
│ │ definitions │ │ v2 (데스크톱) │ │ pop (POP) │ │
│ │ (공통) │ └─────────────────┘ └─────────────────┘ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ [백엔드 API] │
│ /screen-management/screens/:id/layout-v2 (데스크톱) │
│ /screen-management/screens/:id/layout-pop (POP) │
└─────────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ [프론트엔드 - 데스크톱] │ │ [프론트엔드 - POP] │
│ │ │ │
│ app/(main)/ │ │ app/(pop)/ │
│ lib/registry/ │ │ lib/registry/ │
│ components/ │ │ pop-components/ │
│ components/screen/ │ │ components/pop/ │
└─────────────────────────┘ └─────────────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ PC 브라우저 │ │ 모바일/태블릿 브라우저 │
│ (마우스 + 키보드) │ │ (터치 + 스캐너) │
└─────────────────────────┘ └─────────────────────────┘
```
---
## 1. 데이터베이스 변경사항
### 1-1. 테이블 추가/유지 현황
| 구분 | 테이블명 | 변경 내용 | 비고 |
|------|----------|----------|------|
| **추가** | `screen_layouts_pop` | POP 레이아웃 저장용 | 신규 테이블 |
| **유지** | `screen_definitions` | 변경 없음 | 공통 사용 |
| **유지** | `screen_layouts_v2` | 변경 없음 | 데스크톱 전용 |
### 1-2. 신규 테이블 DDL
```sql
-- 마이그레이션 파일: db/migrations/XXX_create_screen_layouts_pop.sql
CREATE TABLE screen_layouts_pop (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL REFERENCES screen_definitions(screen_id),
company_code VARCHAR(20) NOT NULL,
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- 반응형 레이아웃 JSON
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
UNIQUE(screen_id, company_code)
);
CREATE INDEX idx_pop_screen_id ON screen_layouts_pop(screen_id);
CREATE INDEX idx_pop_company_code ON screen_layouts_pop(company_code);
COMMENT ON TABLE screen_layouts_pop IS 'POP 화면 레이아웃 저장 테이블 (모바일/태블릿 반응형)';
COMMENT ON COLUMN screen_layouts_pop.layout_data IS 'V2 형식의 레이아웃 JSON (반응형 구조)';
```
### 1-3. 레이아웃 JSON 구조 (V2 형식 동일)
```json
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/pop-components/pop-card-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "user_info",
"columns": ["id", "name", "status"],
"cardStyle": "compact"
}
}
],
"updatedAt": "2026-01-29T12:00:00Z"
}
```
---
## 2. 백엔드 변경사항
### 2-1. 파일 수정 목록
| 구분 | 파일 경로 | 변경 내용 |
|------|----------|----------|
| **수정** | `backend-node/src/services/screenManagementService.ts` | POP 레이아웃 CRUD 함수 추가 |
| **수정** | `backend-node/src/routes/screenManagementRoutes.ts` | POP API 엔드포인트 추가 |
### 2-2. 추가 API 엔드포인트
```
GET /screen-management/screens/:screenId/layout-pop # POP 레이아웃 조회
POST /screen-management/screens/:screenId/layout-pop # POP 레이아웃 저장
DELETE /screen-management/screens/:screenId/layout-pop # POP 레이아웃 삭제
```
### 2-3. screenManagementService.ts 추가 함수
```typescript
// 기존 함수 (유지)
getScreenLayoutV2(screenId, companyCode)
saveLayoutV2(screenId, companyCode, layoutData)
// 추가 함수 (신규) - 로직은 V2와 동일, 테이블명만 다름
getScreenLayoutPop(screenId, companyCode)
saveLayoutPop(screenId, companyCode, layoutData)
deleteLayoutPop(screenId, companyCode)
```
---
## 3. 프론트엔드 변경사항
### 3-1. 폴더 구조
```
frontend/
├── app/
│ └── (pop)/ # [기존] POP 라우팅 그룹
│ ├── layout.tsx # [수정] POP 전용 레이아웃
│ ├── pop/
│ │ └── page.tsx # [기존] POP 메인
│ └── screens/ # [추가] POP 화면 뷰어
│ └── [screenId]/
│ └── page.tsx # [추가] POP 동적 화면
├── lib/
│ ├── api/
│ │ └── screen.ts # [수정] POP API 함수 추가
│ │
│ ├── registry/
│ │ ├── pop-components/ # [추가] POP 전용 컴포넌트
│ │ │ ├── pop-card-list/
│ │ │ │ ├── PopCardListComponent.tsx
│ │ │ │ ├── PopCardListConfigPanel.tsx
│ │ │ │ └── index.ts
│ │ │ ├── pop-touch-button/
│ │ │ ├── pop-scanner-input/
│ │ │ └── index.ts # POP 컴포넌트 내보내기
│ │ │
│ │ ├── PopComponentRegistry.ts # [추가] POP 컴포넌트 레지스트리
│ │ └── ComponentRegistry.ts # [유지] 기존 유지
│ │
│ ├── schemas/
│ │ └── popComponentConfig.ts # [추가] POP용 Zod 스키마
│ │
│ └── utils/
│ └── layoutPopConverter.ts # [추가] POP 레이아웃 변환기
└── components/
└── pop/ # [기존] POP UI 컴포넌트
├── PopScreenDesigner.tsx # [추가] POP 화면 설계 도구
├── PopPreview.tsx # [추가] POP 미리보기
└── PopDynamicRenderer.tsx # [추가] POP 동적 렌더러
```
### 3-2. 파일별 상세 내용
#### A. 신규 파일 (추가)
| 파일 | 역할 | 기반 |
|------|------|------|
| `app/(pop)/screens/[screenId]/page.tsx` | POP 화면 뷰어 | `app/(main)/screens/[screenId]/page.tsx` 참고 |
| `lib/registry/PopComponentRegistry.ts` | POP 컴포넌트 등록 | `ComponentRegistry.ts` 구조 동일 |
| `lib/registry/pop-components/*` | POP 전용 컴포넌트 | 신규 개발 |
| `lib/schemas/popComponentConfig.ts` | POP Zod 스키마 | `componentConfig.ts` 구조 동일 |
| `lib/utils/layoutPopConverter.ts` | POP 레이아웃 변환 | `layoutV2Converter.ts` 구조 동일 |
| `components/pop/PopScreenDesigner.tsx` | POP 화면 설계 | 신규 개발 |
| `components/pop/PopDynamicRenderer.tsx` | POP 동적 렌더러 | `DynamicComponentRenderer.tsx` 참고 |
#### B. 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `lib/api/screen.ts` | `getLayoutPop()`, `saveLayoutPop()` 함수 추가 |
| `app/(pop)/layout.tsx` | POP 전용 레이아웃 스타일 적용 |
#### C. 유지 파일 (변경 없음)
| 파일 | 이유 |
|------|------|
| `lib/registry/ComponentRegistry.ts` | 데스크톱 전용, 분리 유지 |
| `lib/schemas/componentConfig.ts` | 데스크톱 전용, 분리 유지 |
| `lib/utils/layoutV2Converter.ts` | 데스크톱 전용, 분리 유지 |
| `app/(main)/*` | 데스크톱 전용, 변경 없음 |
---
## 4. 서비스 로직 흐름
### 4-1. 데스크톱 (기존 - 변경 없음)
```
[사용자] → /screens/123 접속
[app/(main)/screens/[screenId]/page.tsx]
[getLayoutV2(screenId)] → API 호출
[screen_layouts_v2 테이블] → 레이아웃 JSON 반환
[DynamicComponentRenderer] → 컴포넌트 렌더링
[ComponentRegistry] → 컴포넌트 찾기
[lib/registry/components/table-list] → 컴포넌트 실행
[화면 표시]
```
### 4-2. POP (신규 - 동일 로직)
```
[사용자] → /pop/screens/123 접속
[app/(pop)/screens/[screenId]/page.tsx]
[getLayoutPop(screenId)] → API 호출
[screen_layouts_pop 테이블] → 레이아웃 JSON 반환
[PopDynamicRenderer] → 컴포넌트 렌더링
[PopComponentRegistry] → 컴포넌트 찾기
[lib/registry/pop-components/pop-card-list] → 컴포넌트 실행
[화면 표시]
```
---
## 5. 로직 변경 여부
| 구분 | 로직 변경 | 설명 |
|------|----------|------|
| 데이터베이스 CRUD | **없음** | 동일한 SELECT/INSERT/UPDATE 패턴 |
| API 호출 방식 | **없음** | 동일한 REST API 패턴 |
| 컴포넌트 렌더링 | **없음** | 동일한 URL 기반 + overrides 방식 |
| Zod 스키마 검증 | **없음** | 동일한 검증 로직 |
| 레이아웃 JSON 구조 | **없음** | 동일한 V2 JSON 구조 사용 |
**결론: 로직 변경 없음, 파일/테이블 분리만 진행**
---
## 6. 데스크톱 vs POP 비교
| 구분 | Vexplor (데스크톱) | POP (모바일/태블릿) |
|------|-------------------|---------------------|
| **타겟 기기** | PC (마우스+키보드) | 모바일/태블릿 (터치) |
| **화면 크기** | 1920x1080 고정 | 반응형 (다양한 크기) |
| **UI 스타일** | 테이블 중심, 작은 버튼 | 카드 중심, 큰 터치 버튼 |
| **입력 방식** | 키보드 타이핑 | 터치, 스캐너, 음성 |
| **사용 환경** | 사무실 | 현장, 창고, 공장 |
| **레이아웃 테이블** | `screen_layouts_v2` | `screen_layouts_pop` |
| **컴포넌트 경로** | `lib/registry/components/` | `lib/registry/pop-components/` |
| **레지스트리** | `ComponentRegistry.ts` | `PopComponentRegistry.ts` |
---
## 7. 장기 통합 시나리오
### Phase 1: 분리 개발 (현재 목표)
```
[데스크톱] [POP]
ComponentRegistry PopComponentRegistry
components/ pop-components/
screen_layouts_v2 screen_layouts_pop
```
### Phase 2: 부분 통합 (향후)
```
[통합 가능한 부분]
- 공통 유틸리티 함수
- 공통 Zod 스키마
- 공통 타입 정의
[분리 유지]
- 플랫폼별 컴포넌트
- 플랫폼별 레이아웃
```
### Phase 3: 완전 통합 (최종)
```
[단일 컴포넌트 레지스트리]
ComponentRegistry
├── components/ (공통)
├── desktop-components/ (데스크톱 전용)
└── pop-components/ (POP 전용)
[단일 레이아웃 테이블] (선택사항)
screen_layouts
├── platform = 'desktop'
└── platform = 'pop'
```
---
## 8. V2 공통 요소 (통합 핵심)
POP과 데스크톱이 장기적으로 통합될 수 있는 **핵심 기반**입니다.
### 8-1. 공통 유틸리티 함수
**파일 위치:** `frontend/lib/schemas/componentConfig.ts`, `frontend/lib/utils/layoutV2Converter.ts`
#### 핵심 병합/추출 함수 (가장 중요!)
| 함수명 | 역할 | 사용 시점 |
|--------|------|----------|
| `deepMerge()` | 객체 깊은 병합 | 기본값 + overrides 합칠 때 |
| `mergeComponentConfig()` | 기본값 + 커스텀 병합 | **렌더링 시** (화면 표시) |
| `extractCustomConfig()` | 기본값과 다른 부분만 추출 | **저장 시** (DB 저장) |
| `isDeepEqual()` | 두 객체 깊은 비교 | 변경 여부 판단 |
```typescript
// 예시: 저장 시 차이값만 추출
const defaults = { showHeader: true, pageSize: 20 };
const fullConfig = { showHeader: true, pageSize: 50, customField: "test" };
const overrides = extractCustomConfig(fullConfig, defaults);
// 결과: { pageSize: 50, customField: "test" } (차이값만!)
```
#### URL 처리 함수
| 함수명 | 역할 | 예시 |
|--------|------|------|
| `getComponentUrl()` | 타입 → URL 변환 | `"v2-table-list"``"@/lib/registry/components/v2-table-list"` |
| `getComponentTypeFromUrl()` | URL → 타입 추출 | `"@/lib/registry/components/v2-table-list"``"v2-table-list"` |
#### 기본값 조회 함수
| 함수명 | 역할 |
|--------|------|
| `getComponentDefaults()` | 컴포넌트 타입으로 기본값 조회 |
| `getDefaultsByUrl()` | URL로 기본값 조회 |
#### V2 로드/저장 함수 (핵심!)
| 함수명 | 역할 | 사용 시점 |
|--------|------|----------|
| `loadComponentV2()` | 컴포넌트 로드 (기본값 병합) | DB → 화면 |
| `saveComponentV2()` | 컴포넌트 저장 (차이값 추출) | 화면 → DB |
| `loadLayoutV2()` | 레이아웃 전체 로드 | DB → 화면 |
| `saveLayoutV2()` | 레이아웃 전체 저장 | 화면 → DB |
#### 변환 함수
| 함수명 | 역할 |
|--------|------|
| `convertV2ToLegacy()` | V2 → Legacy 변환 (하위 호환) |
| `convertLegacyToV2()` | Legacy → V2 변환 |
| `isValidV2Layout()` | V2 레이아웃인지 검증 |
| `isLegacyLayout()` | 레거시 레이아웃인지 확인 |
### 8-2. 공통 Zod 스키마
**파일 위치:** `frontend/lib/schemas/componentConfig.ts`
#### 핵심 스키마 (필수!)
```typescript
// 컴포넌트 기본 구조
export const componentV2Schema = z.object({
id: z.string(),
url: z.string(),
position: z.object({ x: z.number(), y: z.number() }),
size: z.object({ width: z.number(), height: z.number() }),
displayOrder: z.number().default(0),
overrides: z.record(z.string(), z.any()).default({}),
});
// 레이아웃 기본 구조
export const layoutV2Schema = z.object({
version: z.string().default("2.0"),
components: z.array(componentV2Schema).default([]),
updatedAt: z.string().optional(),
screenResolution: z.object({...}).optional(),
gridSettings: z.any().optional(),
});
```
#### 컴포넌트별 overrides 스키마 (25개+)
| 스키마명 | 컴포넌트 | 주요 기본값 |
|----------|----------|------------|
| `v2TableListOverridesSchema` | 테이블 리스트 | displayMode: "table", pageSize: 20 |
| `v2ButtonPrimaryOverridesSchema` | 버튼 | text: "저장", variant: "primary" |
| `v2SplitPanelLayoutOverridesSchema` | 분할 레이아웃 | splitRatio: 30, resizable: true |
| `v2SectionCardOverridesSchema` | 섹션 카드 | padding: "md", collapsible: false |
| `v2TabsWidgetOverridesSchema` | 탭 위젯 | orientation: "horizontal" |
| `v2RepeaterOverridesSchema` | 리피터 | renderMode: "inline" |
#### 스키마 레지스트리 (자동 매핑)
```typescript
const componentOverridesSchemaRegistry = {
"v2-table-list": v2TableListOverridesSchema,
"v2-button-primary": v2ButtonPrimaryOverridesSchema,
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
// ... 25개+ 컴포넌트
};
```
### 8-3. 공통 타입 정의
**파일 위치:** `frontend/types/v2-core.ts`, `frontend/types/v2-components.ts`
#### 핵심 공통 타입 (v2-core.ts)
```typescript
// 웹 입력 타입
export type WebType =
| "text" | "textarea" | "email" | "tel" | "url"
| "number" | "decimal"
| "date" | "datetime"
| "select" | "dropdown" | "radio" | "checkbox" | "boolean"
| "code" | "entity" | "file" | "image" | "button"
| "container" | "group" | "list" | "tree" | "custom";
// 버튼 액션 타입
export type ButtonActionType =
| "save" | "cancel" | "delete" | "edit" | "copy" | "add"
| "search" | "reset" | "submit"
| "close" | "popup" | "modal"
| "navigate" | "newWindow"
| "control" | "transferData" | "quickInsert";
// 위치/크기
export interface Position { x: number; y: number; z?: number; }
export interface Size { width: number; height: number; }
// 공통 스타일
export interface CommonStyle {
margin?: string;
padding?: string;
border?: string;
backgroundColor?: string;
color?: string;
fontSize?: string;
// ... 30개+ 속성
}
// 유효성 검사
export interface ValidationRule {
type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max" | "email" | "url";
value?: unknown;
message: string;
}
```
#### V2 컴포넌트 타입 (v2-components.ts)
```typescript
// 10개 통합 컴포넌트 타입
export type V2ComponentType =
| "V2Input" | "V2Select" | "V2Date" | "V2Text" | "V2Media"
| "V2List" | "V2Layout" | "V2Group" | "V2Biz" | "V2Hierarchy";
// 공통 속성
export interface V2BaseProps {
id: string;
label?: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
tableName?: string;
columnName?: string;
position?: Position;
size?: Size;
style?: CommonStyle;
validation?: ValidationRule[];
}
```
### 8-4. POP 통합 시 공유/분리 기준
#### 반드시 공유 (그대로 사용)
| 구분 | 파일/요소 | 이유 |
|------|----------|------|
| **유틸리티** | `deepMerge`, `extractCustomConfig`, `mergeComponentConfig` | 저장/로드 로직 동일 |
| **스키마** | `componentV2Schema`, `layoutV2Schema` | JSON 구조 동일 |
| **타입** | `Position`, `Size`, `WebType`, `ButtonActionType` | 기본 구조 동일 |
#### POP 전용으로 분리
| 구분 | 파일/요소 | 이유 |
|------|----------|------|
| **overrides 스키마** | `popCardListOverridesSchema` 등 | POP 컴포넌트 전용 기본값 |
| **스키마 레지스트리** | `popComponentOverridesSchemaRegistry` | POP 컴포넌트 매핑 |
| **기본값 레지스트리** | `popComponentDefaultsRegistry` | POP 컴포넌트 기본값 |
### 8-5. 추천 폴더 구조 (공유 분리)
```
frontend/lib/schemas/
├── componentConfig.ts # 기존 (데스크톱)
├── popComponentConfig.ts # 신규 (POP) - 구조는 동일
└── shared/ # 신규 (공유) - 향후 통합 시
├── baseSchemas.ts # componentV2Schema, layoutV2Schema
├── mergeUtils.ts # deepMerge, extractCustomConfig 등
└── types.ts # Position, Size 등
```
---
## 9. 작업 우선순위
### [ ] 1단계: 데이터베이스
- [ ] `screen_layouts_pop` 테이블 생성 마이그레이션 작성
- [ ] 마이그레이션 실행 및 검증
### [ ] 2단계: 백엔드 API
- [ ] `screenManagementService.ts`에 POP 함수 추가
- [ ] `getScreenLayoutPop()`
- [ ] `saveLayoutPop()`
- [ ] `deleteLayoutPop()`
- [ ] `screenManagementRoutes.ts`에 엔드포인트 추가
- [ ] `GET /screens/:screenId/layout-pop`
- [ ] `POST /screens/:screenId/layout-pop`
- [ ] `DELETE /screens/:screenId/layout-pop`
### [ ] 3단계: 프론트엔드 기반
- [ ] `lib/api/screen.ts`에 POP API 함수 추가
- [ ] `getLayoutPop()`
- [ ] `saveLayoutPop()`
- [ ] `lib/registry/PopComponentRegistry.ts` 생성
- [ ] `lib/schemas/popComponentConfig.ts` 생성
- [ ] `lib/utils/layoutPopConverter.ts` 생성
### [ ] 4단계: POP 컴포넌트 개발
- [ ] `lib/registry/pop-components/` 폴더 구조 생성
- [ ] 기본 컴포넌트 개발
- [ ] `pop-card-list` (카드형 리스트)
- [ ] `pop-touch-button` (터치 버튼)
- [ ] `pop-scanner-input` (스캐너 입력)
- [ ] `pop-status-badge` (상태 배지)
### [ ] 5단계: POP 화면 페이지
- [ ] `app/(pop)/screens/[screenId]/page.tsx` 생성
- [ ] `components/pop/PopDynamicRenderer.tsx` 생성
- [ ] `app/(pop)/layout.tsx` 수정 (POP 전용 스타일)
### [ ] 6단계: POP 화면 디자이너 (선택)
- [ ] `components/pop/PopScreenDesigner.tsx` 생성
- [ ] `components/pop/PopPreview.tsx` 생성
- [ ] 관리자 메뉴에 POP 화면 설계 기능 추가
---
## 10. 참고 파일 위치
### 데스크톱 참고 파일 (기존)
| 구분 | 파일 경로 |
|------|----------|
| 화면 페이지 | `frontend/app/(main)/screens/[screenId]/page.tsx` |
| 컴포넌트 레지스트리 | `frontend/lib/registry/ComponentRegistry.ts` |
| 동적 렌더러 | `frontend/lib/registry/DynamicComponentRenderer.tsx` |
| Zod 스키마 | `frontend/lib/schemas/componentConfig.ts` |
| 레이아웃 변환기 | `frontend/lib/utils/layoutV2Converter.ts` |
| 화면 API | `frontend/lib/api/screen.ts` |
| 백엔드 서비스 | `backend-node/src/services/screenManagementService.ts` |
| 백엔드 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` |
### 관련 문서
| 문서 | 경로 |
|------|------|
| V2 아키텍처 | `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` |
| 화면관리 설계 | `docs/kjs/화면관리_시스템_설계.md` |
---
## 11. 주의사항
### 멀티테넌시
- 모든 테이블에 `company_code` 필수
- 모든 쿼리에 `company_code` 필터링 적용
- 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능
### 충돌 방지
- 기존 데스크톱 파일 수정 최소화
- POP 전용 폴더/파일에서 작업
- 공통 로직은 별도 유틸리티로 분리
### 테스트
- 데스크톱 기능 회귀 테스트 필수
- POP 반응형 테스트 (모바일/태블릿)
- 멀티테넌시 격리 테스트
---
## 변경 이력
| 날짜 | 버전 | 내용 |
|------|------|------|
| 2026-01-29 | 1.0 | 초기 계획서 작성 |
| 2026-01-29 | 1.1 | V2 공통 요소 (통합 핵심) 섹션 추가 |
---
## 작성자
- 작성일: 2026-01-29
- 프로젝트: Vexplor POP 화면 시스템

File diff suppressed because it is too large Load Diff

View File

@ -1,760 +0,0 @@
# POP v4.0 제약조건 기반 시스템 구현 계획
## 1. 현재 시스템 분석
### 1.1 현재 구조 (v3.0)
```typescript
// 4개 모드별 그리드 위치 기반
interface PopLayoutDataV3 {
version: "pop-3.0";
layouts: {
tablet_landscape: { componentPositions: Record<string, GridPosition> };
tablet_portrait: { componentPositions: Record<string, GridPosition> };
mobile_landscape: { componentPositions: Record<string, GridPosition> };
mobile_portrait: { componentPositions: Record<string, GridPosition> };
};
components: Record<string, PopComponentDefinition>;
// ...
}
interface GridPosition {
col: number; // 1-based
row: number; // 1-based
colSpan: number;
rowSpan: number;
}
```
### 1.2 현재 문제점
1. **4배 작업량**: 4개 모드 각각 설계 필요
2. **수동 동기화**: 컴포넌트 추가/삭제 시 4모드 수동 동기화
3. **그리드 한계**: col/row 기반이라 자동 재배치 불가
4. **반응형 미흡**: 화면 크기 변화에 자동 적응 불가
5. **디바이스 차이 무시**: 태블릿/모바일 물리적 크기 차이 고려 안됨
---
## 2. 새로운 시스템 설계 (v4.0)
### 2.1 핵심 철학
```
"하나의 레이아웃 설계 → 제약조건 설정 → 모든 화면 자동 적응"
```
- **단일 소스**: 1개 레이아웃만 설계
- **제약조건 기반**: 컴포넌트가 "어떻게 반응할지" 규칙 정의
- **Flexbox 렌더링**: CSS Grid에서 Flexbox 기반으로 전환
- **자동 줄바꿈**: 공간 부족 시 자동 재배치
### 2.2 새로운 데이터 구조
```typescript
// v4.0 레이아웃
interface PopLayoutDataV4 {
version: "pop-4.0";
// 루트 컨테이너
root: PopContainer;
// 컴포넌트 정의 (ID → 정의)
components: Record<string, PopComponentDefinitionV4>;
// 데이터 흐름
dataFlow: PopDataFlow;
// 전역 설정
settings: PopGlobalSettingsV4;
// 메타데이터
metadata?: PopLayoutMetadata;
}
```
### 2.3 컨테이너 (스택)
```typescript
// 컨테이너: 컴포넌트들을 담는 그룹
interface PopContainer {
id: string;
type: "stack";
// 스택 방향
direction: "horizontal" | "vertical";
// 줄바꿈 허용
wrap: boolean;
// 요소 간 간격
gap: number;
// 정렬
alignItems: "start" | "center" | "end" | "stretch";
justifyContent: "start" | "center" | "end" | "space-between" | "space-around";
// 패딩
padding?: {
top: number;
right: number;
bottom: number;
left: number;
};
// 반응형 규칙 (선택)
responsive?: {
// 브레이크포인트 (이 너비 이하에서 적용)
breakpoint: number;
// 변경할 방향
direction?: "horizontal" | "vertical";
// 변경할 간격
gap?: number;
}[];
// 자식 요소 (컴포넌트 ID 또는 중첩 컨테이너)
children: (string | PopContainer)[];
}
```
### 2.4 컴포넌트 제약조건
```typescript
interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
label?: string;
// ===== 크기 제약 (핵심) =====
size: {
// 너비 모드
width: "fixed" | "fill" | "hug";
// 높이 모드
height: "fixed" | "fill" | "hug";
// 고정 크기 (width/height가 fixed일 때)
fixedWidth?: number;
fixedHeight?: number;
// 최소/최대 크기
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
// 비율 (fill일 때, 같은 컨테이너 내 다른 요소와의 비율)
flexGrow?: number; // 기본 1
flexShrink?: number; // 기본 1
};
// ===== 정렬 =====
alignSelf?: "start" | "center" | "end" | "stretch";
// ===== 여백 =====
margin?: {
top: number;
right: number;
bottom: number;
left: number;
};
// ===== 모바일 스케일 (선택) =====
// 모바일에서 컴포넌트를 더 크게 표시
mobileScale?: number; // 기본 1.0, 예: 1.2 = 20% 더 크게
// ===== 기존 속성 =====
dataBinding?: PopDataBinding;
style?: PopStylePreset;
config?: PopComponentConfig;
}
```
### 2.5 크기 모드 설명
| 모드 | 설명 | CSS 변환 |
|------|------|----------|
| `fixed` | 고정 크기 (px) | `width: {fixedWidth}px` |
| `fill` | 부모 공간 채우기 | `flex: {flexGrow} {flexShrink} 0` |
| `hug` | 내용에 맞춤 | `flex: 0 0 auto` |
### 2.6 전역 설정
```typescript
interface PopGlobalSettingsV4 {
// 기본 터치 타겟 크기
touchTargetMin: number; // 48px
// 모드 (일반/산업현장)
mode: "normal" | "industrial";
// 기본 간격
defaultGap: number; // 8px
// 기본 패딩
defaultPadding: number; // 16px
// 반응형 브레이크포인트 (전역)
breakpoints: {
tablet: number; // 768px
mobile: number; // 480px
};
}
```
---
## 3. 디자이너 UI 변경
### 3.1 기존 디자이너 vs 새 디자이너
```
기존 (그리드 기반):
┌──────────────────────────────────────────────┐
│ [태블릿 가로] [태블릿 세로] [모바일 가로] [모바일 세로] │
│ │
│ 24x24 그리드에 컴포넌트 드래그 배치 │
│ │
└──────────────────────────────────────────────┘
새로운 (제약조건 기반):
┌──────────────────────────────────────────────┐
│ [단일 캔버스] 미리보기: [태블릿▼] │
│ │
│ 스택(컨테이너)에 컴포넌트 배치 │
│ + 우측 패널에서 제약조건 설정 │
│ │
└──────────────────────────────────────────────┘
```
### 3.2 새로운 디자이너 레이아웃
```
┌─────────────────────────────────────────────────────────────────┐
│ POP 화면 디자이너 v4 [저장] [미리보기] │
├────────────────┬────────────────────────┬───────────────────────┤
│ │ │ │
│ 컴포넌트 │ 캔버스 │ 속성 패널 │
│ │ │ │
│ ▼ 기본 │ ┌──────────────────┐ │ ▼ 선택됨: 입력창 │
│ [필드] │ │ ┌──────────────┐ │ │ │
│ [버튼] │ │ │입력창 │ │ │ ▼ 크기 │
│ [리스트] │ │ └──────────────┘ │ │ 너비: [채우기 ▼] │
│ [인디케이터] │ │ │ │ 최소: [100] px │
│ │ │ ┌─────┐ ┌─────┐ │ │ 최대: [없음] │
│ ▼ 입력 │ │ │버튼1│ │버튼2│ │ │ │
│ [스캐너] │ │ └─────┘ └─────┘ │ │ 높이: [고정 ▼] │
│ [숫자패드] │ │ │ │ 값: [48] px │
│ │ └──────────────────┘ │ │
│ ───────── │ │ ▼ 정렬 │
│ │ 미리보기: │ [늘이기 ▼] │
│ ▼ 레이아웃 │ ┌──────────────────┐ │ │
│ [스택 (가로)] │ │[태블릿 가로 ▼] │ │ ▼ 여백 │
│ [스택 (세로)] │ │[768px] │ │ 상[8] 우[0] 하[8] 좌[0]│
│ │ └──────────────────┘ │ │
│ │ │ ▼ 반응형 │
│ │ │ 모바일 스케일: [1.2] │
│ │ │ │
└────────────────┴────────────────────────┴───────────────────────┘
```
### 3.3 컨테이너(스택) 편집
```
┌─ 스택 속성 ─────────────────────┐
│ │
│ 방향: [가로 ▼] │
│ 줄바꿈: [허용 ☑] │
│ 간격: [8] px │
│ │
│ 정렬 (가로): [가운데 ▼] │
│ 정렬 (세로): [늘이기 ▼] │
│ │
│ ▼ 반응형 규칙 │
│ ┌─────────────────────────────┐ │
│ │ 768px 이하: 세로 방향 │ │
│ │ [+ 규칙 추가] │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────┘
```
---
## 4. 렌더링 로직 변경
### 4.1 기존 렌더링 (CSS Grid)
```typescript
// v3: CSS Grid 기반
<div style={{
display: "grid",
gridTemplateColumns: `repeat(24, 1fr)`,
gridTemplateRows: `repeat(24, 1fr)`,
gap: "4px",
}}>
{componentIds.map(id => (
<div style={{
gridColumn: `${pos.col} / span ${pos.colSpan}`,
gridRow: `${pos.row} / span ${pos.rowSpan}`,
}}>
<Component />
</div>
))}
</div>
```
### 4.2 새로운 렌더링 (Flexbox)
```typescript
// v4: Flexbox 기반
function renderContainer(container: PopContainer, components: Record<string, PopComponentDefinitionV4>) {
const direction = useResponsiveValue(container, 'direction');
const gap = useResponsiveValue(container, 'gap');
return (
<div style={{
display: "flex",
flexDirection: direction === "horizontal" ? "row" : "column",
flexWrap: container.wrap ? "wrap" : "nowrap",
gap: `${gap}px`,
alignItems: container.alignItems,
justifyContent: container.justifyContent,
padding: container.padding ?
`${container.padding.top}px ${container.padding.right}px ${container.padding.bottom}px ${container.padding.left}px`
: undefined,
}}>
{container.children.map(child => {
if (typeof child === "string") {
// 컴포넌트 렌더링
return renderComponent(components[child]);
} else {
// 중첩 컨테이너 렌더링
return renderContainer(child, components);
}
})}
</div>
);
}
function renderComponent(component: PopComponentDefinitionV4) {
const { size, margin, mobileScale } = component;
const isMobile = useIsMobile();
const scale = isMobile && mobileScale ? mobileScale : 1;
// 크기 계산
let width: string;
let flex: string;
if (size.width === "fixed") {
width = `${(size.fixedWidth || 100) * scale}px`;
flex = "0 0 auto";
} else if (size.width === "fill") {
width = "auto";
flex = `${size.flexGrow || 1} ${size.flexShrink || 1} 0`;
} else { // hug
width = "auto";
flex = "0 0 auto";
}
return (
<div style={{
flex,
width,
minWidth: size.minWidth ? `${size.minWidth * scale}px` : undefined,
maxWidth: size.maxWidth ? `${size.maxWidth * scale}px` : undefined,
height: size.height === "fixed" ? `${(size.fixedHeight || 48) * scale}px` : "auto",
minHeight: size.minHeight ? `${size.minHeight * scale}px` : undefined,
maxHeight: size.maxHeight ? `${size.maxHeight * scale}px` : undefined,
margin: margin ?
`${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`
: undefined,
alignSelf: component.alignSelf,
}}>
<ActualComponent {...component} />
</div>
);
}
```
### 4.3 반응형 훅
```typescript
function useResponsiveValue<T>(
container: PopContainer,
property: keyof PopContainer
): T {
const windowWidth = useWindowWidth();
// 기본값
let value = container[property] as T;
// 반응형 규칙 적용 (작은 브레이크포인트 우선)
if (container.responsive) {
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
for (const rule of sortedRules) {
if (windowWidth <= rule.breakpoint && rule[property] !== undefined) {
value = rule[property] as T;
}
}
}
return value;
}
```
---
## 5. 구현 단계
### Phase 1: 데이터 구조 (1-2일)
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
1. `PopLayoutDataV4` 인터페이스 정의
2. `PopContainer` 인터페이스 정의
3. `PopComponentDefinitionV4` 인터페이스 정의
4. `createEmptyPopLayoutV4()` 함수
5. `migrateV3ToV4()` 마이그레이션 함수
6. `ensureV4Layout()` 함수
7. 타입 가드 함수들
### Phase 2: 렌더러 (2-3일)
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRendererV4.tsx`
1. `renderContainer()` 함수
2. `renderComponent()` 함수
3. `useResponsiveValue()`
4. `useWindowWidth()`
5. CSS 스타일 계산 로직
6. 반응형 브레이크포인트 처리
### Phase 3: 디자이너 UI (3-4일)
**파일**: `frontend/components/pop/designer/PopDesignerV4.tsx`
1. 캔버스 영역 (드래그 앤 드롭)
2. 컴포넌트 팔레트 (기존 + 스택)
3. 속성 패널
- 크기 제약 편집
- 정렬 편집
- 여백 편집
- 반응형 규칙 편집
4. 미리보기 모드 (다양한 화면 크기)
5. 컨테이너(스택) 관리
- 컨테이너 추가/삭제
- 컨테이너 설정 편집
- 컴포넌트 이동 (컨테이너 간)
### Phase 4: 뷰어 통합 (1-2일)
**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
1. v4 레이아웃 감지 및 렌더링
2. 기존 v3 호환 유지
3. 반응형 모드 감지 연동
4. 성능 최적화
### Phase 5: 백엔드 수정 (1일)
**파일**: `backend-node/src/services/screenManagementService.ts`
1. `saveLayoutPop` - v4 버전 감지 및 저장
2. `getLayoutPop` - v4 버전 반환
3. 버전 마이그레이션 로직
### Phase 6: 테스트 및 마이그레이션 (2-3일)
1. 단위 테스트
2. 통합 테스트
3. 기존 v3 레이아웃 마이그레이션 도구
4. 크로스 디바이스 테스트
---
## 6. 마이그레이션 전략
### 6.1 v3 → v4 자동 변환
```typescript
function migrateV3ToV4(v3: PopLayoutDataV3): PopLayoutDataV4 {
// 태블릿 가로 모드 기준으로 변환
const baseLayout = v3.layouts.tablet_landscape;
const componentIds = Object.keys(baseLayout.componentPositions);
// 컴포넌트를 row, col 순으로 정렬
const sortedIds = componentIds.sort((a, b) => {
const posA = baseLayout.componentPositions[a];
const posB = baseLayout.componentPositions[b];
if (posA.row !== posB.row) return posA.row - posB.row;
return posA.col - posB.col;
});
// 같은 row에 있는 컴포넌트들을 가로 스택으로 그룹화
const rowGroups = groupByRow(sortedIds, baseLayout.componentPositions);
// 루트 컨테이너 (세로 스택)
const rootContainer: PopContainer = {
id: "root",
type: "stack",
direction: "vertical",
wrap: false,
gap: v3.settings.canvasGrid.gap,
alignItems: "stretch",
justifyContent: "start",
children: [],
};
// 각 행을 가로 스택으로 변환
for (const [row, ids] of rowGroups) {
if (ids.length === 1) {
// 단일 컴포넌트면 직접 추가
rootContainer.children.push(ids[0]);
} else {
// 여러 컴포넌트면 가로 스택으로 감싸기
const rowStack: PopContainer = {
id: `row-${row}`,
type: "stack",
direction: "horizontal",
wrap: true,
gap: v3.settings.canvasGrid.gap,
alignItems: "center",
justifyContent: "start",
children: ids,
};
rootContainer.children.push(rowStack);
}
}
// 컴포넌트 정의 변환
const components: Record<string, PopComponentDefinitionV4> = {};
for (const id of componentIds) {
const v3Comp = v3.components[id];
const pos = baseLayout.componentPositions[id];
components[id] = {
...v3Comp,
size: {
// colSpan을 기반으로 크기 모드 결정
width: pos.colSpan >= 20 ? "fill" : "fixed",
height: "fixed",
fixedWidth: pos.colSpan * (1024 / 24), // 대략적인 픽셀 변환
fixedHeight: pos.rowSpan * (768 / 24),
minWidth: 100,
},
};
}
return {
version: "pop-4.0",
root: rootContainer,
components,
dataFlow: v3.dataFlow,
settings: {
touchTargetMin: v3.settings.touchTargetMin,
mode: v3.settings.mode,
defaultGap: v3.settings.canvasGrid.gap,
defaultPadding: 16,
breakpoints: {
tablet: 768,
mobile: 480,
},
},
metadata: v3.metadata,
};
}
```
### 6.2 하위 호환
- v3 레이아웃은 계속 지원
- 디자이너에서 v3 → v4 업그레이드 버튼 제공
- 새로 생성하는 레이아웃은 v4
---
## 7. 예상 효과
### 7.1 사용자 경험
| 항목 | 기존 (v3) | 새로운 (v4) |
|------|-----------|-------------|
| 설계 개수 | 4개 | 1개 |
| 작업 시간 | 4배 | 1배 |
| 반응형 | 수동 | 자동 |
| 디바이스 대응 | 각각 설정 | mobileScale |
### 7.2 개발자 경험
| 항목 | 기존 (v3) | 새로운 (v4) |
|------|-----------|-------------|
| 렌더링 | CSS Grid | Flexbox |
| 위치 계산 | col/row | 자동 |
| 반응형 로직 | 4모드 분기 | 브레이크포인트 |
| 유지보수 | 복잡 | 단순 |
---
## 8. 일정 (예상)
| Phase | 내용 | 기간 |
|-------|------|------|
| 1 | 데이터 구조 | 1-2일 |
| 2 | 렌더러 | 2-3일 |
| 3 | 디자이너 UI | 3-4일 |
| 4 | 뷰어 통합 | 1-2일 |
| 5 | 백엔드 수정 | 1일 |
| 6 | 테스트/마이그레이션 | 2-3일 |
| **총계** | | **10-15일** |
---
## 9. 리스크 및 대응
### 9.1 기존 레이아웃 호환성
- **리스크**: v3 → v4 자동 변환이 완벽하지 않을 수 있음
- **대응**:
- 마이그레이션 미리보기 기능
- 수동 조정 도구 제공
- v3 유지 옵션
### 9.2 학습 곡선
- **리스크**: 제약조건 개념이 익숙하지 않을 수 있음
- **대응**:
- 프리셋 제공 (예: "화면 전체 채우기", "고정 크기")
- 툴팁/도움말
- 예제 템플릿
### 9.3 성능
- **리스크**: Flexbox 중첩으로 렌더링 성능 저하
- **대응**:
- 컨테이너 중첩 깊이 제한 (최대 3-4)
- React.memo 활용
- 가상화 (리스트 컴포넌트)
---
## 10. 결론
v4.0 제약조건 기반 시스템은 업계 표준(Figma, Flutter, SwiftUI)을 따르며, 사용자의 작업량을 75% 줄이고 자동 반응형을 제공합니다.
구현 후 POP 디자이너는:
- **1개 레이아웃**만 설계
- **모든 화면 크기**에 자동 적응
- **모바일 특화 설정** (mobileScale)으로 세밀한 제어 가능
---
## 11. 추가 설정 (2026-02-03 업데이트)
### 11.1 확장된 전역 설정
```typescript
interface PopGlobalSettingsV4 {
// 기존
touchTargetMin: number; // 48 (normal) / 60 (industrial)
mode: "normal" | "industrial";
defaultGap: number;
defaultPadding: number;
breakpoints: {
tablet: number; // 768
mobile: number; // 480
};
// 신규 추가
environment: "indoor" | "outdoor"; // 야외면 대비 높임
typography: {
body: { min: number; max: number }; // 14-18px
heading: { min: number; max: number }; // 18-28px
caption: { min: number; max: number }; // 12-14px
};
contrast: "normal" | "high"; // outdoor면 자동 high
}
```
### 11.2 컴포넌트 기본값 프리셋
컴포넌트 추가 시 자동 적용되는 안전한 기본값:
```typescript
const COMPONENT_DEFAULTS = {
"pop-button": {
minWidth: 80,
minHeight: 48,
height: "fixed",
fixedHeight: 48,
},
"pop-field": {
minWidth: 120,
minHeight: 40,
height: "fixed",
fixedHeight: 48,
},
"pop-list": {
minHeight: 200,
itemHeight: 48,
},
// ...
};
```
### 11.3 리스트 반응형 컬럼
```typescript
interface PopListConfig {
// 기존
listType: PopListType;
displayColumns?: string[];
// 신규 추가
responsiveColumns?: {
tablet: string[]; // 전체 컬럼
mobile: string[]; // 주요 컬럼만
};
}
```
### 11.4 라벨 배치 자동화
```typescript
interface PopContainer {
// 기존
direction: "horizontal" | "vertical";
// 신규 추가
labelPlacement?: "auto" | "above" | "beside";
// auto: 모바일 세로=위, 태블릿 가로=옆
}
```
---
## 12. 관련 문서
- [v4 핵심 규칙 가이드](./V4_CORE_RULES.md) - **3가지 핵심 규칙 (필독)**
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md)
- [컴포넌트 로드맵](./COMPONENT_ROADMAP.md)
- [크기 프리셋 가이드](./SIZE_PRESETS.md)
- [컴포넌트 상세 스펙](./components-spec.md)
---
## 13. 현재 상태 (2026-02-03)
**구현 대기**: 컴포넌트가 아직 없어서 레이아웃 시스템보다 컴포넌트 개발이 선행되어야 함.
**권장 진행 순서**:
1. 기초 컴포넌트 개발 (PopButton, PopInput 등)
2. 조합 컴포넌트 개발 (PopFormField, PopCard 등)
3. 복합 컴포넌트 개발 (PopDataTable, PopCardList 등)
4. v4 레이아웃 시스템 구현
5. 디자이너 UI 개발
---
*최종 업데이트: 2026-02-03*

View File

@ -1,285 +0,0 @@
# VEXPLOR (WACE 솔루션) 프로젝트 아키텍처
> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조
---
## Quick Reference
### 기술 스택 요약
| 영역 | 기술 |
|------|------|
| Frontend | Next.js 14, TypeScript, shadcn/ui, Tailwind CSS |
| Backend | Node.js + Express (주력), Java Spring (레거시) |
| Database | PostgreSQL (173개 테이블) |
| 핵심 기능 | 노코드 화면 빌더, 멀티테넌시, 워크플로우 |
### 디렉토리 맵
```
ERP-node/
├── frontend/ # Next.js
│ ├── app/ # 라우팅 (main, auth, pop)
│ ├── components/ # UI 컴포넌트 (281개+)
│ └── lib/ # API, 레지스트리 (463개)
├── backend-node/ # Node.js 백엔드
│ └── src/
│ ├── controllers/ # 68개
│ ├── services/ # 78개
│ └── routes/ # 47개
├── db/ # 마이그레이션
└── docs/ # 문서
```
### 핵심 테이블
| 분류 | 테이블 |
|------|--------|
| 화면 | screen_definitions, screen_layouts_v2, screen_layouts_pop |
| 메뉴 | menu_info, authority_master |
| 사용자 | user_info, company_mng |
| 플로우 | flow_definition, flow_step |
---
## 1. 프로젝트 개요
### 1.1 제품 소개
- **WACE 솔루션**: PLM(Product Lifecycle Management) + 노코드 화면 빌더
- **멀티테넌시**: company_code 기반 회사별 데이터 격리
- **마이그레이션**: JSP에서 Next.js로 완전 전환
### 1.2 핵심 기능
1. **Screen Designer**: 드래그앤드롭 화면 구성
2. **워크플로우**: 플로우 기반 업무 자동화
3. **배치 시스템**: 스케줄 기반 작업 자동화
4. **외부 연동**: DB, REST API 통합
5. **리포트**: 동적 보고서 생성
6. **다국어**: i18n 지원
---
## 2. Frontend 구조
### 2.1 라우트 그룹
```
app/
├── (main)/ # 메인 레이아웃
│ ├── admin/ # 관리자 기능
│ │ ├── screenMng/ # 화면 관리
│ │ ├── systemMng/ # 시스템 관리
│ │ ├── userMng/ # 사용자 관리
│ │ └── automaticMng/ # 자동화 관리
│ ├── screens/[screenId]/ # 동적 화면 뷰어
│ └── dashboard/[id]/ # 대시보드 뷰어
├── (auth)/ # 인증
│ └── login/
└── (pop)/ # POP 전용
└── pop/screens/[screenId]/
```
### 2.2 주요 컴포넌트
| 폴더 | 파일 수 | 역할 |
|------|---------|------|
| screen/ | 70+ | 화면 디자이너, 위젯 |
| admin/ | 137 | 테이블, 메뉴, 코드 관리 |
| dataflow/ | 101 | 노드 기반 플로우 에디터 |
| dashboard/ | 32 | 대시보드 빌더 |
| v2/ | 20+ | V2 컴포넌트 시스템 |
| pop/ | 26 | POP 전용 컴포넌트 |
### 2.3 라이브러리 (lib/)
```
lib/
├── api/ # API 클라이언트 (50개+)
│ ├── screen.ts # 화면 API
│ ├── menu.ts # 메뉴 API
│ └── flow.ts # 플로우 API
├── registry/ # 컴포넌트 레지스트리 (463개)
│ ├── DynamicComponentRenderer.tsx
│ └── components/
├── v2-core/ # Zod 기반 타입 시스템
└── utils/ # 유틸리티 (30개+)
```
---
## 3. Backend 구조
### 3.1 디렉토리
```
backend-node/src/
├── controllers/ # 68개 컨트롤러
├── services/ # 78개 서비스
├── routes/ # 47개 라우트
├── middleware/ # 인증, 에러 처리
├── database/ # DB 연결
├── types/ # 26개 타입 정의
└── utils/ # 16개 유틸
```
### 3.2 주요 서비스
| 영역 | 서비스 |
|------|--------|
| 화면 | screenManagementService, layoutService |
| 데이터 | dataService, tableManagementService |
| 플로우 | flowDefinitionService, flowExecutionService |
| 배치 | batchService, batchSchedulerService |
| 외부연동 | externalDbConnectionService, externalCallService |
### 3.3 API 엔드포인트
```
# 화면 관리
GET /api/screen-management/screens
GET /api/screen-management/screen/:id
POST /api/screen-management/screen
PUT /api/screen-management/screen/:id
GET /api/screen-management/layout-v2/:screenId
POST /api/screen-management/layout-v2/:screenId
GET /api/screen-management/layout-pop/:screenId
POST /api/screen-management/layout-pop/:screenId
# 데이터 CRUD
GET /api/data/:tableName
POST /api/data/:tableName
PUT /api/data/:tableName/:id
DELETE /api/data/:tableName/:id
# 인증
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
```
---
## 4. Database 구조
### 4.1 테이블 분류 (173개)
| 분류 | 개수 | 주요 테이블 |
|------|------|-------------|
| 화면/레이아웃 | 15 | screen_definitions, screen_layouts_v2, screen_layouts_pop |
| 메뉴/권한 | 7 | menu_info, authority_master, rel_menu_auth |
| 사용자/회사 | 6 | user_info, company_mng, dept_info |
| 테이블/컬럼 | 8 | table_type_columns, column_labels |
| 다국어 | 4 | multi_lang_key_master, multi_lang_text |
| 플로우/배치 | 12 | flow_definition, flow_step, batch_configs |
| 외부연동 | 4 | external_db_connections, external_call_configs |
| 리포트 | 5 | report_master, report_layout, report_query |
| 대시보드 | 2 | dashboards, dashboard_elements |
| 컴포넌트 | 6 | component_standards, web_type_standards |
| 비즈니스 | 100+ | item_info, sales_order_mng, inventory_stock |
### 4.2 화면 관련 테이블 상세
```sql
-- 화면 정의
screen_definitions: screen_id, screen_name, table_name, company_code
-- 데스크톱 레이아웃 (V2, 현재 사용)
screen_layouts_v2: id, screen_id, components(JSONB), grid_settings
-- POP 레이아웃
screen_layouts_pop: id, screen_id, components(JSONB), grid_settings
-- 화면 그룹
screen_groups: group_id, group_name, company_code
screen_group_screens: id, group_id, screen_id
```
### 4.3 멀티테넌시
```sql
-- 모든 테이블에 company_code 필수
ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20) NOT NULL;
-- 모든 쿼리에 company_code 필터링 필수
SELECT * FROM example_table WHERE company_code = $1;
-- 예외: company_mng (회사 마스터 테이블)
```
---
## 5. 핵심 기능 상세
### 5.1 노코드 Screen Designer
**아키텍처**:
```
screen_definitions (화면 정의)
screen_layouts_v2 (레이아웃, JSONB)
DynamicComponentRenderer (동적 렌더링)
registry/components (컴포넌트 라이브러리)
```
**컴포넌트 레지스트리**:
- V2 컴포넌트: Input, Select, Table, Button 등
- 위젯: FlowWidget, CategoryWidget 등
- 레이아웃: SplitPanel, TabPanel 등
### 5.2 워크플로우 (Flow)
**테이블 구조**:
```
flow_definition (플로우 정의)
flow_step (단계 정의)
flow_step_connection (단계 연결)
flow_data_status (데이터 상태 추적)
```
### 5.3 POP 시스템
**별도 레이아웃 테이블**:
- `screen_layouts_pop`: POP 전용 레이아웃
- 모바일/태블릿 반응형 지원
- 제조 현장 최적화 컴포넌트
---
## 6. 개발 환경
### 6.1 로컬 개발
```bash
# Docker 실행
docker-compose -f docker-compose.win.yml up -d
# 프론트엔드: http://localhost:9771
# 백엔드: http://localhost:8080
```
### 6.2 데이터베이스
```
Host: 39.117.244.52
Port: 11132
Database: plm
Username: postgres
```
---
## 7. 관련 문서
- [POPUPDATE.md](../POPUPDATE.md): POP 개발 기록
- [docs/pop/components-spec.md](pop/components-spec.md): POP 컴포넌트 설계
- [.cursorrules](../.cursorrules): 개발 가이드라인
---
*최종 업데이트: 2026-01-29*

View File

@ -1,153 +0,0 @@
# POP 반응형 디자인 가이드
## 쉬운 요약
### 핵심 원칙: 3가지만 기억하세요
```
1. 누르는 것 → 크기 고정 (최소 48px)
2. 읽는 것 → 범위 안에서 자동 조절
3. 담는 것 → 화면에 맞춰 늘어남
```
---
## 1. 터치 요소 (고정 크기)
손가락 크기는 화면이 커져도 변하지 않습니다.
| 요소 | 일반 | 산업현장(장갑) |
|------|-----|--------------|
| 버튼 | 48px | 60px |
| 아이콘 (누르는 용) | 48px | 60px |
| 체크박스 | 24px (터치영역 48px) | 24px (터치영역 60px) |
| 리스트 한 줄 높이 | 48px | 56px |
| 입력창 높이 | 40px | 48px |
---
## 2. 텍스트 (범위 조절)
화면 크기에 따라 자동으로 커지거나 작아집니다.
| 용도 | 최소 | 최대 |
|------|-----|-----|
| 본문 | 14px | 18px |
| 제목 | 18px | 28px |
| 설명 | 12px | 14px |
**CSS 예시**:
```css
font-size: clamp(14px, 1.5vw, 18px);
```
---
## 3. 레이아웃 (비율 기반)
컨테이너는 화면에 맞춰 늘어납니다.
| 요소 | 방식 | 예시 |
|------|-----|-----|
| 컨테이너 | 100% | 화면 전체 채움 |
| 카드 2열 | 48% + 48% | 화면 반씩 |
| 입력창 너비 | fill | 부모 채움 |
| 여백 | 8/16/24px | 화면 크기별 |
---
## 4. 환경별 설정
### 일반 (실내)
- 터치: 48px
- 대비: 4.5:1
- 폰트: 14-18px
### 산업현장 (야외/장갑)
- 터치: 60px (+25%)
- 대비: 7:1 이상
- 폰트: 18-22px (+25%)
---
## 5. 리스트/테이블 반응형
화면이 좁아지면 컬럼을 줄입니다.
```
태블릿 (넓음) 모바일 (좁음)
┌──────┬──────┬──────┬──────┐ ┌──────┬──────┐
│품번 │품명 │수량 │상태 │ │품번 │수량 │
├──────┼──────┼──────┼──────┤ ├──────┼──────┤
│A001 │나사 │100 │완료 │ │A001 │100 │
└──────┴──────┴──────┴──────┘ └──────┴──────┘
↳ 터치하면 상세보기
```
---
## 6. 폼 라벨 배치
| 화면 | 라벨 위치 | 이유 |
|------|----------|-----|
| 모바일 세로 | 위 | 입력창 너비 확보 |
| 태블릿 가로 | 옆 | 공간 여유 |
```
모바일 태블릿
┌─────────────────┐ ┌─────────────────────────┐
│ 이름 │ │ 이름: [입력____________] │
│ [입력__________]│ └─────────────────────────┘
└─────────────────┘
```
---
## 7. 그림으로 보는 반응형
```
8인치 태블릿 12인치 태블릿
┌─────────────────┐ ┌───────────────────────┐
│ [버튼 48px] │ │ [버튼 48px] │ ← 버튼 크기 동일!
│ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────────┐ │
│ │ 입력창 │ │ │ │ 입력창 │ │ ← 너비만 늘어남
│ └─────────────┘ │ │ └─────────────────┘ │
│ │ │ │
│ 글자 14px │ │ 글자 18px │ ← 글자만 커짐
└─────────────────┘ └───────────────────────┘
```
---
## 8. 색상 대비 (야외용)
| 환경 | 최소 대비 | 권장 |
|------|----------|-----|
| 실내 | 4.5:1 | 7:1 |
| 야외 | 7:1 | 10:1+ |
**좋은 조합**:
- 흰 배경 + 검정 글자
- 검정 배경 + 흰 글자
- 노랑 경고 + 검정 글자
**피해야 할 조합**:
- 연한 회색 + 밝은 회색
- 빨강 + 녹색 (색맹 고려)
---
## 체크리스트
### 컴포넌트 만들 때 확인
- [ ] 버튼/터치 요소 최소 48px인가?
- [ ] 폰트에 clamp() 적용했나?
- [ ] 색상 대비 4.5:1 이상인가?
- [ ] 모바일에서 라벨이 위에 있나?
- [ ] 리스트가 좁은 화면에서 컬럼 줄어드나?
---
*최종 업데이트: 2026-02-03*

View File

@ -1,205 +0,0 @@
# POP 크기 프리셋 가이드
## 컴포넌트별 기본 크기
컴포넌트를 만들면 자동으로 적용되는 크기입니다.
---
## 버튼 (PopButton)
| 사이즈 | 높이 | 최소 너비 | 폰트 | 용도 |
|-------|------|----------|------|-----|
| sm | 32px | 60px | 12px | 보조 버튼 |
| md | 40px | 80px | 14px | 일반 버튼 |
| **lg** | **48px** | **100px** | **16px** | **POP 기본** |
| xl | 56px | 120px | 18px | 주요 액션 |
| industrial | 60px | 140px | 20px | 장갑 착용 |
```typescript
// POP에서는 lg가 기본
<PopButton size="lg">확인</PopButton>
// 산업현장
<PopButton size="industrial">작업 완료</PopButton>
```
---
## 입력창 (PopInput)
| 사이즈 | 높이 | 폰트 | 용도 |
|-------|------|------|-----|
| md | 40px | 14px | 일반 |
| **lg** | **48px** | **16px** | **POP 기본** |
| xl | 56px | 18px | 강조 입력 |
```typescript
// 입력창 너비는 항상 부모 채움 (fill)
<PopInput size="lg" />
```
---
## 리스트 행 (PopListItem)
| 사이즈 | 높이 | 폰트 | 용도 |
|-------|------|------|-----|
| compact | 40px | 14px | 많은 데이터 |
| **normal** | **48px** | **16px** | **POP 기본** |
| spacious | 56px | 18px | 여유로운 |
| industrial | 64px | 20px | 장갑 착용 |
```typescript
<PopListItem size="normal">
<span>작업지시 #1234</span>
</PopListItem>
```
---
## 아이콘 (PopIcon)
| 사이즈 | 크기 | 터치 영역 | 용도 |
|-------|-----|----------|-----|
| sm | 16px | 32px | 뱃지 안 |
| md | 20px | 40px | 텍스트 옆 |
| **lg** | **24px** | **48px** | **POP 기본** |
| xl | 32px | 56px | 강조 |
```typescript
// 아이콘만 있는 버튼
<PopButton icon="check" size="lg" />
// 텍스트 + 아이콘
<PopButton icon="save" size="lg">저장</PopButton>
```
---
## 카드 (PopCard)
| 요소 | 크기 |
|------|-----|
| 패딩 | 16px |
| 제목 폰트 | 18px (heading) |
| 본문 폰트 | 16px (body) |
| 모서리 | 8px |
| 최소 높이 | 100px |
```typescript
<PopCard>
<PopCard.Header>작업지시</PopCard.Header>
<PopCard.Body>내용</PopCard.Body>
</PopCard>
```
---
## 숫자패드 (PopNumberPad)
| 요소 | 크기 |
|------|-----|
| 버튼 크기 | 60px x 60px |
| 버튼 간격 | 8px |
| 전체 너비 | 240px |
| 폰트 | 24px |
```
┌─────────────────────┐
│ [ 123 ] │ ← 디스플레이 48px
├─────┬─────┬─────────┤
│ 7 │ 8 │ 9 │ ← │ ← 각 버튼 60x60
├─────┼─────┼─────────┤
│ 4 │ 5 │ 6 │ C │
├─────┼─────┼─────────┤
│ 1 │ 2 │ 3 │ │
├─────┼─────┼─────│ OK│
│ 0 │ . │ +- │ │
└─────┴─────┴─────────┘
```
---
## 상태 표시 (PopStatusBox)
| 사이즈 | 너비 | 높이 | 아이콘 | 용도 |
|-------|-----|------|-------|-----|
| sm | 80px | 60px | 24px | 여러 개 나열 |
| **md** | **120px** | **80px** | **32px** | **POP 기본** |
| lg | 160px | 100px | 40px | 강조 |
```typescript
<PopStatusBox
label="설비 상태"
value="가동중"
status="success"
size="md"
/>
```
---
## KPI 게이지 (PopKpiGauge)
| 사이즈 | 너비 | 높이 | 용도 |
|-------|-----|------|-----|
| sm | 120px | 120px | 여러 개 나열 |
| **md** | **180px** | **180px** | **POP 기본** |
| lg | 240px | 240px | 강조 |
---
## 간격 (Gap/Padding)
| 이름 | 값 | 용도 |
|------|---|-----|
| xs | 4px | 아이콘-텍스트 |
| sm | 8px | 요소 내부 |
| **md** | **16px** | **컴포넌트 간** |
| lg | 24px | 섹션 간 |
| xl | 32px | 영역 구분 |
---
## 반응형 조절
화면 크기에 따라 자동 조절되는 값들:
| 요소 | 8인치 태블릿 | 12인치 태블릿 |
|------|------------|--------------|
| 본문 폰트 | 14px | 18px |
| 제목 폰트 | 18px | 28px |
| 컨테이너 패딩 | 12px | 24px |
| 카드 간격 | 12px | 16px |
**고정되는 값들 (변하지 않음)**:
- 버튼 높이: 48px
- 입력창 높이: 48px
- 리스트 행 높이: 48px
- 터치 최소 영역: 48px
---
## 적용 예시
```typescript
// 컴포넌트 내부에서 자동 적용
function PopButton({ size = "lg", ...props }) {
const sizeStyles = {
sm: { height: 32, minWidth: 60, fontSize: 12 },
md: { height: 40, minWidth: 80, fontSize: 14 },
lg: { height: 48, minWidth: 100, fontSize: 16 }, // POP 기본
xl: { height: 56, minWidth: 120, fontSize: 18 },
industrial: { height: 60, minWidth: 140, fontSize: 20 },
};
return (
<button style={sizeStyles[size]} {...props} />
);
}
```
---
*최종 업데이트: 2026-02-03*

View File

@ -1,183 +0,0 @@
# POP 저장/조회 규칙
**AI가 POP 관련 저장/조회 요청을 처리할 때 참고하는 규칙**
---
## 1. 저장 요청 처리
사용자가 저장/기록/정리/업데이트 등을 요청하면:
### 1.1 파일 관련
| 요청 유형 | 처리 방법 |
|----------|----------|
| 새 파일 추가됨 | `FILES.md`에 파일 정보 추가 |
| 구조 변경됨 | `ARCHITECTURE.md` 업데이트 |
| 작업 완료 | `CHANGELOG.md`에 기록 |
| 중요 결정 | `decisions/` 폴더에 ADR 추가 |
### 1.2 rangraph 동기화
| 요청 유형 | rangraph 처리 |
|----------|--------------|
| 중요 결정 | `save_decision` 호출 |
| 교훈/규칙 | `save_lesson` 호출 |
| 새 키워드 | `add_keyword` 호출 |
| 작업 흐름 | `workflow_submit` 호출 |
### 1.3 예시
```
사용자: "오늘 작업 정리해줘"
AI:
1. CHANGELOG.md에 오늘 날짜로 Added/Changed/Fixed 기록
2. rangraph save_decision 또는 save_lesson 호출
3. 필요시 FILES.md, ARCHITECTURE.md 업데이트
```
---
## 2. 조회 요청 처리
사용자가 조회/검색/찾기 등을 요청하면:
### 2.1 popdocs 우선순위
| 필요한 정보 | 참조 문서 | 토큰 비용 |
|------------|----------|----------|
| 빠른 참조 | `README.md` | 낮음 (151줄) |
| 전체 구조 | `ARCHITECTURE.md` | 중간 (530줄) |
| 파일 위치 | `FILES.md` | 높음 (900줄) |
| 기술 스펙 | `SPEC.md` | 중간 |
| 컴포넌트 | `components-spec.md` | 중간 |
| 진행 상황 | `PLAN.md` | 낮음 |
| 변경 이력 | `CHANGELOG.md` | 낮음 |
### 2.2 조회 전략
```
1단계: rangraph search_memory로 빠르게 확인
2단계: 관련 popdocs 문서 참조
3단계: 필요시 실제 소스 파일 Read
```
### 2.3 예시
```
사용자: "v4 렌더러 어디있어?"
AI:
1. rangraph search_memory "v4 렌더러" (캐시된 정보)
2. FILES.md에서 확인: PopFlexRenderer.tsx (498줄)
3. 필요시 실제 파일 Read
```
---
## 3. 토큰 효율화 전략
### 3.1 문서 읽기 우선순위
```
최소 토큰: README.md (빠른 참조)
중간 토큰: ARCHITECTURE.md (구조 이해)
필요시: FILES.md (파일 상세)
마지막: 실제 소스 파일
```
### 3.2 효율적 패턴
| 상황 | 효율적 방법 | 비효율적 방법 |
|------|------------|--------------|
| 파일 위치 찾기 | FILES.md 검색 | 전체 폴더 탐색 |
| 구조 이해 | ARCHITECTURE.md | 모든 파일 읽기 |
| 빠른 확인 | rangraph 검색 | 문서 전체 읽기 |
| 특정 코드 | FILES.md → Read | Glob + 전체 Read |
### 3.3 캐싱 활용
rangraph에 저장된 정보:
- POP 문서 구조 (save_decision)
- 저장/조회 규칙 (save_lesson)
- 핵심 파일 위치 (save_lesson)
- 키워드: popdocs, ARCHITECTURE.md, FILES.md
---
## 4. 문서별 용도
| 문서 | 읽을 때 | 수정할 때 |
|------|--------|----------|
| README.md | 빠른 참조 필요 시 | 문서 구조 변경 시 |
| ARCHITECTURE.md | 구조 이해 필요 시 | 폴더/모듈 변경 시 |
| FILES.md | 파일 찾을 때 | 파일 추가/삭제 시 |
| SPEC.md | 기술 스펙 확인 시 | 스펙 변경 시 |
| PLAN.md | 진행 상황 확인 시 | 계획 변경 시 |
| CHANGELOG.md | 이력 확인 시 | 작업 완료 시 |
---
## 5. 실제 처리 예시
### 5.1 저장 요청
```
사용자: "PopButton 컴포넌트 만들었어"
AI 처리:
1. FILES.md에 파일 정보 추가
2. CHANGELOG.md에 Added 기록
3. components-spec.md에 스펙 추가 (필요시)
4. rangraph save_decision 호출
```
### 5.2 조회 요청
```
사용자: "v4 캔버스 어떻게 동작해?"
AI 처리:
1. rangraph search_memory "v4 캔버스"
2. FILES.md에서 PopCanvasV4.tsx 확인
3. ARCHITECTURE.md에서 캔버스 섹션 확인
4. 필요시 PopCanvasV4.tsx 직접 Read
```
### 5.3 업데이트 요청
```
사용자: "PLAN.md 업데이트해줘"
AI 처리:
1. 현재 PLAN.md 읽기
2. 완료된 항목 체크, 새 항목 추가
3. rangraph에 진행 상황 저장 (필요시)
```
---
## 6. 키워드 체계
rangraph 키워드 카테고리:
| 카테고리 | 키워드 예시 |
|----------|-----------|
| pop | popdocs, ARCHITECTURE.md, FILES.md |
| v4 | PopFlexRenderer, PopCanvasV4, 제약조건 |
| designer | PopDesigner, PopPanel |
---
## 7. 이 문서의 용도
- AI가 POP 관련 요청을 받으면 이 규칙을 참고
- 저장 시: popdocs 문서 + rangraph 동기화
- 조회 시: 토큰 효율적인 순서로 확인
- 사용자가 규칙 변경을 요청하면 이 문서 수정
---
*최종 업데이트: 2026-02-04*

View File

@ -1,240 +0,0 @@
# POP v4 핵심 규칙 가이드
## 개요
v4에서는 **"위치"를 설정하는 게 아니라 "규칙"을 설정**합니다.
```
v3 (기존): 4개 모드 각각 컴포넌트 위치 설정 → 4배 작업량
v4 (신규): 3가지 규칙만 설정 → 모든 화면 자동 적응
```
---
## 핵심 규칙 3가지
### 1. 크기 규칙 (Size Rules)
각 컴포넌트의 **너비**와 **높이**를 어떻게 결정할지 정합니다.
| 모드 | 설명 | 예시 |
|------|------|------|
| **fixed** | 고정 px | 버튼 높이 48px |
| **fill** | 부모 공간 채움 | 입력창 너비 = 화면 너비 |
| **hug** | 내용에 맞춤 | 라벨 너비 = 텍스트 길이 |
```typescript
// 예시: 버튼
{
width: "fill", // 화면 너비에 맞춤
height: "fixed", // 고정
fixedHeight: 48 // 48px
}
// 예시: 라벨
{
width: "hug", // 텍스트 길이만큼
height: "hug" // 텍스트 높이만큼
}
```
#### 크기 모드 시각화
```
fixed (고정):
├────48px────┤
┌────────────┐
│ 버튼 │ ← 화면 커져도 48px 유지
└────────────┘
fill (채움):
├─────────────────────────────────┤
┌─────────────────────────────────┐
│ 입력창 │ ← 화면 크기에 맞춤
└─────────────────────────────────┘
hug (맞춤):
├──────┤
┌──────┐
│라벨 │ ← 내용 길이만큼만
└──────┘
```
---
### 2. 배치 규칙 (Layout Rules)
컴포넌트들을 **어떻게 나열할지** 정합니다.
#### 스택 방향
```
가로 스택 (horizontal): 세로 스택 (vertical):
┌─────┬─────┬─────┐ ┌─────────────┐
│ A │ B │ C │ │ A │
└─────┴─────┴─────┘ ├─────────────┤
│ B │
├─────────────┤
│ C │
└─────────────┘
```
#### 설정 항목
| 항목 | 설명 | 옵션 |
|------|------|------|
| **direction** | 스택 방향 | horizontal / vertical |
| **wrap** | 줄바꿈 허용 | true / false |
| **gap** | 요소 간 간격 | 8 / 16 / 24 px |
| **alignItems** | 교차축 정렬 | start / center / end / stretch |
| **justifyContent** | 주축 정렬 | start / center / end / space-between |
```typescript
// 예시: 버튼 그룹 (가로 배치)
{
direction: "horizontal",
wrap: true, // 공간 부족하면 줄바꿈
gap: 16, // 버튼 간격 16px
alignItems: "center" // 세로 중앙 정렬
}
```
---
### 3. 반응형 규칙 (Responsive Rules)
**화면이 좁아지면** 어떻게 바꿀지 정합니다.
```typescript
// 예시: 768px 이하면 가로→세로 전환
{
direction: "horizontal",
responsive: [
{
breakpoint: 768, // 768px 이하일 때
direction: "vertical" // 세로로 바꿈
}
]
}
```
#### 시각화
```
768px 이상 (태블릿): 768px 이하 (모바일):
┌─────┬─────┬─────┐ ┌─────────────┐
│ A │ B │ C │ → │ A │
└─────┴─────┴─────┘ │ B │
│ C │
└─────────────┘
```
---
## 실제 예시: 작업지시 화면
### 규칙 설정
```typescript
{
root: {
type: "stack",
direction: "vertical",
gap: 16,
padding: { top: 16, right: 16, bottom: 16, left: 16 },
children: ["header", "form", "buttons"]
},
containers: {
"header": {
type: "stack",
direction: "horizontal",
alignItems: "center",
children: ["title", "status"]
},
"form": {
type: "stack",
direction: "vertical",
gap: 12,
children: ["field1", "field2", "field3"]
},
"buttons": {
type: "stack",
direction: "horizontal",
gap: 12,
responsive: [
{ breakpoint: 480, direction: "vertical" }
],
children: ["cancelBtn", "submitBtn"]
}
},
components: {
"title": { width: "hug", height: "hug" },
"status": { width: "hug", height: "hug" },
"field1": { width: "fill", height: "fixed", fixedHeight: 48 },
"field2": { width: "fill", height: "fixed", fixedHeight: 48 },
"field3": { width: "fill", height: "fixed", fixedHeight: 48 },
"cancelBtn": { width: "fill", height: "fixed", fixedHeight: 48 },
"submitBtn": { width: "fill", height: "fixed", fixedHeight: 48 }
}
}
```
### 결과
```
태블릿 가로 (1024px) 모바일 세로 (375px)
┌──────────────────────────┐ ┌─────────────────┐
│ 작업지시 #1234 [진행중]│ │작업지시 [진행]│
├──────────────────────────┤ ├─────────────────┤
│ [품번____________] │ │[품번_________] │
│ [품명____________] │ │[품명_________] │
│ [수량____________] │ │[수량_________] │
├──────────────────────────┤ ├─────────────────┤
│ [취소] [작업완료] │ │[취소] │
└──────────────────────────┘ │[작업완료] │
└─────────────────┘
```
---
## v3 vs v4 비교
| 항목 | v3 (기존) | v4 (신규) |
|------|----------|----------|
| **설계 방식** | 4개 모드 각각 위치 설정 | 3가지 규칙 설정 |
| **작업량** | 4배 | 1배 |
| **데이터** | col, row, colSpan, rowSpan | width, height, direction, gap |
| **반응형** | 수동 (모드별 설정) | 자동 (브레이크포인트) |
| **유지보수** | 4곳 수정 | 1곳 수정 |
---
## 규칙 설계 체크리스트
### 크기 규칙
- [ ] 터치 요소(버튼, 입력창) 높이: fixed 48px
- [ ] 너비가 화면에 맞아야 하는 요소: fill
- [ ] 내용 길이에 맞아야 하는 요소: hug
### 배치 규칙
- [ ] 컴포넌트 나열 방향 결정 (가로/세로)
- [ ] 간격 설정 (8/16/24px)
- [ ] 정렬 방식 결정 (start/center/stretch)
### 반응형 규칙
- [ ] 768px 이하에서 가로→세로 전환 필요한 곳
- [ ] 480px 이하에서 추가 조정 필요한 곳
---
## 관련 문서
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md) - 크기 기준
- [크기 프리셋](./SIZE_PRESETS.md) - 컴포넌트별 기본값
- [v4 구현 계획](./POP_V4_CONSTRAINT_SYSTEM_PLAN.md) - 전체 계획
---
*최종 업데이트: 2026-02-04*

View File

@ -1,391 +0,0 @@
# POP v4 통합 설계 모드 스펙
**작성일: 2026-02-04**
**최종 업데이트: 2026-02-04**
**상태: Phase 3 완료 (visibility + 줄바꿈 컴포넌트)**
---
## 개요
v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드별 오버라이드** 기능을 지원하는 통합 설계 방식.
---
## 핵심 개념
### 기존 방식 (v3)
```
4개 모드 각각 설계 필요
태블릿 가로: 버튼 → col 1, row 1
태블릿 세로: 버튼 → col 1, row 5 (따로 설정)
모바일 가로: 버튼 → col 1, row 1 (따로 설정)
모바일 세로: 버튼 → col 1, row 10 (따로 설정)
```
### 새로운 방식 (v4 통합)
```
기본: 태블릿 가로에서 규칙 설정
버튼 → width: fill, height: 48px
결과: 모든 모드에 자동 적용
태블릿 가로: 버튼 너비 1024px, 높이 48px
태블릿 세로: 버튼 너비 768px, 높이 48px
모바일 가로: 버튼 너비 667px, 높이 48px
모바일 세로: 버튼 너비 375px, 높이 48px
예외: 특정 모드에서 편집하면 오버라이드
모바일 세로: 버튼 높이 36px (수동 설정)
```
---
## 현재 UI (Phase 1.5 완료)
```
┌─────────────────────────────────────────────────────────────────┐
│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │
├─────────────────────────────────────────────────────────────────┤
│ 편집 중: v4 (자동 반응형) │
│ 규칙 기반 레이아웃 │
├────────────┬────────────────────────────────────┬───────────────┤
│ 컴포넌트 │ 미리보기: [모바일↕][모바일↔] │ 속성 │
│ │ [태블릿↕][태블릿↔(기본)] │ │
│ 필드 │ 너비: [====●====] 1024 x 768 │ │
│ 버튼 │ │ │
│ 리스트 │ ┌──────────────────────────────┐ │ 탭: 크기 │
│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ 설정 │
│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ 표시 ⬅ 🆕│
│ 숫자패드 │ │ │ │ 데이터 │
│ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │
│ 줄바꿈 🆕 │ │ (스크롤 가능) │ │ │
│ │ └──────────────────────────────┘ │ │
│ │ 태블릿 가로 (1024x768) │ │
└────────────┴────────────────────────────────────┴───────────────┘
```
### 레이아웃 방식 (업계 표준)
| 서비스 | 방식 |
|--------|------|
| Figma | Auto Layout (Flexbox) |
| Webflow | Flexbox + CSS Grid |
| FlutterFlow | Row/Column/Stack |
| Adalo 2.0 | Flexbox + Constraints |
| **POP v4** | **Flexbox (horizontal + wrap)** |
### 특수 컴포넌트 사용법
#### Spacer (빈 공간)
```
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
```
#### 줄바꿈 (Break) 🆕 Phase 3
```
[필드A] [필드B] [줄바꿈] [필드C] → 필드C가 새 줄로 이동
태블릿: [필드A] [필드B] [필드C] ← 줄바꿈 숨김 (한 줄)
모바일: [필드A] [필드B] ← 줄바꿈 표시 (두 줄)
[필드C]
```
### 프리셋 버튼 (4개 모드)
| 버튼 | 해상도 | 설명 |
|------|--------|------|
| 모바일↕ | 375 x 667 | 모바일 세로 |
| 모바일↔ | 667 x 375 | 모바일 가로 |
| 태블릿↕ | 768 x 1024 | 태블릿 세로 |
| 태블릿↔* | 1024 x 768 | 태블릿 가로 (기본) |
### 레이아웃 판별 로직
```typescript
// 새 화면 또는 빈 레이아웃 → v4로 시작
const hasValidLayout = loadedLayout && loadedLayout.version;
const hasComponents = loadedLayout?.components &&
Object.keys(loadedLayout.components).length > 0;
if (hasValidLayout && hasComponents) {
// v4면 v4, 그 외 v3로 변환
} else {
// v4로 새로 시작
}
```
---
## 오버라이드 동작 (Phase 2 예정)
### 자동 감지 방식
1. 사용자가 **태블릿 가로(기본)**에서 편집 → 기본 규칙 저장
2. 사용자가 **다른 모드**에서 편집 → 해당 모드 오버라이드 자동 저장
3. 편집 안 한 모드 → 기본 규칙에서 자동 계산
### 편집 상태 표시
| 상태 | 버튼 색상 | 설명 |
|------|----------|------|
| 기본 (태블릿 가로) | 강조 + "(기본)" | 항상 표시 |
| 자동 | 기본 색상 | 편집 안 함 |
| 편집됨 | 강조 색상 | 오버라이드 있음 |
### 되돌리기
- 편집된 모드에만 "자동으로 되돌리기" 버튼 활성화
- 클릭 시 오버라이드 삭제 → 기본 규칙 복원
---
## 데이터 구조
### PopLayoutDataV4 (Phase 2에서 수정 예정)
```typescript
interface PopLayoutDataV4 {
version: "pop-4.0";
root: PopContainerV4;
components: Record<string, PopComponentDefinitionV4>;
dataFlow: PopDataFlow;
settings: PopGlobalSettingsV4;
// 모드별 오버라이드 (Phase 2에서 추가)
overrides?: {
mobile_portrait?: ModeOverride;
mobile_landscape?: ModeOverride;
tablet_portrait?: ModeOverride;
// tablet_landscape는 기본이므로 오버라이드 없음
};
}
interface ModeOverride {
components?: Record<string, Partial<PopComponentDefinitionV4>>;
containers?: Record<string, Partial<PopContainerV4>>;
}
```
### PopComponentDefinitionV4 (Phase 3에서 수정 예정)
```typescript
interface PopComponentDefinitionV4 {
type: PopComponentType;
label?: string;
size: PopSizeConstraintV4;
alignSelf?: "start" | "center" | "end" | "stretch";
// 모드별 표시 설정 (Phase 3에서 추가)
visibility?: {
mobile_portrait?: boolean; // 기본 true
mobile_landscape?: boolean; // 기본 true
tablet_portrait?: boolean; // 기본 true
tablet_landscape?: boolean; // 기본 true
};
}
```
---
## 컴포넌트 표시/숨김 (Phase 3 예정)
### 업계 표준 (Webflow, Figma)
- 삭제가 아닌 **숨김** 처리
- 특정 모드에서만 `display: none`
- 언제든 다시 표시 가능
### UI (속성 패널)
```
┌─────────────────────────┐
│ 버튼 │
├─────────────────────────┤
│ 표시 설정 │
│ [x] 모바일 세로 │
│ [x] 모바일 가로 │
│ [x] 태블릿 세로 │
│ [x] 태블릿 가로 │
│ │
│ (체크 해제 = 숨김) │
└─────────────────────────┘
```
---
## 구현 상태
### Phase 1: 기본 구조 (완료)
- [x] v3/v4 탭 제거 (자동 판별)
- [x] 새 화면 → v4로 시작
- [x] 기존 v3 화면 → v3로 로드 (하위 호환)
- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔)
- [x] 기본 프리셋 표시 (태블릿 가로 + "(기본)")
- [x] 슬라이더 유지 (320~1200px, 비율 유지)
- [x] ComponentPaletteV4 생성
### Phase 1.5: Flexbox 가로 배치 (완료)
- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z, 데스크탑 모드와 동일 방식)
- [x] 드래그 리사이즈 핸들
- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`)
- [x] 컴포넌트 타입별 기본 크기 설정
- [x] Spacer 컴포넌트 (`pop-spacer`)
- [x] 컴포넌트 순서 변경 (드래그 앤 드롭)
- [x] 디바이스 스크린 무한 스크롤
### Phase 1.6: 비율 스케일링 시스템 (완료)
- [x] 기준 너비 1024px (10인치 태블릿 가로)
- [x] 최대 너비 1366px (12인치 태블릿)
- [x] 뷰포트 감지 및 resize 이벤트 리스너
- [x] 컴포넌트 크기 스케일 적용 (fixedWidth/Height)
- [x] 컨테이너 스케일 적용 (gap, padding)
- [x] 디자인 모드 분리 (scale=1)
- [x] DndProvider 에러 수정
### Phase 2: 오버라이드 기능 (다음)
- [ ] ModeOverride 데이터 구조 추가
- [ ] 편집 감지 → 자동 오버라이드 저장
- [ ] 편집 상태 표시 (버튼 색상)
- [ ] "자동으로 되돌리기" 버튼
### Phase 3: 컴포넌트 표시/숨김
- [ ] visibility 속성 추가
- [ ] 속성 패널 체크박스 UI
- [ ] 렌더러에서 visibility 처리
### Phase 4: 순서 오버라이드
- [ ] 모드별 children 순서 오버라이드
- [ ] 드래그로 순서 변경 UI
---
## 관련 파일
| 파일 | 역할 | 상태 |
|------|------|------|
| `PopDesigner.tsx` | v3/v4 통합 디자이너 | 완료 |
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) | 완료 |
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 + 비율 스케일링 | 완료 |
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 | 완료 |
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 | 완료 |
| `pop-layout.ts` | v3/v4 타입 정의 | 완료, Phase 2-3에서 수정 예정 |
| `page.tsx` (뷰어) | v4 뷰어 + viewportWidth 감지 | 완료 |
---
## 비율 스케일링 시스템
### 업계 표준
Rockwell Automation HMI의 "Scale with Fixed Aspect Ratio" 방식 적용
### 원리
10인치(1024px) 기준으로 디자인 → 8~12인치에서 배치 유지, 크기만 비례 조정
### 계산
```
scale = viewportWidth / 1024
scaledWidth = originalWidth * scale
scaledHeight = originalHeight * scale
scaledGap = originalGap * scale
scaledPadding = originalPadding * scale
```
### 화면별 결과
| 화면 | scale | 200px 컴포넌트 | 8px gap |
|------|-------|----------------|---------|
| 8인치 (800px) | 0.78 | 156px | 6px |
| 10인치 (1024px) | 1.00 | 200px | 8px |
| 12인치 (1366px) | 1.33 | 266px | 11px |
| 14인치+ | 1.33 (max) | 266px + 여백 | 11px |
### 적용 위치
| 파일 | 함수/변수 | 역할 |
|------|----------|------|
| `PopFlexRenderer.tsx` | `BASE_VIEWPORT_WIDTH` | 기준 너비 상수 (1024) |
| `PopFlexRenderer.tsx` | `calculateSizeStyle(size, settings, scale)` | 크기 스케일 적용 |
| `PopFlexRenderer.tsx` | `ContainerRenderer.containerStyle` | gap, padding 스케일 적용 |
| `page.tsx` | `viewportWidth` state | 뷰포트 너비 감지 |
| `page.tsx` | `Math.min(window.innerWidth, 1366)` | 최대 너비 제한 |
---
## Phase 3: Visibility + 줄바꿈 컴포넌트 (완료) ✅
### 개요
모드별 컴포넌트 표시/숨김 제어 및 강제 줄바꿈 기능 추가.
### 추가 타입
#### visibility 속성
```typescript
interface PopComponentDefinitionV4 {
// 기존 속성...
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
```
#### pop-break 컴포넌트
```typescript
type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕 줄바꿈
```
### 사용 예시
#### 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
visibility: {
tablet_landscape: false, // 태블릿: 숨김
mobile_portrait: true, // 모바일: 표시
},
}
```
#### 모드별 줄바꿈
```
레이아웃: [A] [B] [줄바꿈] [C] [D]
줄바꿈 visibility: { tablet_landscape: false, mobile_portrait: true }
결과:
태블릿: [A] [B] [C] [D] (한 줄)
모바일: [A] [B] (두 줄)
[C] [D]
```
### 속성 패널 "표시" 탭
```
┌─────────────────────┐
│ 탭: 크기 설정 표시 📍│
├─────────────────────┤
│ 모드별 표시 설정 │
│ ☑ 태블릿 가로 │
│ ☑ 태블릿 세로 │
│ ☐ 모바일 가로 (숨김)│
│ ☑ 모바일 세로 │
└─────────────────────┘
```
### 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
---
*이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.*
*최종 업데이트: 2026-02-04 (Phase 3 완료)*

View File

@ -1,461 +0,0 @@
# POP 컴포넌트 상세 설계서
> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조
---
## Quick Reference
### 총 컴포넌트 수: 15개 (🆕 줄바꿈 추가)
| 분류 | 개수 | 컴포넌트 |
|------|------|----------|
| 레이아웃 | 4 | container, tab-panel, **spacer**, **break 🆕** |
| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator |
| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button |
| 특화 기능 | 3 | timer, alarm-list, process-flow |
### 개발 우선순위
1단계: number-pad, status-indicator, kpi-gauge, action-button
2단계: data-table, card-list, barcode-scanner, timer
3단계: container, tab-panel, form-field, alarm-list, process-flow
### POP UI 필수 원칙
- 버튼 최소 크기: 48px (1.5cm)
- 고대비 테마 지원
- 단순 탭 조작 (스와이프 최소화)
- 알람에만 원색 사용
- 숫자 우측 정렬
---
## 컴포넌트 목록
| # | 컴포넌트 | 역할 |
|---|----------|------|
| 1 | pop-container | 레이아웃 뼈대 |
| 2 | pop-tab-panel | 정보 분류 |
| 3 | **pop-spacer** | **빈 공간 (정렬용)** |
| 4 | **pop-break 🆕** | **강제 줄바꿈 (Flexbox)** |
| 5 | pop-data-table | 대량 데이터 |
| 6 | pop-card-list | 시각적 목록 |
| 7 | pop-kpi-gauge | 목표 달성률 |
| 8 | pop-status-indicator | 상태 표시 |
| 9 | pop-number-pad | 수량 입력 |
| 10 | pop-barcode-scanner | 스캔 입력 |
| 11 | pop-form-field | 범용 입력 |
| 12 | pop-action-button | 작업 실행 |
| 13 | pop-timer | 시간 측정 |
| 14 | pop-alarm-list | 알람 관리 |
| 15 | pop-process-flow | 공정 현황 |
---
## 1. pop-container
역할: 모든 컴포넌트의 부모, 화면 뼈대
| 기능 | 설명 |
|------|------|
| 반응형 그리드 | 모바일/태블릿 자동 대응 |
| 플렉스 방향 | row, column, wrap |
| 간격 설정 | gap, padding |
| 배경/테두리 | 색상, 둥근모서리 |
| 스크롤 설정 | 가로/세로/없음 |
---
## 2. pop-tab-panel
역할: 정보 분류, 화면 공간 효율화
| 기능 | 설명 |
|------|------|
| 탭 모드 | 상단/하단/좌측 탭 |
| 아코디언 모드 | 접기/펼치기 |
| 아이콘 지원 | 탭별 아이콘 |
| 뱃지 표시 | 알림 개수 표시 |
| 기본 활성 탭 | 초기 선택 설정 |
---
## 3. pop-spacer (v4 전용)
역할: 빈 공간을 차지하여 레이아웃 정렬에 사용 (Figma, Webflow 등 업계 표준)
| 기능 | 설명 |
|------|------|
| 공간 채우기 | 남은 공간을 자동으로 채움 (`width: fill`) |
| 고정 크기 | 특정 크기의 빈 공간 (`width: fixed`) |
| 정렬 용도 | 컴포넌트를 오른쪽/가운데 정렬 |
### 사용 예시
```
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
```
### 기본 설정
| 속성 | 기본값 |
|------|--------|
| width | fill (남은 공간 채움) |
| height | 48px (고정) |
| 디자인 모드 표시 | 점선 배경 + "빈 공간" 텍스트 |
| 실제 모드 | 완전히 투명 (공간만 차지) |
---
## 4. pop-break (v4 전용) 🆕
역할: Flexbox에서 강제 줄바꿈을 위한 컴포넌트 (업계 표준: Figma Auto Layout의 줄바꿈과 동일)
| 기능 | 설명 |
|------|------|
| 강제 줄바꿈 | `flex-basis: 100%`로 다음 컴포넌트를 새 줄로 이동 |
| 모드별 표시 | visibility 속성으로 특정 모드에서만 줄바꿈 적용 |
| 시각적 표시 | 디자인 모드에서만 점선으로 표시 |
| 실제 화면 | 높이 0px (완전히 보이지 않음) |
### 동작 원리
```
Flexbox wrap: true 상태에서
flex-basis: 100%를 가진 요소 → 전체 너비 차지 → 다음 요소는 자동으로 새 줄로 이동
```
### 사용 예시
```
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
결과:
┌────────────────────┐
│ [필드A] [필드B] │ ← 첫째 줄
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 후)
└────────────────────┘
```
### 모드별 줄바꿈
```typescript
// 줄바꿈 컴포넌트 설정
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
}
}
// 결과
태블릿: [A] [B] [C] [D] (한 줄)
모바일: [A] [B] (두 줄)
[C] [D]
```
### 기본 설정
| 속성 | 기본값 |
|------|--------|
| width | fill (`flex-basis: 100%`) |
| height | 0px (높이 없음) |
| 디자인 모드 표시 | 점선 + "줄바꿈" 텍스트 (높이 16px) |
| 실제 모드 | 완전히 투명 (높이 0px) |
| flex-basis | 100% (핵심 속성) |
### CSS 구현
```css
/* 디자인 모드 */
.pop-break-design {
flex-basis: 100%;
width: 100%;
height: 16px;
border: 2px dashed #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
/* 실제 모드 */
.pop-break-runtime {
flex-basis: 100%;
width: 100%;
height: 0;
}
```
### 업계 비교
| 서비스 | 줄바꿈 방식 |
|--------|------------|
| Figma Auto Layout | "Wrap" 설정 + 수동 줄 분리 |
| Webflow Flexbox | "Wrap" + 100% width spacer |
| Framer | "Break" 컴포넌트 |
| **POP v4** | **pop-break (flex-basis: 100%)** |
### 주의사항
- 컨테이너의 `wrap: true` 설정 필수
- wrap이 false면 줄바꿈 무시됨
- visibility로 모드별 제어 가능
- 디자인 모드에서만 시각적으로 보임
---
## 5. pop-data-table
역할: 대량 데이터 표시, 선택, 편집
| 기능 | 설명 |
|------|------|
| 가상 스크롤 | 대량 데이터 성능 |
| 행 선택 | 단일/다중 선택 |
| 인라인 편집 | 셀 직접 수정 |
| 정렬/필터 | 컬럼별 정렬, 검색 |
| 고정 컬럼 | 좌측 컬럼 고정 |
| 행 색상 조건 | 상태별 배경색 |
| 큰 글씨 모드 | POP 전용 가독성 |
---
## 4. pop-card-list
역할: 시각적 강조가 필요한 목록
| 기능 | 설명 |
|------|------|
| 카드 레이아웃 | 1열/2열/3열 |
| 이미지 표시 | 부품/제품 사진 |
| 상태 뱃지 | 진행중/완료/대기 |
| 진행률 바 | 작업 진척도 |
| 스와이프 액션 | 완료/삭제 (선택적) |
| 클릭 이벤트 | 상세 모달 연결 |
---
## 5. pop-kpi-gauge
역할: 목표 대비 실적 시각화
| 기능 | 설명 |
|------|------|
| 게이지 타입 | 원형/반원형/수평바 |
| 목표값 | 기준선 표시 |
| 현재값 | 실시간 바인딩 |
| 색상 구간 | 위험/경고/정상 |
| 단위 표시 | %, 개, 초 등 |
| 애니메이션 | 값 변경 시 전환 |
| 라벨 | 제목, 부제목 |
---
## 6. pop-status-indicator
역할: 설비/공정 상태 즉시 파악
| 기능 | 설명 |
|------|------|
| 표시 타입 | 원형/사각/아이콘 |
| 상태 매핑 | 값 -> 색상/아이콘 자동 |
| 색상 설정 | 상태별 커스텀 |
| 크기 | S/M/L/XL |
| 깜빡임 | 알람 상태 강조 |
| 라벨 위치 | 상단/하단/우측 |
| 그룹 표시 | 여러 상태 한 줄 |
---
## 7. pop-number-pad
역할: 장갑 착용 상태 수량 입력
| 기능 | 설명 |
|------|------|
| 큰 버튼 | 최소 48px (1.5cm) |
| 레이아웃 | 전화기식/계산기식 |
| 소수점 | 허용/불허 |
| 음수 | 허용/불허 |
| 최소/최대값 | 범위 제한 |
| 단위 표시 | 개, kg, m 등 |
| 빠른 증감 | +1, +10, +100 버튼 |
| 진동 피드백 | 터치 확인 |
| 클리어 | 전체 삭제, 한 자리 삭제 |
---
## 8. pop-barcode-scanner
역할: 자재 투입, 로트 추적
| 기능 | 설명 |
|------|------|
| 카메라 스캔 | 모바일 카메라 연동 |
| 외부 스캐너 | USB/블루투스 연동 |
| 스캔 타입 | 바코드/QR/RFID |
| 연속 스캔 | 다중 입력 모드 |
| 이력 표시 | 최근 스캔 목록 |
| 유효성 검증 | 포맷 체크 |
| 자동 조회 | 스캔 후 API 연동 |
| 소리/진동 | 스캔 성공 피드백 |
---
## 9. pop-form-field
역할: 텍스트, 선택, 날짜 등 범용 입력
| 기능 | 설명 |
|------|------|
| 입력 타입 | text/number/date/time/select |
| 라벨 | 상단/좌측/플로팅 |
| 필수 표시 | 별표, 색상 |
| 유효성 검증 | 실시간 체크 |
| 에러 메시지 | 하단 표시 |
| 비활성화 | 읽기 전용 모드 |
| 큰 사이즈 | POP 전용 높이 |
| 도움말 | 툴팁/하단 설명 |
---
## 10. pop-action-button
역할: 작업 실행, 상태 변경
| 기능 | 설명 |
|------|------|
| 크기 | S/M/L/XL/전체너비 |
| 스타일 | primary/secondary/danger/success |
| 아이콘 | 좌측/우측/단독 |
| 로딩 상태 | 스피너 표시 |
| 비활성화 | 조건부 |
| 확인 다이얼로그 | 위험 작업 전 확인 |
| 길게 누르기 | 특수 동작 (선택적) |
| 뱃지 | 개수 표시 |
---
## 11. pop-timer
역할: 사이클 타임, 비가동 시간 측정
| 기능 | 설명 |
|------|------|
| 모드 | 스톱워치/카운트다운 |
| 시작/정지/리셋 | 기본 제어 |
| 랩 타임 | 구간 기록 |
| 목표 시간 | 초과 시 알림 |
| 표시 형식 | HH:MM:SS / MM:SS |
| 크기 | 작은/중간/큰 |
| 배경 색상 | 상태별 변경 |
| 자동 시작 | 조건부 트리거 |
---
## 12. pop-alarm-list
역할: 이상 상황 알림 및 확인
| 기능 | 설명 |
|------|------|
| 우선순위 | 긴급/경고/정보 |
| 색상 구분 | 레벨별 배경색 |
| 시간 표시 | 발생 시각 |
| 확인(Ack) | 알람 인지 처리 |
| 필터 | 미확인만/전체 |
| 정렬 | 시간순/우선순위순 |
| 상세 보기 | 클릭 시 모달 |
| 소리 알림 | 신규 알람 |
---
## 13. pop-process-flow
역할: 전체 공정 현황 시각화
| 기능 | 설명 |
|------|------|
| 노드 타입 | 공정/검사/대기 |
| 연결선 | 화살표, 분기 |
| 현재 위치 | 강조 표시 |
| 상태 색상 | 완료/진행/대기 |
| 클릭 이벤트 | 공정 상세 이동 |
| 가로/세로 | 방향 설정 |
| 축소/확대 | 핀치 줌 |
| 진행률 | 전체 대비 현재 |
---
## 커버 가능한 시나리오
이 13개 컴포넌트 조합으로 대응 가능한 화면:
- 작업 지시 화면
- 실적 입력 화면
- 품질 검사 화면
- 설비 모니터링 대시보드
- 자재 투입/출고 화면
- 알람 관리 화면
- 공정 현황판
---
## 기존 컴포넌트 재사용 가능 목록
| POP 컴포넌트 | 기존 컴포넌트 | 수정 필요 |
|-------------|--------------|----------|
| pop-data-table | v2-table-widget | 큰 글씨 모드 추가 |
| pop-form-field | v2-input-widget, v2-select-widget | 큰 사이즈 옵션 |
| pop-action-button | v2-button-widget | 크기/확인 다이얼로그 |
| pop-tab-panel | tab-widget | POP 스타일 적용 |
---
## Phase 3 업데이트 (2026-02-04) 🆕
### 추가된 컴포넌트
#### pop-break (줄바꿈)
- **역할**: Flexbox에서 강제 줄바꿈
- **핵심 기술**: `flex-basis: 100%`
- **모드별 제어**: visibility 속성 지원
- **시각적 표시**: 디자인 모드에서만 점선 표시 (실제 높이 0px)
### 모든 컴포넌트 공통 추가 속성
#### visibility (모드별 표시/숨김)
```typescript
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
}
```
**사용 예시**:
```typescript
// 모바일 전용 버튼
{
type: "pop-action-button",
visibility: {
tablet_landscape: false,
mobile_portrait: true,
}
}
```
### 참고 문서
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
---
*최종 업데이트: 2026-02-04 (Phase 3 완료)*

View File

@ -1,119 +0,0 @@
# ADR-001: v4 제약조건 기반 레이아웃 채택
**날짜**: 2026-02-03
**상태**: 채택됨
**의사결정자**: 프로젝트 담당자
---
## 배경
v3에서는 4개 모드(tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait)에 대해 각각 컴포넌트 위치를 설정해야 했습니다.
**문제점**:
1. 같은 컴포넌트를 4번 배치해야 함 (4배 작업량)
2. 모드 간 일관성 유지 어려움
3. 새 모드 추가 시 또 다른 배치 필요
---
## 결정
**"단일 소스 + 자동 적응" 방식 채택**
Figma, Framer, Flutter, SwiftUI에서 사용하는 업계 표준 접근법:
- 하나의 레이아웃 정의
- 제약조건(constraints) 설정
- 모든 화면에 자동 적응
---
## 핵심 규칙 3가지
### 1. 크기 규칙 (Size Rules)
| 모드 | 설명 | 예시 |
|------|------|------|
| fixed | 고정 px | 버튼 높이 48px |
| fill | 부모 채움 | 입력창 100% |
| hug | 내용 맞춤 | 라벨 = 텍스트 길이 |
### 2. 배치 규칙 (Layout Rules)
- 스택 방향: horizontal / vertical
- 줄바꿈: wrap / nowrap
- 간격: gap (8/16/24px)
- 정렬: start / center / end / stretch
### 3. 반응형 규칙 (Responsive Rules)
```typescript
{
direction: "horizontal",
responsive: [
{ breakpoint: 768, direction: "vertical" }
]
}
```
---
## 구현 방식
**Flexbox 기반** (CSS Grid 아님)
이유:
- 1차원 배치에 최적화
- 자동 크기 계산 (hug)
- 반응형 전환 간단
---
## 대안 검토
### A. 기존 4모드 유지 (기각)
장점: 기존 코드 변경 없음
단점: 근본 문제 해결 안 됨
### B. CSS Grid 기반 (기각)
장점: 2차원 배치 가능
단점: hug 구현 복잡, 학습 곡선
### C. 제약조건 기반 (채택)
장점: 업계 표준, 1회 설계
단점: 기존 v3와 호환성 고려 필요
---
## 영향
### 변경 필요
- 타입 정의 (PopLayoutDataV4)
- 렌더러 (Flexbox 기반)
- 디자이너 UI (제약조건 편집)
### 호환성
- v3 레이아웃은 기존 방식으로 계속 작동
- v4는 새로운 레이아웃에만 적용
- 점진적 마이그레이션 가능
---
## 참조
- Figma Auto Layout: https://help.figma.com/hc/en-us/articles/5731482952599-Using-auto-layout
- Flutter Flex: https://docs.flutter.dev/development/ui/layout
- SwiftUI Stacks: https://developer.apple.com/documentation/swiftui/hstack
---
## 관련
- rangraph 검색: "v4 constraint", "layout system"
- SPEC.md: 상세 규칙
- PLAN.md: 구현 로드맵

View File

@ -1,690 +0,0 @@
# Phase 3: Visibility + 줄바꿈 컴포넌트 구현
**날짜**: 2026-02-04
**상태**: 구현 완료 ✅
**관련 이슈**: 모드별 컴포넌트 표시/숨김, 강제 줄바꿈
---
## 📋 목표
Phase 2의 배치 고정 기능에 이어, 다음 기능들을 추가:
1. **모드별 컴포넌트 표시/숨김** (visibility)
2. **강제 줄바꿈 컴포넌트** (pop-break)
3. **컴포넌트 오버라이드 병합** (모드별 설정 변경)
---
## 🔍 문제 정의
### 문제 1: 모드별 컴포넌트 추가/삭제 불가
```
현재 상황:
- 모든 모드에서 같은 컴포넌트만 표시 가능
- 모바일 전용 버튼(예: "전화 걸기")을 추가할 수 없음
요구사항:
- 특정 모드에서만 컴포넌트 표시
- 다른 모드에서는 자동 숨김
```
### 문제 2: Flexbox에서 강제 줄바꿈 불가
```
현재 상황:
- wrap: true여도 컴포넌트가 공간을 채워야 줄바꿈
- [A] [B] [C] → 강제로 [A] [B] / [C] 불가능
요구사항:
- 사용자가 원하는 위치에서 강제 줄바꿈
- 디자인 모드에서 시각적으로 표시
```
### 문제 3: 컴포넌트 설정을 모드별로 변경 불가
```
현재 상황:
- 컨테이너 배치만 오버라이드 가능
- 리스트 컬럼 수, 버튼 스타일 등은 모든 모드 동일
요구사항 (확장성):
- 태블릿: 리스트 7개 컬럼
- 모바일: 리스트 3개 컬럼
```
---
## 💡 해결 방안
### 방안 A: children 배열 오버라이드 (추가/삭제)
```typescript
overrides: {
mobile_portrait: {
containers: {
root: {
children: ["comp1", "comp2", "mobile-only-button"] // 컴포넌트 추가
}
}
}
}
```
**장점**:
- 모드별로 완전히 다른 컴포넌트 구성 가능
- 유연성 극대화
**단점**:
- 데이터 동기화 복잡
- 삭제/추가 시 다른 모드에도 영향
- 순서 변경 시 충돌 가능
---
### 방안 B: visibility 속성 (표시/숨김) ✅ 채택
```typescript
interface PopComponentDefinitionV4 {
visibility?: {
tablet_landscape?: boolean;
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
}
```
**장점**:
- 단순하고 명확
- 컴포넌트는 항상 존재 (숨김만)
- 데이터 일관성 유지
**단점**:
- 완전히 다른 컴포넌트 추가는 불가능
- 많은 모드 전용 컴포넌트는 비효율적
---
### 최종 결정: 하이브리드 접근 ⭐
```typescript
1. visibility: 기본 기능 (Phase 3)
- 간단한 표시/숨김
- 줄바꿈 컴포넌트 제어
2. components 오버라이드: 고급 기능 (Phase 3 기반)
- 컴포넌트 설정 변경 (리스트 컬럼 수 등)
- 스타일 변경
3. children 오버라이드: 추후 고려
- 모드별 완전히 다른 구성 필요 시
```
---
## 🛠️ 구현 내용
### 1. 타입 정의 확장
#### pop-break 컴포넌트 추가
```typescript
export type PopComponentType =
| "pop-field"
| "pop-button"
| "pop-list"
| "pop-indicator"
| "pop-scanner"
| "pop-numpad"
| "pop-spacer"
| "pop-break"; // 🆕 줄바꿈
```
#### visibility 속성 추가
```typescript
export interface PopComponentDefinitionV4 {
id: string;
type: PopComponentType;
size: PopSizeConstraintV4;
// 🆕 모드별 표시/숨김
visibility?: {
tablet_landscape?: boolean; // undefined = true (기본 표시)
tablet_portrait?: boolean;
mobile_landscape?: boolean;
mobile_portrait?: boolean;
};
// 기존: 픽셀 기반 반응형
hideBelow?: number;
// 기타...
}
```
#### 기본 크기 설정
```typescript
const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
// ...
"pop-break": {
width: "fill", // 100% 너비 (flex-basis: 100%)
height: "fixed",
fixedHeight: 0, // 높이 0 (보이지 않음)
},
};
```
---
### 2. 렌더러 로직 개선
#### visibility 체크 함수
```typescript
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
if (!component.visibility) return true; // 기본값: 표시
const modeVisibility = component.visibility[currentMode];
return modeVisibility !== false; // undefined도 true로 취급
};
```
**로직 설명**:
- `visibility` 속성이 없으면 → 모든 모드에서 표시
- `visibility.mobile_portrait === false` → 모바일 세로에서 숨김
- `visibility.mobile_portrait === undefined` → 모바일 세로에서 표시 (기본값)
---
#### 컴포넌트 오버라이드 병합
```typescript
const getMergedComponent = (
baseComponent: PopComponentDefinitionV4
): PopComponentDefinitionV4 => {
if (currentMode === "tablet_landscape") return baseComponent;
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
if (!componentOverride) return baseComponent;
// 깊은 병합 (config, size)
return {
...baseComponent,
...componentOverride,
size: { ...baseComponent.size, ...componentOverride.size },
config: { ...baseComponent.config, ...componentOverride.config },
};
};
```
**병합 우선순위**:
1. `baseComponent` (기본값)
2. `overrides[currentMode].components[id]` (모드별 오버라이드)
3. 중첩 객체는 깊은 병합 (`size`, `config`)
**확장 가능성**:
- 리스트 컬럼 수 변경
- 버튼 스타일 변경
- 필드 표시 형식 변경
---
#### pop-break 전용 렌더링
```typescript
// pop-break 특수 처리
if (mergedComponent.type === "pop-break") {
return (
<div
key={componentId}
className={cn(
"w-full",
isDesignMode
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
: "h-0"
)}
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
onClick={() => onComponentClick?.(componentId)}
>
{isDesignMode && (
<span className="text-xs text-gray-400">줄바꿈</span>
)}
</div>
);
}
```
**동작 방식**:
- `flex-basis: 100%` → 컨테이너 전체 너비 차지
- 다음 컴포넌트는 자동으로 새 줄로 이동
- 디자인 모드: 점선 표시 (높이 16px)
- 실제 화면: 높이 0 (안 보임)
---
### 3. 삭제 함수 개선
#### 오버라이드 정리 로직
```typescript
export const removeComponentFromV4Layout = (
layout: PopLayoutDataV4,
componentId: string
): PopLayoutDataV4 => {
// 1. 컴포넌트 정의 삭제
const { [componentId]: _, ...remainingComponents } = layout.components;
// 2. root.children에서 제거
const newRoot = removeChildFromContainer(layout.root, componentId);
// 3. 🆕 모든 오버라이드에서 제거
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
return {
...layout,
root: newRoot,
components: remainingComponents,
overrides: newOverrides,
};
};
```
#### 오버라이드 정리 상세
```typescript
function cleanupOverridesAfterDelete(
overrides: PopLayoutDataV4["overrides"],
componentId: string
): PopLayoutDataV4["overrides"] {
if (!overrides) return undefined;
const newOverrides = { ...overrides };
for (const mode of Object.keys(newOverrides)) {
const override = newOverrides[mode];
if (!override) continue;
const updated = { ...override };
// containers.root.children에서 제거
if (updated.containers?.root?.children) {
updated.containers = {
...updated.containers,
root: {
...updated.containers.root,
children: updated.containers.root.children.filter(id => id !== componentId),
},
};
}
// components에서 제거
if (updated.components?.[componentId]) {
const { [componentId]: _, ...rest } = updated.components;
updated.components = Object.keys(rest).length > 0 ? rest : undefined;
}
// 빈 오버라이드 정리
if (!updated.containers && !updated.components) {
delete newOverrides[mode];
} else {
newOverrides[mode] = updated;
}
}
// 모든 오버라이드가 비었으면 undefined 반환
return Object.keys(newOverrides).length > 0 ? newOverrides : undefined;
}
```
**정리 항목**:
1. `overrides[mode].containers.root.children` - 컴포넌트 ID 제거
2. `overrides[mode].components[componentId]` - 컴포넌트 설정 제거
3. 빈 오버라이드 객체 삭제 (메모리 절약)
---
### 4. 속성 패널 UI
#### "표시" 탭 추가
```typescript
<TabsList>
<TabsTrigger value="size">크기</TabsTrigger>
<TabsTrigger value="settings">설정</TabsTrigger>
<TabsTrigger value="visibility">
<Eye className="h-3 w-3" />
표시
</TabsTrigger>
<TabsTrigger value="data">데이터</TabsTrigger>
</TabsList>
```
#### VisibilityForm 컴포넌트
```typescript
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes = [
{ key: "tablet_landscape", label: "태블릿 가로 (1024×768)" },
{ key: "tablet_portrait", label: "태블릿 세로 (768×1024)" },
{ key: "mobile_landscape", label: "모바일 가로 (667×375)" },
{ key: "mobile_portrait", label: "모바일 세로 (375×667)" },
];
return (
<div className="space-y-4">
<Label>모드별 표시 설정</Label>
<p className="text-xs text-muted-foreground">
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
/>
<label>{label}</label>
{!isChecked && <span>(숨김)</span>}
</div>
);
})}
</div>
{/* 기존: 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label>반응형 숨김 (픽셀 기반)</Label>
<Input
type="number"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<p className="text-xs text-muted-foreground">
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
</p>
</div>
</div>
);
}
```
**UI 특징**:
- 체크박스로 직관적인 표시/숨김 제어
- 기본값은 모든 모드 체크 (표시)
- `hideBelow` (픽셀 기반)와 별도 유지
---
### 5. 팔레트 업데이트
```typescript
const COMPONENT_PALETTE = [
// ... 기존 컴포넌트들
{
type: "pop-break",
label: "줄바꿈",
icon: WrapText,
description: "강제 줄바꿈 (flex-basis: 100%)",
},
];
```
---
## 🎯 사용 예시
### 예시 1: 모바일 전용 버튼
```typescript
{
id: "call-button",
type: "pop-button",
label: "전화 걸기",
size: { width: "fixed", height: "fixed", fixedWidth: 120, fixedHeight: 48 },
visibility: {
tablet_landscape: false, // 태블릿 가로: 숨김
tablet_portrait: false, // 태블릿 세로: 숨김
mobile_landscape: true, // 모바일 가로: 표시
mobile_portrait: true, // 모바일 세로: 표시
},
}
```
**결과**:
- 태블릿: "전화 걸기" 버튼 안 보임
- 모바일: "전화 걸기" 버튼 보임
---
### 예시 2: 모드별 줄바꿈
```typescript
레이아웃:
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
줄바꿈 컴포넌트 설정:
{
id: "break-1",
type: "pop-break",
visibility: {
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
},
}
```
**결과**:
```
태블릿 가로 (1024px):
┌─────────────────────────────────┐
│ [필드A] [필드B] [필드C] [필드D] │ ← 한 줄
└─────────────────────────────────┘
모바일 세로 (375px):
┌─────────────────┐
│ [필드A] [필드B] │ ← 첫 줄
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 적용)
└─────────────────┘
```
---
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
```typescript
// 기본 (태블릿 가로)
{
id: "product-list",
type: "pop-list",
config: {
columns: 7, // 7개 컬럼
}
}
// 오버라이드 (모바일 세로)
overrides: {
mobile_portrait: {
components: {
"product-list": {
config: {
columns: 3, // 3개 컬럼
}
}
}
}
}
```
**결과**:
- 태블릿: 7개 컬럼 표시
- 모바일: 3개 컬럼 표시 (병합됨)
---
## ✅ 테스트 시나리오
### 테스트 1: 줄바꿈 기본 동작
```
1. 팔레트에서 "줄바꿈" 드래그
2. [A] [B] [C] 사이에 드롭
3. 예상 결과: [A] [B] / [C]
4. 디자인 모드에서 점선 "줄바꿈" 표시 확인
5. 미리보기에서 줄바꿈이 안 보이는지 확인
```
### 테스트 2: 모드별 줄바꿈 표시
```
1. 줄바꿈 컴포넌트 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로 모드: [A] [B] [C] (한 줄)
4. 모바일 세로 모드: [A] [B] / [C] (두 줄)
```
### 테스트 3: 컴포넌트 삭제 시 오버라이드 정리
```
1. 모바일 세로 모드에서 배치 고정
2. 컴포넌트 삭제
3. 저장 후 로드
4. DB 확인: overrides에서도 제거되었는지
```
### 테스트 4: 모드별 컴포넌트 숨김
```
1. "전화 걸기" 버튼 추가
2. "표시" 탭 → 태블릿 모드 체크 해제
3. 태블릿 가로: 버튼 안 보임
4. 모바일 세로: 버튼 보임
```
### 테스트 5: 속성 패널 UI
```
1. 컴포넌트 선택
2. "표시" 탭 클릭
3. 4개 체크박스 확인 (모두 체크됨)
4. 체크 해제 시 "(숨김)" 표시 확인
5. 저장 후 로드 → 체크 상태 유지
```
---
## 🔍 기술적 고려사항
### 1. 데이터 일관성
```
문제: 컴포넌트 삭제 시 오버라이드 잔여물
해결:
- cleanupOverridesAfterDelete() 함수
- containers.root.children 정리
- components 오버라이드 정리
- 빈 오버라이드 자동 삭제
```
### 2. 병합 우선순위
```
우선순위 (높음 → 낮음):
1. tempLayout (고정 전 미리보기)
2. overrides[currentMode].containers.root
3. overrides[currentMode].components[id]
4. layout.root (기본값)
5. layout.components[id] (기본값)
```
### 3. 성능 최적화
```typescript
// useMemo로 병합 결과 캐싱
const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]);
const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]);
```
### 4. 타입 안전성
```typescript
// visibility 키는 ViewportPreset에서만 허용
visibility?: {
[K in ViewportPreset]?: boolean;
};
// 컴파일 타임에 오타 방지
visibility.tablet_landspace = false; // ❌ 오타 감지!
visibility.tablet_landscape = false; // ✅ 정상
```
---
## 📊 영향 받는 파일
### 코드 파일
```
✅ frontend/components/pop/designer/types/pop-layout.ts
- PopComponentType 확장 (pop-break)
- PopComponentDefinitionV4.visibility 추가
- cleanupOverridesAfterDelete() 추가
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
- isComponentVisible() 추가
- getMergedComponent() 추가
- pop-break 렌더링 추가
- ContainerRenderer props 확장
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
- "표시" 탭 추가
- VisibilityForm 컴포넌트 추가
- COMPONENT_TYPE_LABELS 업데이트
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
- "줄바꿈" 컴포넌트 추가
```
### 문서 파일
```
✅ popdocs/CHANGELOG.md
- Phase 3 완료 기록
✅ popdocs/PLAN.md
- Phase 3 체크 완료
- Phase 4 계획 추가
✅ popdocs/decisions/002-phase3-visibility-break.md (이 문서)
- 설계 결정 및 구현 상세
```
---
## 🚀 다음 단계
### Phase 4: 실제 컴포넌트 구현
```
우선순위:
1. pop-field (입력/표시 필드)
2. pop-button (액션 버튼)
3. pop-list (데이터 리스트)
4. pop-indicator (KPI 표시)
5. pop-scanner (바코드/QR)
6. pop-numpad (숫자 입력)
```
### 추가 개선 사항
```
1. 컴포넌트 오버라이드 UI
- 리스트 컬럼 수 조정
- 버튼 스타일 변경
- 필드 표시 형식 변경
2. "모든 모드에 적용" 기능
- 한 번에 모든 모드 체크/해제
3. 오버라이드 비교 뷰
- 기본값 vs 오버라이드 차이 표시
```
---
## 📝 결론
Phase 3를 통해 다음을 달성:
1. ✅ 모드별 컴포넌트 표시/숨김 제어
2. ✅ 강제 줄바꿈 컴포넌트 (Flexbox 한계 극복)
3. ✅ 컴포넌트 오버라이드 병합 (확장성 확보)
4. ✅ 데이터 일관성 유지 (삭제 시 정리)
이제 v4 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.

View File

@ -1,143 +0,0 @@
# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택
**날짜**: 2026-02-05
**상태**: 채택됨
**의사결정자**: 프로젝트 담당자, 상급자
---
## 배경
### 문제 상황
v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패:
1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감
2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐
3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음
### 상급자 피드백
> "이런 식이면 나중에 문제가 생긴다."
>
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
### 연구 내용
| 도구 | 핵심 특징 | 적용 가능 요소 |
|------|----------|---------------|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
---
## 결정
**CSS Grid 기반 그리드 시스템 (v5) 채택**
### 핵심 규칙
| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 |
|------|----------|-------|--------------|
| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 |
| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 |
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 |
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) |
### 컴포넌트 배치
```typescript
interface PopGridPosition {
col: number; // 시작 열 (1부터)
row: number; // 시작 행 (1부터)
colSpan: number; // 열 크기 (1~12)
rowSpan: number; // 행 크기 (1~)
}
```
### v4 대비 변경점
| 항목 | v4 (Flexbox) | v5 (Grid) |
|------|-------------|-----------|
| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) |
| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) |
| 예측성 | 낮음 | 높음 |
| 반응형 | 복잡한 규칙 | 칸 수 변환 |
---
## 대안 검토
### A. v4 Flexbox 유지 (기각)
- **장점**: 기존 코드 활용 가능
- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재)
- **결과**: 기각
### B. 자유 배치 (절대 좌표) (기각)
- **장점**: 완전한 자유도
- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요
- **결과**: 기각
### C. CSS Grid 그리드 시스템 (채택)
- **장점**:
- 규칙 기반으로 예측 가능
- 반응형 자동화 (12칸 → 4칸 변환)
- Material Design 표준 준수
- **단점**:
- 기존 v4 데이터 호환 불가
- 자유도 제한 (칸 단위로만)
- **결과**: **채택**
---
## 영향
### 변경 필요
- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`)
- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid)
- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시)
- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산)
- [x] 레거시 삭제 (v1~v4 코드, 데이터)
### 호환성
- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화)
- 새 화면: v5로만 생성
### 제한 사항
- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가)
- 12칸 기준으로 설계 후 다른 모드는 자동 변환
---
## 교훈
1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능
2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시
3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시
4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음
---
## 참조
- Softr: https://www.softr.io
- Ant Design Grid: https://ant.design/components/grid
- Material Design Layout: https://m3.material.io/foundations/layout
- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙
---
## 관련
- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도)
- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역
- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록

View File

@ -1,143 +0,0 @@
# ADR-004: 그리드 가이드 CSS Grid 통합
**상태**: 승인됨
**날짜**: 2026-02-05
**결정자**: 개발팀
---
## 컨텍스트
그리드 가이드는 다음 목적을 가짐:
1. **시각적 기준**: 어디에 배치할지 눈으로 확인 가능
2. **정렬 도움**: 칸에 맞춰 배치하기 쉬움
3. **디자인 일관성**: 규칙적인 배치 유도
기존 구현:
- `GridGuide.tsx`: SVG `<line>` 요소로 격자선 렌더링
- `PopRenderer.tsx`: CSS Grid로 컴포넌트 배치
---
## 문제
### 좌표계 불일치
```
SVG 좌표: 픽셀 기반 (0, 0) ~ (width, height)
CSS Grid 좌표: 칸 기반 (col 1~12, row 1~20)
→ 두 좌표계를 정확히 동기화하기 어려움
→ 격자선과 컴포넌트가 정렬되지 않음 ("무늬가 따로 논다")
```
### 구체적 증상
1. GridGuide의 행/열 라벨이 4부터 시작 (잘못된 계산)
2. 격자선 위치와 실제 CSS Grid 셀 위치 불일치
3. 줌/패닝 시 두 레이어가 다르게 동작
---
## 결정
**GridGuide.tsx를 삭제하고, PopRenderer.tsx에서 CSS Grid 기반으로 격자를 직접 렌더링한다.**
핵심 원칙:
> "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다"
---
## 대안 검토
### Option A: SVG 계산 수정
- **방법**: GridGuide의 좌표 계산을 정확히 수정
- **장점**: 기존 코드 활용
- **단점**: 근본적으로 두 좌표계가 다름, 유지보수 어려움
- **결정**: 채택 안 함
### Option B: PopRenderer에 CSS 배경 격자
- **방법**: `background-image: linear-gradient()`로 격자 표현
- **장점**: 구현 간단
- **단점**: 라벨 표시 불가, 셀 단위 상호작용 불가
- **결정**: 채택 안 함
### Option C: CSS Grid 셀로 격자 렌더링 (채택)
- **방법**: 실제 `div` 요소를 12x20 = 240개 생성, CSS Grid로 배치
- **장점**:
- 컴포넌트와 100% 동일한 좌표계
- 셀 단위 hover, 클릭 등 상호작용 가능
- 라벨은 캔버스 외부에 별도 렌더링
- **단점**: DOM 요소 증가 (240개)
- **결정**: 채택
---
## 구현 상세
### 역할 분담
| 컴포넌트 | 역할 | 좌표계 |
|----------|------|--------|
| PopRenderer | 격자 셀 + 컴포넌트 | CSS Grid |
| PopCanvas | 라벨 + 줌/패닝 + 토글 | absolute |
| GridGuide | (삭제) | - |
### PopRenderer 변경
```typescript
// gridCells 생성 (useMemo)
const gridCells = useMemo(() => {
const cells = [];
for (let row = 1; row <= 20; row++) {
for (let col = 1; col <= 12; col++) {
cells.push({ id: `${col}-${row}`, col, row });
}
}
return cells;
}, []);
// 렌더링
{showGridGuide && gridCells.map(cell => (
<div
key={cell.id}
className="border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
```
### PopCanvas 라벨 구조
```
[1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단)
┌───────────────────────────────────────────┐
[1] │ │ │ │ │ │ │ │ │ │ │ │
[2] │ │ │ │ │ │ │ │ │ │ │ │
[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행
└───────────────────────────────────────────┘
↑ 행 라벨 (캔버스 좌측)
```
---
## 결과
### 기대 효과
1. 격자선과 컴포넌트 100% 정렬
2. 정확한 행/열 번호 표시 (1부터 시작)
3. 줌/패닝 시 일관된 동작
4. 향후 셀 클릭으로 빠른 배치 기능 확장 가능
### 트레이드오프
- DOM 요소 240개 추가 (성능 영향 미미)
- GridGuide 코드 삭제 필요
---
## 관련 문서
- 문제: [PROBLEMS.md](../PROBLEMS.md) > P004
- 변경: [CHANGELOG.md](../CHANGELOG.md) > 2026-02-05 오후
- 세션: [sessions/2026-02-05.md](../sessions/2026-02-05.md)

View File

@ -1,181 +0,0 @@
# ADR 005: 브레이크포인트 재설계 (기기 기반)
**날짜**: 2026-02-06
**상태**: 채택
**의사결정자**: 시스템 아키텍트
---
## 상황 (Context)
### 문제 1: 뷰어에서 모드 전환 불일치
```
브라우저 수동 리사이즈 시:
- useResponsiveMode 훅: 768px 이상 → "tablet" 판정
- GRID_BREAKPOINTS: 768~839px → "mobile_landscape" (6칸)
결과: 768~839px 구간에서 모드 불일치 발생
```
### 문제 2: 기존 브레이크포인트 근거 부족
```
기존 설정:
- mobile_portrait: ~599px
- mobile_landscape: 600~839px
- tablet_portrait: 840~1023px
문제: 실제 기기 뷰포트와 맞지 않음
- iPad Mini 세로: 768px (mobile_landscape로 분류됨)
```
### 사용자 요구사항
> "현장 모바일 기기가 최소 8인치 ~ 최대 14인치,
> 핸드폰은 아이폰 미니 ~ 갤럭시 울트라 사이즈"
---
## 연구 (Research)
### 실제 기기 CSS 뷰포트 조사 (2026년 기준)
| 기기 | 화면 크기 | CSS 뷰포트 너비 |
|------|----------|----------------|
| iPhone SE | 4.7" | 375px |
| iPhone 16 Pro | 6.3" | 402px |
| Galaxy S25 Ultra | 6.9" | 440px |
| iPad Mini 7 | 8.3" | 768px |
| iPad Pro 11 | 11" | 834px (세로), 1194px (가로) |
| iPad Pro 13 | 13" | 1024px (세로), 1366px (가로) |
### 업계 표준 브레이크포인트
| 프레임워크 | 모바일/태블릿 경계 | 태블릿/데스크톱 경계 |
|-----------|------------------|-------------------|
| Tailwind CSS | 768px | 1024px |
| Bootstrap 5 | 768px | 992px |
| Material Design 3 | 600px | 840px |
**공통점**: 768px, 1024px가 거의 표준
---
## 결정 (Decision)
### 채택: 기기 기반 브레이크포인트
| 모드 | 너비 범위 | 변경 전 | 근거 |
|------|----------|--------|------|
| mobile_portrait | 0~479px | 0~599px | 스마트폰 세로 최대 440px |
| mobile_landscape | 480~767px | 600~839px | 스마트폰 가로, 767px까지 |
| tablet_portrait | 768~1023px | 840~1023px | iPad Mini 768px 포함 |
| tablet_landscape | 1024px+ | 동일 | 대형 태블릿 가로 |
### 핵심 변경
```typescript
// pop-layout.ts - GRID_BREAKPOINTS
mobile_portrait: { maxWidth: 479 } // was 599
mobile_landscape: { minWidth: 480, maxWidth: 767 } // was 600, 839
tablet_portrait: { minWidth: 768, maxWidth: 1023 } // was 840, 1023
tablet_landscape: { minWidth: 1024 } // 동일
// pop-layout.ts - detectGridMode()
if (viewportWidth < 480) return "mobile_portrait"; // was 600
if (viewportWidth < 768) return "mobile_landscape"; // was 840
if (viewportWidth < 1024) return "tablet_portrait";
// useDeviceOrientation.ts - BREAKPOINTS
TABLET_MIN: 768 // was 840
```
---
## 구현 (Implementation)
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `pop-layout.ts` | GRID_BREAKPOINTS 값 수정, detectGridMode() 조건 수정 |
| `useDeviceOrientation.ts` | BREAKPOINTS.TABLET_MIN = 768 |
| `PopCanvas.tsx` | VIEWPORT_PRESETS width 값 조정 |
| `page.tsx (뷰어)` | detectGridMode() 사용으로 일관성 확보 |
### 뷰어 모드 감지 방식 변경
```typescript
// 변경 전: useResponsiveModeWithOverride만 사용
const currentModeKey = getModeKey(deviceType, isLandscape);
// 변경 후: 프리뷰 모드와 일반 모드 분리
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
: detectGridMode(viewportWidth); // 일반: 너비 기반
```
---
## 결과 (Consequences)
### 긍정적 효과
| 효과 | 설명 |
|------|------|
| **기기 커버리지** | 아이폰 SE ~ 갤럭시 울트라, 8~14인치 태블릿 모두 포함 |
| **업계 표준 호환** | 768px, 1024px는 거의 모든 프레임워크 기준점 |
| **일관성 확보** | GRID_BREAKPOINTS와 detectGridMode() 완전 일치 |
| **직관적 매핑** | 스마트폰 세로/가로, 태블릿 세로/가로 자연스럽게 분류 |
### 트레이드오프
| 항목 | 설명 |
|------|------|
| **기존 데이터 영향** | 600~767px 구간이 6칸→6칸 (영향 없음) |
| **768~839px 변경** | 기존 6칸→8칸 (태블릿으로 재분류) |
---
## 세로 자동 확장 (추가 결정)
### 배경
> "세로는 신경쓸 필요가 없는 것 맞지?
> 그렇다면 캔버스도 세로 무한 스크롤이 가능해야겠네?"
### 결정
1. **뷰포트 프리셋에서 height 제거** (width만 유지)
2. **캔버스 높이 동적 계산** (컴포넌트 배치 기준)
3. **항상 여유 행 3개 유지** (추가 배치 공간)
4. **뷰어에서 터치 스크롤** 지원
### 구현
```typescript
// PopCanvas.tsx
const MIN_CANVAS_HEIGHT = 600;
const CANVAS_EXTRA_ROWS = 3;
const dynamicCanvasHeight = useMemo(() => {
const maxRowEnd = visibleComps.reduce((max, comp) => {
return Math.max(max, comp.row + comp.rowSpan);
}, 1);
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight);
}, [layout.components, ...]);
```
---
## 관련 문서
- [003-v5-grid-system.md](./003-v5-grid-system.md) - v5 그리드 시스템 채택
- [006-auto-wrap-review-system.md](./006-auto-wrap-review-system.md) - 자동 줄바꿈
---
**결론**: 실제 기기 뷰포트 기반 브레이크포인트로 일관성 확보 + 세로 무한 스크롤로 UX 개선

View File

@ -1,220 +0,0 @@
# ADR 006: v5.1 자동 줄바꿈 + 검토 필요 시스템
**날짜**: 2026-02-06
**상태**: 채택
**의사결정자**: 시스템 아키텍트
---
## 상황 (Context)
v5 반응형 레이아웃에서 "화면 밖" 개념으로 컴포넌트를 처리했으나, 다음 문제가 발생했습니다:
### 문제 1: 정보 손실
```
12칸 모드:
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ A │ B (col=5, 6칸) │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
4칸 모드 (기존):
┌────┬────┬────┬────┐ 화면 밖:
│ A │ │ - B
└────┴────┴────┴────┘
↑ A만 보임 ↑ 뷰어에서 안 보임!
```
### 문제 2: 사용자 의도 불일치
사용자가 기대한 "화면 밖" 역할:
- ❌ 컴포넌트 숨김 (현재 동작)
- ✅ "이 컴포넌트 검토 필요" 알림
---
## 결정 (Decision)
### 채택: 자동 줄바꿈 + 검토 필요 시스템
```
col > maxCol → 자동으로 맨 아래에 배치 (줄바꿈)
오버라이드 없음 → "검토 필요" 알림
```
---
## 구현 (Implementation)
### 1. 자동 줄바꿈 로직
**파일**: `gridUtils.ts` - `convertAndResolvePositions()`
```typescript
// 단계별 처리:
1. 비율 변환 + 원본 col 보존
converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // ⭐ 원본 보존
}))
2. 정상 vs 초과 분리
normalComponents = originalCol ≤ targetColumns
overflowComponents = originalCol > targetColumns
3. 초과 컴포넌트 자동 배치
maxRow = normalComponents의 최대 row
overflowComponents → col=1, row=맨아래+1
4. 겹침 해결
resolveOverlaps([...normalComponents, ...wrappedComponents])
```
### 2. 검토 필요 판별
**파일**: `gridUtils.ts` - `needsReview()`
```typescript
function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
// 12칸 모드는 기본 모드이므로 검토 불필요
if (GRID_BREAKPOINTS[currentMode].columns === 12) return false;
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) return false;
// 오버라이드 없으면 → 검토 필요
return true;
}
```
**판단 기준 (최종)**: "이 모드에서 편집했냐 안 했냐"
### 3. 검토 필요 패널
**파일**: `PopCanvas.tsx` - `ReviewPanel`
```typescript
// 필터링
const reviewComponents = visibleComponents.filter(comp => {
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
return needsReview(currentMode, hasOverride);
});
// UI
<ReviewPanel
components={reviewComponents}
onSelectComponent={onSelectComponent} // 클릭 시 선택
/>
```
**변경 사항**:
- 기존: `OutOfBoundsPanel` (주황색, 드래그로 복원)
- 변경: `ReviewPanel` (파란색, 클릭으로 선택)
---
## 결과 (Consequences)
### 긍정적 효과
| 효과 | 설명 |
|------|------|
| **정보 손실 방지** | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
| **사용자 부담 감소** | 자동 배치를 먼저 제공, 필요시에만 편집 |
| **의도 명확화** | "숨김" ≠ "검토 필요" (기능 분리) |
| **뷰어 호환** | 자동 배치가 뷰어에도 적용됨 |
### 트레이드오프
| 항목 | 설명 |
|------|------|
| **스크롤 증가** | 아래로 자동 배치되면 페이지가 길어질 수 있음 |
| **자동 배치 품질** | 사용자가 원하지 않는 위치에 배치될 수 있음 |
---
## 사용자 시나리오
### 시나리오 1: 수용 (자동 배치 그대로)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: 확인 → "괜찮네" → 아무것도 안 함
5. 결과: 자동 배치 유지 (오버라이드 없음)
```
### 시나리오 2: 편집 (오버라이드 저장)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: A 클릭 → 드래그/리사이즈
5. 결과: A 오버라이드 저장 → A 검토 완료
6. "검토 필요 (2개)" (B, C만 남음)
```
### 시나리오 3: 보류 (나중에)
```
1. 12칸에서 컴포넌트 A, B, C 배치
2. 4칸 모드로 전환
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
4. 사용자: 다른 모드로 전환 또는 저장
5. 결과: 자동 배치 유지, 나중에도 "검토 필요" 표시
```
---
## 기능 비교
| 구분 | 역할 | 뷰어에서 | 판단 기준 |
|------|------|---------|----------|
| **검토 필요** | 자동 배치 알림 | **보임** | 오버라이드 없음 |
| **숨김** | 의도적 숨김 | **안 보임** | hidden 배열에 ID |
---
## 대안 (Alternatives Considered)
### A안: 완전 자동 (채택 ✅)
- 모든 초과 컴포넌트 자동 배치
- "검토 필요" 알림으로 확인 유도
- 업계 표준 (Webflow, Retool)
### B안: 선택적 자동 (미채택)
- 첫 전환 시만 자동 배치
- 사용자가 원하면 "화면 밖"으로 드래그
- 복잡성 증가
### C안: 수동 배치 유지 (미채택)
- 기존 "화면 밖" 패널 유지
- 사용자가 모든 모드 수동 편집
- 사용자 부담 과다
---
## 참고 자료
### 업계 표준 (2026년 기준)
- **Grafana, Tableau**: Masonry Layout (조적식)
- **Retool, PowerApps**: Vertical Stacking (수직 스택)
- **Webflow, Framer**: CSS Grid Auto-Placement
**공통점**: "Fluid Reflow (유동적 재배치)" - 정보 손실 방지
---
## 관련 파일
| 파일 | 변경 내용 |
|------|----------|
| `gridUtils.ts` | convertAndResolvePositions, needsReview 추가 |
| `PopCanvas.tsx` | ReviewPanel로 변경 |
| `PopRenderer.tsx` | isOutOfBounds import 제거 |
| `pop-layout.ts` | 타입 변경 없음 (기존 구조 유지) |
---
**결론**: 자동 줄바꿈 + 검토 필요 시스템으로 정보 손실 방지 및 사용자 부담 최소화

View File

@ -1,177 +0,0 @@
# 2026-02-05 작업 기록
## 요약
v5 그리드 시스템 통합 완료, 그리드 가이드 재설계, **드래그앤드롭 좌표 버그 수정**, popdocs 문서 구조 재정비
---
## 완료
### 드래그앤드롭 완전 수정 (저녁)
- [x] 스케일 보정 누락 문제 해결
- [x] calcGridPosition 함수 추가
- [x] DND 타입 상수 통합 (constants/dnd.ts)
- [x] 불필요한 toast 메시지 제거
- [x] 컴포넌트 이동/리사이즈 정상 작동 확인
- [x] **컴포넌트 중첩(겹침) 문제 해결** - toast import 누락 수정
- [x] **리사이즈 핸들 작동 문제 해결** - useDrop 훅 통합
### v5 통합 작업
- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등)
- [x] 파일명 정규화 (V5 접미사 제거)
- [x] 뷰어 페이지 v5 전용으로 업데이트
- [x] 백엔드 screenManagementService v5 전용 단순화
- [x] DB 기존 레이아웃 데이터 삭제
### 문서 재정비 작업
- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙)
- [x] README.md 재작성 (진입점 역할)
- [x] STATUS.md 생성 (현재 상태)
- [x] PROBLEMS.md 생성 (문제-해결 색인)
- [x] INDEX.md 생성 (기능별 색인)
- [x] sessions/ 폴더 구조 도입
### 디자이너 완성 작업
- [x] 컴포넌트 팔레트 UI 추가 (ComponentPalette.tsx)
- [x] PopCanvas.tsx 타입 오류 수정
- [x] 드래그앤드롭 연결
### 그리드 가이드 재설계
- [x] GridGuide.tsx 삭제 (SVG 기반 → 좌표 불일치 문제)
- [x] PopRenderer.tsx 격자 셀 렌더링 (CSS Grid 기반, 동일 좌표계)
- [x] PopCanvas.tsx 행/열 라벨 추가 (캔버스 바깥)
- [x] 컴포넌트 타입 단순화 (pop-sample 1개)
### 기반 정리 작업
- [x] pop-layout.ts: PopComponentType을 pop-sample 1개로 단순화
- [x] ComponentPalette.tsx: 샘플 박스 1개만 표시
- [x] PopRenderer.tsx: 샘플 박스 렌더링으로 단순화
---
## 미완료
- [x] 실제 화면 테스트 (디자이너 페이지) → 완료, 정상 작동
- [x] 간격 조정 규칙 결정 → 2026-02-06 Gap 프리셋으로 해결 (좁게/보통/넓게)
---
## 그리드 가이드 재설계 상세
### 문제 원인
1. GridGuide.tsx가 SVG로 별도 렌더링 → CSS Grid 기반 컴포넌트와 좌표계 불일치
2. PopRenderer의 그리드 배경이 희미 (rgba 0.2)
3. 행/열 번호 라벨 없음
### 해결 방안 (Option C 하이브리드)
```
역할 분담:
- PopRenderer: 격자선 + 컴포넌트 (같은 좌표계)
- PopCanvas: 라벨 + 줌/패닝 + 드롭존
- GridGuide: 삭제
```
### 핵심 설계
```
SVG 격자 (별도 좌표) → CSS Grid 셀 (동일 좌표)
- gridCells: 12열 × 20행 = 240개 실제 DOM 셀
- border-dashed border-blue-300/40 스타일
- 컴포넌트는 z-index:10으로 위에 표시
```
### 라벨 구조
```
[1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단)
┌───────────────────────────────────────────┐
[1] │ │ │ │ │ │ │ │ │ │ │ │
[2] │ │ │ │ │ │ │ │ │ │ │ │
[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행
└───────────────────────────────────────────┘
↑ 행 라벨 (캔버스 좌측)
```
---
## 대화 핵심
### v5 전환 배경
- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능
- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라"
- **연구**: Softr, Ant Design, Material Design 분석
- **결정**: CSS Grid 기반 그리드 시스템 채택
### 그리드 가이드 재설계 배경
- **문제**: SVG GridGuide와 CSS Grid PopRenderer가 좌표계 불일치
- **원칙**: "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다"
- **결정**: CSS Grid 기반 실제 DOM 셀로 격자 렌더링
### popdocs 재정비 배경
- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함
- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화
- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법
---
## 빌드 결과
```
exit_code: 0
popScreenMngList: 29.4 kB (311 KB First Load)
총 변경: 8,453줄 삭제, 1,819줄 추가 (순감 6,634줄)
```
---
## 관련 링크
- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md)
- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션
---
## 드래그앤드롭 좌표 버그 수정 상세
### 문제 현상
- 컴포넌트를 아래로 드래그해도 위로 올라감
- Row 92 같은 비정상적인 좌표로 배치됨
- 드래그 이동/리사이즈가 전혀 작동하지 않음
### 핵심 원인
캔버스에 `transform: scale(0.8)` 적용 시 좌표 계산 불일치:
```
getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px)
getClientOffset() → 뷰포트 기준 실제 마우스 좌표
이 둘을 그대로 계산하면 좌표가 완전히 틀림
```
### 해결 방법
단순한 상대 좌표 + 스케일 보정:
```typescript
// 캔버스 내 상대 좌표 (스케일 보정)
const relX = (offset.x - canvasRect.left) / canvasScale;
const relY = (offset.y - canvasRect.top) / canvasScale;
// 그리드 좌표 계산 (실제 캔버스 크기 사용)
calcGridPosition(relX, relY, customWidth, breakpoint.columns, ...);
```
### 추가 수정
- DND 타입 상수를 3개 파일에서 중복 정의 → `constants/dnd.ts`로 통합
- 불필요한 "컴포넌트가 이동되었습니다" toast 메시지 제거
---
## 다음 작업자 참고
1. **테스트 완료**
- 디자이너 페이지에서 그리드 가이드 확인 ✅
- 컴포넌트 드래그앤드롭 테스트 ✅
- 4가지 모드 전환 테스트 (추가 확인 필요)
2. **향후 결정 필요**
- 간격 조정: 전역 고정 vs 화면별 vs 컴포넌트별
- 행 수: 현재 20행 고정, 동적 변경 여부
3. **Phase 4 준비**
- 실제 컴포넌트 구현 (pop-label, pop-button 등)
- 데이터 바인딩 연결

View File

@ -1,239 +0,0 @@
# 2026-02-06 작업 기록
## 요약
v5.1 자동 줄바꿈 + 검토 필요 시스템 완성, 브레이크포인트 재설계, 세로 자동 확장 구현
---
## 완료
### 브레이크포인트 재설계
- [x] GRID_BREAKPOINTS 값 수정 (기기 기반)
- [x] detectGridMode() 조건 수정
- [x] useDeviceOrientation.ts TABLET_MIN 768로 변경
- [x] 뷰어에서 detectGridMode() 사용하여 일관성 확보
### 세로 자동 확장
- [x] VIEWPORT_PRESETS에서 height 속성 제거
- [x] dynamicCanvasHeight useMemo 추가
- [x] MIN_CANVAS_HEIGHT, CANVAS_EXTRA_ROWS 상수 추가
- [x] gridLabels 동적 계산 (행 수 자동 조정)
- [x] gridCells 동적 계산 (PopRenderer)
- [x] 뷰어 프리뷰 모드 스크롤 지원
### 자동 줄바꿈 시스템 (v5.1)
- [x] convertAndResolvePositions() 자동 줄바꿈 로직
- [x] 원본 col 보존 로직
- [x] 초과 컴포넌트 맨 아래 배치
- [x] colSpan 자동 축소
### 검토 필요 시스템
- [x] needsReview() 함수 추가
- [x] OutOfBoundsPanel → ReviewPanel 변경
- [x] 파란색 테마 (안내 느낌)
- [x] 클릭 시 컴포넌트 선택
### 버그 수정
- [x] hiddenComponentIds 중복 정의 에러 수정
- [x] useDrop 의존성 배열 수정
- [x] 검토 필요 패널 모드별 표시 불일치 수정
### 그리드 셀 크기 강제 고정 (v5.2.1)
- [x] gridAutoRows → gridTemplateRows 변경 (행 높이 강제 고정)
- [x] dynamicRowCount를 gridStyle과 gridCells에서 공유
- [x] 컴포넌트 overflow: visible → overflow: hidden 변경
- [x] PopRenderer dynamicRowCount에서 숨김 컴포넌트 제외
- [x] PopCanvas와 PopRenderer의 여유행 기준 통일 (+3)
- [x] 디버깅용 console.log 2개 삭제
- [x] 뷰어 page.tsx viewportWidth 선언 순서 수정
---
## 브레이크포인트 변경 상세
### 변경 전 → 변경 후
| 모드 | 변경 전 | 변경 후 | 근거 |
|------|--------|--------|------|
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 최대 440px |
| mobile_landscape | 600~839px | 480~767px | 767px까지 스마트폰 |
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 768px 포함 |
| tablet_landscape | 1024px+ | 동일 | 변경 없음 |
### 연구 결과 (기기별 CSS 뷰포트)
| 기기 | CSS 뷰포트 너비 |
|------|----------------|
| iPhone SE | 375px |
| iPhone 16 Pro | 402px |
| Galaxy S25 Ultra | 440px |
| iPad Mini 7 | 768px |
| iPad Pro 11 | 834px (세로), 1194px (가로) |
| iPad Pro 13 | 1024px (세로), 1366px (가로) |
---
## 세로 자동 확장 상세
### 핵심 상수
```typescript
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
```
### 동적 높이 계산 로직
```typescript
const dynamicCanvasHeight = useMemo(() => {
const visibleComps = Object.values(layout.components)
.filter(comp => !hiddenComponentIds.includes(comp.id));
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
const maxRowEnd = visibleComps.reduce((max, comp) => {
const pos = getEffectivePosition(comp);
return Math.max(max, pos.row + pos.rowSpan);
}, 1);
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
const height = totalRows * (rowHeight + gap) + padding * 2;
return Math.max(MIN_CANVAS_HEIGHT, height);
}, [dependencies]);
```
### 영향받는 영역
| 영역 | 변경 |
|------|------|
| 캔버스 컨테이너 | minHeight: dynamicCanvasHeight |
| 디바이스 스크린 | minHeight: dynamicCanvasHeight |
| 행 라벨 | 동적 행 수 계산 |
| 격자 셀 | 동적 행 수 계산 |
---
## 자동 줄바꿈 로직 상세
### 처리 단계
```
1. 비율 변환 + 원본 col 보존
converted = map(comp => ({
position: convertPositionToMode(comp.position),
originalCol: comp.position.col, // 원본 보존
}))
2. 정상 vs 초과 분리
normalComponents = filter(originalCol <= targetColumns)
overflowComponents = filter(originalCol > targetColumns)
3. 초과 컴포넌트 맨 아래 배치
maxRow = normalComponents의 최대 (row + rowSpan - 1)
overflowComponents → col=1, row=maxRow+1
4. colSpan 자동 축소
if (colSpan > targetColumns) colSpan = targetColumns
5. 겹침 해결
resolveOverlaps([...normalComponents, ...wrappedComponents])
```
---
## 대화 핵심
### 반응형 불일치 문제
**사용자 리포트**:
> "아이폰 SE, iPad Pro 프리셋은 잘 되는데,
> 브라우저 수동 리사이즈 시 6칸 모드가 적용 안 되는 것 같아"
**원인 분석**:
- useResponsiveMode: width/height 비율로 landscape/portrait 판정
- GRID_BREAKPOINTS: 순수 너비 기반
- 768~839px 구간에서 불일치 발생
**해결**:
- 뷰어에서 detectGridMode(viewportWidth) 사용
- 프리뷰 모드만 useResponsiveModeWithOverride 유지
### 세로 무한 스크롤 결정
**사용자 질문**:
> "우리 화면 모드는 너비만 신경쓰면 되잖아?
> 세로는 무한 스크롤이 가능해야 하겠네?"
**확인 사항**:
1. 너비만 신경쓰면 됨 ✅
2. 캔버스 세로 무한 스크롤 필요 ✅
3. 뷰어에서 터치 스크롤 지원 ✅
**구현 방식 선택**:
- 수동 행 추가 방식 vs **자동 확장 방식 (채택)**
- 이유: 여유 공간 3행 자동 유지, 사용자 부담 최소화
---
## 빌드 결과
```
exit_code: 0
주요 변경 파일: 6개
```
---
## 관련 링크
- ADR: [decisions/005-breakpoint-redesign.md](../decisions/005-breakpoint-redesign.md)
- ADR: [decisions/006-auto-wrap-review-system.md](../decisions/006-auto-wrap-review-system.md)
- 이전 세션: [sessions/2026-02-05.md](./2026-02-05.md)
---
## 이번 작업에서 배운 것
### 새로 알게 된 기술 개념
- **gridAutoRows vs gridTemplateRows**: `gridAutoRows`는 행의 *최소* 높이만 보장하고 콘텐츠에 따라 늘어날 수 있음. `gridTemplateRows`는 행 높이를 *강제 고정*함. 가이드 셀과 컴포넌트가 같은 Grid 컨테이너에 있을 때, 컴포넌트 콘텐츠가 행 높이를 밀어내면 인접한 빈 가이드 셀 크기도 함께 변해 시각적 불일치가 발생함.
### 발생했던 에러와 원인 패턴
| 에러 | 원인 패턴 |
|------|-----------|
| 그리드 셀 크기 불균일 | 같은 CSS Grid에서 gridAutoRows(최소값)를 사용하면 콘텐츠가 행 높이를 변형시킴 |
| Canvas vs Renderer 행 수 불일치 | 같은 데이터(행 수)를 두 곳에서 계산하면서 필터 조건(숨김 제외)이 달랐음 |
| 디버깅 console.log 잔존 | 기능 완료 후 정리 단계를 생략함 |
| viewportWidth 참조 순서 | 변수 사용 코드가 선언 코드보다 위에 위치 (JS 호이스팅으로 동작은 하지만 가독성 저하) |
### 다음에 비슷한 작업할 때 주의할 점
1. **CSS Grid에서 "고정 크기" 셀이 필요하면 `gridTemplateRows`를 사용**하고, `gridAutoRows`는 동적 추가행 대비용으로만 유지
2. **같은 데이터를 여러 곳에서 계산할 때, 필터 조건이 동일한지 반드시 비교** (숨김 제외 등)
3. **기능 완료 후 `console.log`를 Grep으로 검색하여 디버깅 로그 정리**
4. **변수 선언 순서는 의존 관계 순서와 일치**시켜야 가독성과 유지보수성 확보
---
## 중단점
> **다음 작업**: Phase 4 실제 컴포넌트 구현
> - pop-label, pop-button 등 실제 렌더링 구현
> - 데이터 바인딩 연결
> - STATUS.md의 "다음 작업" 섹션 참조
---
## 다음 작업자 참고
1. **테스트 필요**
- 아이폰 SE 실기기 테스트
- iPad Mini 세로 모드 확인
- 브라우저 리사이즈로 모드 전환 확인
2. **향후 작업**
- Phase 4: 실제 컴포넌트 구현 (pop-label, pop-button 등)
- 데이터 바인딩 연결
- 워크플로우 연동