Merge branch 'main' into barcode

This commit is contained in:
kjs 2026-03-04 20:54:46 +09:00
commit 5f3b144b12
41 changed files with 7560 additions and 475 deletions

View File

@ -1,9 +1,5 @@
{ {
"mcpServers": { "mcpServers": {
"agent-orchestrator": {
"command": "node",
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
},
"Framelink Figma MCP": { "Framelink Figma MCP": {
"command": "npx", "command": "npx",
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]

View File

@ -115,6 +115,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@ -242,6 +243,7 @@ app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/common-codes", commonCodeRoutes); app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes); app.use("/api/files", fileRoutes);

View File

@ -20,7 +20,7 @@ const pool = getPool();
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const companyCode = req.user?.companyCode || "*"; const companyCode = req.user?.companyCode || "*";
const { page = 1, size = 20, searchTerm } = req.query; const { page = 1, size = 20, searchTerm, excludePop } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string); const offset = (parseInt(page as string) - 1) * parseInt(size as string);
let whereClause = "WHERE 1=1"; let whereClause = "WHERE 1=1";
@ -34,6 +34,11 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
paramIndex++; paramIndex++;
} }
// POP 그룹 제외 (PC 화면관리용)
if (excludePop === "true") {
whereClause += ` AND (hierarchy_path IS NULL OR (hierarchy_path NOT LIKE 'POP/%' AND hierarchy_path != 'POP'))`;
}
// 검색어 필터링 // 검색어 필터링
if (searchTerm) { if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
@ -2574,11 +2579,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
const companyCode = req.user?.companyCode || "*"; const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query; const { searchTerm } = req.query;
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'"; let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')";
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시) // 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
if (companyCode !== "*") { if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`; whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode); params.push(companyCode);
@ -2592,11 +2597,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
paramIndex++; paramIndex++;
} }
// POP 그룹 조회 (계층 구조를 위해 전체 조회) // POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
const dataQuery = ` const dataQuery = `
SELECT SELECT
sg.*, sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, (SELECT COUNT(*) FROM screen_group_screens sgs
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count,
(SELECT json_agg( (SELECT json_agg(
json_build_object( json_build_object(
'id', sgs.id, 'id', sgs.id,
@ -2609,7 +2616,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
) ORDER BY sgs.display_order ) ORDER BY sgs.display_order
) FROM screen_group_screens sgs ) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
) as screens ) as screens
FROM screen_groups sg FROM screen_groups sg
${whereClause} ${whereClause}
@ -2768,6 +2776,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
const existing = await pool.query(checkQuery, checkParams); const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) { if (existing.rows.length === 0) {
// 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공
const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]);
if (anyGroup.rows.length > 0) {
return res.status(403).json({
success: false,
message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.`
});
}
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
} }
@ -2782,7 +2798,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id] [id]
); );
if (parseInt(childCheck.rows[0].count) > 0) { if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." }); return res.status(400).json({
success: false,
message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.`
});
} }
// 연결된 화면 확인 // 연결된 화면 확인
@ -2791,7 +2810,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id] [id]
); );
if (parseInt(screenCheck.rows[0].count) > 0) { if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." }); return res.status(400).json({
success: false,
message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.`
});
} }
// 삭제 // 삭제
@ -2806,33 +2828,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
} }
}; };
// POP 루트 그룹 확보 (없으면 자동 생성) // POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포)
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => { export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const companyCode = req.user?.companyCode || "*"; const companyCode = req.user?.companyCode || "*";
// POP 루트 그룹 확인 // 최고관리자만 자동 생성
const checkQuery = ` if (companyCode !== "*") {
SELECT * FROM screen_groups const existing = await pool.query(
WHERE hierarchy_path = 'POP' AND company_code = $1 `SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
`; [companyCode]
const existing = await pool.query(checkQuery, [companyCode]); );
if (existing.rows.length > 0) {
if (existing.rows.length > 0) { return res.json({ success: true, data: existing.rows[0] });
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." }); }
return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." });
}
// 최고관리자(*): 루트 그룹 확인 후 없으면 생성
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = '*'
`;
const existing = await pool.query(checkQuery, []);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
} }
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = ` const insertQuery = `
INSERT INTO screen_groups ( INSERT INTO screen_groups (
group_name, group_code, hierarchy_path, company_code, group_name, group_code, hierarchy_path, company_code,
description, display_order, is_active, writer description, display_order, is_active, writer
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2) ) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
RETURNING * RETURNING *
`; `;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]); const result = await pool.query(insertQuery, [req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode }); logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." }); res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) { } catch (error: any) {

View File

@ -6,7 +6,7 @@ import { AuthenticatedRequest } from "../types/auth";
export const getScreens = async (req: AuthenticatedRequest, res: Response) => { export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const userCompanyCode = (req.user as any).companyCode; const userCompanyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm, companyCode } = req.query; const { page = 1, size = 20, searchTerm, companyCode, excludePop } = req.query;
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용) // 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
// 아니면 현재 사용자의 companyCode 사용 // 아니면 현재 사용자의 companyCode 사용
@ -24,7 +24,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
targetCompanyCode, targetCompanyCode,
parseInt(page as string), parseInt(page as string),
parseInt(size as string), parseInt(size as string),
searchTerm as string // 검색어 전달 searchTerm as string,
{ excludePop: excludePop === "true" },
); );
res.json({ res.json({
@ -1364,3 +1365,82 @@ export const copyCascadingRelation = async (
}); });
} }
}; };
// POP 화면 연결 분석
export const analyzePopScreenLinks = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const result = await screenManagementService.analyzePopScreenLinks(
parseInt(screenId),
companyCode,
);
res.json({ success: true, data: result });
} catch (error: any) {
console.error("POP 화면 연결 분석 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 연결 분석에 실패했습니다.",
});
}
};
// POP 화면 배포 (다른 회사로 복사)
export const deployPopScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screens, targetCompanyCode, groupStructure } = req.body;
const { companyCode, userId } = req.user as any;
if (!screens || !Array.isArray(screens) || screens.length === 0) {
res.status(400).json({
success: false,
message: "배포할 화면 목록이 필요합니다.",
});
return;
}
if (!targetCompanyCode) {
res.status(400).json({
success: false,
message: "대상 회사 코드가 필요합니다.",
});
return;
}
if (companyCode !== "*") {
res.status(403).json({
success: false,
message: "최고 관리자만 POP 화면을 배포할 수 있습니다.",
});
return;
}
const result = await screenManagementService.deployPopScreens({
screens,
groupStructure: groupStructure || undefined,
targetCompanyCode,
companyCode,
userId,
});
res.json({
success: true,
data: result,
message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`,
});
} catch (error: any) {
console.error("POP 화면 배포 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 배포에 실패했습니다.",
});
}
};

View File

@ -0,0 +1,280 @@
import { Router, Request, Response } from "express";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
function isSafeIdentifier(name: string): boolean {
return SAFE_IDENTIFIER.test(name);
}
interface MappingInfo {
targetTable: string;
columnMapping: Record<string, string>;
}
interface StatusConditionRule {
whenColumn: string;
operator: string;
whenValue: string;
thenValue: string;
}
interface ConditionalValueRule {
conditions: StatusConditionRule[];
defaultValue?: string;
}
interface StatusChangeRuleBody {
targetTable: string;
targetColumn: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
valueType: "fixed" | "conditional";
fixedValue?: string;
conditionalValue?: ConditionalValueRule;
// 하위호환: 기존 형식
value?: string;
condition?: string;
}
interface ExecuteActionBody {
action: string;
data: {
items?: Record<string, unknown>[];
fieldValues?: Record<string, unknown>;
};
mappings?: {
cardList?: MappingInfo | null;
field?: MappingInfo | null;
};
statusChanges?: StatusChangeRuleBody[];
}
function resolveStatusValue(
valueType: string,
fixedValue: string,
conditionalValue: ConditionalValueRule | undefined,
item: Record<string, unknown>
): string {
if (valueType !== "conditional" || !conditionalValue) return fixedValue;
for (const cond of conditionalValue.conditions) {
const actual = String(item[cond.whenColumn] ?? "");
const expected = cond.whenValue;
let match = false;
switch (cond.operator) {
case "=": match = actual === expected; break;
case "!=": match = actual !== expected; break;
case ">": match = parseFloat(actual) > parseFloat(expected); break;
case "<": match = parseFloat(actual) < parseFloat(expected); break;
case ">=": match = parseFloat(actual) >= parseFloat(expected); break;
case "<=": match = parseFloat(actual) <= parseFloat(expected); break;
default: match = actual === expected;
}
if (match) return cond.thenValue;
}
return conditionalValue.defaultValue ?? fixedValue;
}
router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = (req as any).user?.companyCode;
const userId = (req as any).user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
const items = data?.items ?? [];
const fieldValues = data?.fieldValues ?? {};
logger.info("[pop/execute-action] 요청", {
action,
companyCode,
userId,
itemCount: items.length,
hasFieldValues: Object.keys(fieldValues).length > 0,
hasMappings: !!mappings,
statusChangeCount: statusChanges?.length ?? 0,
});
await client.query("BEGIN");
let processedCount = 0;
let insertedCount = 0;
if (action === "inbound-confirm") {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
if (!isSafeIdentifier(cardMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(item[sourceField] ?? null);
}
if (fieldMapping?.targetTable === cardMapping.targetTable) {
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
if (columns.includes(`"${targetColumn}"`)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
logger.info("[pop/execute-action] INSERT 실행", {
table: cardMapping.targetTable,
columnCount: columns.length,
});
await client.query(sql, values);
insertedCount++;
}
}
}
if (
fieldMapping?.targetTable &&
Object.keys(fieldMapping.columnMapping).length > 0 &&
fieldMapping.targetTable !== cardMapping?.targetTable
) {
if (!isSafeIdentifier(fieldMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
}
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
await client.query(sql, values);
}
}
// 2. 상태 변경 규칙 실행 (설정 기반)
if (statusChanges && statusChanges.length > 0) {
for (const rule of statusChanges) {
if (!rule.targetTable || !rule.targetColumn) continue;
if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) {
logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn });
continue;
}
const valueType = rule.valueType ?? "fixed";
const fixedValue = rule.fixedValue ?? rule.value ?? "";
const lookupMode = rule.lookupMode ?? "auto";
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
let itemField: string;
let pkColumn: string;
if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) {
if (!isSafeIdentifier(rule.manualPkColumn)) {
logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn });
continue;
}
itemField = rule.manualItemField;
pkColumn = rule.manualPkColumn;
logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable });
} else if (rule.targetTable === "cart_items") {
itemField = "__cart_id";
pkColumn = "id";
} else {
itemField = "__cart_row_key";
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[rule.targetTable]
);
pkColumn = pkResult.rows[0]?.attname || "id";
}
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
if (lookupValues.length === 0) {
logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField });
continue;
}
if (valueType === "fixed") {
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
processedCount += lookupValues.length;
} else {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
await client.query(
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolvedValue, companyCode, lookupValues[i]]
);
processedCount++;
}
}
logger.info("[pop/execute-action] 상태 변경 실행", {
table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length,
});
}
}
}
await client.query("COMMIT");
logger.info("[pop/execute-action] 완료", {
action,
companyCode,
processedCount,
insertedCount,
});
return res.json({
success: true,
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
data: { processedCount, insertedCount },
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("[pop/execute-action] 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "처리 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
});
export default router;

View File

@ -51,6 +51,8 @@ import {
updateZone, updateZone,
deleteZone, deleteZone,
addLayerToZone, addLayerToZone,
analyzePopScreenLinks,
deployPopScreens,
} from "../controllers/screenManagementController"; } from "../controllers/screenManagementController";
const router = express.Router(); const router = express.Router();
@ -145,4 +147,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
// 연쇄관계 설정 복제 // 연쇄관계 설정 복제
router.post("/copy-cascading-relation", copyCascadingRelation); router.post("/copy-cascading-relation", copyCascadingRelation);
// POP 화면 배포 (다른 회사로 복사)
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
router.post("/deploy-pop-screens", deployPopScreens);
export default router; export default router;

View File

@ -108,42 +108,49 @@ export class ScreenManagementService {
companyCode: string, companyCode: string,
page: number = 1, page: number = 1,
size: number = 20, size: number = 20,
searchTerm?: string, // 검색어 추가 searchTerm?: string,
options?: { excludePop?: boolean },
): Promise<PaginatedResponse<ScreenDefinition>> { ): Promise<PaginatedResponse<ScreenDefinition>> {
const offset = (page - 1) * size; const offset = (page - 1) * size;
// WHERE 절 동적 생성 // WHERE 절 동적 생성
const whereConditions: string[] = ["is_active != 'D'"]; const whereConditions: string[] = ["sd.is_active != 'D'"];
const params: any[] = []; const params: any[] = [];
if (companyCode !== "*") { if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`); whereConditions.push(`sd.company_code = $${params.length + 1}`);
params.push(companyCode); params.push(companyCode);
} }
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
if (searchTerm && searchTerm.trim() !== "") { if (searchTerm && searchTerm.trim() !== "") {
whereConditions.push(`( whereConditions.push(`(
screen_name ILIKE $${params.length + 1} OR sd.screen_name ILIKE $${params.length + 1} OR
screen_code ILIKE $${params.length + 1} OR sd.screen_code ILIKE $${params.length + 1} OR
table_name ILIKE $${params.length + 1} sd.table_name ILIKE $${params.length + 1}
)`); )`);
params.push(`%${searchTerm.trim()}%`); params.push(`%${searchTerm.trim()}%`);
} }
// POP 화면 제외 필터: screen_layouts_pop에 레이아웃이 있는 화면 제외
if (options?.excludePop) {
whereConditions.push(
`NOT EXISTS (SELECT 1 FROM screen_layouts_pop slp WHERE slp.screen_id = sd.screen_id)`
);
}
const whereSQL = whereConditions.join(" AND "); const whereSQL = whereConditions.join(" AND ");
// 페이징 쿼리 (Raw Query) // 페이징 쿼리 (Raw Query)
const [screens, totalResult] = await Promise.all([ const [screens, totalResult] = await Promise.all([
query<any>( query<any>(
`SELECT * FROM screen_definitions `SELECT sd.* FROM screen_definitions sd
WHERE ${whereSQL} WHERE ${whereSQL}
ORDER BY created_date DESC ORDER BY sd.created_date DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset], [...params, size, offset],
), ),
query<{ count: string }>( query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions `SELECT COUNT(*)::text as count FROM screen_definitions sd
WHERE ${whereSQL}`, WHERE ${whereSQL}`,
params, params,
), ),
@ -5814,28 +5821,24 @@ export class ScreenManagementService {
async getScreenIdsWithPopLayout( async getScreenIdsWithPopLayout(
companyCode: string, companyCode: string,
): Promise<number[]> { ): Promise<number[]> {
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
console.log(`회사 코드: ${companyCode}`);
let result: { screen_id: number }[]; let result: { screen_id: number }[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 POP 레이아웃 조회
result = await query<{ screen_id: number }>( result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop`, `SELECT DISTINCT screen_id FROM screen_layouts_pop`,
[], [],
); );
} else { } else {
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 // 일반 회사: 해당 회사 레이아웃 조회 (company_code='*'는 최고관리자 전용)
result = await query<{ screen_id: number }>( result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop `SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code = $1 OR company_code = '*'`, WHERE company_code = $1`,
[companyCode], [companyCode],
); );
} }
const screenIds = result.map((r) => r.screen_id); const screenIds = result.map((r) => r.screen_id);
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}`); logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
return screenIds; return screenIds;
} }
@ -5873,6 +5876,512 @@ export class ScreenManagementService {
console.log(`POP 레이아웃 삭제 완료`); console.log(`POP 레이아웃 삭제 완료`);
return true; return true;
} }
// ============================================================
// POP 화면 배포 (다른 회사로 복사)
// ============================================================
/**
* POP layout_data
*/
async analyzePopScreenLinks(
screenId: number,
companyCode: string,
): Promise<{
linkedScreenIds: number[];
references: Array<{
componentId: string;
referenceType: string;
targetScreenId: number;
}>;
}> {
const layoutResult = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
if (!layoutResult?.layout_data) {
return { linkedScreenIds: [], references: [] };
}
const layoutData = layoutResult.layout_data;
const references: Array<{
componentId: string;
referenceType: string;
targetScreenId: number;
}> = [];
const scanComponents = (components: Record<string, any>) => {
for (const [compId, comp] of Object.entries(components)) {
const config = (comp as any).config || {};
if (config.cart?.cartScreenId) {
const sid = parseInt(config.cart.cartScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "cartScreenId",
targetScreenId: sid,
});
}
}
if (config.cartListMode?.sourceScreenId) {
const sid =
typeof config.cartListMode.sourceScreenId === "number"
? config.cartListMode.sourceScreenId
: parseInt(config.cartListMode.sourceScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "sourceScreenId",
targetScreenId: sid,
});
}
}
if (Array.isArray(config.followUpActions)) {
for (const action of config.followUpActions) {
if (action.targetScreenId) {
const sid = parseInt(action.targetScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "targetScreenId",
targetScreenId: sid,
});
}
}
}
}
if (config.action?.modalScreenId) {
const sid = parseInt(config.action.modalScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "modalScreenId",
targetScreenId: sid,
});
}
}
}
};
if (layoutData.components) {
scanComponents(layoutData.components);
}
if (Array.isArray(layoutData.modals)) {
for (const modal of layoutData.modals) {
if (modal.components) {
scanComponents(modal.components);
}
}
}
const linkedScreenIds = [
...new Set(references.map((r) => r.targetScreenId)),
];
return { linkedScreenIds, references };
}
/**
* POP ( )
* - screen_definitions + screen_layouts_pop
* - (cartScreenId, sourceScreenId )
* - numberingRuleId
*/
async deployPopScreens(data: {
screens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
groupStructure?: {
sourceGroupId: number;
groupName: string;
groupCode: string;
children?: Array<{
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
}>;
screenIds: number[];
};
targetCompanyCode: string;
companyCode: string;
userId: string;
}): Promise<{
deployedScreens: Array<{
sourceScreenId: number;
newScreenId: number;
screenName: string;
screenCode: string;
}>;
createdGroups?: number;
}> {
if (data.companyCode !== "*") {
throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다.");
}
return await transaction(async (client) => {
const screenIdMap = new Map<number, number>();
const deployedScreens: Array<{
sourceScreenId: number;
newScreenId: number;
screenName: string;
screenCode: string;
}> = [];
// 1단계: screen_definitions 복사
for (const screen of data.screens) {
const sourceResult = await client.query<any>(
`SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screen.sourceScreenId],
);
if (sourceResult.rows.length === 0) {
throw new Error(
`원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`,
);
}
const sourceScreen = sourceResult.rows[0];
const existingResult = await client.query<any>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[screen.screenCode, data.targetCompanyCode],
);
if (existingResult.rows.length > 0) {
throw new Error(
`화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`,
);
}
const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name,
is_active, created_by, created_date, updated_by, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
RETURNING *`,
[
screen.screenCode,
screen.screenName,
sourceScreen.description,
data.targetCompanyCode,
sourceScreen.table_name,
"Y",
data.userId,
],
);
const newScreen = newScreenResult.rows[0];
screenIdMap.set(screen.sourceScreenId, newScreen.screen_id);
deployedScreens.push({
sourceScreenId: screen.sourceScreenId,
newScreenId: newScreen.screen_id,
screenName: screen.screenName,
screenCode: screen.screenCode,
});
logger.info("POP 화면 배포 - screen_definitions 생성", {
sourceScreenId: screen.sourceScreenId,
newScreenId: newScreen.screen_id,
targetCompanyCode: data.targetCompanyCode,
});
}
// 2단계: screen_layouts_pop 복사 + 참조 치환
for (const screen of data.screens) {
const newScreenId = screenIdMap.get(screen.sourceScreenId);
if (!newScreenId) continue;
// 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback)
let layoutResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = '*'`,
[screen.sourceScreenId],
);
let layoutData = layoutResult.rows[0]?.layout_data;
if (!layoutData) {
const fallbackResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 LIMIT 1`,
[screen.sourceScreenId],
);
layoutData = fallbackResult.rows[0]?.layout_data;
}
if (!layoutData) {
logger.warn("POP 레이아웃 없음, 건너뜀", {
sourceScreenId: screen.sourceScreenId,
});
continue;
}
const updatedLayoutData = this.updatePopLayoutScreenReferences(
JSON.parse(JSON.stringify(layoutData)),
screenIdMap,
);
await client.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`,
[
newScreenId,
data.targetCompanyCode,
JSON.stringify(updatedLayoutData),
data.userId,
],
);
logger.info("POP 레이아웃 복사 완료", {
sourceScreenId: screen.sourceScreenId,
newScreenId,
componentCount: Object.keys(updatedLayoutData.components || {})
.length,
});
}
// 3단계: 그룹 구조 복사 (groupStructure가 있는 경우)
let createdGroups = 0;
if (data.groupStructure) {
const gs = data.groupStructure;
// 대상 회사의 POP 루트 그룹 찾기/생성
let popRootResult = await client.query<any>(
`SELECT id FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`,
[data.targetCompanyCode],
);
let popRootId: number;
if (popRootResult.rows.length > 0) {
popRootId = popRootResult.rows[0].id;
} else {
const createRootResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order)
VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`,
[data.targetCompanyCode, data.userId],
);
popRootId = createRootResult.rows[0].id;
}
// 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가)
const mainGroupCode = gs.groupCode + "_COPY";
const dupCheck = await client.query<any>(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[mainGroupCode, data.targetCompanyCode],
);
let mainGroupId: number;
if (dupCheck.rows.length > 0) {
mainGroupId = dupCheck.rows[0].id;
} else {
const mainGroupResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`,
[
gs.groupName,
mainGroupCode,
`POP/${mainGroupCode}`,
data.targetCompanyCode,
popRootId,
data.userId,
],
);
mainGroupId = mainGroupResult.rows[0].id;
createdGroups++;
}
// 메인 그룹에 화면 연결
for (const oldScreenId of gs.screenIds) {
const newScreenId = screenIdMap.get(oldScreenId);
if (!newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
VALUES ($1, $2, 'main', 0, 'N', $3)
ON CONFLICT DO NOTHING`,
[mainGroupId, newScreenId, data.targetCompanyCode],
);
}
// 하위 그룹 생성 + 화면 연결
if (gs.children) {
for (let i = 0; i < gs.children.length; i++) {
const child = gs.children[i];
const childGroupCode = child.groupCode + "_COPY";
const childDupCheck = await client.query<any>(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[childGroupCode, data.targetCompanyCode],
);
let childGroupId: number;
if (childDupCheck.rows.length > 0) {
childGroupId = childDupCheck.rows[0].id;
} else {
const childResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`,
[
child.groupName,
childGroupCode,
`POP/${mainGroupCode}/${childGroupCode}`,
data.targetCompanyCode,
mainGroupId,
data.userId,
i,
],
);
childGroupId = childResult.rows[0].id;
createdGroups++;
}
for (const oldScreenId of child.screenIds) {
const newScreenId = screenIdMap.get(oldScreenId);
if (!newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
VALUES ($1, $2, 'main', 0, 'N', $3)
ON CONFLICT DO NOTHING`,
[childGroupId, newScreenId, data.targetCompanyCode],
);
}
}
}
logger.info("POP 그룹 구조 복사 완료", {
targetCompanyCode: data.targetCompanyCode,
createdGroups,
mainGroupName: gs.groupName,
});
}
return { deployedScreens, createdGroups };
});
}
/**
* POP layout_data screen_id
* componentId, connectionId는
*/
private updatePopLayoutScreenReferences(
layoutData: any,
screenIdMap: Map<number, number>,
): any {
if (!layoutData?.components) return layoutData;
const updateComponents = (
components: Record<string, any>,
): Record<string, any> => {
const updated: Record<string, any> = {};
for (const [compId, comp] of Object.entries(components)) {
const updatedComp = JSON.parse(JSON.stringify(comp));
const config = updatedComp.config || {};
// cart.cartScreenId (string)
if (config.cart?.cartScreenId) {
const oldId = parseInt(config.cart.cartScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
config.cart.cartScreenId = String(newId);
logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`);
}
}
// cartListMode.sourceScreenId (number)
if (config.cartListMode?.sourceScreenId) {
const oldId =
typeof config.cartListMode.sourceScreenId === "number"
? config.cartListMode.sourceScreenId
: parseInt(config.cartListMode.sourceScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
config.cartListMode.sourceScreenId = newId;
logger.info(
`POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`,
);
}
}
// followUpActions[].targetScreenId (string)
if (Array.isArray(config.followUpActions)) {
for (const action of config.followUpActions) {
if (action.targetScreenId) {
const oldId = parseInt(action.targetScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
action.targetScreenId = String(newId);
logger.info(
`POP 참조 치환: targetScreenId ${oldId} -> ${newId}`,
);
}
}
}
}
// action.modalScreenId (숫자형이면 화면 참조로 간주)
if (config.action?.modalScreenId) {
const oldId = parseInt(config.action.modalScreenId);
if (!isNaN(oldId)) {
const newId = screenIdMap.get(oldId);
if (newId) {
config.action.modalScreenId = String(newId);
logger.info(
`POP 참조 치환: modalScreenId ${oldId} -> ${newId}`,
);
}
}
}
// numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요)
if (config.numberingRuleId) {
logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`);
config.numberingRuleId = "";
}
if (config.autoGenMappings) {
for (const mapping of Object.values(config.autoGenMappings) as any[]) {
if (mapping?.numberingRuleId) {
logger.info(
`POP 채번규칙 초기화: ${mapping.numberingRuleId}`,
);
mapping.numberingRuleId = "";
}
}
}
updatedComp.config = config;
updated[compId] = updatedComp;
}
return updated;
};
layoutData.components = updateComponents(layoutData.components);
if (Array.isArray(layoutData.modals)) {
for (const modal of layoutData.modals) {
if (modal.components) {
modal.components = updateComponents(modal.components);
}
}
}
return layoutData;
}
} }
// 서비스 인스턴스 export // 서비스 인스턴스 export

