jskim-node #387

Merged
kjs merged 6 commits from jskim-node into main 2026-02-09 13:28:08 +09:00
78 changed files with 23661 additions and 36 deletions
Showing only changes of commit 78f23ea0a9 - Show all commits

1041
POPUPDATE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2563,3 +2563,280 @@ 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,7 +732,7 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
}
};
// 🆕 레이어 목록 조회
// 레이어 목록 조회
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
@ -745,7 +745,7 @@ export const getScreenLayers = async (req: AuthenticatedRequest, res: Response)
}
};
// 🆕 특정 레이어 레이아웃 조회
// 특정 레이어 레이아웃 조회
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
@ -758,7 +758,7 @@ export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) =
}
};
// 🆕 레이어 삭제
// 레이어 삭제
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
@ -771,7 +771,7 @@ export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
}
};
// 🆕 레이어 조건 설정 업데이트
// 레이어 조건 설정 업데이트
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
@ -787,6 +787,90 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo
}
};
// ========================================
// 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 (
req: AuthenticatedRequest,

View File

@ -36,6 +36,12 @@ import {
syncMenuToScreenGroupsController,
getSyncStatusController,
syncAllCompaniesController,
// POP 전용 화면 그룹
getPopScreenGroups,
createPopScreenGroup,
updatePopScreenGroup,
deletePopScreenGroup,
ensurePopRootGroup,
} from "../controllers/screenGroupController";
const router = Router();
@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만)
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;

View File

@ -26,6 +26,10 @@ import {
getLayoutV1,
getLayoutV2,
saveLayoutV2,
getLayoutPop,
saveLayoutPop,
deleteLayoutPop,
getScreenIdsWithPopLayout,
generateScreenCode,
generateMultipleScreenCodes,
assignScreenToMenu,
@ -88,12 +92,18 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 🆕 레이어 관리
// 레이어 관리
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 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.get("/menus/:menuObjid/screens", getScreensByMenu);

View File

@ -5348,6 +5348,322 @@ export class ScreenManagementService {
params,
);
}
// ========================================
// 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

View File

@ -0,0 +1,390 @@
"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

@ -0,0 +1,340 @@
"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

@ -0,0 +1,971 @@
"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

@ -0,0 +1,661 @@
"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