View File

@ -13,6 +13,7 @@ import {
Settings, Settings,
LayoutGrid, LayoutGrid,
GitBranch, GitBranch,
Upload,
} from "lucide-react"; } from "lucide-react";
import { PopDesigner } from "@/components/pop/designer"; import { PopDesigner } from "@/components/pop/designer";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
@ -27,6 +28,7 @@ import {
PopScreenPreview, PopScreenPreview,
PopScreenFlowView, PopScreenFlowView,
PopScreenSettingModal, PopScreenSettingModal,
PopDeployModal,
} from "@/components/pop/management"; } from "@/components/pop/management";
import { PopScreenGroup } from "@/lib/api/popScreenGroup"; import { PopScreenGroup } from "@/lib/api/popScreenGroup";
@ -62,6 +64,10 @@ export default function PopScreenManagementPage() {
// UI 상태 // UI 상태
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
const [deployGroupName, setDeployGroupName] = useState("");
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet"); const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview"); const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
@ -235,6 +241,21 @@ export default function PopScreenManagementPage() {
<Button variant="outline" size="icon" onClick={loadScreens}> <Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{selectedScreen && (
<Button
variant="outline"
onClick={() => {
setDeployGroupScreens([]);
setDeployGroupName("");
setDeployGroupInfo(undefined);
setIsDeployModalOpen(true);
}}
className="gap-2"
>
<Upload className="h-4 w-4" />
</Button>
)}
<Button onClick={() => setIsCreateOpen(true)} className="gap-2"> <Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
POP POP
@ -264,7 +285,7 @@ export default function PopScreenManagementPage() {
) : ( ) : (
<div className="flex-1 overflow-hidden flex"> <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="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background overflow-hidden">
{/* 검색 */} {/* 검색 */}
<div className="shrink-0 p-3 border-b"> <div className="shrink-0 p-3 border-b">
<div className="relative"> <div className="relative">
@ -290,6 +311,24 @@ export default function PopScreenManagementPage() {
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect} onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen} onScreenDesign={handleDesignScreen}
onScreenSettings={(screen) => {
setSelectedScreen(screen);
setIsSettingModalOpen(true);
}}
onScreenCopy={(screen) => {
setSelectedScreen(screen);
setDeployGroupScreens([]);
setDeployGroupName("");
setDeployGroupInfo(undefined);
setIsDeployModalOpen(true);
}}
onGroupCopy={(groupScreensList, groupName, gInfo) => {
setSelectedScreen(null);
setDeployGroupScreens(groupScreensList);
setDeployGroupName(groupName);
setDeployGroupInfo(gInfo);
setIsDeployModalOpen(true);
}}
onGroupSelect={handleGroupSelect} onGroupSelect={handleGroupSelect}
searchTerm={searchTerm} searchTerm={searchTerm}
/> />
@ -383,6 +422,18 @@ export default function PopScreenManagementPage() {
}} }}
/> />
{/* POP 화면 배포 모달 */}
<PopDeployModal
open={isDeployModalOpen}
onOpenChange={setIsDeployModalOpen}
screen={selectedScreen}
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
groupName={deployGroupName || undefined}
groupInfo={deployGroupInfo}
allScreens={screens}
onDeployed={loadScreens}
/>
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>

View File

@ -38,7 +38,7 @@ export default function ScreenManagementPage() {
const loadScreens = useCallback(async () => { const loadScreens = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }); const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "", excludePop: true });
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환 // screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
if (result.data && result.data.length > 0) { if (result.data && result.data.length > 0) {
setScreens(result.data); setScreens(result.data);

View File

@ -285,7 +285,7 @@ function PopScreenViewPage() {
)} )}
{/* POP 화면 컨텐츠 */} {/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}> <div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */} {/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && ( {!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded"> <div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd"; import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout"; import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants"; import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의 // 컴포넌트 정의
@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: Search, icon: Search,
description: "조건 입력 (텍스트/날짜/선택/모달)", description: "조건 입력 (텍스트/날짜/선택/모달)",
}, },
{
type: "pop-field",
label: "입력 필드",
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -36,6 +36,15 @@ interface ConnectionEditorProps {
onRemoveConnection?: (connectionId: string) => void; onRemoveConnection?: (connectionId: string) => void;
} }
// ========================================
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
// ========================================
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
if (!meta?.sendable) return false;
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
}
// ======================================== // ========================================
// ConnectionEditor // ConnectionEditor
// ======================================== // ========================================
@ -75,6 +84,8 @@ export default function ConnectionEditor({
); );
} }
const isFilterSource = hasFilterSendable(meta);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{hasSendable && ( {hasSendable && (
@ -83,6 +94,7 @@ export default function ConnectionEditor({
meta={meta!} meta={meta!}
allComponents={allComponents} allComponents={allComponents}
outgoing={outgoing} outgoing={outgoing}
isFilterSource={isFilterSource}
onAddConnection={onAddConnection} onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection} onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection} onRemoveConnection={onRemoveConnection}
@ -92,7 +104,6 @@ export default function ConnectionEditor({
{hasReceivable && ( {hasReceivable && (
<ReceiveSection <ReceiveSection
component={component} component={component}
meta={meta!}
allComponents={allComponents} allComponents={allComponents}
incoming={incoming} incoming={incoming}
/> />
@ -105,7 +116,6 @@ export default function ConnectionEditor({
// 대상 컴포넌트에서 정보 추출 // 대상 컴포넌트에서 정보 추출
// ======================================== // ========================================
/** 화면에 표시 중인 컬럼만 추출 */
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return []; if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>; const cfg = comp.config as Record<string, unknown>;
@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
return cols; return cols;
} }
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return ""; if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>; const cfg = comp.config as Record<string, unknown>;
@ -143,6 +152,7 @@ interface SendSectionProps {
meta: ComponentConnectionMeta; meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[]; allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[]; outgoing: PopDataConnection[];
isFilterSource: boolean;
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void; onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void; onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void; onRemoveConnection?: (connectionId: string) => void;
@ -153,6 +163,7 @@ function SendSection({
meta, meta,
allComponents, allComponents,
outgoing, outgoing,
isFilterSource,
onAddConnection, onAddConnection,
onUpdateConnection, onUpdateConnection,
onRemoveConnection, onRemoveConnection,
@ -163,29 +174,42 @@ function SendSection({
<div className="space-y-3"> <div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium"> <Label className="flex items-center gap-1 text-xs font-medium">
<ArrowRight className="h-3 w-3 text-blue-500" /> <ArrowRight className="h-3 w-3 text-blue-500" />
()
</Label> </Label>
{/* 기존 연결 목록 */}
{outgoing.map((conn) => ( {outgoing.map((conn) => (
<div key={conn.id}> <div key={conn.id}>
{editingId === conn.id ? ( {editingId === conn.id ? (
<ConnectionForm isFilterSource ? (
component={component} <FilterConnectionForm
meta={meta} component={component}
allComponents={allComponents} meta={meta}
initial={conn} allComponents={allComponents}
onSubmit={(data) => { initial={conn}
onUpdateConnection?.(conn.id, data); onSubmit={(data) => {
setEditingId(null); onUpdateConnection?.(conn.id, data);
}} setEditingId(null);
onCancel={() => setEditingId(null)} }}
submitLabel="수정" onCancel={() => setEditingId(null)}
/> submitLabel="수정"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
)
) : ( ) : (
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2"> <div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
<span className="flex-1 truncate text-xs"> <span className="flex-1 truncate text-xs">
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} {conn.label || `${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
</span> </span>
<button <button
onClick={() => setEditingId(conn.id)} onClick={() => setEditingId(conn.id)}
@ -206,23 +230,131 @@ function SendSection({
</div> </div>
))} ))}
{/* 새 연결 추가 */} {isFilterSource ? (
<ConnectionForm <FilterConnectionForm
component={component} component={component}
meta={meta} meta={meta}
allComponents={allComponents} allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)} onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가" submitLabel="연결 추가"
/> />
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
)}
</div> </div>
); );
} }
// ======================================== // ========================================
// 연결 폼 (추가/수정 공용) // 단순 연결 폼 (이벤트 타입: "어디로" 1개만)
// ======================================== // ========================================
interface ConnectionFormProps { interface SimpleConnectionFormProps {
component: PopComponentDefinitionV5;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
onCancel?: () => void;
submitLabel: string;
}
function SimpleConnectionForm({
component,
allComponents,
initial,
onSubmit,
onCancel,
submitLabel,
}: SimpleConnectionFormProps) {
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const targetCandidates = allComponents.filter((c) => {
if (c.id === component.id) return false;
const reg = PopComponentRegistry.getComponent(c.type);
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
});
const handleSubmit = () => {
if (!selectedTargetId) return;
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
const srcLabel = component.label || component.id;
const tgtLabel = targetComp?.label || targetComp?.id || "?";
onSubmit({
sourceComponent: component.id,
sourceField: "",
sourceOutput: "_auto",
targetComponent: selectedTargetId,
targetField: "",
targetInput: "_auto",
label: `${srcLabel}${tgtLabel}`,
});
if (!initial) {
setSelectedTargetId("");
}
};
return (
<div className="space-y-2 rounded border border-dashed p-3">
{onCancel && (
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
{!onCancel && (
<p className="text-[10px] font-medium text-muted-foreground"> </p>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">?</span>
<Select
value={selectedTargetId}
onValueChange={setSelectedTargetId}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{targetCandidates.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.label || c.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedTargetId}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
{submitLabel}
</Button>
</div>
);
}
// ========================================
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
// ========================================
interface FilterConnectionFormProps {
component: PopComponentDefinitionV5; component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta; meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[]; allComponents: PopComponentDefinitionV5[];
@ -232,7 +364,7 @@ interface ConnectionFormProps {
submitLabel: string; submitLabel: string;
} }
function ConnectionForm({ function FilterConnectionForm({
component, component,
meta, meta,
allComponents, allComponents,
@ -240,7 +372,7 @@ function ConnectionForm({
onSubmit, onSubmit,
onCancel, onCancel,
submitLabel, submitLabel,
}: ConnectionFormProps) { }: FilterConnectionFormProps) {
const [selectedOutput, setSelectedOutput] = React.useState( const [selectedOutput, setSelectedOutput] = React.useState(
initial?.sourceOutput || meta.sendable[0]?.key || "" initial?.sourceOutput || meta.sendable[0]?.key || ""
); );
@ -272,32 +404,26 @@ function ConnectionForm({
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null; : null;
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
React.useEffect(() => { React.useEffect(() => {
if (!selectedOutput || !targetMeta?.receivable?.length) return; if (!selectedOutput || !targetMeta?.receivable?.length) return;
// 이미 선택된 값이 있으면 건드리지 않음
if (selectedTargetInput) return; if (selectedTargetInput) return;
const receivables = targetMeta.receivable; const receivables = targetMeta.receivable;
// 1) 같은 key가 있으면 자동 매칭
const exactMatch = receivables.find((r) => r.key === selectedOutput); const exactMatch = receivables.find((r) => r.key === selectedOutput);
if (exactMatch) { if (exactMatch) {
setSelectedTargetInput(exactMatch.key); setSelectedTargetInput(exactMatch.key);
return; return;
} }
// 2) receivable이 1개뿐이면 자동 선택
if (receivables.length === 1) { if (receivables.length === 1) {
setSelectedTargetInput(receivables[0].key); setSelectedTargetInput(receivables[0].key);
} }
}, [selectedOutput, targetMeta, selectedTargetInput]); }, [selectedOutput, targetMeta, selectedTargetInput]);
// 화면에 표시 중인 컬럼
const displayColumns = React.useMemo( const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined), () => extractDisplayColumns(targetComp || undefined),
[targetComp] [targetComp]
); );
// DB 테이블 전체 컬럼 (비동기 조회)
const tableName = React.useMemo( const tableName = React.useMemo(
() => extractTableName(targetComp || undefined), () => extractTableName(targetComp || undefined),
[targetComp] [targetComp]
@ -324,7 +450,6 @@ function ConnectionForm({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [tableName]); }, [tableName]);
// 표시 컬럼과 데이터 전용 컬럼 분리
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
const dataOnlyColumns = React.useMemo( const dataOnlyColumns = React.useMemo(
() => allDbColumns.filter((c) => !displaySet.has(c)), () => allDbColumns.filter((c) => !displaySet.has(c)),
@ -388,7 +513,6 @@ function ConnectionForm({
<p className="text-[10px] font-medium text-muted-foreground"> </p> <p className="text-[10px] font-medium text-muted-foreground"> </p>
)} )}
{/* 보내는 값 */}
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span> <span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedOutput} onValueChange={setSelectedOutput}> <Select value={selectedOutput} onValueChange={setSelectedOutput}>
@ -405,7 +529,6 @@ function ConnectionForm({
</Select> </Select>
</div> </div>
{/* 받는 컴포넌트 */}
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span> <span className="text-[10px] text-muted-foreground"> </span>
<Select <Select
@ -429,7 +552,6 @@ function ConnectionForm({
</Select> </Select>
</div> </div>
{/* 받는 방식 */}
{targetMeta && ( {targetMeta && (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span> <span className="text-[10px] text-muted-foreground"> </span>
@ -448,7 +570,6 @@ function ConnectionForm({
</div> </div>
)} )}
{/* 필터 설정: event 타입 연결이면 숨김 */}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-gray-50 p-2"> <div className="space-y-2 rounded bg-gray-50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p> <p className="text-[10px] font-medium text-muted-foreground"> </p>
@ -460,7 +581,6 @@ function ConnectionForm({
</div> </div>
) : hasAnyColumns ? ( ) : hasAnyColumns ? (
<div className="space-y-2"> <div className="space-y-2">
{/* 표시 컬럼 그룹 */}
{displayColumns.length > 0 && ( {displayColumns.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[9px] font-medium text-green-600"> </p> <p className="text-[9px] font-medium text-green-600"> </p>
@ -482,7 +602,6 @@ function ConnectionForm({
</div> </div>
)} )}
{/* 데이터 전용 컬럼 그룹 */}
{dataOnlyColumns.length > 0 && ( {dataOnlyColumns.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{displayColumns.length > 0 && ( {displayColumns.length > 0 && (
@ -522,7 +641,6 @@ function ConnectionForm({
</p> </p>
)} )}
{/* 필터 방식 */}
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}> <Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
@ -540,7 +658,6 @@ function ConnectionForm({
</div> </div>
)} )}
{/* 제출 버튼 */}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -556,19 +673,17 @@ function ConnectionForm({
} }
// ======================================== // ========================================
// 받기 섹션 (읽기 전용) // 받기 섹션 (읽기 전용: 연결된 소스만 표시)
// ======================================== // ========================================
interface ReceiveSectionProps { interface ReceiveSectionProps {
component: PopComponentDefinitionV5; component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[]; allComponents: PopComponentDefinitionV5[];
incoming: PopDataConnection[]; incoming: PopDataConnection[];
} }
function ReceiveSection({ function ReceiveSection({
component, component,
meta,
allComponents, allComponents,
incoming, incoming,
}: ReceiveSectionProps) { }: ReceiveSectionProps) {
@ -576,28 +691,11 @@ function ReceiveSection({
<div className="space-y-3"> <div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium"> <Label className="flex items-center gap-1 text-xs font-medium">
<Unlink2 className="h-3 w-3 text-green-500" /> <Unlink2 className="h-3 w-3 text-green-500" />
()
</Label> </Label>
<div className="space-y-1">
{meta.receivable.map((r) => (
<div
key={r.key}
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
>
<span className="font-medium">{r.label}</span>
{r.description && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
{r.description}
</p>
)}
</div>
))}
</div>
{incoming.length > 0 ? ( {incoming.length > 0 ? (
<div className="space-y-2"> <div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
{incoming.map((conn) => { {incoming.map((conn) => {
const sourceComp = allComponents.find( const sourceComp = allComponents.find(
(c) => c.id === conn.sourceComponent (c) => c.id === conn.sourceComponent
@ -605,9 +703,9 @@ function ReceiveSection({
return ( return (
<div <div
key={conn.id} key={conn.id}
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs" className="flex items-center gap-2 rounded border bg-green-50/50 px-3 py-2 text-xs"
> >
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-green-500" />
<span className="truncate"> <span className="truncate">
{sourceComp?.label || conn.sourceComponent} {sourceComp?.label || conn.sourceComponent}
</span> </span>
@ -617,7 +715,7 @@ function ReceiveSection({
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
. .
</p> </p>
)} )}
</div> </div>
@ -651,5 +749,5 @@ function buildConnectionLabel(
const colInfo = columns && columns.length > 0 const colInfo = columns && columns.length > 0
? ` [${columns.join(", ")}]` ? ` [${columns.join(", ")}]`
: ""; : "";
return `${srcLabel} -> ${tgtLabel}${colInfo}`; return `${srcLabel} ${tgtLabel}${colInfo}`;
} }

View File

@ -75,6 +75,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-button": "버튼", "pop-button": "버튼",
"pop-string-list": "리스트 목록", "pop-string-list": "리스트 목록",
"pop-search": "검색", "pop-search": "검색",
"pop-field": "입력",
}; };
// ======================================== // ========================================

View File

@ -9,7 +9,7 @@
/** /**
* POP * POP
*/ */
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
/** /**
* *
@ -361,6 +361,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-button": { colSpan: 2, rowSpan: 1 }, "pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 }, "pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 }, "pop-search": { colSpan: 4, rowSpan: 2 },
"pop-field": { colSpan: 6, rowSpan: 2 },
}; };
/** /**

View File

@ -20,6 +20,8 @@ import {
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
Search, Search,
Settings,
Copy,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -68,11 +70,27 @@ import {
// 타입 정의 // 타입 정의
// ============================================================ // ============================================================
export interface GroupCopyInfo {
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
children: Array<{
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
}>;
}
interface PopCategoryTreeProps { interface PopCategoryTreeProps {
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록 screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
selectedScreen: ScreenDefinition | null; selectedScreen: ScreenDefinition | null;
onScreenSelect: (screen: ScreenDefinition) => void; onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void;
onScreenSettings?: (screen: ScreenDefinition) => void;
onScreenCopy?: (screen: ScreenDefinition) => void;
onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
onGroupSelect?: (group: PopScreenGroup | null) => void; onGroupSelect?: (group: PopScreenGroup | null) => void;
searchTerm?: string; searchTerm?: string;
} }
@ -87,6 +105,8 @@ interface TreeNodeProps {
onGroupSelect: (group: PopScreenGroup) => void; onGroupSelect: (group: PopScreenGroup) => void;
onScreenSelect: (screen: ScreenDefinition) => void; onScreenSelect: (screen: ScreenDefinition) => void;
onScreenDesign: (screen: ScreenDefinition) => void; onScreenDesign: (screen: ScreenDefinition) => void;
onScreenSettings: (screen: ScreenDefinition) => void;
onScreenCopy: (screen: ScreenDefinition) => void;
onEditGroup: (group: PopScreenGroup) => void; onEditGroup: (group: PopScreenGroup) => void;
onDeleteGroup: (group: PopScreenGroup) => void; onDeleteGroup: (group: PopScreenGroup) => void;
onAddSubGroup: (parentGroup: PopScreenGroup) => void; onAddSubGroup: (parentGroup: PopScreenGroup) => void;
@ -101,6 +121,7 @@ interface TreeNodeProps {
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void; onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void; onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
onDeleteScreen: (screen: ScreenDefinition) => void; onDeleteScreen: (screen: ScreenDefinition) => void;
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
} }
// ============================================================ // ============================================================
@ -118,6 +139,7 @@ function TreeNode({
onMoveScreenUp, onMoveScreenUp,
onMoveScreenDown, onMoveScreenDown,
onDeleteScreen, onDeleteScreen,
onGroupCopy,
expandedGroups, expandedGroups,
onToggle, onToggle,
selectedGroupId, selectedGroupId,
@ -125,6 +147,8 @@ function TreeNode({
onGroupSelect, onGroupSelect,
onScreenSelect, onScreenSelect,
onScreenDesign, onScreenDesign,
onScreenSettings,
onScreenCopy,
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onAddSubGroup, onAddSubGroup,
@ -134,7 +158,7 @@ function TreeNode({
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0); const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
const isSelected = selectedGroupId === group.id; const isSelected = selectedGroupId === group.id;
// 그룹에 연결된 화면 목록 // 그룹에 직접 연결된 화면 목록
const groupScreens = useMemo(() => { const groupScreens = useMemo(() => {
if (!group.screens) return []; if (!group.screens) return [];
return group.screens return group.screens
@ -142,6 +166,20 @@ function TreeNode({
.filter((s): s is ScreenDefinition => s !== undefined); .filter((s): s is ScreenDefinition => s !== undefined);
}, [group.screens, screensMap]); }, [group.screens, screensMap]);
// 하위 그룹 포함 전체 화면 (복사용)
const allDescendantScreens = useMemo(() => {
const collected = new Map<number, ScreenDefinition>();
const collectRecursive = (g: PopScreenGroup) => {
g.screens?.forEach((gs) => {
const screen = screensMap.get(gs.screen_id);
if (screen) collected.set(screen.screenId, screen);
});
g.children?.forEach(collectRecursive);
};
collectRecursive(group);
return Array.from(collected.values());
}, [group, screensMap]);
// 루트 레벨(POP 화면)인지 확인 // 루트 레벨(POP 화면)인지 확인
const isRootLevel = level === 0; const isRootLevel = level === 0;
@ -193,8 +231,15 @@ function TreeNode({
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} /> <Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
)} )}
{/* 그룹명 - 루트는 볼드체 */} {/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */}
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span> <span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>
{group.group_name}
{isRootLevel && group.company_code && (
<span className="ml-1 text-[10px] text-muted-foreground font-normal">
{group.company_code === "*" ? "(전체)" : `(${group.company_code})`}
</span>
)}
</span>
{/* 화면 수 배지 */} {/* 화면 수 배지 */}
{group.screen_count && group.screen_count > 0 && ( {group.screen_count && group.screen_count > 0 && (
@ -224,6 +269,34 @@ function TreeNode({
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem> </DropdownMenuItem>
{allDescendantScreens.length > 0 && (
<DropdownMenuItem onClick={() => {
const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => {
const directScreenIds = (g.screens || [])
.map((gs) => gs.screen_id)
.filter((id) => screensMap.has(id));
const children = (g.children || []).map((child) => ({
sourceGroupId: child.id,
groupName: child.group_name,
groupCode: child.group_code,
screenIds: (child.screens || [])
.map((gs) => gs.screen_id)
.filter((id) => screensMap.has(id)),
}));
return {
sourceGroupId: g.id,
groupName: g.group_name,
groupCode: g.group_code,
screenIds: directScreenIds,
children,
};
};
onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group));
}}>
<Copy className="h-4 w-4 mr-2" />
({allDescendantScreens.length} )
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onMoveGroupUp(group)} onClick={() => onMoveGroupUp(group)}
@ -267,6 +340,8 @@ function TreeNode({
onGroupSelect={onGroupSelect} onGroupSelect={onGroupSelect}
onScreenSelect={onScreenSelect} onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign} onScreenDesign={onScreenDesign}
onScreenSettings={onScreenSettings}
onScreenCopy={onScreenCopy}
onEditGroup={onEditGroup} onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup} onDeleteGroup={onDeleteGroup}
onAddSubGroup={onAddSubGroup} onAddSubGroup={onAddSubGroup}
@ -279,6 +354,7 @@ function TreeNode({
onMoveScreenUp={onMoveScreenUp} onMoveScreenUp={onMoveScreenUp}
onMoveScreenDown={onMoveScreenDown} onMoveScreenDown={onMoveScreenDown}
onDeleteScreen={onDeleteScreen} onDeleteScreen={onDeleteScreen}
onGroupCopy={onGroupCopy}
/> />
))} ))}
@ -324,6 +400,14 @@ function TreeNode({
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onScreenSettings(screen)}>
<Settings className="h-4 w-4 mr-2" />
( )
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onScreenCopy(screen)}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onMoveScreenUp(screen, group.id)} onClick={() => onMoveScreenUp(screen, group.id)}
@ -378,6 +462,9 @@ export function PopCategoryTree({
selectedScreen, selectedScreen,
onScreenSelect, onScreenSelect,
onScreenDesign, onScreenDesign,
onScreenSettings,
onScreenCopy,
onGroupCopy,
onGroupSelect, onGroupSelect,
searchTerm = "", searchTerm = "",
}: PopCategoryTreeProps) { }: PopCategoryTreeProps) {
@ -412,6 +499,9 @@ export function PopCategoryTree({
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(null); const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(null);
const [moveSearchTerm, setMoveSearchTerm] = useState(""); const [moveSearchTerm, setMoveSearchTerm] = useState("");
// 미분류 회사코드별 접기/펼치기
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set());
// 화면 맵 생성 (screen_id로 빠르게 조회) // 화면 맵 생성 (screen_id로 빠르게 조회)
const screensMap = useMemo(() => { const screensMap = useMemo(() => {
const map = new Map<number, ScreenDefinition>(); const map = new Map<number, ScreenDefinition>();
@ -430,14 +520,6 @@ export function PopCategoryTree({
// 그룹 목록 조회 // 그룹 목록 조회
const data = await getPopScreenGroups(searchTerm); const data = await getPopScreenGroups(searchTerm);
setGroups(data); setGroups(data);
// 첫 로드 시 루트 그룹들 자동 확장
if (expandedGroups.size === 0 && data.length > 0) {
const rootIds = data
.filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2)
.map((g) => g.id);
setExpandedGroups(new Set(rootIds));
}
} catch (error) { } catch (error) {
console.error("POP 그룹 로드 실패:", error); console.error("POP 그룹 로드 실패:", error);
toast.error("그룹 목록 로드에 실패했습니다."); toast.error("그룹 목록 로드에 실패했습니다.");
@ -847,7 +929,7 @@ export function PopCategoryTree({
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */} {/* 헤더 */}
<div className="shrink-0 p-3 border-b flex items-center justify-between"> <div className="shrink-0 p-3 border-b flex items-center justify-between">
<h3 className="text-sm font-medium">POP </h3> <h3 className="text-sm font-medium">POP </h3>
@ -862,7 +944,7 @@ export function PopCategoryTree({
</div> </div>
{/* 트리 영역 */} {/* 트리 영역 */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1 min-h-0">
<div className="p-2"> <div className="p-2">
{treeData.length === 0 && ungroupedScreens.length === 0 ? ( {treeData.length === 0 && ungroupedScreens.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-8"> <div className="text-center text-sm text-muted-foreground py-8">
@ -887,6 +969,8 @@ export function PopCategoryTree({
onGroupSelect={handleGroupSelect} onGroupSelect={handleGroupSelect}
onScreenSelect={onScreenSelect} onScreenSelect={onScreenSelect}
onScreenDesign={onScreenDesign} onScreenDesign={onScreenDesign}
onScreenSettings={onScreenSettings || (() => {})}
onScreenCopy={onScreenCopy || (() => {})}
onEditGroup={(g) => openGroupModal(undefined, g)} onEditGroup={(g) => openGroupModal(undefined, g)}
onDeleteGroup={(g) => { onDeleteGroup={(g) => {
setDeletingGroup(g); setDeletingGroup(g);
@ -902,66 +986,122 @@ export function PopCategoryTree({
onMoveScreenUp={handleMoveScreenUp} onMoveScreenUp={handleMoveScreenUp}
onMoveScreenDown={handleMoveScreenDown} onMoveScreenDown={handleMoveScreenDown}
onDeleteScreen={handleDeleteScreen} onDeleteScreen={handleDeleteScreen}
onGroupCopy={onGroupCopy || (() => {})}
/> />
))} ))}
{/* 미분류 화면 */} {/* 미분류 화면 - 회사코드별 그룹핑 */}
{ungroupedScreens.length > 0 && ( {ungroupedScreens.length > 0 && (
<div className="mt-4 pt-4 border-t"> <div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground px-2 mb-2"> <div className="text-xs text-muted-foreground px-2 mb-2">
({ungroupedScreens.length}) ({ungroupedScreens.length})
</div> </div>
{ungroupedScreens.map((screen) => ( {(() => {
<div const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
key={`ungrouped-${screen.screenId}`} const code = screen.companyCode || "unknown";
className={cn( if (!acc[code]) acc[code] = [];
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors", acc[code].push(screen);
selectedScreen?.screenId === screen.screenId return acc;
? "bg-primary/10 text-primary" }, {});
: "hover:bg-muted", const companyKeys = Object.keys(grouped).sort();
"group"
)} const toggleCompanyCode = (code: string) => {
onClick={() => onScreenSelect(screen)} setExpandedCompanyCodes((prev) => {
onDoubleClick={() => onScreenDesign(screen)} const next = new Set(prev);
> if (next.has(code)) {
<Monitor className="h-4 w-4 text-gray-400 shrink-0" /> next.delete(code);
<span className="flex-1 text-sm truncate">{screen.screenName}</span> } else {
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span> next.add(code);
}
{/* 더보기 메뉴 */} return next;
<DropdownMenu> });
<DropdownMenuTrigger asChild> };
<Button
variant="ghost" return companyKeys.map((companyCode) => {
size="icon" const isExpanded = expandedCompanyCodes.has(companyCode);
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" const label = companyCode === "*" ? "최고관리자" : companyCode;
onClick={(e) => e.stopPropagation()}
return (
<div key={`ungrouped-company-${companyCode}`}>
<div
className="flex items-center gap-1 px-2 py-1 mt-1 bg-muted/50 rounded cursor-pointer select-none hover:bg-muted transition-colors"
onClick={() => toggleCompanyCode(companyCode)}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="text-[10px] font-medium text-muted-foreground">
{label}
</span>
<Badge variant="outline" className="ml-auto h-4 text-[9px] px-1">
{grouped[companyCode].length}
</Badge>
</div>
{isExpanded && grouped[companyCode].map((screen) => (
<div
key={`ungrouped-${screen.screenId}`}
className={cn(
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
selectedScreen?.screenId === screen.screenId
? "bg-primary/10 text-primary"
: "hover:bg-muted",
"group",
"pl-4"
)}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
> >
<MoreVertical className="h-3.5 w-3.5" /> <Monitor className="h-4 w-4 text-gray-400 shrink-0" />
</Button> <span className="flex-1 text-sm truncate">{screen.screenName}</span>
</DropdownMenuTrigger> <span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onScreenDesign(screen)}> <DropdownMenu>
<Edit className="h-4 w-4 mr-2" /> <DropdownMenuTrigger asChild>
<Button
</DropdownMenuItem> variant="ghost"
<DropdownMenuSeparator /> size="icon"
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}> className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
<MoveRight className="h-4 w-4 mr-2" /> onClick={(e) => e.stopPropagation()}
>
</DropdownMenuItem> <MoreVertical className="h-3.5 w-3.5" />
<DropdownMenuSeparator /> </Button>
<DropdownMenuItem </DropdownMenuTrigger>
className="text-destructive" <DropdownMenuContent align="end" className="w-48">
onClick={() => handleDeleteScreen(screen)} <DropdownMenuItem onClick={() => onScreenDesign(screen)}>
> <Edit className="h-4 w-4 mr-2" />
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem onClick={() => onScreenSettings?.(screen)}>
</DropdownMenuContent> <Settings className="h-4 w-4 mr-2" />
</DropdownMenu> ( )
</div> </DropdownMenuItem>
))} <DropdownMenuItem onClick={() => onScreenCopy?.(screen)}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
<MoveRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteScreen(screen)}
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
);
});
})()}
</div> </div>
)} )}
</> </>

View File

@ -0,0 +1,560 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { GroupCopyInfo } from "./PopCategoryTree";
import { getCompanyList } from "@/lib/api/company";
import { ScreenDefinition } from "@/types/screen";
import { Company } from "@/types/company";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
interface LinkedScreenInfo {
screenId: number;
screenName: string;
screenCode: string;
references: Array<{
componentId: string;
referenceType: string;
}>;
deploy: boolean;
newScreenName: string;
newScreenCode: string;
}
interface ScreenEntry {
screenId: number;
screenName: string;
newScreenName: string;
newScreenCode: string;
included: boolean;
}
interface PopDeployModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
screen: ScreenDefinition | null;
groupScreens?: ScreenDefinition[];
groupName?: string;
groupInfo?: GroupCopyInfo;
allScreens: ScreenDefinition[];
onDeployed?: () => void;
}
export function PopDeployModal({
open,
onOpenChange,
screen,
groupScreens,
groupName,
groupInfo,
allScreens,
onDeployed,
}: PopDeployModalProps) {
const isGroupMode = !!(groupScreens && groupScreens.length > 0);
const [companies, setCompanies] = useState<Company[]>([]);
const [targetCompanyCode, setTargetCompanyCode] = useState("");
// 단일 화면 모드
const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState("");
const [linkedScreens, setLinkedScreens] = useState<LinkedScreenInfo[]>([]);
// 그룹 모드
const [groupEntries, setGroupEntries] = useState<ScreenEntry[]>([]);
const [analyzing, setAnalyzing] = useState(false);
const [deploying, setDeploying] = useState(false);
// 회사 목록 로드
useEffect(() => {
if (open) {
getCompanyList({ status: "active" })
.then((list) => {
setCompanies(list.filter((c) => c.company_code !== "*"));
})
.catch(console.error);
}
}, [open]);
// 모달 열릴 때 초기화
useEffect(() => {
if (!open) return;
setTargetCompanyCode("");
setLinkedScreens([]);
if (isGroupMode && groupScreens) {
setGroupEntries(
groupScreens.map((s) => ({
screenId: s.screenId,
screenName: s.screenName,
newScreenName: s.screenName,
newScreenCode: "",
included: true,
})),
);
setScreenName("");
setScreenCode("");
} else if (screen) {
setScreenName(screen.screenName);
setScreenCode("");
setGroupEntries([]);
analyzeLinks(screen.screenId);
}
}, [open, screen, groupScreens, isGroupMode]);
// 회사 선택 시 화면 코드 자동 생성
useEffect(() => {
if (!targetCompanyCode) return;
if (isGroupMode) {
const count = groupEntries.filter((e) => e.included).length;
if (count > 0) {
screenApi
.generateMultipleScreenCodes(targetCompanyCode, count)
.then((codes) => {
let codeIdx = 0;
setGroupEntries((prev) =>
prev.map((e) =>
e.included
? { ...e, newScreenCode: codes[codeIdx++] || "" }
: e,
),
);
})
.catch(console.error);
}
} else {
const count = 1 + linkedScreens.filter((ls) => ls.deploy).length;
screenApi
.generateMultipleScreenCodes(targetCompanyCode, count)
.then((codes) => {
setScreenCode(codes[0] || "");
setLinkedScreens((prev) =>
prev.map((ls, idx) => ({
...ls,
newScreenCode: codes[idx + 1] || "",
})),
);
})
.catch(console.error);
}
}, [targetCompanyCode]);
const analyzeLinks = async (screenId: number) => {
setAnalyzing(true);
try {
const result = await screenApi.analyzePopScreenLinks(screenId);
const linked: LinkedScreenInfo[] = result.linkedScreenIds.map(
(linkedId) => {
const linkedScreen = allScreens.find(
(s) => s.screenId === linkedId,
);
const refs = result.references.filter(
(r) => r.targetScreenId === linkedId,
);
return {
screenId: linkedId,
screenName: linkedScreen?.screenName || `화면 ${linkedId}`,
screenCode: linkedScreen?.screenCode || "",
references: refs.map((r) => ({
componentId: r.componentId,
referenceType: r.referenceType,
})),
deploy: true,
newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`,
newScreenCode: "",
};
},
);
setLinkedScreens(linked);
} catch (error) {
console.error("연결 분석 실패:", error);
} finally {
setAnalyzing(false);
}
};
const handleDeploy = async () => {
if (!targetCompanyCode) return;
setDeploying(true);
try {
let screensToSend: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
if (isGroupMode) {
screensToSend = groupEntries
.filter((e) => e.included && e.newScreenCode)
.map((e) => ({
sourceScreenId: e.screenId,
screenName: e.newScreenName,
screenCode: e.newScreenCode,
}));
} else {
if (!screen || !screenName || !screenCode) return;
screensToSend = [
{
sourceScreenId: screen.screenId,
screenName,
screenCode,
},
...linkedScreens
.filter((ls) => ls.deploy)
.map((ls) => ({
sourceScreenId: ls.screenId,
screenName: ls.newScreenName,
screenCode: ls.newScreenCode,
})),
];
}
if (screensToSend.length === 0) {
toast.error("복사할 화면이 없습니다.");
return;
}
const deployPayload: Parameters<typeof screenApi.deployPopScreens>[0] = {
screens: screensToSend,
targetCompanyCode,
};
if (isGroupMode && groupInfo) {
deployPayload.groupStructure = groupInfo;
}
const result = await screenApi.deployPopScreens(deployPayload);
const groupMsg = result.createdGroups
? ` (카테고리 ${result.createdGroups}개 생성)`
: "";
toast.success(
`POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`,
);
onOpenChange(false);
onDeployed?.();
} catch (error: any) {
toast.error(error?.response?.data?.message || "복사에 실패했습니다.");
} finally {
setDeploying(false);
}
};
const totalCount = isGroupMode
? groupEntries.filter((e) => e.included).length
: 1 + linkedScreens.filter((ls) => ls.deploy).length;
const canDeploy = isGroupMode
? !deploying && targetCompanyCode && groupEntries.some((e) => e.included)
: !deploying && targetCompanyCode && screenName && screenCode;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
POP
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isGroupMode
? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.`
: screen
? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.`
: "화면을 선택해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 대상 회사 선택 */}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={targetCompanyCode}
onValueChange={setTargetCompanyCode}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="회사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem
key={c.company_code}
value={c.company_code}
className="text-xs sm:text-sm"
>
{c.company_name} ({c.company_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */}
{isGroupMode ? (
<div>
<Label className="text-xs sm:text-sm">
({groupEntries.filter((e) => e.included).length}
{groupInfo
? ` + ${1 + (groupInfo.children?.length || 0)}개 카테고리`
: ""}
)
</Label>
<div className="mt-1 max-h-[280px] overflow-y-auto rounded-md border p-2">
{groupInfo ? (
<div className="space-y-0.5">
{/* 메인 카테고리 */}
<div className="flex items-center gap-1.5 rounded bg-muted/50 p-1.5 text-xs font-medium">
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{groupInfo.groupName}</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">
</span>
</div>
{/* 메인 카테고리의 직접 화면 */}
{groupEntries
.filter((e) => groupInfo.screenIds.includes(e.screenId))
.map((entry) => (
<div
key={entry.screenId}
className="flex items-center gap-2 rounded p-1.5 pl-6 text-xs hover:bg-muted/50"
>
<Checkbox
checked={entry.included}
onCheckedChange={(checked) => {
setGroupEntries((prev) =>
prev.map((e) =>
e.screenId === entry.screenId
? { ...e, included: !!checked }
: e,
),
);
}}
/>
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
<span className="flex-1 truncate">
{entry.screenName}
</span>
<span className="shrink-0 text-muted-foreground">
#{entry.screenId}
</span>
</div>
))}
{/* 하위 카테고리들 */}
{groupInfo.children?.map((child) => (
<div key={child.sourceGroupId}>
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/30 p-1.5 pl-4 text-xs font-medium">
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
<span>{child.groupName}</span>
</div>
{groupEntries
.filter((e) =>
child.screenIds.includes(e.screenId),
)
.map((entry) => (
<div
key={entry.screenId}
className="flex items-center gap-2 rounded p-1.5 pl-10 text-xs hover:bg-muted/50"
>
<Checkbox
checked={entry.included}
onCheckedChange={(checked) => {
setGroupEntries((prev) =>
prev.map((e) =>
e.screenId === entry.screenId
? { ...e, included: !!checked }
: e,
),
);
}}
/>
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
<span className="flex-1 truncate">
{entry.screenName}
</span>
<span className="shrink-0 text-muted-foreground">
#{entry.screenId}
</span>
</div>
))}
</div>
))}
</div>
) : (
<div className="space-y-1">
{groupEntries.map((entry) => (
<div
key={entry.screenId}
className="flex items-center gap-2 rounded p-1.5 text-xs hover:bg-muted/50"
>
<Checkbox
checked={entry.included}
onCheckedChange={(checked) => {
setGroupEntries((prev) =>
prev.map((e) =>
e.screenId === entry.screenId
? { ...e, included: !!checked }
: e,
),
);
}}
/>
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">
{entry.screenName}
</span>
<span className="shrink-0 text-muted-foreground">
#{entry.screenId}
</span>
</div>
))}
</div>
)}
</div>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
(cartScreenId )
.
</p>
</div>
) : (
<>
{/* ===== 단일 모드: 화면명 + 코드 ===== */}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="화면 이름"
/>
</div>
<div>
<Label className="text-xs sm:text-sm">
()
</Label>
<Input
className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm"
value={screenCode}
readOnly
/>
</div>
{/* 연결 화면 감지 */}
{analyzing ? (
<div className="flex items-center gap-2 rounded-md border p-3 text-xs text-muted-foreground sm:text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : linkedScreens.length > 0 ? (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-300 sm:text-sm">
<Link2 className="h-3.5 w-3.5" />
POP {linkedScreens.length}
</div>
<div className="space-y-1.5">
{linkedScreens.map((ls) => (
<div
key={ls.screenId}
className="flex items-center justify-between rounded bg-background p-2 text-xs"
>
<div className="flex-1">
<div className="font-medium">{ls.screenName}</div>
<div className="text-muted-foreground">
ID: {ls.screenId} |{" "}
{ls.references
.map((r) => r.referenceType)
.join(", ")}
</div>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
checked={ls.deploy}
onCheckedChange={(checked) => {
setLinkedScreens((prev) =>
prev.map((item) =>
item.screenId === ls.screenId
? { ...item, deploy: !!checked }
: item,
),
);
}}
/>
<span className="text-xs"> </span>
</div>
</div>
))}
</div>
<p className="mt-2 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
(cartScreenId ) ID로
.
</p>
</div>
) : (
!analyzing && (
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground sm:text-sm">
POP . .
</div>
)
)}
</>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deploying}
>
</Button>
<Button
onClick={handleDeploy}
disabled={!canDeploy}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{deploying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
`${totalCount}개 화면 복사`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -165,19 +165,26 @@ export function PopScreenSettingModal({
try { try {
setSaving(true); setSaving(true);
// 화면 기본 정보 업데이트
const screenUpdate: Partial<ScreenDefinition> = { const screenUpdate: Partial<ScreenDefinition> = {
screenName, screenName,
description: screenDescription, description: screenDescription,
}; };
// screen_definitions 테이블에 화면명/설명 업데이트
if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) {
await screenApi.updateScreenInfo(screen.screenId, {
screenName,
description: screenDescription,
isActive: "Y",
});
}
// 레이아웃에 하위 화면 정보 저장 // 레이아웃에 하위 화면 정보 저장
const currentLayout = await screenApi.getLayoutPop(screen.screenId); const currentLayout = await screenApi.getLayoutPop(screen.screenId);
const updatedLayout = { const updatedLayout = {
...currentLayout, ...currentLayout,
version: "pop-1.0", version: "pop-1.0",
subScreens: subScreens, subScreens: subScreens,
// flow 배열 자동 생성 (메인 → 각 서브)
flow: subScreens.map((sub) => ({ flow: subScreens.map((sub) => ({
from: sub.triggerFrom || "main", from: sub.triggerFrom || "main",
to: sub.id, to: sub.id,
@ -201,11 +208,11 @@ export function PopScreenSettingModal({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0"> <DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0 shrink-0"> <DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle className="text-base sm:text-lg">POP </DialogTitle> <DialogTitle className="text-base sm:text-lg">POP </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
{screen.screenName} ({screen.screenCode}) {screen.screenName} [{screen.screenCode}]
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -214,57 +221,57 @@ export function PopScreenSettingModal({
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0" 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"> <TabsList className="shrink-0 mx-6 justify-start border-b rounded-none bg-transparent h-auto p-0">
<TabsTrigger <TabsTrigger
value="overview" value="overview"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
> >
<FileText className="h-4 w-4 mr-2" /> <FileText className="h-3.5 w-3.5 mr-1.5" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="subscreens" value="subscreens"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
> >
<Layers className="h-4 w-4 mr-2" /> <Layers className="h-3.5 w-3.5 mr-1.5" />
{subScreens.length > 0 && ( {subScreens.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs"> <Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
{subScreens.length} {subScreens.length}
</Badge> </Badge>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="flow" value="flow"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
> >
<GitBranch className="h-4 w-4 mr-2" /> <GitBranch className="h-3.5 w-3.5 mr-1.5" />
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* 개요 탭 */} {/* 기본 정보 탭 */}
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto"> <TabsContent value="overview" className="flex-1 m-0 overflow-auto">
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<div className="space-y-4 max-w-[500px]"> <div className="space-y-4 p-6">
<div> <div className="space-y-1.5">
<Label htmlFor="screenName" className="text-xs sm:text-sm"> <Label htmlFor="screenName" className="text-xs sm:text-sm">
* <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="screenName" id="screenName"
value={screenName} value={screenName}
onChange={(e) => setScreenName(e.target.value)} onChange={(e) => setScreenName(e.target.value)}
placeholder="화면 이름" placeholder="화면 이름을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor="category" className="text-xs sm:text-sm"> <Label htmlFor="category" className="text-xs sm:text-sm">
</Label> </Label>
@ -282,7 +289,7 @@ export function PopScreenSettingModal({
</Select> </Select>
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor="description" className="text-xs sm:text-sm"> <Label htmlFor="description" className="text-xs sm:text-sm">
</Label> </Label>
@ -290,13 +297,13 @@ export function PopScreenSettingModal({
id="description" id="description"
value={screenDescription} value={screenDescription}
onChange={(e) => setScreenDescription(e.target.value)} onChange={(e) => setScreenDescription(e.target.value)}
placeholder="화면에 대한 설명" placeholder="화면에 대한 설명을 입력하세요"
rows={3} rows={3}
className="text-xs sm:text-sm resize-none" className="text-xs sm:text-sm resize-none"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor="icon" className="text-xs sm:text-sm"> <Label htmlFor="icon" className="text-xs sm:text-sm">
</Label> </Label>
@ -307,7 +314,7 @@ export function PopScreenSettingModal({
placeholder="lucide 아이콘 이름 (예: Package)" placeholder="lucide 아이콘 이름 (예: Package)"
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
/> />
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
lucide-react . lucide-react .
</p> </p>
</div> </div>
@ -316,19 +323,19 @@ export function PopScreenSettingModal({
</TabsContent> </TabsContent>
{/* 하위 화면 탭 */} {/* 하위 화면 탭 */}
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto"> <TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
<div className="space-y-4"> <div className="p-6 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-xs sm:text-sm text-muted-foreground">
, . , .
</p> </p>
<Button size="sm" onClick={addSubScreen}> <Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />
</Button> </Button>
</div> </div>
<ScrollArea className="h-[300px]"> <ScrollArea className="h-[280px]">
{subScreens.length === 0 ? ( {subScreens.length === 0 ? (
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" /> <Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
@ -339,12 +346,12 @@ export function PopScreenSettingModal({
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{subScreens.map((subScreen, index) => ( {subScreens.map((subScreen) => (
<div <div
key={subScreen.id} key={subScreen.id}
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30" className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30"
> >
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" /> <GripVertical className="h-4 w-4 text-muted-foreground shrink-0 mt-1.5 cursor-grab" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -362,7 +369,7 @@ export function PopScreenSettingModal({
updateSubScreen(subScreen.id, "type", v) updateSubScreen(subScreen.id, "type", v)
} }
> >
<SelectTrigger className="h-8 text-xs w-[100px]"> <SelectTrigger className="h-8 text-xs w-[90px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -374,7 +381,7 @@ export function PopScreenSettingModal({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0"> <span className="text-[10px] text-muted-foreground shrink-0">
: :
</span> </span>
<Select <Select
@ -403,10 +410,10 @@ export function PopScreenSettingModal({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeSubScreen(subScreen.id)} onClick={() => removeSubScreen(subScreen.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
))} ))}
@ -423,11 +430,19 @@ export function PopScreenSettingModal({
</Tabs> </Tabs>
{/* 푸터 */} {/* 푸터 */}
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2"> <div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button> </Button>
<Button onClick={handleSave} disabled={saving}> <Button
onClick={handleSave}
disabled={saving}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{saving ? ( {saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : ( ) : (

View File

@ -3,6 +3,8 @@
*/ */
export { PopCategoryTree } from "./PopCategoryTree"; export { PopCategoryTree } from "./PopCategoryTree";
export type { GroupCopyInfo } from "./PopCategoryTree";
export { PopScreenPreview } from "./PopScreenPreview"; export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView"; export { PopScreenFlowView } from "./PopScreenFlowView";
export { PopScreenSettingModal } from "./PopScreenSettingModal"; export { PopScreenSettingModal } from "./PopScreenSettingModal";
export { PopDeployModal } from "./PopDeployModal";

View File

@ -12,6 +12,7 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useMemo } from "react"; import { useState, useCallback, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -61,6 +62,7 @@ export default function PopViewerWithModals({
overrideGap, overrideGap,
overridePadding, overridePadding,
}: PopViewerWithModalsProps) { }: PopViewerWithModalsProps) {
const router = useRouter();
const [modalStack, setModalStack] = useState<OpenModal[]>([]); const [modalStack, setModalStack] = useState<OpenModal[]>([]);
const { subscribe, publish } = usePopEvent(screenId); const { subscribe, publish } = usePopEvent(screenId);
@ -69,9 +71,21 @@ export default function PopViewerWithModals({
() => layout.dataFlow?.connections ?? [], () => layout.dataFlow?.connections ?? [],
[layout.dataFlow?.connections] [layout.dataFlow?.connections]
); );
const componentTypes = useMemo(() => {
const map = new Map<string, string>();
if (layout.components) {
for (const comp of Object.values(layout.components)) {
map.set(comp.id, comp.type);
}
}
return map;
}, [layout.components]);
useConnectionResolver({ useConnectionResolver({
screenId, screenId,
connections: stableConnections, connections: stableConnections,
componentTypes,
}); });
// 모달 열기/닫기 이벤트 구독 // 모달 열기/닫기 이벤트 구독
@ -114,11 +128,30 @@ export default function PopViewerWithModals({
}); });
}); });
const unsubNavigate = subscribe("__pop_navigate__", (payload: unknown) => {
const data = payload as {
screenId?: string;
params?: Record<string, string>;
};
if (!data?.screenId) return;
if (data.screenId === "back") {
router.back();
} else {
const query = data.params
? "?" + new URLSearchParams(data.params).toString()
: "";
window.location.href = `/pop/screens/${data.screenId}${query}`;
}
});
return () => { return () => {
unsubOpen(); unsubOpen();
unsubClose(); unsubClose();
unsubNavigate();
}; };
}, [subscribe, publish, layout.modals]); }, [subscribe, publish, layout.modals, router]);
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC) // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
const handleCloseTopModal = useCallback(() => { const handleCloseTopModal = useCallback(() => {

View File

@ -1011,7 +1011,7 @@ export function ScreenGroupTreeView({
const loadGroupsData = async () => { const loadGroupsData = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기 const response = await getScreenGroups({ size: 1000, excludePop: true });
if (response.success && response.data) { if (response.success && response.data) {
setGroups(response.data); setGroups(response.data);

View File

@ -223,7 +223,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const loadGroups = async () => { const loadGroups = async () => {
try { try {
setLoadingGroups(true); setLoadingGroups(true);
const response = await getScreenGroups(); const response = await getScreenGroups({ excludePop: true });
if (response.success && response.data) { if (response.success && response.data) {
setGroups(response.data); setGroups(response.data);
} }

View File

@ -99,10 +99,8 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
function cartItemToDbRecord( function cartItemToDbRecord(
item: CartItemWithId, item: CartItemWithId,
screenId: string, screenId: string,
cartType: string = "pop",
selectedColumns?: string[], selectedColumns?: string[],
): Record<string, unknown> { ): Record<string, unknown> {
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
const rowData = const rowData =
selectedColumns && selectedColumns.length > 0 selectedColumns && selectedColumns.length > 0
? Object.fromEntries( ? Object.fromEntries(
@ -111,7 +109,7 @@ function cartItemToDbRecord(
: item.row; : item.row;
return { return {
cart_type: cartType, cart_type: "pop",
screen_id: screenId, screen_id: screenId,
source_table: item.sourceTable, source_table: item.sourceTable,
row_key: item.rowKey, row_key: item.rowKey,
@ -144,7 +142,6 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
export function useCartSync( export function useCartSync(
screenId: string, screenId: string,
sourceTable: string, sourceTable: string,
cartType?: string,
): UseCartSyncReturn { ): UseCartSyncReturn {
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]); const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]); const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
@ -153,21 +150,19 @@ export function useCartSync(
const screenIdRef = useRef(screenId); const screenIdRef = useRef(screenId);
const sourceTableRef = useRef(sourceTable); const sourceTableRef = useRef(sourceTable);
const cartTypeRef = useRef(cartType || "pop");
screenIdRef.current = screenId; screenIdRef.current = screenId;
sourceTableRef.current = sourceTable; sourceTableRef.current = sourceTable;
cartTypeRef.current = cartType || "pop";
// ----- DB에서 장바구니 로드 ----- // ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => { const loadFromDb = useCallback(async () => {
if (!screenId) return; if (!screenId || !sourceTable) return;
setLoading(true); setLoading(true);
try { try {
const result = await dataApi.getTableData("cart_items", { const result = await dataApi.getTableData("cart_items", {
size: 500, size: 500,
filters: { filters: {
screen_id: screenId, screen_id: screenId,
cart_type: cartTypeRef.current, cart_type: "pop",
status: "in_cart", status: "in_cart",
}, },
}); });
@ -181,7 +176,7 @@ export function useCartSync(
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [screenId]); }, [screenId, sourceTable]);
// 마운트 시 자동 로드 // 마운트 시 자동 로드
useEffect(() => { useEffect(() => {
@ -286,18 +281,16 @@ export function useCartSync(
const promises: Promise<unknown>[] = []; const promises: Promise<unknown>[] = [];
for (const item of toDelete) { for (const item of toDelete) {
promises.push(dataApi.deleteRecord("cart_items", item.cartId!)); promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
} }
const currentCartType = cartTypeRef.current;
for (const item of toCreate) { for (const item of toCreate) {
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.createRecord("cart_items", record)); promises.push(dataApi.createRecord("cart_items", record));
} }
for (const item of toUpdate) { for (const item of toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record)); promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
} }

View File

@ -8,47 +8,119 @@
* : * :
* 소스: __comp_output__${sourceComponentId}__${outputKey} * 소스: __comp_output__${sourceComponentId}__${outputKey}
* 타겟: __comp_input__${targetComponentId}__${inputKey} * 타겟: __comp_input__${targetComponentId}__${inputKey}
*
* _auto :
* sourceOutput="_auto" / connectionMeta를
* key가 category="event" .
* (정방향: 소스->, 역방향: 타겟->)
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { usePopEvent } from "./usePopEvent"; import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
import {
PopComponentRegistry,
type ConnectionMetaItem,
} from "@/lib/registry/PopComponentRegistry";
interface UseConnectionResolverOptions { interface UseConnectionResolverOptions {
screenId: string; screenId: string;
connections: PopDataConnection[]; connections: PopDataConnection[];
componentTypes?: Map<string, string>;
}
/**
* / connectionMeta에서 .
* 규칙: category="event" key가
*/
function getAutoMatchPairs(
sourceType: string,
targetType: string
): { sourceKey: string; targetKey: string }[] {
const sourceDef = PopComponentRegistry.getComponent(sourceType);
const targetDef = PopComponentRegistry.getComponent(targetType);
if (!sourceDef?.connectionMeta?.sendable || !targetDef?.connectionMeta?.receivable) {
return [];
}
const pairs: { sourceKey: string; targetKey: string }[] = [];
for (const s of sourceDef.connectionMeta.sendable) {
if (s.category !== "event") continue;
for (const r of targetDef.connectionMeta.receivable) {
if (r.category !== "event") continue;
if (s.key === r.key) {
pairs.push({ sourceKey: s.key, targetKey: r.key });
}
}
}
return pairs;
} }
export function useConnectionResolver({ export function useConnectionResolver({
screenId, screenId,
connections, connections,
componentTypes,
}: UseConnectionResolverOptions): void { }: UseConnectionResolverOptions): void {
const { publish, subscribe } = usePopEvent(screenId); const { publish, subscribe } = usePopEvent(screenId);
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
const connectionsRef = useRef(connections); const connectionsRef = useRef(connections);
connectionsRef.current = connections; connectionsRef.current = connections;
const componentTypesRef = useRef(componentTypes);
componentTypesRef.current = componentTypes;
useEffect(() => { useEffect(() => {
if (!connections || connections.length === 0) return; if (!connections || connections.length === 0) return;
const unsubscribers: (() => void)[] = []; const unsubscribers: (() => void)[] = [];
// 소스별로 그룹핑하여 구독 생성
const sourceGroups = new Map<string, PopDataConnection[]>();
for (const conn of connections) { for (const conn of connections) {
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; const isAutoMode = conn.sourceOutput === "_auto" || !conn.sourceOutput;
const existing = sourceGroups.get(sourceEvent) || [];
existing.push(conn);
sourceGroups.set(sourceEvent, existing);
}
for (const [sourceEvent, conns] of sourceGroups) { if (isAutoMode && componentTypesRef.current) {
const unsub = subscribe(sourceEvent, (payload: unknown) => { const sourceType = componentTypesRef.current.get(conn.sourceComponent);
for (const conn of conns) { const targetType = componentTypesRef.current.get(conn.targetComponent);
if (!sourceType || !targetType) continue;
// 정방향: 소스 sendable -> 타겟 receivable
const forwardPairs = getAutoMatchPairs(sourceType, targetType);
for (const pair of forwardPairs) {
const sourceEvent = `__comp_output__${conn.sourceComponent}__${pair.sourceKey}`;
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
});
unsubscribers.push(unsub);
}
// 역방향: 타겟 sendable -> 소스 receivable
const reversePairs = getAutoMatchPairs(targetType, sourceType);
for (const pair of reversePairs) {
const sourceEvent = `__comp_output__${conn.targetComponent}__${pair.sourceKey}`;
const targetEvent = `__comp_input__${conn.sourceComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
});
unsubscribers.push(unsub);
}
} else {
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
const enrichedPayload = { const enrichedPayload = {
value: payload, value: payload,
filterConfig: conn.filterConfig, filterConfig: conn.filterConfig,
@ -56,9 +128,9 @@ export function useConnectionResolver({
}; };
publish(targetEvent, enrichedPayload); publish(targetEvent, enrichedPayload);
} });
}); unsubscribers.push(unsub);
unsubscribers.push(unsub); }
} }
return () => { return () => {

View File

@ -16,6 +16,7 @@ export const screenApi = {
size?: number; size?: number;
companyCode?: string; companyCode?: string;
searchTerm?: string; searchTerm?: string;
excludePop?: boolean;
}): Promise<PaginatedResponse<ScreenDefinition>> => { }): Promise<PaginatedResponse<ScreenDefinition>> => {
const response = await apiClient.get("/screen-management/screens", { params }); const response = await apiClient.get("/screen-management/screens", { params });
const raw = response.data || {}; const raw = response.data || {};
@ -342,6 +343,59 @@ export const screenApi = {
return response.data.data; return response.data.data;
}, },
// POP 화면 연결 분석 (다른 화면과의 참조 관계)
analyzePopScreenLinks: async (
screenId: number,
): Promise<{
linkedScreenIds: number[];
references: Array<{
componentId: string;
referenceType: string;
targetScreenId: number;
}>;
}> => {
const response = await apiClient.get(
`/screen-management/screens/${screenId}/pop-links`,
);
return response.data.data || { linkedScreenIds: [], references: [] };
},
// POP 화면 배포 (다른 회사로 복사)
deployPopScreens: async (data: {
screens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
targetCompanyCode: string;
groupStructure?: {
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
children?: Array<{
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
}>;
};
}): Promise<{
deployedScreens: Array<{
sourceScreenId: number;
newScreenId: number;
screenName: string;
screenCode: string;
}>;
createdGroups?: number;
}> => {
const response = await apiClient.post(
`/screen-management/deploy-pop-screens`,
data,
);
return response.data.data;
},
// 메인 화면 + 모달 화면들 일괄 복사 // 메인 화면 + 모달 화면들 일괄 복사
copyScreenWithModals: async ( copyScreenWithModals: async (
sourceScreenId: number, sourceScreenId: number,

View File

@ -115,12 +115,14 @@ export async function getScreenGroups(params?: {
page?: number; page?: number;
size?: number; size?: number;
searchTerm?: string; searchTerm?: string;
excludePop?: boolean;
}): Promise<ApiResponse<ScreenGroup[]>> { }): Promise<ApiResponse<ScreenGroup[]>> {
try { try {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params?.page) queryParams.append("page", params.page.toString()); if (params?.page) queryParams.append("page", params.page.toString());
if (params?.size) queryParams.append("size", params.size.toString()); if (params?.size) queryParams.append("size", params.size.toString());
if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm); if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm);
if (params?.excludePop) queryParams.append("excludePop", "true");
const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`); const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`);
return response.data; return response.data;

View File

@ -9,6 +9,7 @@ export interface ConnectionMetaItem {
key: string; key: string;
label: string; label: string;
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string; type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
category?: "event" | "filter" | "data";
description?: string; description?: string;
} }

View File

@ -21,6 +21,7 @@ import "./pop-button";
import "./pop-string-list"; import "./pop-string-list";
import "./pop-search"; import "./pop-search";
import "./pop-field";
// 향후 추가될 컴포넌트들: // 향후 추가될 컴포넌트들:
// import "./pop-field";
// import "./pop-list"; // import "./pop-list";

View File

@ -48,9 +48,20 @@ import {
ChevronDown, ChevronDown,
ShoppingCart, ShoppingCart,
ShoppingBag, ShoppingBag,
PackageCheck,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { CollectedDataResponse, StatusChangeRule } from "./types";
import { apiClient } from "@/lib/api/client";
import { TableCombobox } from "./pop-shared/TableCombobox";
import { ColumnCombobox } from "./pop-shared/ColumnCombobox";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "./pop-dashboard/utils/dataFetcher";
// ======================================== // ========================================
// STEP 1: 타입 정의 // STEP 1: 타입 정의
@ -118,6 +129,7 @@ export type ButtonPreset =
| "menu" | "menu"
| "modal-open" | "modal-open"
| "cart" | "cart"
| "inbound-confirm"
| "custom"; | "custom";
/** row_data 저장 모드 */ /** row_data 저장 모드 */
@ -141,6 +153,9 @@ export interface PopButtonConfig {
action: ButtonMainAction; action: ButtonMainAction;
followUpActions?: FollowUpAction[]; followUpActions?: FollowUpAction[];
cart?: CartButtonConfig; cart?: CartButtonConfig;
statusChangeRules?: StatusChangeRule[];
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
} }
// ======================================== // ========================================
@ -180,6 +195,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
menu: "메뉴 (드롭다운)", menu: "메뉴 (드롭다운)",
"modal-open": "모달 열기", "modal-open": "모달 열기",
cart: "장바구니 저장", cart: "장바구니 저장",
"inbound-confirm": "입고 확정",
custom: "직접 설정", custom: "직접 설정",
}; };
@ -270,6 +286,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" }, confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
action: { type: "event" }, action: { type: "event" },
}, },
"inbound-confirm": {
label: "입고 확정",
variant: "default",
icon: "PackageCheck",
confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" },
action: { type: "event" },
},
custom: { custom: {
label: "버튼", label: "버튼",
variant: "default", variant: "default",
@ -341,6 +364,7 @@ const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown, Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
ShoppingCart, ShoppingCart,
ShoppingBag, ShoppingBag,
PackageCheck,
}; };
/** Lucide 아이콘 동적 렌더링 */ /** Lucide 아이콘 동적 렌더링 */
@ -389,10 +413,28 @@ export function PopButtonComponent({
// 장바구니 모드 상태 // 장바구니 모드 상태
const isCartMode = config?.preset === "cart"; const isCartMode = config?.preset === "cart";
const isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0); const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false); const [cartIsDirty, setCartIsDirty] = useState(false);
const [cartSaving, setCartSaving] = useState(false); const [cartSaving, setCartSaving] = useState(false);
const [showCartConfirm, setShowCartConfirm] = useState(false); const [showCartConfirm, setShowCartConfirm] = useState(false);
const [confirmProcessing, setConfirmProcessing] = useState(false);
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
const [inboundSelectedCount, setInboundSelectedCount] = useState(0);
// 입고 확정 모드: 선택 항목 수 수신
useEffect(() => {
if (!isInboundConfirmMode || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__selected_items`,
(payload: unknown) => {
const data = payload as { value?: unknown[] } | undefined;
const items = Array.isArray(data?.value) ? data.value : [];
setInboundSelectedCount(items.length);
}
);
return unsub;
}, [isInboundConfirmMode, componentId, subscribe]);
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
useEffect(() => { useEffect(() => {
@ -474,12 +516,117 @@ export function PopButtonComponent({
} }
}, [cartSaving]); }, [cartSaving]);
// 입고 확정: 데이터 수집 → API 호출
const handleInboundConfirm = useCallback(async () => {
if (!componentId) return;
setConfirmProcessing(true);
try {
// 동기적 이벤트 수집 (connectionResolver가 동기 중계)
const responses: CollectedDataResponse[] = [];
const unsub = subscribe(
`__comp_input__${componentId}__collected_data`,
(payload: unknown) => {
const enriched = payload as { value?: CollectedDataResponse };
if (enriched?.value) {
responses.push(enriched.value);
}
}
);
publish(`__comp_output__${componentId}__collect_data`, {
requestId: crypto.randomUUID(),
action: "inbound-confirm",
});
unsub();
if (responses.length === 0) {
toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요.");
return;
}
const cardListData = responses.find(r => r.componentType === "pop-card-list");
const fieldData = responses.find(r => r.componentType === "pop-field");
const selectedItems = cardListData?.data?.items ?? [];
if (selectedItems.length === 0) {
toast.error("확정할 항목을 선택해주세요.");
return;
}
const fieldValues = fieldData?.data?.values ?? {};
const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? [];
const cardListMapping = cardListData?.mapping ?? null;
const fieldMapping = fieldData?.mapping ?? null;
const result = await apiClient.post("/pop/execute-action", {
action: "inbound-confirm",
data: {
items: selectedItems,
fieldValues,
},
mappings: {
cardList: cardListMapping,
field: fieldMapping,
},
statusChanges: statusChangeRules,
});
if (result.data?.success) {
toast.success(`${selectedItems.length}건 입고 확정 완료`);
publish(`__comp_output__${componentId}__action_completed`, {
action: "inbound-confirm",
success: true,
count: selectedItems.length,
});
// 후속 액션 실행 (navigate, refresh 등)
const followUps = config?.followUpActions ?? [];
for (const fa of followUps) {
switch (fa.type) {
case "navigate":
if (fa.targetScreenId) {
publish("__pop_navigate__", { screenId: fa.targetScreenId, params: fa.params });
}
break;
case "refresh":
publish("__pop_refresh__");
break;
case "event":
if (fa.eventName) publish(fa.eventName, fa.eventPayload);
break;
}
}
} else {
toast.error(result.data?.message || "입고 확정에 실패했습니다.");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다.";
toast.error(message);
} finally {
setConfirmProcessing(false);
setShowInboundConfirm(false);
}
}, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules, config?.followUpActions]);
// 클릭 핸들러 // 클릭 핸들러
const handleClick = useCallback(async () => { const handleClick = useCallback(async () => {
if (isDesignMode) { if (isDesignMode) {
toast.info( const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` toast.info(`[디자인 모드] ${modeLabel} 액션`);
); return;
}
// 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출
if (isInboundConfirmMode) {
if (config?.confirm?.enabled !== false) {
setShowInboundConfirm(true);
} else {
await handleInboundConfirm();
}
return; return;
} }
@ -513,7 +660,7 @@ export function PopButtonComponent({
confirm: config?.confirm, confirm: config?.confirm,
followUpActions: config?.followUpActions, followUpActions: config?.followUpActions,
}); });
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]); }, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]);
// 외형 // 외형
const buttonLabel = config?.label || label || "버튼"; const buttonLabel = config?.label || label || "버튼";
@ -541,6 +688,20 @@ export function PopButtonComponent({
return ""; return "";
}, [isCartMode, cartCount, cartIsDirty]); }, [isCartMode, cartCount, cartIsDirty]);
// 입고 확정 2상태 아이콘: 미선택(기본 아이콘) / 선택됨(체크 아이콘)
const inboundIconName = useMemo(() => {
if (!isInboundConfirmMode) return iconName;
return inboundSelectedCount > 0 ? (config?.icon || "PackageCheck") : (config?.icon || "PackageCheck");
}, [isInboundConfirmMode, inboundSelectedCount, config?.icon, iconName]);
// 입고 확정 2상태 버튼 색상: 미선택(기본) / 선택됨(초록)
const inboundButtonClass = useMemo(() => {
if (!isInboundConfirmMode) return "";
return inboundSelectedCount > 0
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"
: "";
}, [isInboundConfirmMode, inboundSelectedCount]);
return ( return (
<> <>
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
@ -548,16 +709,17 @@ export function PopButtonComponent({
<Button <Button
variant={variant} variant={variant}
onClick={handleClick} onClick={handleClick}
disabled={isLoading || cartSaving} disabled={isLoading || cartSaving || confirmProcessing}
className={cn( className={cn(
"transition-transform active:scale-95", "transition-transform active:scale-95",
isIconOnly && "px-2", isIconOnly && "px-2",
cartButtonClass, cartButtonClass,
inboundButtonClass,
)} )}
> >
{(isCartMode ? cartIconName : iconName) && ( {(isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName) && (
<DynamicLucideIcon <DynamicLucideIcon
name={isCartMode ? cartIconName : iconName} name={isCartMode ? cartIconName : isInboundConfirmMode ? inboundIconName : iconName}
size={16} size={16}
className={isIconOnly ? "" : "mr-1.5"} className={isIconOnly ? "" : "mr-1.5"}
/> />
@ -579,6 +741,16 @@ export function PopButtonComponent({
{cartCount} {cartCount}
</div> </div>
)} )}
{/* 입고 확정 선택 개수 배지 */}
{isInboundConfirmMode && inboundSelectedCount > 0 && (
<div
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-white text-[10px] font-bold"
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
{inboundSelectedCount}
</div>
)}
</div> </div>
</div> </div>
@ -610,6 +782,35 @@ export function PopButtonComponent({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 입고 확정 확인 다이얼로그 */}
<AlertDialog open={showInboundConfirm} onOpenChange={setShowInboundConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
{config?.confirm?.message || "선택한 품목을 입고 확정하시겠습니까?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
disabled={confirmProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => { handleInboundConfirm(); }}
disabled={confirmProcessing}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{confirmProcessing ? "처리 중..." : "확정"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일반 확인 다이얼로그 */} {/* 일반 확인 다이얼로그 */}
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}> <AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]"> <AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
@ -1029,7 +1230,7 @@ export function PopButtonConfigPanel({
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5"> <div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
<p className="mb-1 text-[10px] font-medium text-muted-foreground"> </p> <p className="mb-1 text-[10px] font-medium text-muted-foreground"> </p>
<CartMappingRow source="현재 화면 ID" target="screen_id" auto /> <CartMappingRow source="현재 화면 ID" target="screen_id" auto />
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto /> <CartMappingRow source='장바구니 타입 (미사용)' target="cart_type" auto />
<CartMappingRow source='상태 ("in_cart")' target="status" auto /> <CartMappingRow source='상태 ("in_cart")' target="status" auto />
<CartMappingRow source="회사 코드" target="company_code" auto /> <CartMappingRow source="회사 코드" target="company_code" auto />
<CartMappingRow source="사용자 ID" target="user_id" auto /> <CartMappingRow source="사용자 ID" target="user_id" auto />
@ -1130,6 +1331,17 @@ export function PopButtonConfigPanel({
)} )}
</div> </div>
{/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */}
{config?.preset !== "cart" && (
<>
<SectionDivider label="상태 변경 규칙" />
<StatusChangeRuleEditor
rules={config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []}
onUpdate={(rules) => onUpdate({ ...config, statusChangeRules: rules })}
/>
</>
)}
{/* 후속 액션 */} {/* 후속 액션 */}
<SectionDivider label="후속 액션" /> <SectionDivider label="후속 액션" />
<FollowUpActionsEditor <FollowUpActionsEditor
@ -1467,6 +1679,330 @@ function PopButtonPreviewComponent({
); );
} }
// ========================================
// 상태 변경 규칙 편집기
// ========================================
const KNOWN_ITEM_FIELDS = [
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
{ value: "id", label: "id" },
{ value: "row_key", label: "row_key" },
];
function StatusChangeRuleEditor({
rules,
onUpdate,
}: {
rules: StatusChangeRule[];
onUpdate: (rules: StatusChangeRule[]) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columnsMap, setColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
useEffect(() => {
fetchTableList().then(setTables);
}, []);
const loadColumns = (tableName: string) => {
if (!tableName || columnsMap[tableName]) return;
fetchTableColumns(tableName).then((cols) => {
setColumnsMap((prev) => ({ ...prev, [tableName]: cols }));
});
};
const updateRule = (idx: number, partial: Partial<StatusChangeRule>) => {
const next = [...rules];
next[idx] = { ...next[idx], ...partial };
onUpdate(next);
};
const removeRule = (idx: number) => {
const next = [...rules];
next.splice(idx, 1);
onUpdate(next);
};
const addRule = () => {
onUpdate([
...rules,
{
targetTable: "",
targetColumn: "",
valueType: "fixed",
fixedValue: "",
},
]);
};
return (
<div className="space-y-2 px-1">
{rules.map((rule, idx) => (
<SingleRuleEditor
key={idx}
rule={rule}
idx={idx}
tables={tables}
columns={columnsMap[rule.targetTable] ?? []}
onLoadColumns={loadColumns}
onUpdate={(partial) => updateRule(idx, partial)}
onRemove={() => removeRule(idx)}
/>
))}
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addRule}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
function SingleRuleEditor({
rule,
idx,
tables,
columns,
onLoadColumns,
onUpdate,
onRemove,
}: {
rule: StatusChangeRule;
idx: number;
tables: TableInfo[];
columns: ColumnInfo[];
onLoadColumns: (tableName: string) => void;
onUpdate: (partial: Partial<StatusChangeRule>) => void;
onRemove: () => void;
}) {
useEffect(() => {
if (rule.targetTable) onLoadColumns(rule.targetTable);
}, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps
const conditions = rule.conditionalValue?.conditions ?? [];
const defaultValue = rule.conditionalValue?.defaultValue ?? "";
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
const next = [...conditions];
next[cIdx] = { ...next[cIdx], ...partial };
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
});
};
const removeCondition = (cIdx: number) => {
const next = [...conditions];
next.splice(cIdx, 1);
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
});
};
const addCondition = () => {
onUpdate({
conditionalValue: {
...rule.conditionalValue,
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
defaultValue,
},
});
};
return (
<div className="space-y-2 rounded border border-border p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground"> {idx + 1}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onRemove}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<TableCombobox
tables={tables}
value={rule.targetTable}
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
/>
</div>
{/* 변경 컬럼 */}
{rule.targetTable && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ColumnCombobox
columns={columns}
value={rule.targetColumn}
onSelect={(v) => onUpdate({ targetColumn: v })}
/>
</div>
)}
{/* 조회 키 */}
{rule.targetColumn && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-[10px]"> </Label>
<Select
value={rule.lookupMode ?? "auto"}
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-[10px]"></SelectItem>
<SelectItem value="manual" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
</div>
{(rule.lookupMode ?? "auto") === "auto" ? (
<p className="text-[10px] text-muted-foreground">
{rule.targetTable === "cart_items"
? `카드 항목.__cart_id → ${rule.targetTable}.id`
: `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}
</p>
) : (
<div className="flex items-center gap-1">
<Select
value={rule.manualItemField ?? ""}
onValueChange={(v) => onUpdate({ manualItemField: v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]">
<SelectValue placeholder="카드 항목 필드" />
</SelectTrigger>
<SelectContent>
{KNOWN_ITEM_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox
columns={columns}
value={rule.manualPkColumn ?? ""}
onSelect={(v) => onUpdate({ manualPkColumn: v })}
placeholder="대상 PK 컬럼"
/>
</div>
)}
</div>
)}
{/* 변경 값 타입 */}
{rule.targetColumn && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1 text-[10px]">
<input
type="radio"
name={`valueType-${idx}`}
checked={rule.valueType === "fixed"}
onChange={() => onUpdate({ valueType: "fixed" })}
className="h-3 w-3"
/>
</label>
<label className="flex items-center gap-1 text-[10px]">
<input
type="radio"
name={`valueType-${idx}`}
checked={rule.valueType === "conditional"}
onChange={() => onUpdate({ valueType: "conditional" })}
className="h-3 w-3"
/>
</label>
</div>
</div>
{/* 고정값 */}
{rule.valueType === "fixed" && (
<div className="space-y-1">
<Input
value={rule.fixedValue ?? ""}
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
className="h-7 text-xs"
placeholder="변경할 값 입력"
/>
</div>
)}
{/* 조건부 */}
{rule.valueType === "conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
{conditions.map((cond, cIdx) => (
<div key={cIdx} className="space-y-1">
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox
columns={columns}
value={cond.whenColumn}
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
placeholder="컬럼"
/>
<Select
value={cond.operator}
onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}
>
<SelectTrigger className="h-7 w-14 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={cond.whenValue}
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
className="h-7 w-16 text-[10px]"
placeholder="값"
/>
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 pl-4">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input
value={cond.thenValue}
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
className="h-7 text-[10px]"
placeholder="변경할 값"
/>
</div>
</div>
))}
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
<Plus className="mr-1 h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input
value={defaultValue}
onChange={(e) =>
onUpdate({
conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value },
})
}
className="h-7 text-[10px]"
placeholder="기본값"
/>
</div>
</div>
)}
</>
)}
</div>
);
}
// 레지스트리 등록 // 레지스트리 등록
PopComponentRegistry.registerComponent({ PopComponentRegistry.registerComponent({
id: "pop-button", id: "pop-button",
@ -1486,11 +2022,15 @@ PopComponentRegistry.registerComponent({
} as PopButtonConfig, } as PopButtonConfig,
connectionMeta: { connectionMeta: {
sendable: [ sendable: [
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
{ key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" },
{ key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" },
], ],
receivable: [ receivable: [
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "카드 목록에서 체크박스로 선택된 항목 수 수신" },
], ],
}, },
touchOptimized: true, touchOptimized: true,

View File

@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
import { import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -28,12 +29,15 @@ import type {
CardPresetSpec, CardPresetSpec,
CartItem, CartItem,
PackageEntry, PackageEntry,
CollectDataRequest,
CollectedDataResponse,
} from "../types"; } from "../types";
import { import {
DEFAULT_CARD_IMAGE, DEFAULT_CARD_IMAGE,
CARD_PRESET_SPECS, CARD_PRESET_SPECS,
} from "../types"; } from "../types";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { screenApi } from "@/lib/api/screen";
import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync"; import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal"; import { NumberInputModal } from "./NumberInputModal";
@ -121,6 +125,28 @@ function MarqueeText({
); );
} }
// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw);
else if (typeof raw === "object" && raw !== null) rowData = raw as Record<string, unknown>;
} catch { rowData = {}; }
return {
...rowData,
__cart_id: dbRow.id,
__cart_quantity: Number(dbRow.quantity) || 0,
__cart_package_unit: dbRow.package_unit || "",
__cart_package_entries: dbRow.package_entries,
__cart_status: dbRow.status || "in_cart",
__cart_memo: dbRow.memo || "",
__cart_row_key: dbRow.row_key || "",
__cart_modified: false,
};
}
interface PopCardListComponentProps { interface PopCardListComponentProps {
config?: PopCardListConfig; config?: PopCardListConfig;
className?: string; className?: string;
@ -158,19 +184,34 @@ export function PopCardListComponent({
currentColSpan, currentColSpan,
onRequestResize, onRequestResize,
}: PopCardListComponentProps) { }: PopCardListComponentProps) {
const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
const maxGridColumns = config?.gridColumns || 2;
const configGridRows = config?.gridRows || 3;
const dataSource = config?.dataSource;
const template = config?.cardTemplate;
const { subscribe, publish } = usePopEvent(screenId || "default"); const { subscribe, publish } = usePopEvent(screenId || "default");
const router = useRouter(); const router = useRouter();
// 장바구니 DB 동기화 // 장바구니 목록 모드 플래그 및 상태
const sourceTableName = dataSource?.tableName || ""; const isCartListMode = config?.cartListMode?.enabled === true;
const cartType = config?.cartAction?.cartType; const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListConfig> | null>(null);
const cart = useCartSync(screenId || "", sourceTableName, cartType); const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
// 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등)
const effectiveConfig = useMemo<PopCardListConfig | undefined>(() => {
if (!isCartListMode || !inheritedConfig) return config;
return {
...config,
...inheritedConfig,
cartListMode: config?.cartListMode,
dataSource: config?.dataSource,
} as PopCardListConfig;
}, [config, inheritedConfig, isCartListMode]);
const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal";
const maxGridColumns = effectiveConfig?.gridColumns || 2;
const configGridRows = effectiveConfig?.gridRows || 3;
const dataSource = effectiveConfig?.dataSource;
const effectiveTemplate = effectiveConfig?.cardTemplate;
// 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화)
const sourceTableName = (!isCartListMode && dataSource?.tableName) || "";
const cart = useCartSync(screenId || "", sourceTableName);
// 데이터 상태 // 데이터 상태
const [rows, setRows] = useState<RowData[]>([]); const [rows, setRows] = useState<RowData[]>([]);
@ -219,9 +260,9 @@ export function PopCardListComponent({
const cartRef = useRef(cart); const cartRef = useRef(cart);
cartRef.current = cart; cartRef.current = cart;
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외)
useEffect(() => { useEffect(() => {
if (!componentId) return; if (!componentId || isCartListMode) return;
const unsub = subscribe( const unsub = subscribe(
`__comp_input__${componentId}__cart_save_trigger`, `__comp_input__${componentId}__cart_save_trigger`,
async (payload: unknown) => { async (payload: unknown) => {
@ -233,16 +274,16 @@ export function PopCardListComponent({
} }
); );
return unsub; return unsub;
}, [componentId, subscribe, publish]); }, [componentId, subscribe, publish, isCartListMode]);
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외)
useEffect(() => { useEffect(() => {
if (!componentId || cart.loading) return; if (!componentId || cart.loading || isCartListMode) return;
publish(`__comp_output__${componentId}__cart_updated`, { publish(`__comp_output__${componentId}__cart_updated`, {
count: cart.cartCount, count: cart.cartCount,
isDirty: cart.isDirty, isDirty: cart.isDirty,
}); });
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]);
// 카드 선택 시 selected_row 이벤트 발행 // 카드 선택 시 selected_row 이벤트 발행
const handleCardSelect = useCallback((row: RowData) => { const handleCardSelect = useCallback((row: RowData) => {
@ -278,7 +319,7 @@ export function PopCardListComponent({
const missingImageCountRef = useRef(0); const missingImageCountRef = useRef(0);
const cardSizeKey = config?.cardSize || "large"; const cardSizeKey = effectiveConfig?.cardSize || "large";
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
@ -454,7 +495,69 @@ export function PopCardListComponent({
[dataSource] [dataSource]
); );
// 장바구니 목록 모드 설정을 직렬화 (의존성 안정화)
const cartListModeKey = useMemo(
() => JSON.stringify(config?.cartListMode || null),
[config?.cartListMode]
);
useEffect(() => { useEffect(() => {
// 장바구니 목록 모드: cart_items에서 직접 조회
if (isCartListMode) {
const cartListMode = config!.cartListMode!;
// 원본 화면 미선택 시 데이터 조회하지 않음
if (!cartListMode.sourceScreenId) {
setLoading(false);
setRows([]);
return;
}
const fetchCartData = async () => {
setLoading(true);
setError(null);
try {
// 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등)
try {
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
const componentsMap = layoutJson?.components || {};
const componentList = Object.values(componentsMap) as any[];
const matched = cartListMode.sourceComponentId
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
: componentList.find((c: any) => c.type === "pop-card-list");
if (matched?.config) {
setInheritedConfig(matched.config);
}
} catch {
// 레이아웃 로드 실패 시 자체 config 폴백
}
const cartFilters: Record<string, unknown> = {
status: cartListMode.statusFilter || "in_cart",
};
if (cartListMode.sourceScreenId) {
cartFilters.screen_id = String(cartListMode.sourceScreenId);
}
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: cartFilters,
});
const parsed = (result.data || []).map(parseCartRow);
setRows(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패";
setError(message);
setRows([]);
} finally {
setLoading(false);
}
};
fetchCartData();
return;
}
// 기본 모드: 데이터 소스에서 조회
if (!dataSource?.tableName) { if (!dataSource?.tableName) {
setLoading(false); setLoading(false);
setRows([]); setRows([]);
@ -467,10 +570,11 @@ export function PopCardListComponent({
missingImageCountRef.current = 0; missingImageCountRef.current = 0;
try { try {
// 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리
const filters: Record<string, unknown> = {}; const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) { if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => { dataSource.filters.forEach((f) => {
if (f.column && f.value) { if (f.column && f.value && (!f.operator || f.operator === "=")) {
filters[f.column] = f.value; filters[f.column] = f.value;
} }
}); });
@ -499,7 +603,31 @@ export function PopCardListComponent({
filters: Object.keys(filters).length > 0 ? filters : undefined, filters: Object.keys(filters).length > 0 ? filters : undefined,
}); });
setRows(result.data || []); let fetchedRows = result.data || [];
// 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리
const clientFilters = (dataSource.filters || []).filter(
(f) => f.column && f.value && f.operator && f.operator !== "="
);
if (clientFilters.length > 0) {
fetchedRows = fetchedRows.filter((row) =>
clientFilters.every((f) => {
const cellVal = row[f.column];
const filterVal = f.value;
switch (f.operator) {
case "!=": return String(cellVal ?? "") !== filterVal;
case ">": return Number(cellVal) > Number(filterVal);
case ">=": return Number(cellVal) >= Number(filterVal);
case "<": return Number(cellVal) < Number(filterVal);
case "<=": return Number(cellVal) <= Number(filterVal);
case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase());
default: return true;
}
})
);
}
setRows(fetchedRows);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "데이터 조회 실패"; const message = err instanceof Error ? err.message : "데이터 조회 실패";
setError(message); setError(message);
@ -510,16 +638,90 @@ export function PopCardListComponent({
}; };
fetchData(); fetchData();
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
useEffect(() => { useEffect(() => {
if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) {
const imageColumn = template.image.imageColumn; const imageColumn = effectiveTemplate.image.imageColumn;
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
} }
}, [loading, rows, template?.image]); }, [loading, rows, effectiveTemplate?.image]);
// 장바구니 목록 모드: 항목 삭제 콜백
const handleDeleteItem = useCallback((cartId: string) => {
setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId));
setSelectedKeys(prev => {
const next = new Set(prev);
next.delete(cartId);
return next;
});
}, []);
// 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영)
const handleUpdateQuantity = useCallback((
cartId: string,
quantity: number,
unit?: string,
entries?: PackageEntry[],
) => {
setRows(prev => prev.map(r => {
if (String(r.__cart_id) !== cartId) return r;
return {
...r,
__cart_quantity: quantity,
__cart_package_unit: unit || r.__cart_package_unit,
__cart_package_entries: entries || r.__cart_package_entries,
__cart_modified: true,
};
}));
}, []);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__collect_data`,
(payload: unknown) => {
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
const selectedItems = isCartListMode
? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")))
: rows;
// CardListSaveMapping → SaveMapping 변환
const sm = config?.saveMapping;
const mapping = sm?.targetTable && sm.mappings.length > 0
? {
targetTable: sm.targetTable,
columnMapping: Object.fromEntries(
sm.mappings
.filter(m => m.sourceField && m.targetColumn)
.map(m => [m.sourceField, m.targetColumn])
),
}
: null;
const response: CollectedDataResponse = {
requestId: request?.requestId ?? "",
componentId: componentId,
componentType: "pop-card-list",
data: { items: selectedItems },
mapping,
};
publish(`__comp_output__${componentId}__collected_data`, response);
}
);
return unsub;
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
// 장바구니 목록 모드: 선택 항목 이벤트 발행
useEffect(() => {
if (!componentId || !isCartListMode) return;
const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")));
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
// 카드 영역 스타일 // 카드 영역 스타일
const cardAreaStyle: React.CSSProperties = { const cardAreaStyle: React.CSSProperties = {
@ -549,7 +751,13 @@ export function PopCardListComponent({
ref={containerRef} ref={containerRef}
className={`flex h-full w-full flex-col ${className || ""}`} className={`flex h-full w-full flex-col ${className || ""}`}
> >
{!dataSource?.tableName ? ( {isCartListMode && !config?.cartListMode?.sourceScreenId ? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : !isCartListMode && !dataSource?.tableName ? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4"> <div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
. .
@ -569,6 +777,27 @@ export function PopCardListComponent({
</div> </div>
) : ( ) : (
<> <>
{/* 장바구니 목록 모드: 선택 바 */}
{isCartListMode && (
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
<input
type="checkbox"
checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0}
onChange={(e) => {
if (e.target.checked) {
setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? ""))));
} else {
setSelectedKeys(new Set());
}
}}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm text-muted-foreground">
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
</span>
</div>
)}
{/* 카드 영역 (스크롤 가능) */} {/* 카드 영역 (스크롤 가능) */}
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
@ -580,25 +809,39 @@ export function PopCardListComponent({
}} }}
> >
{displayCards.map((row, index) => { {displayCards.map((row, index) => {
const codeValue = template?.header?.codeField && row[template.header.codeField] const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField]
? String(row[template.header.codeField]) ? String(row[effectiveTemplate.header.codeField])
: null; : null;
const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`;
return ( return (
<Card <Card
key={rowKey} key={rowKey}
row={row} row={row}
template={template} template={effectiveTemplate}
scaled={scaled} scaled={scaled}
inputField={config?.inputField} inputField={effectiveConfig?.inputField}
packageConfig={config?.packageConfig} packageConfig={effectiveConfig?.packageConfig}
cartAction={config?.cartAction} cartAction={effectiveConfig?.cartAction}
publish={publish} publish={publish}
router={router} router={router}
onSelect={handleCardSelect} onSelect={handleCardSelect}
cart={cart} cart={cart}
codeFieldName={template?.header?.codeField} keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"}
parentComponentId={componentId} parentComponentId={componentId}
isCartListMode={isCartListMode}
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
onToggleSelect={() => {
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
setSelectedKeys(prev => {
const next = new Set(prev);
if (next.has(cartId)) next.delete(cartId);
else next.add(cartId);
return next;
});
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
/> />
); );
})} })}
@ -679,8 +922,13 @@ function Card({
router, router,
onSelect, onSelect,
cart, cart,
codeFieldName, keyColumnName,
parentComponentId, parentComponentId,
isCartListMode,
isSelected,
onToggleSelect,
onDeleteItem,
onUpdateQuantity,
}: { }: {
row: RowData; row: RowData;
template?: CardTemplateConfig; template?: CardTemplateConfig;
@ -692,8 +940,13 @@ function Card({
router: ReturnType<typeof useRouter>; router: ReturnType<typeof useRouter>;
onSelect?: (row: RowData) => void; onSelect?: (row: RowData) => void;
cart: ReturnType<typeof useCartSync>; cart: ReturnType<typeof useCartSync>;
codeFieldName?: string; keyColumnName?: string;
parentComponentId?: string; parentComponentId?: string;
isCartListMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
onDeleteItem?: (cartId: string) => void;
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
}) { }) {
const header = template?.header; const header = template?.header;
const image = template?.image; const image = template?.image;
@ -707,19 +960,28 @@ function Card({
const codeValue = header?.codeField ? row[header.codeField] : null; const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null; const titleValue = header?.titleField ? row[header.titleField] : null;
// 장바구니 상태: codeField 값을 rowKey로 사용 const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
const isCarted = cart.isItemInCart(rowKey); const isCarted = cart.isItemInCart(rowKey);
const existingCartItem = cart.getCartItem(rowKey); const existingCartItem = cart.getCartItem(rowKey);
// DB에서 로드된 장바구니 품목이면 입력값 복원 // DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드)
useEffect(() => { useEffect(() => {
if (isCartListMode) return;
if (existingCartItem && existingCartItem._origin === "db") { if (existingCartItem && existingCartItem._origin === "db") {
setInputValue(existingCartItem.quantity); setInputValue(existingCartItem.quantity);
setPackageUnit(existingCartItem.packageUnit); setPackageUnit(existingCartItem.packageUnit);
setPackageEntries(existingCartItem.packageEntries || []); setPackageEntries(existingCartItem.packageEntries || []);
} }
}, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
// 장바구니 목록 모드: __cart_quantity에서 초기값 복원
useEffect(() => {
if (!isCartListMode) return;
const cartQty = Number(row.__cart_quantity) || 0;
setInputValue(cartQty);
const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined;
setPackageUnit(cartUnit);
}, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]);
const imageUrl = const imageUrl =
image?.enabled && image?.imageColumn && row[image.imageColumn] image?.enabled && image?.imageColumn && row[image.imageColumn]
@ -771,6 +1033,9 @@ function Card({
setInputValue(value); setInputValue(value);
setPackageUnit(unit); setPackageUnit(unit);
setPackageEntries(entries || []); setPackageEntries(entries || []);
if (isCartListMode) {
onUpdateQuantity?.(String(row.__cart_id), value, unit, entries);
}
}; };
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
@ -806,6 +1071,23 @@ function Card({
} }
}; };
// 장바구니 목록 모드: 개별 삭제
const handleCartDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
if (!ok) return;
try {
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
onDeleteItem?.(cartId);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11)));
const cartLabel = cartAction?.label || "담기"; const cartLabel = cartAction?.label || "담기";
@ -815,13 +1097,23 @@ function Card({
onSelect?.(row); onSelect?.(row);
}; };
// 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
const borderClass = isCartListMode
? isSelected
? "border-primary border-2 hover:border-primary/80"
: "hover:border-2 hover:border-blue-500"
: isCarted
? "border-emerald-500 border-2 hover:border-emerald-600"
: "hover:border-2 hover:border-blue-500";
// 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
const headerBgClass = isCartListMode
? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30"
: isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30";
return ( return (
<div <div
className={`cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${ className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
isCarted
? "border-emerald-500 border-2 hover:border-emerald-600"
: "hover:border-2 hover:border-blue-500"
}`}
style={cardStyle} style={cardStyle}
onClick={handleCardClick} onClick={handleCardClick}
role="button" role="button"
@ -829,9 +1121,18 @@ function Card({
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
> >
{/* 헤더 영역 */} {/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && ( {(codeValue !== null || titleValue !== null || isCartListMode) && (
<div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}> <div className={`border-b ${headerBgClass}`} style={headerStyle}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isCartListMode && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0 rounded border-input"
/>
)}
{codeValue !== null && ( {codeValue !== null && (
<span <span
className="shrink-0 font-medium text-muted-foreground" className="shrink-0 font-medium text-muted-foreground"
@ -892,8 +1193,8 @@ function Card({
</div> </div>
</div> </div>
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
{(inputField?.enabled || cartAction) && ( {(inputField?.enabled || cartAction || isCartListMode) && (
<div <div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2" className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
style={{ minWidth: "100px" }} style={{ minWidth: "100px" }}
@ -903,7 +1204,7 @@ function Card({
<button <button
type="button" type="button"
onClick={handleInputClick} onClick={handleInputClick}
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50" className="rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
> >
<span className="block text-lg font-bold leading-tight"> <span className="block text-lg font-bold leading-tight">
{inputValue.toLocaleString()} {inputValue.toLocaleString()}
@ -914,8 +1215,22 @@ function Card({
</button> </button>
)} )}
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */} {/* 장바구니 목록 모드: 삭제 버튼 */}
{cartAction && ( {isCartListMode && (
<button
type="button"
onClick={handleCartDelete}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<Trash2 size={iconSize} />
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
</span>
</button>
)}
{/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */}
{!isCartListMode && cartAction && (
<> <>
{isCarted ? ( {isCarted ? (
<button <button

View File

@ -60,13 +60,17 @@ PopComponentRegistry.registerComponent({
defaultProps: defaultConfig, defaultProps: defaultConfig,
connectionMeta: { connectionMeta: {
sendable: [ sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
], ],
receivable: [ receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
], ],
}, },
touchOptimized: true, touchOptimized: true,

View File

@ -33,6 +33,7 @@ export interface ColumnInfo {
name: string; name: string;
type: string; type: string;
udtName: string; udtName: string;
isPrimaryKey?: boolean;
} }
// ===== SQL 값 이스케이프 ===== // ===== SQL 값 이스케이프 =====
@ -328,6 +329,7 @@ export async function fetchTableColumns(
name: col.columnName || col.column_name || col.name, name: col.columnName || col.column_name || col.name,
type: col.dataType || col.data_type || col.type || "unknown", type: col.dataType || col.data_type || col.type || "unknown",
udtName: col.dbType || col.udt_name || col.udtName || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown",
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
})); }));
} }
} }

View File

@ -0,0 +1,762 @@
"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { usePopEvent } from "@/hooks/pop";
import { dataApi } from "@/lib/api/data";
import type {
PopFieldConfig,
PopFieldItem,
PopFieldSection,
FieldSectionStyle,
PopFieldReadSource,
PopFieldAutoGenMapping,
} from "./types";
import type { CollectDataRequest, CollectedDataResponse } from "../types";
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
// ========================================
// Props
// ========================================
interface PopFieldComponentProps {
config?: PopFieldConfig;
screenId?: string;
componentId?: string;
}
// ========================================
// 메인 컴포넌트
// ========================================
export function PopFieldComponent({
config,
screenId,
componentId,
}: PopFieldComponentProps) {
const cfg: PopFieldConfig = {
...DEFAULT_FIELD_CONFIG,
...config,
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
};
const { publish, subscribe } = usePopEvent(screenId || "default");
const containerRef = useRef<HTMLDivElement>(null);
const [allValues, setAllValues] = useState<Record<string, unknown>>({});
const [hiddenValues, setHiddenValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [containerWidth, setContainerWidth] = useState(0);
const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? [];
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
// ResizeObserver로 컨테이너 너비 감시
useEffect(() => {
if (typeof window === "undefined" || !containerRef.current) return;
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
ro.observe(containerRef.current);
return () => ro.disconnect();
}, []);
// readSource 기반 DB 조회 + JSON 파싱
const fetchReadSourceData = useCallback(
async (pkValue: unknown, readSource: PopFieldReadSource) => {
if (!readSource.tableName || !readSource.pkColumn || !pkValue) return;
try {
const res = await dataApi.getTableData(readSource.tableName, {
page: 1,
size: 1,
filters: { [readSource.pkColumn]: String(pkValue) },
});
if (!Array.isArray(res.data) || res.data.length === 0) return;
const row = res.data[0] as Record<string, unknown>;
const extracted: Record<string, unknown> = {};
for (const mapping of readSource.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const raw = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
} else if (typeof raw === "object" && raw !== null) {
parsed = raw as Record<string, unknown>;
}
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
}
}
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
const valuesUpdate: Record<string, unknown> = {};
for (const [fieldId, val] of Object.entries(extracted)) {
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
const key = f?.fieldName || f?.id || fieldId;
valuesUpdate[key] = val;
}
if (Object.keys(valuesUpdate).length > 0) {
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
}
} catch {
// 조회 실패 시 무시
}
},
[cfg.sections]
);
// set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리)
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const raw = payload as Record<string, unknown> | undefined;
if (!raw) return;
// useConnectionResolver가 감싼 enrichedPayload인지 확인
const isConnectionPayload = raw._connectionId !== undefined;
const actual = isConnectionPayload
? (raw.value as Record<string, unknown> | undefined)
: raw;
if (!actual) return;
const data = actual as {
fieldName?: string;
value?: unknown;
values?: Record<string, unknown>;
pkValue?: unknown;
};
// row 객체가 통째로 온 경우 (pop-card-list selected_row 등)
if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") {
const rowObj = actual as Record<string, unknown>;
setAllValues((prev) => ({ ...prev, ...rowObj }));
// 숨은 필드 값 추출 (valueSource 기반)
if (hiddenMappings.length > 0) {
const extracted: Record<string, unknown> = {};
for (const hm of hiddenMappings) {
if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
if (rowObj[hm.sourceDbColumn] !== undefined) {
extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn];
}
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
const raw = rowObj[hm.sourceJsonColumn];
let parsed: Record<string, unknown> = {};
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
} else if (typeof raw === "object" && raw !== null) {
parsed = raw as Record<string, unknown>;
}
if (parsed[hm.sourceJsonKey] !== undefined) {
extracted[hm.targetColumn] = parsed[hm.sourceJsonKey];
}
}
}
if (Object.keys(extracted).length > 0) {
setHiddenValues((prev) => ({ ...prev, ...extracted }));
}
}
const pkCol = cfg.readSource?.pkColumn;
const pkVal = pkCol ? rowObj[pkCol] : undefined;
if (pkVal && cfg.readSource) {
fetchReadSourceData(pkVal, cfg.readSource);
}
return;
}
if (data.values) {
setAllValues((prev) => ({ ...prev, ...data.values }));
} else if (data.fieldName) {
setAllValues((prev) => ({
...prev,
[data.fieldName!]: data.value,
}));
}
if (data.pkValue && cfg.readSource) {
fetchReadSourceData(data.pkValue, cfg.readSource);
}
}
);
return unsub;
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__collect_data`,
(payload: unknown) => {
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
const response: CollectedDataResponse = {
requestId: request?.requestId ?? "",
componentId: componentId,
componentType: "pop-field",
data: { values: allValues },
mapping: cfg.saveConfig?.tableName
? {
targetTable: cfg.saveConfig.tableName,
columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
),
}
: null,
};
publish(`__comp_output__${componentId}__collected_data`, response);
}
);
return unsub;
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(fieldName: string, value: unknown) => {
setAllValues((prev) => {
const next = { ...prev, [fieldName]: value };
if (componentId) {
publish(`__comp_output__${componentId}__value_changed`, {
fieldName,
value,
allValues: next,
hiddenValues,
targetTable: cfg.saveConfig?.tableName || cfg.targetTable,
saveConfig: cfg.saveConfig,
readSource: cfg.readSource,
});
}
return next;
});
setErrors((prev) => {
if (!prev[fieldName]) return prev;
const next = { ...prev };
delete next[fieldName];
return next;
});
},
[componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues]
);
// readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기)
const readSourceKey = cfg.readSource
? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}`
: "";
const previewFetchedRef = useRef("");
useEffect(() => {
if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return;
if (previewFetchedRef.current === readSourceKey) return;
previewFetchedRef.current = readSourceKey;
(async () => {
try {
const res = await dataApi.getTableData(cfg.readSource!.tableName, {
page: 1,
size: 1,
});
if (!Array.isArray(res.data) || res.data.length === 0) return;
const row = res.data[0] as Record<string, unknown>;
const extracted: Record<string, unknown> = {};
for (const mapping of cfg.readSource!.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const rawVal = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof rawVal === "string") {
try { parsed = JSON.parse(rawVal); } catch { /* ignore */ }
} else if (typeof rawVal === "object" && rawVal !== null) {
parsed = rawVal as Record<string, unknown>;
}
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
}
}
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
const valuesUpdate: Record<string, unknown> = {};
for (const [fieldId, val] of Object.entries(extracted)) {
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
const key = f?.fieldName || f?.id || fieldId;
valuesUpdate[key] = val;
}
if (Object.keys(valuesUpdate).length > 0) {
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
}
} catch {
// 미리보기 조회 실패 시 무시
}
})();
}, [readSourceKey, cfg.readSource, cfg.sections]);
// "auto" 열 수 계산
function resolveColumns(
columns: "auto" | 1 | 2 | 3 | 4,
fieldCount: number
): number {
if (columns !== "auto") return columns;
if (containerWidth < 200) return 1;
if (containerWidth < 400) return Math.min(2, fieldCount);
if (containerWidth < 600) return Math.min(3, fieldCount);
return Math.min(4, fieldCount);
}
function migrateStyle(style: string): FieldSectionStyle {
if (style === "display" || style === "input") return style;
if (style === "summary") return "display";
if (style === "form") return "input";
return "input";
}
function sectionClassName(section: PopFieldSection): string {
const resolved = migrateStyle(section.style);
const defaults = DEFAULT_SECTION_APPEARANCES[resolved];
const a = section.appearance || {};
const bg = a.bgColor || defaults.bgColor;
const border = a.borderColor || defaults.borderColor;
return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3");
}
return (
<div
ref={containerRef}
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
>
{cfg.sections.map((section) => {
const fields = section.fields || [];
const fieldCount = fields.length;
if (fieldCount === 0) return null;
const cols = resolveColumns(section.columns, fieldCount);
return (
<div key={section.id} className={sectionClassName(section)}>
{section.label && (
<div className="mb-1 text-xs font-medium text-muted-foreground">
{section.label}
</div>
)}
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
>
{fields.map((field) => {
const fKey = field.fieldName || field.id;
return (
<FieldRenderer
key={field.id}
field={{ ...field, fieldName: fKey }}
value={allValues[fKey]}
showLabel={section.showLabels}
error={errors[fKey]}
onChange={handleFieldChange}
sectionStyle={section.style}
/>
);
})}
</div>
</div>
);
})}
{visibleAutoGens.length > 0 && (
<div className="rounded-lg border border-border bg-background px-4 py-3">
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
>
{visibleAutoGens.map((ag) => (
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
))}
</div>
</div>
)}
</div>
);
}
// ========================================
// FieldRenderer: 개별 필드 렌더링
// ========================================
interface FieldRendererProps {
field: PopFieldItem;
value: unknown;
showLabel: boolean;
error?: string;
onChange: (fieldName: string, value: unknown) => void;
sectionStyle: FieldSectionStyle;
}
function FieldRenderer({
field,
value,
showLabel,
error,
onChange,
sectionStyle,
}: FieldRendererProps) {
const handleChange = useCallback(
(v: unknown) => onChange(field.fieldName, v),
[onChange, field.fieldName]
);
const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle;
const inputClassName = cn(
"h-9 w-full rounded-md border px-3 text-sm",
field.readOnly
? "cursor-default bg-muted text-muted-foreground"
: "bg-background",
resolvedStyle === "display" &&
"border-transparent bg-transparent text-sm font-medium"
);
return (
<div className="flex flex-col gap-1">
{showLabel && field.labelText && (
<label className="text-xs font-medium text-muted-foreground">
{field.labelText}
{field.validation?.required && (
<span className="ml-0.5 text-destructive">*</span>
)}
</label>
)}
{renderByType(field, value, handleChange, inputClassName)}
{error && <p className="text-[10px] text-destructive">{error}</p>}
</div>
);
}
// ========================================
// 서브타입별 렌더링 분기
// ========================================
function renderByType(
field: PopFieldItem,
value: unknown,
onChange: (v: unknown) => void,
className: string
) {
switch (field.inputType) {
case "text":
return (
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
readOnly={field.readOnly}
placeholder={field.placeholder}
className={className}
/>
);
case "number":
return (
<NumberFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
case "date":
return (
<Input
type="date"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
readOnly={field.readOnly}
className={className}
/>
);
case "select":
return (
<SelectFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
case "auto":
return <AutoFieldInput field={field} value={value} className={className} />;
case "numpad":
return (
<NumpadFieldInput
field={field}
value={value}
onChange={onChange}
className={className}
/>
);
default:
return (
<Input
value={String(value ?? "")}
readOnly
className={className}
/>
);
}
}
// ========================================
// NumberFieldInput
// ========================================
function NumberFieldInput({
field,
value,
onChange,
className,
}: {
field: PopFieldItem;
value: unknown;
onChange: (v: unknown) => void;
className: string;
}) {
return (
<div className="flex items-center gap-1">
<Input
type="number"
value={value !== undefined && value !== null ? String(value) : ""}
onChange={(e) => {
const num = e.target.value === "" ? "" : Number(e.target.value);
onChange(num);
}}
readOnly={field.readOnly}
placeholder={field.placeholder}
min={field.validation?.min}
max={field.validation?.max}
className={cn(className, "flex-1")}
/>
{field.unit && (
<span className="shrink-0 text-xs text-muted-foreground">
{field.unit}
</span>
)}
</div>
);
}
// ========================================
// SelectFieldInput
// ========================================
function SelectFieldInput({
field,
value,
onChange,
className,
}: {
field: PopFieldItem;
value: unknown;
onChange: (v: unknown) => void;
className: string;
}) {
const [options, setOptions] = useState<{ value: string; label: string }[]>(
[]
);
const [loading, setLoading] = useState(false);
const source = field.selectSource;
useEffect(() => {
if (!source) return;
if (source.type === "static" && source.staticOptions) {
setOptions(source.staticOptions);
return;
}
if (
source.type === "table" &&
source.tableName &&
source.valueColumn &&
source.labelColumn
) {
setLoading(true);
dataApi
.getTableData(source.tableName, {
page: 1,
pageSize: 500,
sortColumn: source.labelColumn,
sortDirection: "asc",
})
.then((res) => {
if (res.data?.success && Array.isArray(res.data.data?.data)) {
setOptions(
res.data.data.data.map((row: Record<string, unknown>) => ({
value: String(row[source.valueColumn!] ?? ""),
label: String(row[source.labelColumn!] ?? ""),
}))
);
}
})
.catch(() => {
setOptions([]);
})
.finally(() => setLoading(false));
}
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]);
if (loading) {
return (
<div className={cn(className, "flex items-center justify-center")}>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (field.readOnly) {
const selectedLabel =
options.find((o) => o.value === String(value ?? ""))?.label ??
String(value ?? "-");
return (
<Input value={selectedLabel} readOnly className={className} />
);
}
if (!source) {
return (
<div className={cn(className, "flex items-center text-muted-foreground")}>
</div>
);
}
return (
<Select
value={String(value ?? "")}
onValueChange={(v) => onChange(v)}
>
<SelectTrigger className={cn(className, "justify-between")}>
<SelectValue placeholder={field.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
</div>
) : (
options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}
// ========================================
// AutoFieldInput (자동 채번 - 읽기전용)
// ========================================
function AutoFieldInput({
field,
value,
className,
}: {
field: PopFieldItem;
value: unknown;
className: string;
}) {
const displayValue = useMemo(() => {
if (value) return String(value);
if (!field.autoNumber) return "자동생성";
const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber;
const parts: string[] = [];
if (prefix) parts.push(prefix);
if (dateFormat) {
const now = new Date();
const dateStr = dateFormat
.replace("YYYY", String(now.getFullYear()))
.replace("MM", String(now.getMonth() + 1).padStart(2, "0"))
.replace("DD", String(now.getDate()).padStart(2, "0"));
parts.push(dateStr);
}
if (sequenceDigits) {
parts.push("0".repeat(sequenceDigits));
}
return parts.join(separator || "-") || "자동생성";
}, [value, field.autoNumber]);
return (
<Input
value={displayValue}
readOnly
className={cn(className, "cursor-default bg-muted text-muted-foreground")}
placeholder="자동생성"
/>
);
}
// ========================================
// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시)
// ========================================
function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) {
return (
<div className="flex flex-col gap-1">
{mapping.label && (
<label className="text-xs font-medium text-muted-foreground">
{mapping.label}
</label>
)}
<div className="flex h-9 items-center rounded-md border border-dashed border-muted-foreground/30 bg-muted px-3">
<span className="text-xs text-muted-foreground">
</span>
</div>
</div>
);
}
// ========================================
// NumpadFieldInput (클릭 시 숫자 직접 입력)
// ========================================
function NumpadFieldInput({
field,
value,
onChange,
className,
}: {
field: PopFieldItem;
value: unknown;
onChange: (v: unknown) => void;
className: string;
}) {
const displayValue =
value !== undefined && value !== null ? String(value) : "";
return (
<div className="flex items-center gap-1">
<Input
type="number"
value={displayValue}
onChange={(e) => {
const num = e.target.value === "" ? "" : Number(e.target.value);
onChange(num);
}}
readOnly={field.readOnly}
placeholder={field.placeholder || "수량 입력"}
className={cn(className, "flex-1")}
/>
{field.unit && (
<span className="shrink-0 text-xs text-muted-foreground">
{field.unit}
</span>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopFieldComponent } from "./PopFieldComponent";
import { PopFieldConfigPanel } from "./PopFieldConfig";
import type { PopFieldConfig } from "./types";
import { DEFAULT_FIELD_CONFIG, FIELD_INPUT_TYPE_LABELS } from "./types";
function PopFieldPreviewComponent({
config,
label,
}: {
config?: PopFieldConfig;
label?: string;
}) {
const cfg: PopFieldConfig = {
...DEFAULT_FIELD_CONFIG,
...config,
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
};
const totalFields = cfg.sections.reduce(
(sum, s) => sum + (s.fields?.length || 0),
0
);
const sectionCount = cfg.sections.length;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<span className="text-[10px] font-medium text-muted-foreground">
{label || "입력 필드"}
</span>
<div className="flex flex-wrap gap-1">
{cfg.sections.map((section) =>
(section.fields || []).slice(0, 3).map((field) => (
<div
key={field.id}
className="flex h-5 items-center rounded border border-dashed border-muted-foreground/30 px-1.5"
>
<span className="text-[8px] text-muted-foreground">
{field.labelText || field.fieldName || FIELD_INPUT_TYPE_LABELS[field.inputType]}
</span>
</div>
))
)}
</div>
<span className="text-[8px] text-muted-foreground">
{sectionCount} / {totalFields}
</span>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-field",
name: "입력 필드",
description: "저장용 값 입력 (섹션별 멀티필드, 읽기전용/입력 혼합)",
category: "input",
icon: "TextCursorInput",
component: PopFieldComponent,
configPanel: PopFieldConfigPanel,
preview: PopFieldPreviewComponent,
defaultProps: DEFAULT_FIELD_CONFIG,
connectionMeta: {
sendable: [
{
key: "value_changed",
label: "값 변경",
type: "value",
category: "data",
description: "필드값 변경 시 fieldName + value + allValues 전달",
},
{
key: "collected_data",
label: "수집 응답",
type: "event",
category: "event",
description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)",
},
],
receivable: [
{
key: "set_value",
label: "값 설정",
type: "value",
category: "data",
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
},
{
key: "collect_data",
label: "수집 요청",
type: "event",
category: "event",
description: "버튼에서 데이터+매핑 수집 요청 수신",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,210 @@
/**
* pop-field
* 멀티필드: 하나의 ,
* (text/number/date/select/auto/numpad)
*/
import type { DataSourceFilter } from "../types";
// ===== 서브타입 =====
export type FieldInputType = "text" | "number" | "date" | "select" | "auto" | "numpad";
export const FIELD_INPUT_TYPE_LABELS: Record<FieldInputType, string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
select: "선택",
auto: "자동채번",
numpad: "숫자패드",
};
// ===== 섹션 스타일 =====
export type FieldSectionStyle = "display" | "input";
export const FIELD_SECTION_STYLE_LABELS: Record<FieldSectionStyle, string> = {
display: "읽기 폼",
input: "입력 폼",
};
// 섹션 커스텀 외관 옵션
export interface FieldSectionAppearance {
bgColor?: string;
borderColor?: string;
textColor?: string;
}
export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSectionAppearance> = {
display: { bgColor: "bg-emerald-50", borderColor: "border-emerald-200", textColor: "text-foreground" },
input: { bgColor: "bg-background", borderColor: "border-border", textColor: "text-foreground" },
};
// ===== select 옵션 소스 =====
export type FieldSelectSourceType = "static" | "table";
export interface FieldSelectSource {
type: FieldSelectSourceType;
staticOptions?: { value: string; label: string }[];
tableName?: string;
valueColumn?: string;
labelColumn?: string;
filters?: DataSourceFilter[];
}
// ===== 자동 채번 설정 =====
export interface AutoNumberConfig {
prefix?: string;
dateFormat?: string;
separator?: string;
sequenceDigits?: number;
numberingRuleId?: string;
}
// ===== 유효성 검증 =====
export interface PopFieldValidation {
required?: boolean;
min?: number;
max?: number;
pattern?: string;
customMessage?: string;
}
// ===== 개별 필드 정의 =====
export interface PopFieldItem {
id: string;
inputType: FieldInputType;
fieldName: string;
labelText?: string;
placeholder?: string;
defaultValue?: unknown;
readOnly?: boolean;
unit?: string;
selectSource?: FieldSelectSource;
autoNumber?: AutoNumberConfig;
validation?: PopFieldValidation;
}
// ===== 섹션 정의 =====
export interface PopFieldSection {
id: string;
label?: string;
style: FieldSectionStyle;
columns: "auto" | 1 | 2 | 3 | 4;
showLabels: boolean;
appearance?: FieldSectionAppearance;
fields: PopFieldItem[];
}
// ===== 저장 설정: 값 소스 타입 =====
export type FieldValueSource = "direct" | "json_extract" | "db_column";
export const FIELD_VALUE_SOURCE_LABELS: Record<FieldValueSource, string> = {
direct: "직접 입력",
json_extract: "JSON 추출",
db_column: "DB 컬럼",
};
// ===== 저장 설정: 필드-컬럼 매핑 =====
export interface PopFieldSaveMapping {
fieldId: string;
valueSource: FieldValueSource;
targetColumn: string;
sourceJsonColumn?: string;
sourceJsonKey?: string;
}
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
export type HiddenValueSource = "json_extract" | "db_column";
export interface PopFieldHiddenMapping {
id: string;
label?: string;
valueSource: HiddenValueSource;
sourceJsonColumn?: string;
sourceJsonKey?: string;
sourceDbColumn?: string;
targetColumn: string;
}
// ===== 자동생성 필드 (서버 채번규칙으로 저장 시점 생성) =====
export interface PopFieldAutoGenMapping {
id: string;
linkedFieldId?: string;
label: string;
targetColumn: string;
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
}
export interface PopFieldSaveConfig {
tableName: string;
fieldMappings: PopFieldSaveMapping[];
hiddenMappings?: PopFieldHiddenMapping[];
autoGenMappings?: PopFieldAutoGenMapping[];
}
// ===== 읽기 데이터 소스 =====
export interface PopFieldReadMapping {
fieldId: string;
valueSource: FieldValueSource;
columnName: string;
jsonKey?: string;
}
export interface PopFieldReadSource {
tableName: string;
pkColumn: string;
fieldMappings: PopFieldReadMapping[];
}
// ===== pop-field 전체 설정 (루트) =====
export interface PopFieldConfig {
targetTable?: string;
sections: PopFieldSection[];
saveConfig?: PopFieldSaveConfig;
readSource?: PopFieldReadSource;
}
// ===== 기본값 =====
export const DEFAULT_FIELD_CONFIG: PopFieldConfig = {
targetTable: "",
sections: [
{
id: "section_display",
label: "요약",
style: "display",
columns: "auto",
showLabels: true,
fields: [
{ id: "f_disp_1", inputType: "text", fieldName: "", labelText: "항목1", readOnly: true },
{ id: "f_disp_2", inputType: "text", fieldName: "", labelText: "항목2", readOnly: true },
],
},
{
id: "section_input",
label: "입력",
style: "input",
columns: "auto",
showLabels: true,
fields: [
{ id: "f_input_1", inputType: "text", fieldName: "", labelText: "필드1" },
{ id: "f_input_2", inputType: "number", fieldName: "", labelText: "필드2", unit: "EA" },
],
},
],
};

View File

@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({
defaultProps: DEFAULT_SEARCH_CONFIG, defaultProps: DEFAULT_SEARCH_CONFIG,
connectionMeta: { connectionMeta: {
sendable: [ sendable: [
{ key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, { key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
], ],
receivable: [ receivable: [
{ key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
], ],
}, },
touchOptimized: true, touchOptimized: true,

View File

@ -0,0 +1,105 @@
"use client";
import React, { useState, useMemo } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { ColumnInfo } from "../pop-dashboard/utils/dataFetcher";
interface ColumnComboboxProps {
columns: ColumnInfo[];
value: string;
onSelect: (columnName: string) => void;
placeholder?: string;
}
export function ColumnCombobox({
columns,
value,
onSelect,
placeholder = "컬럼을 선택하세요",
}: ColumnComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
if (!search) return columns;
const q = search.toLowerCase();
return columns.filter((c) => c.name.toLowerCase().includes(q));
}, [columns, search]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value || placeholder}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{filtered.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
onSelect(col.name);
setOpen(false);
setSearch("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3.5 w-3.5",
value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{col.name}</span>
<span className="text-[10px] text-muted-foreground">
{col.type}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,116 @@
"use client";
import React, { useState, useMemo } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { TableInfo } from "../pop-dashboard/utils/dataFetcher";
interface TableComboboxProps {
tables: TableInfo[];
value: string;
onSelect: (tableName: string) => void;
placeholder?: string;
}
export function TableCombobox({
tables,
value,
onSelect,
placeholder = "테이블을 선택하세요",
}: TableComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const selectedLabel = useMemo(() => {
const found = tables.find((t) => t.tableName === value);
return found ? (found.displayName || found.tableName) : "";
}, [tables, value]);
const filtered = useMemo(() => {
if (!search) return tables;
const q = search.toLowerCase();
return tables.filter(
(t) =>
t.tableName.toLowerCase().includes(q) ||
(t.displayName && t.displayName.toLowerCase().includes(q))
);
}, [tables, search]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value ? selectedLabel : placeholder}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder="테이블명 또는 한글명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{filtered.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
onSelect(table.tableName);
setOpen(false);
setSearch("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3.5 w-3.5",
value === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName || table.tableName}</span>
{table.displayName && (
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -35,10 +35,10 @@ PopComponentRegistry.registerComponent({
defaultProps: defaultConfig, defaultProps: defaultConfig,
connectionMeta: { connectionMeta: {
sendable: [ sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" }, { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" },
], ],
receivable: [ receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
], ],
}, },
touchOptimized: true, touchOptimized: true,

View File

@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
export interface CartItemWithId extends CartItem { export interface CartItemWithId extends CartItem {
cartId?: string; // DB id (UUID, 저장 후 할당) cartId?: string; // DB id (UUID, 저장 후 할당)
sourceTable: string; // 원본 테이블명 sourceTable: string; // 원본 테이블명
rowKey: string; // 원본 행 식별키 (codeField 값) rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id)
status: CartItemStatus; status: CartItemStatus;
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가 _origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
memo?: string; memo?: string;
@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct";
export interface CardCartActionConfig { export interface CardCartActionConfig {
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장 saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound") keyColumn?: string; // 행 식별 키 컬럼 (기본: "id")
label?: string; // 담기 라벨 (기본: "담기") label?: string; // 담기 라벨 (기본: "담기")
cancelLabel?: string; // 취소 라벨 (기본: "취소") cancelLabel?: string; // 취소 라벨 (기본: "취소")
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호) // 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
@ -608,6 +608,86 @@ export interface CardResponsiveConfig {
fields?: Record<string, ResponsiveDisplayMode>; fields?: Record<string, ResponsiveDisplayMode>;
} }
// ----- 장바구니 목록 모드 설정 -----
export interface CartListModeConfig {
enabled: boolean;
sourceScreenId?: number;
sourceComponentId?: string;
statusFilter?: string;
}
// ----- 데이터 수집 패턴 (pop-button ↔ 컴포넌트 간 요청-응답) -----
export interface CollectDataRequest {
requestId: string;
action: string;
}
export interface CollectedDataResponse {
requestId: string;
componentId: string;
componentType: string;
data: {
items?: Record<string, unknown>[];
values?: Record<string, unknown>;
};
mapping?: SaveMapping | null;
}
export interface SaveMapping {
targetTable: string;
columnMapping: Record<string, string>;
}
export interface StatusChangeRule {
targetTable: string;
targetColumn: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
valueType: "fixed" | "conditional";
fixedValue?: string;
conditionalValue?: ConditionalValue;
}
export interface ConditionalValue {
conditions: StatusCondition[];
defaultValue?: string;
}
export interface StatusCondition {
whenColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
whenValue: string;
thenValue: string;
}
export interface ExecuteActionPayload {
inserts: {
table: string;
records: Record<string, unknown>[];
}[];
statusChanges: {
table: string;
column: string;
value: string;
where: Record<string, unknown>;
}[];
}
// ----- 저장 매핑 (장바구니 -> 대상 테이블) -----
export interface CardListSaveMappingEntry {
sourceField: string;
targetColumn: string;
}
export interface CardListSaveMapping {
targetTable: string;
mappings: CardListSaveMappingEntry[];
}
// ----- pop-card-list 전체 설정 ----- // ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig { export interface PopCardListConfig {
@ -620,10 +700,12 @@ export interface PopCardListConfig {
gridColumns?: number; gridColumns?: number;
gridRows?: number; gridRows?: number;
// 반응형 표시 설정
responsiveDisplay?: CardResponsiveConfig; responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig; inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig; packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig; cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
} }