@ -0,0 +1,14 @@
/**
* 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

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

View File

@ -0,0 +1,31 @@
// 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

@ -0,0 +1,438 @@
"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

@ -0,0 +1,98 @@
"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

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

View File

@ -0,0 +1,564 @@
"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

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

View File

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

View File

@ -0,0 +1,395 @@
// 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

@ -0,0 +1,562 @@
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

@ -0,0 +1,347 @@
"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

@ -0,0 +1,195 @@
"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

@ -0,0 +1,442 @@
"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

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

View File

@ -26,9 +26,10 @@ interface CreateScreenModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (screen: ScreenDefinition) => void;
isPop?: boolean; // POP 화면 생성 모드
}
export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) {
export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop = false }: CreateScreenModalProps) {
const { user } = useAuth();
const [screenName, setScreenName] = useState("");
@ -246,6 +247,19 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
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 = {
...created,
@ -278,7 +292,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle>{isPop ? "새 POP 화면 생성" : "새 화면 생성"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">

View File

@ -133,6 +133,9 @@ interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
// POP 모드 지원
isPop?: boolean;
defaultDevicePreview?: "mobile" | "tablet";
}
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
@ -159,7 +162,15 @@ const panelConfigs: PanelConfig[] = [
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
export default function ScreenDesigner({
selectedScreen,
onBackToList,
onScreenUpdate,
isPop = false,
defaultDevicePreview = "tablet"
}: ScreenDesignerProps) {
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
@ -1472,9 +1483,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
}
// V2 API 사용 여부에 따라 분기
// V2/POP API 사용 여부에 따라 분기
let response: any;
if (USE_V2_API) {
if (USE_POP_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);
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
@ -2000,12 +2017,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
// V2 API 사용 여부에 따라 분기
if (USE_V2_API) {
// 🆕 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
if (USE_POP_API) {
// POP 모드: screen_layouts_pop 테이블에 저장
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
const currentLayerId = activeLayerIdRef.current || 1;
const v2Layout = convertLegacyToV2(layoutWithResolution);
// layerId를 포함하여 저장
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
@ -2032,6 +2051,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
}, [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 () => {
if (!selectedScreen?.screenId) {
@ -2110,8 +2141,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 자동 저장 (매핑 정보가 손실되지 않도록)
try {
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(updatedLayout);
const v2Layout = convertLegacyToV2(updatedLayout);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
@ -5522,9 +5555,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
});
// V2 API 사용 여부에 따라 분기
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(layoutWithResolution);
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
@ -5913,6 +5948,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onPreview={isPop ? handlePopPreview : undefined}
onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,45 @@
"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

@ -0,0 +1,211 @@
"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

@ -0,0 +1,207 @@
"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

@ -0,0 +1,182 @@
/**
* 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,28 +213,54 @@ export const screenApi = {
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
},
// 🆕 레이어 목록 조회
// 레이어 목록 조회
getScreenLayers: async (screenId: number): Promise<any[]> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers`);
return response.data.data || [];
},
// 🆕 특정 레이어 레이아웃 조회
// 특정 레이어 레이아웃 조회
getLayerLayout: async (screenId: number, layerId: number): Promise<any> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers/${layerId}/layout`);
return response.data.data;
},
// 🆕 레이어 삭제
// 레이어 삭제
deleteLayer: async (screenId: number, layerId: number): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}/layers/${layerId}`);
},
// 🆕 레이어 조건 설정 업데이트
// 레이어 조건 설정 업데이트
updateLayerCondition: async (screenId: number, layerId: number, conditionConfig: any, layerName?: string): Promise<void> => {
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
},
// ========================================
// 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 (
screenId: number,

View File

@ -0,0 +1,268 @@
"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

@ -0,0 +1,20 @@
"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

@ -0,0 +1,831 @@
"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

@ -0,0 +1,88 @@
/**
* 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

@ -0,0 +1,231 @@
/**
* 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,6 +48,7 @@
"@types/d3": "^7.4.3",
"@types/leaflet": "^1.9.21",
"@types/qrcode": "^1.5.6",
"@types/react-grid-layout": "^1.3.6",
"@types/react-window": "^1.8.8",
"@types/three": "^0.180.0",
"@xyflow/react": "^12.8.4",
@ -76,6 +77,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-is": "^18.3.1",
@ -6210,6 +6212,15 @@
"@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": {
"version": "0.32.2",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz",
@ -9776,6 +9787,12 @@
"dev": true,
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@ -11103,7 +11120,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -11711,7 +11727,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@ -12142,7 +12157,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -12771,7 +12785,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -12783,7 +12796,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
@ -13183,6 +13195,38 @@
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"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": {
"version": "7.66.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
@ -13298,6 +13342,20 @@
}
}
},
"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": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
@ -13637,6 +13695,12 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"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": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",

View File

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

286
popdocs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,286 @@
# 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)*

1205
popdocs/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

646
popdocs/FILES.md Normal file
View File

@ -0,0 +1,646 @@
# 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 그리드 시스템 기준)*

120
popdocs/INDEX.md Normal file
View File

@ -0,0 +1,120 @@
# 기능별 색인
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
> **검색 팁**: 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 | 좌표 계산, 겹침 감지, 자동 배치 |
---
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*

150
popdocs/PLAN.md Normal file
View File

@ -0,0 +1,150 @@
# 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 대기)*

254
popdocs/PROBLEMS.md Normal file
View File

@ -0,0 +1,254 @@
# 문제-해결 색인
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
> **검색 팁**: 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 |
---
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*

110
popdocs/README.md Normal file
View File

@ -0,0 +1,110 @@
# 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)*

574
popdocs/SAVE_RULES.md Normal file
View File

@ -0,0 +1,574 @@
# 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*

236
popdocs/SPEC.md Normal file
View File

@ -0,0 +1,236 @@
# 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)*

126
popdocs/STATUS.md Normal file
View File

@ -0,0 +1,126 @@
# 현재 상태
> **마지막 업데이트**: 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)*

302
popdocs/WORKFLOW_PROMPTS.md Normal file
View File

@ -0,0 +1,302 @@
# 워크플로우 프롬프트
> 각 작업 단계에서 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

@ -0,0 +1,227 @@
# 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

@ -0,0 +1,389 @@
# 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

@ -0,0 +1,763 @@
# 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

@ -0,0 +1,329 @@
# 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

@ -0,0 +1,480 @@
# 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

@ -0,0 +1,518 @@
# 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

@ -0,0 +1,658 @@
# 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 화면 시스템

1041
popdocs/archive/POPUPDATE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,760 @@
# 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

@ -0,0 +1,285 @@
# 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

@ -0,0 +1,153 @@
# 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

@ -0,0 +1,205 @@
# 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

@ -0,0 +1,183 @@
# 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

@ -0,0 +1,240 @@
# 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

@ -0,0 +1,391 @@
# 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 완료)*

461
popdocs/components-spec.md Normal file
View File

@ -0,0 +1,461 @@
# 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

@ -0,0 +1,119 @@
# 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

@ -0,0 +1,690 @@
# 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

@ -0,0 +1,143 @@
# 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

@ -0,0 +1,143 @@
# 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

@ -0,0 +1,181 @@
# 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

@ -0,0 +1,220 @@
# 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

@ -0,0 +1,177 @@
# 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

@ -0,0 +1,239 @@
# 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 등)
- 데이터 바인딩 연결
- 워크플로우 연동