Merge branch 'ksh-v2-work' into main

ksh-v2-work의 POP 화면 디자이너 기능을 main에 병합한다.
[병합 내용]
- pop-card-list-v2: 슬롯 기반 CSS Grid 카드 컴포넌트 (12종 셀 타입)
- pop-status-bar: 독립 상태 칩 컴포넌트 (카운트 순환 문제 해결)
- pop-scanner: 바코드/QR 스캐너 + 멀티필드 파싱
- pop-profile: 사용자 프로필/PC전환/로그아웃 컴포넌트
- pop-button: 설정 패널 UX 전면 개선 + 제어 실행 기능
- pop-search: 날짜 입력 타입 + 연결 탭 일관성 통합
- POP 모드 네비게이션: PC <-> POP 양방향 전환 + 로그인 POP 모드 토글
- 타임라인 범용화 + 상태 값 매핑 동적 배열 전환
- 다중 액션 체이닝 + 외부 테이블 선택 + 카드 클릭 모달
[충돌 해결 4건]
- authController.ts: 양쪽 통합 (스마트공장 로그 + POP 랜딩 경로)
- AppLayout.tsx: 양쪽 통합 (메뉴 드래그 + POP 모드 메뉴, 리디자인 UI + POP 모드 항목)
- ConnectionEditor.tsx: ksh-v2-work 선택 (하위 테이블 필터 구조) + CSS 변수 적용
- pop-button.tsx: ksh-v2-work 선택 (자연어 UX + 제어 실행) + CSS 변수 스타일 유지
This commit is contained in:
SeongHyun Kim 2026-03-12 09:00:52 +09:00
commit 55063367ea
56 changed files with 10356 additions and 1044 deletions

4
.gitignore vendored
View File

@ -31,6 +31,10 @@ dist/
build/
build/Release
# Gradle
.gradle/
**/backend/.gradle/
# Cache
.npm
.eslintcache

View File

@ -108,6 +108,46 @@ export async function getUserMenus(
}
}
/**
* POP
* [POP] L1 active
*/
export async function getPopMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const result = await AdminService.getPopMenuList({
userCompanyCode,
userType,
});
const response: ApiResponse<any> = {
success: true,
message: "POP 메뉴 목록 조회 성공",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "POP_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/

View File

@ -51,29 +51,24 @@ export class AuthController {
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 메뉴 조회를 위한 공통 파라미터
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
userType: loginResult.userInfo.userType,
userLang: "ko",
};
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
userType: loginResult.userInfo.userType,
userLang: "ko",
};
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기
// 조건:
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
// 2. MENU_URL이 있고 비어있지 않음
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
const firstMenu = menuList.find((menu: any) => {
const level = menu.lev || menu.level;
const url = menu.menu_url || menu.url;
return level >= 2 && url && url.trim() !== "" && url !== "#";
});
@ -94,6 +89,22 @@ export class AuthController {
useType: "접속",
}).catch(() => {});
// POP 랜딩 경로 조회
let popLandingPath: string | null = null;
try {
const popResult = await AdminService.getPopMenuList(paramMap);
if (popResult.landingMenu?.menu_url) {
popLandingPath = popResult.landingMenu.menu_url;
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
} catch (popError) {
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
}
res.status(200).json({
success: true,
message: "로그인 성공",
@ -101,6 +112,7 @@ export class AuthController {
userInfo,
token: loginResult.token,
firstMenuPath,
popLandingPath,
},
});
} else {

View File

@ -2,6 +2,7 @@ import { Router } from "express";
import {
getAdminMenus,
getUserMenus,
getPopMenus,
getMenuInfo,
saveMenu, // 메뉴 추가
updateMenu, // 메뉴 수정
@ -40,6 +41,7 @@ router.use(authenticateToken);
// 메뉴 관련 API
router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/pop-menus", getPopMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)

View File

@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
shareAcrossItems?: boolean;
}
interface HiddenMappingInfo {
@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
);
processedCount++;
@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
[thenVal, elseVal, companyCode, ...lookupValues],
);
processedCount += lookupValues.length;
@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
if (valSource === "linked") {
value = item[task.sourceField ?? ""] ?? null;
} else {
value = task.fixedValue ?? "";
const raw = task.fixedValue ?? "";
if (raw === "__CURRENT_USER__") {
value = userId;
} else if (raw === "__CURRENT_TIME__") {
value = new Date().toISOString();
} else {
value = raw;
}
}
let setSql: string;
@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
setSql = `"${task.targetColumn}" = $1`;
}
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
);
processedCount++;
@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
}
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
);
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId,
targetColumn: ag.targetColumn,
generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(fieldValues[sourceField] ?? null);
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
if (valueType === "fixed") {
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
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})`;
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} 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);
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolvedValue, companyCode, lookupValues[i]]
);
processedCount++;

View File

@ -621,6 +621,74 @@ export class AdminService {
}
}
/**
* POP
* menu_name_kor에 'POP' menu_desc에 [POP] L1 active
* [POP_LANDING] landingMenu로
*/
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
try {
const { userCompanyCode, userType } = paramMap;
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
let queryParams: any[] = [];
let paramIndex = 1;
let companyFilter = "";
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
companyFilter = `AND COMPANY_CODE = '*'`;
} else {
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
}
// POP L1 메뉴 조회
const parentMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = 0
AND MENU_TYPE = 1
AND (
MENU_DESC LIKE '%[POP]%'
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
)
${companyFilter}
ORDER BY SEQ
LIMIT 1`,
queryParams
);
if (parentMenus.length === 0) {
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
return { parentMenu: null, childMenus: [], landingMenu: null };
}
const parentMenu = parentMenus[0];
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
const childMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = $1
AND STATUS = 'active'
AND COMPANY_CODE = $2
ORDER BY SEQ`,
[parentMenu.objid, parentMenu.company_code]
);
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
return { parentMenu, childMenus, landingMenu };
} catch (error) {
logger.error("AdminService.getPopMenuList 오류:", error);
throw error;
}
}
/**
*
*/

View File

@ -4504,26 +4504,30 @@ export class TableManagementService {
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
c.column_name as "columnName",
c.column_name as "displayName",
c.data_type as "dataType",
c.udt_name as "dbType",
c.is_nullable as "isNullable",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
WHEN c.column_name IN (
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
END as "isPrimaryKey",
col_description(
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
c.ordinal_position
) as "columnComment"
FROM information_schema.columns c
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position`,
[tableName]
);
@ -4533,10 +4537,10 @@ export class TableManagementService {
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
webType: "text",
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
description: col.columnComment || "",
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
@ -4547,6 +4551,7 @@ export class TableManagementService {
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
columnComment: col.columnComment || "",
}));
logger.info(

View File

@ -6,8 +6,17 @@ import { LoginForm } from "@/components/auth/LoginForm";
import { LoginFooter } from "@/components/auth/LoginFooter";
export default function LoginPage() {
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
useLogin();
const {
formData,
isLoading,
error,
showPassword,
isPopMode,
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
} = useLogin();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
@ -19,9 +28,11 @@ export default function LoginPage() {
isLoading={isLoading}
error={error}
showPassword={showPassword}
isPopMode={isPopMode}
onInputChange={handleInputChange}
onSubmit={handleLogin}
onTogglePassword={togglePasswordVisibility}
onTogglePop={togglePopMode}
/>
<LoginFooter />

View File

@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
@ -285,14 +285,23 @@ function PopScreenViewPage() {
</div>
)}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
{/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
{currentModeKey.replace("_", " ")}
</div>
)}
<div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}

View File

@ -82,12 +82,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
});
// 화면 할당 관련 상태
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screenSearchText, setScreenSearchText] = useState("");
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
// POP 화면 할당 관련 상태
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
const [popScreenSearchText, setPopScreenSearchText] = useState("");
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
const [isPopLanding, setIsPopLanding] = useState(false);
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
// 대시보드 할당 관련 상태
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
const [dashboards, setDashboards] = useState<any[]>([]);
@ -196,8 +203,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
};
// POP 화면 선택 시 URL 자동 설정
const handlePopScreenSelect = (screen: ScreenDefinition) => {
const actualScreenId = screen.screenId || screen.id;
if (!actualScreenId) {
toast.error("화면 ID를 찾을 수 없습니다.");
return;
}
setSelectedPopScreen(screen);
setIsPopScreenDropdownOpen(false);
const popUrl = `/pop/screens/${actualScreenId}`;
setFormData((prev) => ({
...prev,
menuUrl: popUrl,
}));
};
// URL 타입 변경 시 처리
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
// console.log("🔄 URL 타입 변경:", {
// from: urlType,
// to: type,
@ -208,36 +234,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setUrlType(type);
if (type === "direct") {
// 직접 입력 모드로 변경 시 선택된 화면 초기화
setSelectedScreen(null);
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
setSelectedPopScreen(null);
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined, // 화면 코드도 함께 초기화
screenCode: undefined,
}));
} else {
// 화면 할당 모드로 변경 시
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
} else if (type === "pop") {
setSelectedScreen(null);
if (selectedPopScreen) {
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
setFormData((prev) => ({
...prev,
menuUrl: `/pop/screens/${actualScreenId}`,
}));
} else {
setFormData((prev) => ({
...prev,
menuUrl: "",
}));
}
} else if (type === "screen") {
setSelectedPopScreen(null);
if (selectedScreen) {
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
// 현재 선택된 화면으로 URL 재생성
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
let screenUrl = `/screens/${actualScreenId}`;
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
screenUrl += "?mode=admin";
}
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
screenCode: selectedScreen.screenCode,
}));
} else {
// 선택된 화면이 없으면 URL과 screenCode 초기화
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
} else {
// dashboard
setSelectedScreen(null);
setSelectedPopScreen(null);
if (!selectedDashboard) {
setFormData((prev) => ({
...prev,
menuUrl: "",
@ -297,8 +340,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const menuUrl = menu.menu_url || menu.MENU_URL || "";
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
const isScreenUrl = menuUrl.startsWith("/screens/");
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
setFormData({
objid: menu.objid || menu.OBJID,
@ -360,10 +403,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}, 500);
}
}
} else if (isPopScreenUrl) {
setUrlType("pop");
setSelectedScreen(null);
// [POP_LANDING] 태그 감지
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId) {
const setPopScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
};
if (screens.length > 0) {
setPopScreenFromId();
} else {
setTimeout(setPopScreenFromId, 500);
}
}
} else if (menuUrl.startsWith("/dashboard/")) {
setUrlType("dashboard");
setSelectedScreen(null);
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
} else {
setUrlType("direct");
setSelectedScreen(null);
@ -408,6 +472,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
} else {
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
setIsEdit(false);
setIsPopLanding(false);
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
let defaultMenuType = "1"; // 기본값은 사용자
@ -470,6 +535,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [isOpen, formData.companyCode]);
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
useEffect(() => {
if (!isOpen) return;
const checkOtherPopLanding = async () => {
try {
const res = await menuApi.getPopMenus();
if (res.success && res.data?.landingMenu) {
const landingObjId = res.data.landingMenu.objid?.toString();
const currentObjId = formData.objid?.toString();
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
} else {
setHasOtherPopLanding(false);
}
} catch {
setHasOtherPopLanding(false);
}
};
if (urlType === "pop") {
checkOtherPopLanding();
}
}, [isOpen, urlType, formData.objid]);
// 화면 목록 및 대시보드 목록 로드
useEffect(() => {
if (isOpen) {
@ -517,6 +607,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
// POP 화면 목록 로드 완료 후 기존 할당 설정
useEffect(() => {
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/pop/screens/")) {
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId && !selectedPopScreen) {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
}
}
}
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -533,16 +639,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setIsDashboardDropdownOpen(false);
setDashboardSearchText("");
}
if (!target.closest(".pop-screen-dropdown")) {
setIsPopScreenDropdownOpen(false);
setPopScreenSearchText("");
}
};
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
const loadCompanies = async () => {
try {
@ -590,10 +700,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
try {
setLoading(true);
// POP 기본 화면 태그 처리
let finalMenuDesc = formData.menuDesc;
if (urlType === "pop") {
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
}
// 백엔드에 전송할 데이터 변환
const submitData = {
...formData,
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
menuDesc: finalMenuDesc,
status: formData.status.toLowerCase(),
};
@ -853,7 +970,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
{/* URL 타입 선택 */}
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen" id="screen" />
<Label htmlFor="screen" className="cursor-pointer">
@ -866,6 +983,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pop" id="pop" />
<Label htmlFor="pop" className="cursor-pointer">
POP
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="direct" id="direct" />
<Label htmlFor="direct" className="cursor-pointer">
@ -1031,6 +1154,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
)}
{/* POP 화면 할당 */}
{urlType === "pop" && (
<div className="space-y-2">
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isPopScreenDropdownOpen && (
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="POP 화면 검색..."
value={popScreenSearchText}
onChange={(e) => setPopScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handlePopScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{selectedPopScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedPopScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
{/* POP 기본 화면 설정 */}
<div className="flex items-center space-x-2 rounded-md border p-3">
<input
type="checkbox"
id="popLanding"
checked={isPopLanding}
disabled={!isPopLanding && hasOtherPopLanding}
onChange={(e) => setIsPopLanding(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor="popLanding"
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
>
POP
</label>
{!isPopLanding && hasOtherPopLanding && (
<span className="text-xs text-muted-foreground">
( )
</span>
)}
</div>
{isPopLanding && (
<p className="text-xs text-muted-foreground">
POP .
</p>
)}
</div>
)}
{/* URL 직접 입력 */}
{urlType === "direct" && (
<Input

View File

@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, Loader2, Monitor } from "lucide-react";
import { LoginFormData } from "@/types/auth";
import { ErrorMessage } from "./ErrorMessage";
@ -11,9 +12,11 @@ interface LoginFormProps {
isLoading: boolean;
error: string;
showPassword: boolean;
isPopMode: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onTogglePassword: () => void;
onTogglePop: () => void;
}
/**
@ -24,9 +27,11 @@ export function LoginForm({
isLoading,
error,
showPassword,
isPopMode,
onInputChange,
onSubmit,
onTogglePassword,
onTogglePop,
}: LoginFormProps) {
return (
<Card className="border shadow-lg">
@ -82,6 +87,19 @@ export function LoginForm({
</div>
</div>
{/* POP 모드 토글 */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-slate-500" />
<span className="text-sm text-slate-600">POP </span>
</div>
<Switch
checked={isPopMode}
onCheckedChange={onTogglePop}
disabled={isLoading}
/>
</div>
{/* 로그인 버튼 */}
<Button
type="submit"

View File

@ -42,9 +42,12 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 바코드 리더 초기화
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
useEffect(() => {
if (open) {
setScannedCode("");
setError("");
setIsScanning(false);
codeReaderRef.current = new BrowserMultiFormatReader();
}
@ -276,7 +279,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
<div className="absolute bottom-4 left-0 right-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
<Scan className="h-4 w-4 animate-pulse text-primary" />
@ -355,6 +358,20 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
</Button>
)}
{scannedCode && (
<Button
variant="outline"
onClick={() => {
setScannedCode("");
startScanning();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Camera className="mr-2 h-4 w-4" />
</Button>
)}
{scannedCode && !autoSubmit && (
<Button
onClick={handleConfirm}

View File

@ -19,11 +19,12 @@ import {
User,
Building2,
FileCheck,
Monitor,
} from "lucide-react";
import { useMenu } from "@/contexts/MenuContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem } from "@/lib/api/menu";
import { MenuItem, menuApi } from "@/lib/api/menu";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@ -453,6 +454,31 @@ function AppLayoutInner({ children }: AppLayoutProps) {
e.dataTransfer.setData("text/plain", menuName);
};
// POP 모드 진입 핸들러
const handlePopModeClick = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data) {
const { childMenus, landingMenu } = response.data;
if (landingMenu?.menu_url) {
router.push(landingMenu.menu_url);
} else if (childMenus.length === 0) {
toast.info("설정된 POP 화면이 없습니다");
} else if (childMenus.length === 1) {
router.push(childMenus[0].menu_url);
} else {
router.push("/pop");
}
} else {
toast.info("설정된 POP 화면이 없습니다");
}
} catch (error) {
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
}
};
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
@ -576,6 +602,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-1 py-0.5">
<ThemeToggle />
@ -748,6 +778,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -6,13 +6,14 @@ interface MainHeaderProps {
user: any;
onSidebarToggle: () => void;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
return (
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
<div className="flex h-full w-full items-center justify-between px-6">
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
{/* Right side - Admin Button + User Menu */}
<div className="flex h-8 items-center gap-2">
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
</div>
</div>
</header>

View File

@ -8,19 +8,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, FileCheck } from "lucide-react";
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserDropdownProps {
user: any;
onProfileClick: () => void;
onPopModeClick?: () => void;
onLogout: () => void;
}
/**
*
*/
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
const router = useRouter();
if (!user) return null;
@ -73,7 +74,6 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
? `${user.deptName}, ${user.positionName}`
: user.deptName || user.positionName || "부서 정보 없음"}
</p>
{/* 사진 상태 표시 */}
</div>
</div>
</DropdownMenuLabel>
@ -86,6 +86,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
{onPopModeClick && (
<DropdownMenuItem onClick={onPopModeClick}>
<Monitor className="mr-2 h-4 w-4" />
<span>POP </span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Moon, Sun } from "lucide-react";
import { Moon, Sun, Monitor } from "lucide-react";
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
interface DashboardHeaderProps {
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
company: CompanyInfo;
onThemeToggle: () => void;
onUserClick: () => void;
onPcModeClick?: () => void;
}
export function DashboardHeader({
@ -20,6 +21,7 @@ export function DashboardHeader({
company,
onThemeToggle,
onUserClick,
onPcModeClick,
}: DashboardHeaderProps) {
const [mounted, setMounted] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
@ -81,6 +83,17 @@ export function DashboardHeader({
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
</div>
{/* PC 모드 복귀 */}
{onPcModeClick && (
<button
className="pop-dashboard-theme-toggle"
onClick={onPcModeClick}
title="PC 모드로 돌아가기"
>
<Monitor size={16} />
</button>
)}
{/* 사용자 배지 */}
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
<div className="pop-dashboard-user-avatar">{user.avatar}</div>

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "./DashboardHeader";
import { NoticeBanner } from "./NoticeBanner";
import { KpiBar } from "./KpiBar";
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
import { ActivityList } from "./ActivityList";
import { NoticeList } from "./NoticeList";
import { DashboardFooter } from "./DashboardFooter";
import { MenuItem as DashboardMenuItem } from "./types";
import { menuApi, PopMenuItem } from "@/lib/api/menu";
import {
KPI_ITEMS,
MENU_ITEMS,
@ -17,10 +20,31 @@ import {
} from "./data";
import "./dashboard.css";
export function PopDashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
"production",
"material",
"quality",
"equipment",
"safety",
];
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
return {
id: item.objid,
title: item.menu_name_kor,
count: 0,
description: item.menu_desc?.replace("[POP]", "").trim() || "",
status: "",
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
href: item.menu_url || "#",
};
}
export function PopDashboard() {
const router = useRouter();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
// 로컬 스토리지에서 테마 로드
useEffect(() => {
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
if (savedTheme) {
@ -28,6 +52,22 @@ export function PopDashboard() {
}
}, []);
// API에서 POP 메뉴 로드
useEffect(() => {
const loadPopMenus = async () => {
try {
const response = await menuApi.getPopMenus();
if (response.success && response.data && response.data.childMenus.length > 0) {
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
setMenuItems(converted);
}
} catch {
// API 실패 시 기존 하드코딩 데이터 유지
}
};
loadPopMenus();
}, []);
const handleThemeToggle = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
@ -40,6 +80,10 @@ export function PopDashboard() {
}
};
const handlePcModeClick = () => {
router.push("/");
};
const handleActivityMore = () => {
alert("전체 활동 내역 화면으로 이동합니다.");
};
@ -58,13 +102,14 @@ export function PopDashboard() {
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
onThemeToggle={handleThemeToggle}
onUserClick={handleUserClick}
onPcModeClick={handlePcModeClick}
/>
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
<KpiBar items={KPI_ITEMS} />
<MenuGrid items={MENU_ITEMS} />
<MenuGrid items={menuItems} />
<div className="pop-dashboard-bottom-section">
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />

View File

@ -150,7 +150,7 @@ export default function PopDesigner({
try {
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) {

View File

@ -69,10 +69,12 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
@ -169,9 +171,7 @@ export default function ComponentEditorPanel({
</div>
<div className="space-y-1">
{allComponents.map((comp) => {
const label = comp.label
|| COMPONENT_TYPE_LABELS[comp.type]
|| comp.type;
const label = comp.label || comp.id;
const isActive = comp.id === selectedComponentId;
return (
<button

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: LayoutGrid,
description: "테이블 데이터를 카드 형태로 표시",
},
{
type: "pop-card-list-v2",
label: "카드 목록 V2",
icon: LayoutGrid,
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
},
{
type: "pop-button",
label: "버튼",
@ -63,12 +69,30 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: Search,
description: "조건 입력 (텍스트/날짜/선택/모달)",
},
{
type: "pop-status-bar",
label: "상태 바",
icon: BarChart2,
description: "상태별 건수 대시보드 + 필터",
},
{
type: "pop-field",
label: "입력 필드",
icon: TextCursorInput,
description: "저장용 값 입력 (섹션별 멀티필드)",
},
{
type: "pop-scanner",
label: "스캐너",
icon: ScanLine,
description: "바코드/QR 카메라 스캔",
},
{
type: "pop-profile",
label: "프로필",
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -4,7 +4,6 @@ import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
@ -19,7 +18,6 @@ import {
} from "../types/pop-layout";
import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
@ -36,15 +34,6 @@ interface ConnectionEditorProps {
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
// ========================================
@ -84,17 +73,13 @@ export default function ConnectionEditor({
);
}
const isFilterSource = hasFilterSendable(meta);
return (
<div className="space-y-6">
{hasSendable && (
<SendSection
component={component}
meta={meta!}
allComponents={allComponents}
outgoing={outgoing}
isFilterSource={isFilterSource}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
@ -112,47 +97,14 @@ export default function ConnectionEditor({
);
}
// ========================================
// 대상 컴포넌트에서 정보 추출
// ========================================
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
if (Array.isArray(cfg.listColumns)) {
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
});
}
if (Array.isArray(cfg.selectedColumns)) {
(cfg.selectedColumns as string[]).forEach((c) => {
if (!cols.includes(c)) cols.push(c);
});
}
return cols;
}
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
isFilterSource: boolean;
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void;
@ -160,10 +112,8 @@ interface SendSectionProps {
function SendSection({
component,
meta,
allComponents,
outgoing,
isFilterSource,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
@ -180,34 +130,20 @@ function SendSection({
{outgoing.map((conn) => (
<div key={conn.id}>
{editingId === conn.id ? (
isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
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-primary/10/50 px-3 py-2">
<div className="space-y-1 rounded border bg-primary/10 px-3 py-2">
<div className="flex items-center gap-1">
<span className="flex-1 truncate text-xs">
{conn.label || `${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
</span>
@ -225,27 +161,33 @@ function SendSection({
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{conn.filterConfig?.targetColumn && (
<div className="flex flex-wrap gap-1">
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.targetColumn}
</span>
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
{conn.filterConfig.filterMode}
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
)}
</div>
)}
</div>
))}
{isFilterSource ? (
<FilterConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
) : (
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
)}
<SimpleConnectionForm
component={component}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
</div>
);
}
@ -263,6 +205,19 @@ interface SimpleConnectionFormProps {
submitLabel: string;
}
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
const cfg = comp.config as Record<string, unknown> | undefined;
if (!cfg) return null;
const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined;
if (grid?.cells) {
for (const cell of grid.cells) {
if (cell.timelineSource?.processTable) return cell.timelineSource.processTable;
}
}
return null;
}
function SimpleConnectionForm({
component,
allComponents,
@ -274,6 +229,18 @@ function SimpleConnectionForm({
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [isSubTable, setIsSubTable] = React.useState(
initial?.filterConfig?.isSubTable || false
);
const [targetColumn, setTargetColumn] = React.useState(
initial?.filterConfig?.targetColumn || ""
);
const [filterMode, setFilterMode] = React.useState<string>(
initial?.filterConfig?.filterMode || "equals"
);
const [subColumns, setSubColumns] = React.useState<string[]>([]);
const [loadingColumns, setLoadingColumns] = React.useState(false);
const targetCandidates = allComponents.filter((c) => {
if (c.id === component.id) return false;
@ -281,14 +248,39 @@ function SimpleConnectionForm({
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
});
const sourceReg = PopComponentRegistry.getComponent(component.type);
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null;
const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value")
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
React.useEffect(() => {
if (!isSubTable || !subTableName) {
setSubColumns([]);
return;
}
setLoadingColumns(true);
getTableColumns(subTableName)
.then((res) => {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
}
})
.catch(() => setSubColumns([]))
.finally(() => setLoadingColumns(false));
}, [isSubTable, subTableName]);
const handleSubmit = () => {
if (!selectedTargetId) return;
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
const tComp = allComponents.find((c) => c.id === selectedTargetId);
const srcLabel = component.label || component.id;
const tgtLabel = targetComp?.label || targetComp?.id || "?";
const tgtLabel = tComp?.label || tComp?.id || "?";
onSubmit({
const conn: Omit<PopDataConnection, "id"> = {
sourceComponent: component.id,
sourceField: "",
sourceOutput: "_auto",
@ -296,10 +288,23 @@ function SimpleConnectionForm({
targetField: "",
targetInput: "_auto",
label: `${srcLabel}${tgtLabel}`,
});
};
if (isFilterConnection && isSubTable && targetColumn) {
conn.filterConfig = {
targetColumn,
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
isSubTable: true,
};
}
onSubmit(conn);
if (!initial) {
setSelectedTargetId("");
setIsSubTable(false);
setTargetColumn("");
setFilterMode("equals");
}
};
@ -319,224 +324,12 @@ function SimpleConnectionForm({
<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;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
onCancel?: () => void;
submitLabel: string;
}
function FilterConnectionForm({
component,
meta,
allComponents,
initial,
onSubmit,
onCancel,
submitLabel,
}: FilterConnectionFormProps) {
const [selectedOutput, setSelectedOutput] = React.useState(
initial?.sourceOutput || meta.sendable[0]?.key || ""
);
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
initial?.targetInput || ""
);
const [filterColumns, setFilterColumns] = React.useState<string[]>(
initial?.filterConfig?.targetColumns ||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
);
const [filterMode, setFilterMode] = React.useState<
"equals" | "contains" | "starts_with" | "range"
>(initial?.filterConfig?.filterMode || "contains");
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 targetComp = selectedTargetId
? allComponents.find((c) => c.id === selectedTargetId)
: null;
const targetMeta = targetComp
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null;
React.useEffect(() => {
if (!selectedOutput || !targetMeta?.receivable?.length) return;
if (selectedTargetInput) return;
const receivables = targetMeta.receivable;
const exactMatch = receivables.find((r) => r.key === selectedOutput);
if (exactMatch) {
setSelectedTargetInput(exactMatch.key);
return;
}
if (receivables.length === 1) {
setSelectedTargetInput(receivables[0].key);
}
}, [selectedOutput, targetMeta, selectedTargetInput]);
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
[targetComp]
);
const tableName = React.useMemo(
() => extractTableName(targetComp || undefined),
[targetComp]
);
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
React.useEffect(() => {
if (!tableName) {
setAllDbColumns([]);
return;
}
let cancelled = false;
setDbColumnsLoading(true);
getTableColumns(tableName).then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setAllDbColumns(res.data.columns.map((c) => c.columnName));
} else {
setAllDbColumns([]);
}
setDbColumnsLoading(false);
});
return () => { cancelled = true; };
}, [tableName]);
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
const dataOnlyColumns = React.useMemo(
() => allDbColumns.filter((c) => !displaySet.has(c)),
[allDbColumns, displaySet]
);
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
const toggleColumn = (col: string) => {
setFilterColumns((prev) =>
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
);
};
const handleSubmit = () => {
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
onSubmit({
sourceComponent: component.id,
sourceField: "",
sourceOutput: selectedOutput,
targetComponent: selectedTargetId,
targetField: "",
targetInput: selectedTargetInput,
filterConfig:
!isEvent && filterColumns.length > 0
? {
targetColumn: filterColumns[0],
targetColumns: filterColumns,
filterMode,
}
: undefined,
label: buildConnectionLabel(
component,
selectedOutput,
allComponents.find((c) => c.id === selectedTargetId),
selectedTargetInput,
filterColumns
),
});
if (!initial) {
setSelectedTargetId("");
setSelectedTargetInput("");
setFilterColumns([]);
}
};
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={selectedOutput} onValueChange={setSelectedOutput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.sendable.map((s) => (
<SelectItem key={s.key} value={s.key} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setSelectedTargetInput("");
setFilterColumns([]);
setIsSubTable(false);
setTargetColumn("");
}}
>
<SelectTrigger className="h-7 text-xs">
@ -552,109 +345,62 @@ function FilterConnectionForm({
</Select>
</div>
{targetMeta && (
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{targetMeta.receivable.map((r) => (
<SelectItem key={r.key} value={r.key} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-muted p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-emerald-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
)}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-muted/80" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
{isFilterConnection && selectedTargetId && subTableName && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-primary">
{filterColumns.length}
</p>
)}
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent>
</Select>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
)}
@ -662,7 +408,7 @@ function FilterConnectionForm({
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
disabled={!selectedTargetId}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
@ -722,32 +468,3 @@ function ReceiveSection({
);
}
// ========================================
// 유틸
// ========================================
function isEventTypeConnection(
sourceMeta: ComponentConnectionMeta | undefined,
outputKey: string,
targetMeta: ComponentConnectionMeta | null | undefined,
inputKey: string,
): boolean {
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
return sourceItem?.type === "event" || targetItem?.type === "event";
}
function buildConnectionLabel(
source: PopComponentDefinitionV5,
_outputKey: string,
target: PopComponentDefinitionV5 | undefined,
_inputKey: string,
columns?: string[]
): string {
const srcLabel = source.label || source.id;
const tgtLabel = target?.label || target?.id || "?";
const colInfo = columns && columns.length > 0
? ` [${columns.join(", ")}]`
: "";
return `${srcLabel}${tgtLabel}${colInfo}`;
}

View File

@ -72,10 +72,14 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-card-list-v2": "카드 목록 V2",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-field": "입력",
"pop-scanner": "스캐너",
"pop-profile": "프로필",
};
// ========================================
@ -554,7 +558,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
if (ActualComp) {
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2";
return (
<div className={cn(

View File

@ -9,7 +9,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
/**
*
@ -33,6 +33,7 @@ export interface PopDataConnection {
targetColumn: string;
targetColumns?: string[];
filterMode: "equals" | "contains" | "starts_with" | "range";
isSubTable?: boolean;
};
label?: string;
}
@ -358,10 +359,14 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-icon": { colSpan: 1, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
"pop-search": { colSpan: 4, rowSpan: 2 },
"pop-search": { colSpan: 2, rowSpan: 1 },
"pop-status-bar": { colSpan: 6, rowSpan: 1 },
"pop-field": { colSpan: 6, rowSpan: 2 },
"pop-scanner": { colSpan: 1, rowSpan: 1 },
"pop-profile": { colSpan: 1, rowSpan: 1 },
};
/**

View File

@ -322,7 +322,9 @@ export async function executeTaskList(
}
case "custom-event":
if (task.eventName) {
if (task.flowId) {
await apiClient.post(`/dataflow/node-flows/${task.flowId}/execute`, {});
} else if (task.eventName) {
publish(task.eventName, task.eventPayload ?? {});
}
break;

View File

@ -20,7 +20,6 @@ import { usePopEvent } from "./usePopEvent";
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
import {
PopComponentRegistry,
type ConnectionMetaItem,
} from "@/lib/registry/PopComponentRegistry";
interface UseConnectionResolverOptions {
@ -29,14 +28,21 @@ interface UseConnectionResolverOptions {
componentTypes?: Map<string, string>;
}
interface AutoMatchPair {
sourceKey: string;
targetKey: string;
isFilter: boolean;
}
/**
* / connectionMeta에서 .
* 규칙: category="event" key가
* / connectionMeta에서 .
* 1: category="event" key가 ( )
* 2: 소스 type="filter_value" + type="filter_value" ( )
*/
function getAutoMatchPairs(
sourceType: string,
targetType: string
): { sourceKey: string; targetKey: string }[] {
): AutoMatchPair[] {
const sourceDef = PopComponentRegistry.getComponent(sourceType);
const targetDef = PopComponentRegistry.getComponent(targetType);
@ -44,14 +50,18 @@ function getAutoMatchPairs(
return [];
}
const pairs: { sourceKey: string; targetKey: string }[] = [];
const pairs: AutoMatchPair[] = [];
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 });
if (s.category === "event" && r.category === "event" && s.key === r.key) {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
}
if (s.type === "filter_value" && r.type === "filter_value") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
}
if (s.type === "all_rows" && r.type === "all_rows") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
}
}
}
@ -93,10 +103,30 @@ export function useConnectionResolver({
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
const unsub = subscribe(sourceEvent, (payload: unknown) => {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
if (pair.isFilter) {
const data = payload as Record<string, unknown> | null;
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
const filterMode = (data?.filterMode as string) || "contains";
// conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용
const effectiveColumn = conn.filterConfig?.targetColumn || fieldName;
const effectiveMode = conn.filterConfig?.filterMode || filterMode;
const baseFilterConfig = effectiveColumn
? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode }
: conn.filterConfig;
publish(targetEvent, {
value: payload,
filterConfig: conn.filterConfig?.isSubTable
? { ...baseFilterConfig, isSubTable: true }
: baseFilterConfig,
_connectionId: conn.id,
});
} else {
publish(targetEvent, {
value: payload,
_connectionId: conn.id,
});
}
});
unsubscribers.push(unsub);
}
@ -121,13 +151,22 @@ export function useConnectionResolver({
const unsub = subscribe(sourceEvent, (payload: unknown) => {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
const enrichedPayload = {
value: payload,
filterConfig: conn.filterConfig,
_connectionId: conn.id,
};
let resolvedFilterConfig = conn.filterConfig;
if (!resolvedFilterConfig) {
const data = payload as Record<string, unknown> | null;
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
if (fieldName) {
const filterMode = (data?.filterMode as string) || "contains";
resolvedFilterConfig = { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode: filterMode as "equals" | "contains" | "starts_with" | "range" };
}
}
publish(targetEvent, enrichedPayload);
publish(targetEvent, {
value: payload,
filterConfig: resolvedFilterConfig,
_connectionId: conn.id,
});
});
unsubscribers.push(unsub);
}

View File

@ -20,6 +20,21 @@ export const useLogin = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isPopMode, setIsPopMode] = useState(false);
// localStorage에서 POP 모드 상태 복원
useEffect(() => {
const saved = localStorage.getItem("popLoginMode");
if (saved === "true") setIsPopMode(true);
}, []);
const togglePopMode = useCallback(() => {
setIsPopMode((prev) => {
const next = !prev;
localStorage.setItem("popLoginMode", String(next));
return next;
});
}, []);
/**
*
@ -141,17 +156,22 @@ export const useLogin = () => {
// 쿠키에도 저장 (미들웨어에서 사용)
document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`;
// 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트
const firstMenuPath = result.data?.firstMenuPath;
if (firstMenuPath) {
// 접근 가능한 메뉴가 있으면 해당 메뉴로 이동
console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath);
router.push(firstMenuPath);
if (isPopMode) {
const popPath = result.data?.popLandingPath;
if (popPath) {
router.push(popPath);
} else {
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
setIsLoading(false);
return;
}
} else {
// 접근 가능한 메뉴가 없으면 메인 페이지로 이동
console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동");
router.push(AUTH_CONFIG.ROUTES.MAIN);
const firstMenuPath = result.data?.firstMenuPath;
if (firstMenuPath) {
router.push(firstMenuPath);
} else {
router.push(AUTH_CONFIG.ROUTES.MAIN);
}
}
} else {
// 로그인 실패
@ -165,7 +185,7 @@ export const useLogin = () => {
setIsLoading(false);
}
},
[formData, validateForm, apiCall, router],
[formData, validateForm, apiCall, router, isPopMode],
);
// 컴포넌트 마운트 시 기존 인증 상태 확인
@ -179,10 +199,12 @@ export const useLogin = () => {
isLoading,
error,
showPassword,
isPopMode,
// 액션
handleInputChange,
handleLogin,
togglePasswordVisibility,
togglePopMode,
};
};

View File

@ -81,6 +81,23 @@ export interface ApiResponse<T> {
errorCode?: string;
}
export interface PopMenuItem {
objid: string;
menu_name_kor: string;
menu_url: string;
menu_desc: string;
seq: number;
company_code: string;
status: string;
screenId?: number;
}
export interface PopMenuResponse {
parentMenu: PopMenuItem | null;
childMenus: PopMenuItem[];
landingMenu: PopMenuItem | null;
}
export const menuApi = {
// 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
@ -96,6 +113,12 @@ export const menuApi = {
return response.data;
},
// POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴)
getPopMenus: async (): Promise<ApiResponse<PopMenuResponse>> => {
const response = await apiClient.get("/admin/pop-menus");
return response.data;
},
// 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시)
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });

View File

@ -35,6 +35,7 @@ export interface PopComponentDefinition {
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
defaultProps?: Record<string, any>;
connectionMeta?: ComponentConnectionMeta;
getDynamicConnectionMeta?: (config: Record<string, unknown>) => ComponentConnectionMeta;
// POP 전용 속성
touchOptimized?: boolean;
minTouchArea?: number;

View File

@ -16,12 +16,13 @@ import "./pop-text";
import "./pop-icon";
import "./pop-dashboard";
import "./pop-card-list";
import "./pop-card-list-v2";
import "./pop-button";
import "./pop-string-list";
import "./pop-search";
import "./pop-status-bar";
import "./pop-field";
// 향후 추가될 컴포넌트들:
// import "./pop-list";
import "./pop-scanner";
import "./pop-profile";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
"use client";
/**
* pop-card-list-v2
*
* .
* CSS Grid .
*/
import React from "react";
import { LayoutGrid, Package } from "lucide-react";
import type { PopCardListV2Config } from "../types";
import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types";
interface PopCardListV2PreviewProps {
config?: PopCardListV2Config;
}
export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) {
const scrollDirection = config?.scrollDirection || "vertical";
const cardSize = config?.cardSize || "medium";
const dataSource = config?.dataSource;
const cardGrid = config?.cardGrid;
const hasTable = !!dataSource?.tableName;
const cellCount = cardGrid?.cells?.length || 0;
return (
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
<span className="text-xs font-medium"> V2</span>
</div>
<div className="flex gap-1">
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
{CARD_SIZE_LABELS[cardSize]}
</span>
</div>
</div>
{!hasTable ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground"> </p>
</div>
</div>
) : (
<>
<div className="mb-2 text-center">
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
{dataSource!.tableName}
</span>
<span className="ml-1 text-[10px] text-muted-foreground/60">
({cellCount})
</span>
</div>
<div className="flex flex-1 flex-col gap-2">
{[0, 1].map((cardIdx) => (
<div key={cardIdx} className="rounded-md border bg-card p-2">
{cellCount === 0 ? (
<div className="flex h-12 items-center justify-center">
<span className="text-[10px] text-muted-foreground"> </span>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: cardGrid!.colWidths?.length
? cardGrid!.colWidths.map((w) => w || "1fr").join(" ")
: `repeat(${cardGrid!.cols || 1}, 1fr)`,
gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`,
gap: "2px",
}}
>
{cardGrid!.cells.map((cell) => (
<div
key={cell.id}
className="rounded border border-dashed border-border/50 bg-muted/20 px-1 py-0.5"
style={{
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
}}
>
<span className="text-[8px] text-muted-foreground">
{cell.type}
{cell.columnName ? `: ${cell.columnName}` : ""}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,733 @@
"use client";
/**
* pop-card-list-v2
*
* CardV2Grid에서 type별 dispatch로 .
* pop-card-list의 pop-string-list의 CardModeView .
*/
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types";
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
import type { ButtonVariant } from "../pop-button";
type RowData = Record<string, unknown>;
// ===== 공통 유틸 =====
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "number") return value.toLocaleString();
if (typeof value === "boolean") return value ? "예" : "아니오";
if (value instanceof Date) return value.toLocaleDateString();
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
return String(value);
}
const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const;
const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const;
// ===== 셀 렌더러 Props =====
export interface CellRendererProps {
cell: CardCellDefinitionV2;
row: RowData;
inputValue?: number;
isCarted?: boolean;
isButtonLoading?: boolean;
onInputClick?: (e: React.MouseEvent) => void;
onCartAdd?: () => void;
onCartCancel?: () => void;
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
packageEntries?: PackageEntry[];
inputUnit?: string;
}
// ===== 메인 디스패치 =====
export function renderCellV2(props: CellRendererProps): React.ReactNode {
switch (props.cell.type) {
case "text":
return <TextCell {...props} />;
case "field":
return <FieldCell {...props} />;
case "image":
return <ImageCell {...props} />;
case "badge":
return <BadgeCell {...props} />;
case "button":
return <ButtonCell {...props} />;
case "number-input":
return <NumberInputCell {...props} />;
case "cart-button":
return <CartButtonCell {...props} />;
case "package-summary":
return <PackageSummaryCell {...props} />;
case "status-badge":
return <StatusBadgeCell {...props} />;
case "timeline":
return <TimelineCell {...props} />;
case "action-buttons":
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
}
// ===== 1. text =====
function TextCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"];
return (
<span
className="truncate"
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
>
{formatValue(value)}
</span>
);
}
// ===== 2. field (라벨+값) =====
function FieldCell({ cell, row, inputValue }: CellRendererProps) {
const valueType = cell.valueType || "column";
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
const displayValue = useMemo(() => {
if (valueType !== "formula") {
const raw = cell.columnName ? row[cell.columnName] : undefined;
const formatted = formatValue(raw);
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
if (cell.formulaLeft && cell.formulaOperator) {
const rightVal =
(cell.formulaRightType || "input") === "input"
? (inputValue ?? 0)
: Number(row[cell.formulaRight || ""] ?? 0);
const leftVal = Number(row[cell.formulaLeft] ?? 0);
let result: number | null = null;
switch (cell.formulaOperator) {
case "+": result = leftVal + rightVal; break;
case "-": result = leftVal - rightVal; break;
case "*": result = leftVal * rightVal; break;
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
}
if (result !== null && isFinite(result)) {
const formatted = (Math.round(result * 100) / 100).toLocaleString();
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
}
return "-";
}
return "-";
}, [valueType, cell, row, inputValue]);
const isFormula = valueType === "formula";
const isLabelLeft = cell.labelPosition === "left";
return (
<div
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
style={{ fontSize: fs }}
>
{cell.label && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{cell.label}{isLabelLeft ? ":" : ""}
</span>
)}
<span
className="truncate font-medium"
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
>
{displayValue}
</span>
</div>
);
}
// ===== 3. image =====
function ImageCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE);
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
<img
src={imageUrl}
alt={cell.label || ""}
className="h-full w-full object-contain p-1"
onError={(e) => {
const target = e.target as HTMLImageElement;
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
}}
/>
</div>
);
}
// ===== 4. badge =====
function BadgeCell({ cell, row }: CellRendererProps) {
const value = cell.columnName ? row[cell.columnName] : "";
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{formatValue(value)}
</span>
);
}
// ===== 5. button =====
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
return (
<Button
variant={cell.buttonVariant || "outline"}
size="sm"
className="h-7 text-[10px]"
disabled={isButtonLoading}
onClick={(e) => {
e.stopPropagation();
onButtonClick?.(cell, row);
}}
>
{isButtonLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
</Button>
);
}
// ===== 6. number-input =====
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
const unit = cell.inputUnit || "EA";
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onInputClick?.(e);
}}
className="w-full 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">
{(inputValue ?? 0).toLocaleString()}
</span>
<span className="block text-[12px] text-muted-foreground">{unit}</span>
</button>
);
}
// ===== 7. cart-button =====
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
const iconSize = 18;
const label = cell.cartLabel || "담기";
const cancelLabel = cell.cartCancelLabel || "취소";
if (isCarted) {
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
className="flex w-full 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"
>
<X size={iconSize} />
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
</button>
);
}
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
) : (
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
)}
<span className="text-[10px] font-semibold leading-tight">{label}</span>
</button>
);
}
// ===== 8. package-summary =====
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
if (!packageEntries || packageEntries.length === 0) return null;
return (
<div className="w-full border-t bg-emerald-50">
{packageEntries.map((entry, idx) => (
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
</span>
<Package className="h-4 w-4 text-emerald-600" />
<span className="text-xs font-medium text-emerald-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
</span>
</div>
<span className="text-xs font-bold text-emerald-700">
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
</span>
</div>
))}
</div>
);
}
// ===== 9. status-badge =====
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
waiting: { bg: "#94a3b820", text: "#64748b" },
accepted: { bg: "#3b82f620", text: "#2563eb" },
in_progress: { bg: "#f59e0b20", text: "#d97706" },
completed: { bg: "#10b98120", text: "#059669" },
};
function StatusBadgeCell({ cell, row }: CellRendererProps) {
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const effectiveValue = hasSubStatus
? row[VIRTUAL_SUB_STATUS]
: (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""));
const strValue = String(effectiveValue || "");
const mapped = cell.statusMap?.find((m) => m.value === strValue);
if (mapped) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
);
}
const defaultColors = STATUS_COLORS[strValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
</span>
);
}
return (
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{formatValue(effectiveValue)}
</span>
);
}
// ===== 10. timeline =====
type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode };
const TIMELINE_SEMANTIC_STYLES: Record<string, TimelineStyle> = {
done: { chipBg: "#10b981", chipText: "#ffffff", icon: <CheckCircle2 className="h-2.5 w-2.5" /> },
active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" /> },
pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: <Clock className="h-2.5 w-2.5" /> },
};
// 레거시 status 값 → semantic 매핑 (기존 데이터 호환)
const LEGACY_STATUS_TO_SEMANTIC: Record<string, string> = {
completed: "done", in_progress: "active", accepted: "active", waiting: "pending",
};
function getTimelineStyle(step: TimelineProcessStep): TimelineStyle {
if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending;
const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status];
return TIMELINE_SEMANTIC_STYLES[fallback || "pending"];
}
function TimelineCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) {
const fallback = cell.processColumn ? row[cell.processColumn] : "";
return (
<span className="text-[10px] text-muted-foreground">
{formatValue(fallback)}
</span>
);
}
const maxVisible = cell.visibleCount || 5;
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
type DisplayItem =
| { kind: "step"; step: TimelineProcessStep }
| { kind: "count"; count: number; side: "before" | "after" };
// 현재 항목 기준으로 앞뒤 배분하여 축약
const displayItems = useMemo((): DisplayItem[] => {
if (processFlow.length <= maxVisible) {
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
}
const effectiveIdx = Math.max(0, currentIdx);
const priority = cell.timelinePriority || "before";
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
const slotForSteps = maxVisible - 2;
const half = Math.floor(slotForSteps / 2);
const extra = slotForSteps - half - 1;
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
const afterSlots = slotForSteps - beforeSlots - 1;
let startIdx = effectiveIdx - beforeSlots;
let endIdx = effectiveIdx + afterSlots;
// 경계 보정
if (startIdx < 0) {
endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx));
startIdx = 0;
}
if (endIdx >= processFlow.length) {
startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1));
endIdx = processFlow.length - 1;
}
const items: DisplayItem[] = [];
const beforeCount = startIdx;
const afterCount = processFlow.length - 1 - endIdx;
if (beforeCount > 0) {
items.push({ kind: "count", count: beforeCount, side: "before" });
}
for (let i = startIdx; i <= endIdx; i++) {
items.push({ kind: "step", step: processFlow[i] });
}
if (afterCount > 0) {
items.push({ kind: "count", count: afterCount, side: "after" });
}
return items;
}, [processFlow, maxVisible, currentIdx]);
const [modalOpen, setModalOpen] = useState(false);
const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length;
const totalCount = processFlow.length;
return (
<>
<div
className={cn(
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
cell.showDetailModal !== false && "cursor-pointer",
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
)}
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined}
>
{displayItems.map((item, idx) => {
const isLast = idx === displayItems.length - 1;
if (item.kind === "count") {
return (
<React.Fragment key={`cnt-${item.side}`}>
<div
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
title={item.side === "before" ? `이전 ${item.count}` : `이후 ${item.count}`}
>
{item.count}
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
}
const styles = getTimelineStyle(item.step);
return (
<React.Fragment key={item.step.seqNo}>
<div
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
style={{
backgroundColor: styles.chipBg,
color: styles.chipText,
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
outlineOffset: "1px",
}}
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
>
{styles.icon}
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
{item.step.processName}
</span>
</div>
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
</React.Fragment>
);
})}
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{totalCount} {completedCount}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow.map((step, idx) => {
const styles = getTimelineStyle(step);
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
{/* 세로 연결선 + 아이콘 */}
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{styles.icon}
</div>
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
</div>
{/* 항목 정보 */}
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
)}>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
<span className={cn(
"text-sm",
step.isCurrent ? "font-semibold" : "font-medium",
)}>
{step.processName}
</span>
{step.isCurrent && (
<Star className="h-3 w-3 fill-primary text-primary" />
)}
</div>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{statusLabel}
</span>
</div>
</div>
);
})}
</div>
{/* 하단 진행률 바 */}
<div className="space-y-1 pt-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
/>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
// ===== 11. action-buttons =====
function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" {
const cond = btn.showCondition;
if (!cond || cond.type === "always") return "visible";
let matched = false;
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else {
return "visible";
}
if (matched) return "visible";
return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden";
}
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row),
}));
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
const pick = activeBtn || disabledBtn;
if (!pick) return null;
const { btn, state } = pick;
return (
<div className="flex items-center gap-1">
<Button
variant={btn.variant || "outline"}
size="sm"
disabled={state === "disabled"}
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = {
...firstAction,
__allActions: actions,
selectModeConfig: firstAction.selectModeButtons
? { filterStatus: btn.showCondition?.value || "", buttons: firstAction.selectModeButtons }
: undefined,
};
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (firstAction.type === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(btn.showCondition?.value || "", config);
return;
}
onActionButtonClick?.(btn.label, row, config);
}}
>
{btn.label}
</Button>
</div>
);
}
// 기존 구조 (actionRules) 폴백
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
const statusValue = hasSubStatus
? String(row[VIRTUAL_SUB_STATUS] || "")
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
const rules = cell.actionRules || [];
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
if (!matchedRule) return null;
return (
<div className="flex items-center gap-1">
{matchedRule.buttons.map((btn, idx) => (
<Button
key={idx}
variant={btn.variant || "outline"}
size="sm"
className="h-7 text-[10px]"
onClick={(e) => {
e.stopPropagation();
const config = { ...(btn as Record<string, unknown>) };
if (currentProcessId !== undefined) config.__processId = currentProcessId;
if (btn.clickMode === "select-mode" && onEnterSelectMode) {
onEnterSelectMode(matchedRule.whenStatus, config);
return;
}
onActionButtonClick?.(btn.taskPreset, row, config);
}}
>
{btn.label}
</Button>
))}
</div>
);
}
// ===== 12. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
const strValue = String(value || "");
const mapped = cell.footerStatusMap?.find((m) => m.value === strValue);
if (!strValue && !cell.footerLabel) return null;
return (
<div
className="flex w-full items-center justify-between px-2 py-1"
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
>
{cell.footerLabel && (
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
)}
{mapped ? (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
>
{mapped.label}
</span>
) : strValue ? (
<span className="text-[10px] font-medium text-muted-foreground">
{strValue}
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,61 @@
"use client";
/**
* pop-card-list-v2
*
* import side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopCardListV2Component } from "./PopCardListV2Component";
import { PopCardListV2ConfigPanel } from "./PopCardListV2Config";
import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview";
import type { PopCardListV2Config } from "../types";
const defaultConfig: PopCardListV2Config = {
dataSource: { tableName: "" },
cardGrid: {
rows: 1,
cols: 1,
colWidths: ["1fr"],
rowHeights: ["32px"],
gap: 4,
showCellBorder: true,
cells: [],
},
gridColumns: 3,
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
};
PopComponentRegistry.registerComponent({
id: "pop-card-list-v2",
name: "카드 목록 V2",
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
category: "display",
icon: "LayoutGrid",
component: PopCardListV2Component,
configPanel: PopCardListV2ConfigPanel,
preview: PopCardListV2PreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ 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: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ 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,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,163 @@
/**
* pop-card-list v1 -> v2
*
* PopCardListConfig의 (/////)
* CardGridConfigV2 PopCardListV2Config를 .
*/
import type {
PopCardListConfig,
PopCardListV2Config,
CardCellDefinitionV2,
} from "../types";
export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config {
const cells: CardCellDefinitionV2[] = [];
let nextRow = 1;
// 1. 헤더 행 (코드 + 제목)
if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) {
if (old.cardTemplate.header.codeField) {
cells.push({
id: "h-code",
row: nextRow,
col: 1,
rowSpan: 1,
colSpan: 1,
type: "text",
columnName: old.cardTemplate.header.codeField,
fontSize: "sm",
textColor: "hsl(var(--muted-foreground))",
});
}
if (old.cardTemplate.header.titleField) {
cells.push({
id: "h-title",
row: nextRow,
col: 2,
rowSpan: 1,
colSpan: old.cardTemplate.header.codeField ? 2 : 3,
type: "text",
columnName: old.cardTemplate.header.titleField,
fontSize: "md",
fontWeight: "bold",
});
}
nextRow++;
}
// 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan)
const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0;
const bodyRowSpan = Math.max(1, bodyFieldCount);
if (old.cardTemplate?.image?.enabled) {
cells.push({
id: "img",
row: nextRow,
col: 1,
rowSpan: bodyRowSpan,
colSpan: 1,
type: "image",
columnName: old.cardTemplate.image.imageColumn || "",
defaultImage: old.cardTemplate.image.defaultImage,
});
}
// 3. 본문 필드들 (이미지 오른쪽)
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
cells.push({
id: `f-${i}`,
row: nextRow + i,
col: fieldStartCol,
rowSpan: 1,
colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan,
type: "field",
columnName: field.columnName,
label: field.label,
valueType: field.valueType,
formulaLeft: field.formulaLeft,
formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"],
formulaRight: field.formulaRight,
formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"],
unit: field.unit,
textColor: field.textColor,
});
});
// 4. 수량 입력 + 담기 버튼 (오른쪽 열)
const rightCol = 3;
if (old.inputField?.enabled) {
cells.push({
id: "input",
row: nextRow,
col: rightCol,
rowSpan: Math.ceil(bodyRowSpan / 2),
colSpan: 1,
type: "number-input",
inputUnit: old.inputField.unit,
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
});
}
if (old.cartAction) {
cells.push({
id: "cart",
row: nextRow + Math.ceil(bodyRowSpan / 2),
col: rightCol,
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
colSpan: 1,
type: "cart-button",
cartLabel: old.cartAction.label,
cartCancelLabel: old.cartAction.cancelLabel,
cartIconType: old.cartAction.iconType,
cartIconValue: old.cartAction.iconValue,
});
}
// 5. 포장 요약 (마지막 행, full-width)
if (old.packageConfig?.enabled) {
const summaryRow = nextRow + bodyRowSpan;
cells.push({
id: "pkg",
row: summaryRow,
col: 1,
rowSpan: 1,
colSpan: 3,
type: "package-summary",
});
}
// 그리드 크기 계산
const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1;
const maxCol = 3;
return {
dataSource: old.dataSource,
cardGrid: {
rows: maxRow,
cols: maxCol,
colWidths: old.cardTemplate?.image?.enabled
? ["1fr", "2fr", "1fr"]
: ["1fr", "2fr", "1fr"],
gap: 2,
showCellBorder: false,
cells,
},
scrollDirection: old.scrollDirection,
cardSize: old.cardSize,
gridColumns: old.gridColumns,
gridRows: old.gridRows,
cardGap: 8,
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
responsiveDisplay: old.responsiveDisplay,
inputField: old.inputField,
packageConfig: old.packageConfig,
cartAction: old.cartAction,
cartListMode: old.cartListMode,
saveMapping: old.saveMapping,
};
}

View File

@ -256,6 +256,12 @@ export function PopCardListComponent({
return unsub;
}, [componentId, subscribe]);
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, rows);
}, [componentId, rows, loading, publish]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;

View File

@ -2039,16 +2039,29 @@ function FilterSettingsSection({
{filters.map((filter, index) => (
<div
key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
className="space-y-1.5 rounded-md border bg-card p-2"
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Select
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
@ -2058,45 +2071,36 @@ function FilterSettingsSection({
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
</div>
))}
</div>
@ -2663,46 +2667,51 @@ function FilterCriteriaSection({
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
<div className="flex-1">
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
<div key={index} className="space-y-1.5 rounded-md border bg-card p-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
/>
<div className="flex items-center gap-2">
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값 입력"
className="h-8 flex-1 text-xs"
/>
</div>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>

View File

@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },

View File

@ -34,6 +34,7 @@ export interface ColumnInfo {
type: string;
udtName: string;
isPrimaryKey?: boolean;
comment?: string;
}
// ===== SQL 값 이스케이프 =====
@ -330,6 +331,7 @@ export async function fetchTableColumns(
type: col.dataType || col.data_type || col.type || "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",
comment: col.columnComment || col.description || "",
}));
}
}

View File

@ -203,6 +203,32 @@ export function PopFieldComponent({
return unsub;
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const fieldNames = new Set<string>();
for (const section of cfg.sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) fieldNames.add(f.fieldName);
}
}
const matched: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (fieldNames.has(key)) {
matched[key] = value;
}
}
if (Object.keys(matched).length > 0) {
setAllValues((prev) => ({ ...prev, ...matched }));
}
});
return unsub;
}, [subscribe, cfg.sections]);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
useEffect(() => {
if (!componentId) return;
@ -220,7 +246,7 @@ export function PopFieldComponent({
? {
targetTable: cfg.saveConfig.tableName,
columnMapping: Object.fromEntries(
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
(cfg.saveConfig.fieldMappings || []).map((m) => [fieldIdToName[m.fieldId] || m.fieldId, m.targetColumn])
),
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
.filter((m) => m.numberingRuleId)
@ -228,6 +254,7 @@ export function PopFieldComponent({
numberingRuleId: m.numberingRuleId!,
targetColumn: m.targetColumn,
showResultModal: m.showResultModal,
shareAcrossItems: m.shareAcrossItems ?? false,
})),
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
.filter((m) => m.targetColumn)
@ -247,7 +274,7 @@ export function PopFieldComponent({
}
);
return unsub;
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
}, [componentId, subscribe, publish, allValues, cfg.saveConfig, fieldIdToName]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(

View File

@ -398,8 +398,19 @@ function SaveTabContent({
syncAndUpdateSaveMappings((prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
);
if (partial.targetColumn !== undefined) {
const newFieldName = partial.targetColumn || "";
const sections = cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
}));
onUpdateConfig({ sections });
}
},
[syncAndUpdateSaveMappings]
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
);
// --- 숨은 필드 매핑 로직 ---
@ -1337,7 +1348,19 @@ function SaveTabContent({
/>
<Label className="text-[10px]"> </Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={m.shareAcrossItems ?? false}
onCheckedChange={(v) => updateAutoGenMapping(m.id, { shareAcrossItems: v })}
/>
<Label className="text-[10px]"> </Label>
</div>
</div>
{m.shareAcrossItems && (
<p className="text-[9px] text-muted-foreground">
</p>
)}
</div>
);
})}
@ -1414,7 +1437,7 @@ function SectionEditor({
const newField: PopFieldItem = {
id: fieldId,
inputType: "text",
fieldName: fieldId,
fieldName: "",
labelText: "",
readOnly: false,
};

View File

@ -153,6 +153,7 @@ export interface PopFieldAutoGenMapping {
numberingRuleId?: string;
showInForm: boolean;
showResultModal: boolean;
shareAcrossItems?: boolean;
}
export interface PopFieldSaveConfig {

View File

@ -0,0 +1,336 @@
"use client";
import React, { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 타입 정의
// ========================================
type AvatarSize = "sm" | "md" | "lg";
export interface PopProfileConfig {
avatarSize?: AvatarSize;
showDashboardLink?: boolean;
showPcMode?: boolean;
showLogout?: boolean;
}
const DEFAULT_CONFIG: PopProfileConfig = {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
};
const AVATAR_SIZE_MAP: Record<AvatarSize, { container: string; text: string; px: number }> = {
sm: { container: "h-8 w-8", text: "text-sm", px: 32 },
md: { container: "h-10 w-10", text: "text-base", px: 40 },
lg: { container: "h-12 w-12", text: "text-lg", px: 48 },
};
const AVATAR_SIZE_LABELS: Record<AvatarSize, string> = {
sm: "작은 (32px)",
md: "보통 (40px)",
lg: "큰 (48px)",
};
// ========================================
// 뷰어 컴포넌트
// ========================================
interface PopProfileComponentProps {
config?: PopProfileConfig;
componentId?: string;
screenId?: string;
}
function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
const router = useRouter();
const { user, isLoggedIn, logout } = useAuth();
const [open, setOpen] = useState(false);
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"];
const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?";
const handlePcMode = () => {
setOpen(false);
router.push("/");
};
const handleDashboard = () => {
setOpen(false);
router.push("/pop");
};
const handleLogout = async () => {
setOpen(false);
await logout();
};
const handleLogin = () => {
setOpen(false);
router.push("/login");
};
return (
<div className="flex h-full w-full items-center justify-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"border-2 border-primary/20 cursor-pointer",
"transition-all duration-150",
"hover:scale-105 hover:border-primary/40",
"active:scale-95",
sizeInfo.container,
sizeInfo.text,
)}
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
>
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-60 p-0"
align="end"
sideOffset={8}
>
{isLoggedIn && user ? (
<>
{/* 사용자 정보 */}
<div className="flex items-center gap-3 border-b p-4">
<div className={cn(
"flex shrink-0 items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"h-10 w-10 text-base",
)}>
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-semibold">
{user.userName || "사용자"} ({user.userId || ""})
</span>
<span className="truncate text-xs text-muted-foreground">
{user.deptName || "부서 정보 없음"}
</span>
</div>
</div>
{/* 메뉴 항목 */}
<div className="p-1.5">
{config.showDashboardLink && (
<button
onClick={handleDashboard}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
POP
</button>
)}
{config.showPcMode && (
<button
onClick={handlePcMode}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<Monitor className="h-4 w-4 text-muted-foreground" />
PC
</button>
)}
{config.showLogout && (
<>
<div className="mx-2 my-1 border-t" />
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-destructive transition-colors hover:bg-destructive/10"
style={{ minHeight: 48 }}
>
<LogOut className="h-4 w-4" />
</button>
</>
)}
</div>
</>
) : (
<div className="p-4">
<p className="mb-3 text-center text-sm text-muted-foreground">
</p>
<button
onClick={handleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
style={{ minHeight: 48 }}
>
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopProfileConfigPanelProps {
config: PopProfileConfig;
onUpdate: (config: PopProfileConfig) => void;
}
function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) {
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const updateConfig = (partial: Partial<PopProfileConfig>) => {
onUpdate({ ...config, ...partial });
};
return (
<div className="space-y-4 p-3">
{/* 아바타 크기 */}
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.avatarSize || "md"}
onValueChange={(v) => updateConfig({ avatarSize: v as AvatarSize })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(AVATAR_SIZE_LABELS) as [AvatarSize, string][]).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs sm:text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 메뉴 항목 토글 */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">POP </Label>
<Switch
checked={config.showDashboardLink ?? true}
onCheckedChange={(v) => updateConfig({ showDashboardLink: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">PC </Label>
<Switch
checked={config.showPcMode ?? true}
onCheckedChange={(v) => updateConfig({ showPcMode: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
checked={config.showLogout ?? true}
onCheckedChange={(v) => updateConfig({ showLogout: v })}
/>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기
// ========================================
function PopProfilePreview({ config }: { config?: PopProfileConfig }) {
const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"];
return (
<div className="flex h-full w-full items-center justify-center gap-2">
<div className={cn(
"flex items-center justify-center rounded-full",
"bg-primary/20 text-primary",
size.container, size.text,
)}>
<UserCircle className="h-5 w-5" />
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-profile",
name: "프로필",
description: "사용자 프로필 / PC 전환 / 로그아웃",
category: "action",
icon: "UserCircle",
component: PopProfileComponent,
configPanel: PopProfileConfigPanel,
preview: PopProfilePreview,
defaultProps: {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
},
connectionMeta: {
sendable: [],
receivable: [],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,694 @@
"use client";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { ScanLine } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { BarcodeScanModal } from "@/components/common/BarcodeScanModal";
import type {
PopDataConnection,
PopComponentDefinitionV5,
} from "@/components/pop/designer/types/pop-layout";
// ========================================
// 타입 정의
// ========================================
export interface ScanFieldMapping {
sourceKey: string;
outputIndex: number;
label: string;
targetComponentId: string;
targetFieldName: string;
enabled: boolean;
}
export interface PopScannerConfig {
barcodeFormat: "all" | "1d" | "2d";
autoSubmit: boolean;
showLastScan: boolean;
buttonLabel: string;
buttonVariant: "default" | "outline" | "secondary";
parseMode: "none" | "auto" | "json";
fieldMappings: ScanFieldMapping[];
}
// 연결된 컴포넌트의 필드 정보
interface ConnectedFieldInfo {
componentId: string;
componentName: string;
componentType: string;
fieldName: string;
fieldLabel: string;
}
const DEFAULT_SCANNER_CONFIG: PopScannerConfig = {
barcodeFormat: "all",
autoSubmit: true,
showLastScan: false,
buttonLabel: "스캔",
buttonVariant: "default",
parseMode: "none",
fieldMappings: [],
};
// ========================================
// 파싱 유틸리티
// ========================================
function tryParseJson(raw: string): Record<string, string> | null {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
result[k] = String(v);
}
return result;
}
} catch {
// JSON이 아닌 경우
}
return null;
}
function parseScanResult(
raw: string,
mode: PopScannerConfig["parseMode"]
): Record<string, string> | null {
if (mode === "none") return null;
return tryParseJson(raw);
}
// ========================================
// 연결된 컴포넌트 필드 추출
// ========================================
function getConnectedFields(
componentId?: string,
connections?: PopDataConnection[],
allComponents?: PopComponentDefinitionV5[],
): ConnectedFieldInfo[] {
if (!componentId || !connections || !allComponents) return [];
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const uniqueTargetIds = [...new Set(targetIds)];
const fields: ConnectedFieldInfo[] = [];
for (const tid of uniqueTargetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const compType = comp.type || "";
const compName = (comp as Record<string, unknown>).label as string || comp.type || tid;
// pop-search: filterColumns (복수) 또는 modalConfig.valueField 또는 fieldName (단일)
const filterCols = compCfg.filterColumns as string[] | undefined;
const modalCfg = compCfg.modalConfig as { valueField?: string; displayField?: string } | undefined;
if (Array.isArray(filterCols) && filterCols.length > 0) {
for (const col of filterCols) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: col,
fieldLabel: col,
});
}
} else if (modalCfg?.valueField) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: modalCfg.valueField,
fieldLabel: (compCfg.placeholder as string) || modalCfg.valueField,
});
} else if (compCfg.fieldName && typeof compCfg.fieldName === "string") {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: compCfg.fieldName,
fieldLabel: (compCfg.placeholder as string) || compCfg.fieldName as string,
});
}
// pop-field: sections 내 fields
const sections = compCfg.sections as Array<{
fields?: Array<{ id: string; fieldName?: string; labelText?: string }>;
}> | undefined;
if (Array.isArray(sections)) {
for (const section of sections) {
for (const f of section.fields ?? []) {
if (f.fieldName) {
fields.push({
componentId: tid,
componentName: compName,
componentType: compType,
fieldName: f.fieldName,
fieldLabel: f.labelText || f.fieldName,
});
}
}
}
}
}
return fields;
}
// ========================================
// 메인 컴포넌트
// ========================================
interface PopScannerComponentProps {
config?: PopScannerConfig;
label?: string;
isDesignMode?: boolean;
screenId?: string;
componentId?: string;
}
function PopScannerComponent({
config,
isDesignMode,
screenId,
componentId,
}: PopScannerComponentProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...(config || {}) };
const { publish } = usePopEvent(screenId || "");
const [lastScan, setLastScan] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const handleScanSuccess = useCallback(
(barcode: string) => {
setLastScan(barcode);
setModalOpen(false);
if (!componentId) return;
if (cfg.parseMode === "none") {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
const parsed = parseScanResult(barcode, cfg.parseMode);
if (!parsed) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.parseMode === "auto") {
publish("scan_auto_fill", parsed);
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
if (cfg.fieldMappings.length === 0) {
publish(`__comp_output__${componentId}__scan_value`, barcode);
return;
}
for (const mapping of cfg.fieldMappings) {
if (!mapping.enabled) continue;
const value = parsed[mapping.sourceKey];
if (value === undefined) continue;
publish(
`__comp_output__${componentId}__scan_field_${mapping.outputIndex}`,
value
);
if (mapping.targetComponentId && mapping.targetFieldName) {
publish(
`__comp_input__${mapping.targetComponentId}__set_value`,
{ fieldName: mapping.targetFieldName, value }
);
}
}
},
[componentId, publish, cfg.parseMode, cfg.fieldMappings],
);
const handleClick = useCallback(() => {
if (isDesignMode) return;
setModalOpen(true);
}, [isDesignMode]);
return (
<div className="flex h-full w-full items-center justify-center">
<Button
variant={cfg.buttonVariant}
size="icon"
onClick={handleClick}
className="h-full w-full rounded-md transition-transform active:scale-95"
>
<ScanLine className="h-7! w-7!" />
<span className="sr-only">{cfg.buttonLabel}</span>
</Button>
{cfg.showLastScan && lastScan && (
<div className="absolute inset-x-0 bottom-0 truncate bg-background/80 px-1 text-center text-[8px] text-muted-foreground backdrop-blur-sm">
{lastScan}
</div>
)}
{!isDesignMode && (
<BarcodeScanModal
open={modalOpen}
onOpenChange={setModalOpen}
barcodeFormat={cfg.barcodeFormat}
autoSubmit={cfg.autoSubmit}
onScanSuccess={handleScanSuccess}
/>
)}
</div>
);
}
// ========================================
// 설정 패널
// ========================================
const FORMAT_LABELS: Record<string, string> = {
all: "모든 형식",
"1d": "1D 바코드",
"2d": "2D 바코드 (QR)",
};
const VARIANT_LABELS: Record<string, string> = {
default: "기본 (Primary)",
outline: "외곽선 (Outline)",
secondary: "보조 (Secondary)",
};
const PARSE_MODE_LABELS: Record<string, string> = {
none: "없음 (단일 값)",
auto: "자동 (검색 필드명과 매칭)",
json: "JSON (수동 매핑)",
};
interface PopScannerConfigPanelProps {
config: PopScannerConfig;
onUpdate: (config: PopScannerConfig) => void;
allComponents?: PopComponentDefinitionV5[];
connections?: PopDataConnection[];
componentId?: string;
}
function PopScannerConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopScannerConfigPanelProps) {
const cfg = { ...DEFAULT_SCANNER_CONFIG, ...config };
const update = (partial: Partial<PopScannerConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const connectedFields = useMemo(
() => getConnectedFields(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const buildMappingsFromFields = useCallback(
(fields: ConnectedFieldInfo[], existing: ScanFieldMapping[]): ScanFieldMapping[] => {
return fields.map((f, i) => {
const prev = existing.find(
(m) => m.targetComponentId === f.componentId && m.targetFieldName === f.fieldName
);
return {
sourceKey: prev?.sourceKey ?? f.fieldName,
outputIndex: i,
label: f.fieldLabel,
targetComponentId: f.componentId,
targetFieldName: f.fieldName,
enabled: prev?.enabled ?? true,
};
});
},
[],
);
const toggleMapping = (fieldName: string, componentId: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, enabled: !m.enabled }
: m
);
update({ fieldMappings: updated });
};
const updateMappingSourceKey = (fieldName: string, componentId: string, sourceKey: string) => {
const updated = cfg.fieldMappings.map((m) =>
m.targetFieldName === fieldName && m.targetComponentId === componentId
? { ...m, sourceKey }
: m
);
update({ fieldMappings: updated });
};
useEffect(() => {
if (cfg.parseMode !== "json" || connectedFields.length === 0) return;
const synced = buildMappingsFromFields(connectedFields, cfg.fieldMappings);
const isSame =
synced.length === cfg.fieldMappings.length &&
synced.every(
(s, i) =>
s.targetComponentId === cfg.fieldMappings[i]?.targetComponentId &&
s.targetFieldName === cfg.fieldMappings[i]?.targetFieldName,
);
if (!isSame) {
onUpdate({ ...cfg, fieldMappings: synced });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedFields, cfg.parseMode]);
return (
<div className="space-y-4 pr-1 pb-16">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.barcodeFormat}
onValueChange={(v) => update({ barcodeFormat: v as PopScannerConfig["barcodeFormat"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={cfg.buttonLabel}
onChange={(e) => update({ buttonLabel: e.target.value })}
placeholder="스캔"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.buttonVariant}
onValueChange={(v) => update({ buttonVariant: v as PopScannerConfig["buttonVariant"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
{cfg.autoSubmit
? "바코드 인식 즉시 값 전달 (확인 버튼 생략)"
: "인식 후 확인 버튼을 눌러야 값 전달"}
</p>
</div>
<Switch
checked={cfg.autoSubmit}
onCheckedChange={(v) => update({ autoSubmit: v })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={cfg.showLastScan}
onCheckedChange={(v) => update({ showLastScan: v })}
/>
</div>
{/* 파싱 설정 섹션 */}
<div className="border-t pt-4">
<Label className="text-xs font-semibold"> </Label>
<p className="mb-3 text-[10px] text-muted-foreground">
/QR에 ,
</p>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select
value={cfg.parseMode}
onValueChange={(v) => {
const mode = v as PopScannerConfig["parseMode"];
update({
parseMode: mode,
fieldMappings: mode === "none" ? [] : cfg.fieldMappings,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PARSE_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{cfg.parseMode === "auto" && (
<div className="mt-3 rounded-md bg-muted/50 p-3">
<p className="text-[10px] font-medium"> </p>
<p className="mt-1 text-[10px] text-muted-foreground">
QR/ JSON .
</p>
{connectedFields.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium"> :</p>
{connectedFields.map((f, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="font-mono text-primary">{f.fieldName}</span>
<span>- {f.fieldLabel}</span>
<span className="text-muted-foreground/50">({f.componentName})</span>
</div>
))}
<p className="mt-1 text-[10px] text-muted-foreground">
QR에 JSON .
</p>
</div>
)}
{connectedFields.length === 0 && (
<p className="mt-2 text-[10px] text-muted-foreground">
.
.
</p>
)}
</div>
)}
{cfg.parseMode === "json" && (
<div className="mt-3 space-y-3">
<p className="text-[10px] text-muted-foreground">
, JSON .
JSON .
</p>
{connectedFields.length === 0 ? (
<div className="rounded-md bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
.
.
</p>
</div>
) : (
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="space-y-1.5">
{cfg.fieldMappings.map((mapping) => (
<div
key={`${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex items-start gap-2 rounded-md border p-2"
>
<Checkbox
id={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
checked={mapping.enabled}
onCheckedChange={() =>
toggleMapping(mapping.targetFieldName, mapping.targetComponentId)
}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<label
htmlFor={`map_${mapping.targetComponentId}_${mapping.targetFieldName}`}
className="flex cursor-pointer items-center gap-1 text-[11px]"
>
<span className="font-mono text-primary">
{mapping.targetFieldName}
</span>
<span className="text-muted-foreground">
({mapping.label})
</span>
</label>
{mapping.enabled && (
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground">
JSON :
</span>
<Input
value={mapping.sourceKey}
onChange={(e) =>
updateMappingSourceKey(
mapping.targetFieldName,
mapping.targetComponentId,
e.target.value,
)
}
placeholder={mapping.targetFieldName}
className="h-6 text-[10px]"
/>
</div>
)}
</div>
</div>
))}
</div>
{cfg.fieldMappings.some((m) => m.enabled) && (
<div className="rounded-md bg-muted/50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> :</p>
<ul className="mt-1 space-y-0.5">
{cfg.fieldMappings
.filter((m) => m.enabled)
.map((m, i) => (
<li key={i} className="text-[10px] text-muted-foreground">
<span className="font-mono">{m.sourceKey || "?"}</span>
{" -> "}
<span className="font-mono text-primary">{m.targetFieldName}</span>
{m.label && <span className="ml-1">({m.label})</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
// ========================================
// 미리보기
// ========================================
function PopScannerPreview({ config }: { config?: PopScannerConfig }) {
const cfg = config || DEFAULT_SCANNER_CONFIG;
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<Button
variant={cfg.buttonVariant}
size="icon"
className="pointer-events-none h-full w-full rounded-md"
tabIndex={-1}
>
<ScanLine className="h-7! w-7!" />
</Button>
</div>
);
}
// ========================================
// 동적 sendable 생성
// ========================================
function buildSendableMeta(config?: PopScannerConfig) {
const base = [
{
key: "scan_value",
label: "스캔 값 (원본)",
type: "filter_value" as const,
category: "filter" as const,
description: "파싱 전 원본 스캔 결과 (단일 값 모드이거나 파싱 실패 시)",
},
];
if (config?.fieldMappings && config.fieldMappings.length > 0) {
for (const mapping of config.fieldMappings) {
base.push({
key: `scan_field_${mapping.outputIndex}`,
label: mapping.label || `스캔 필드 ${mapping.outputIndex}`,
type: "filter_value" as const,
category: "filter" as const,
description: `파싱된 필드: JSON 키 "${mapping.sourceKey}"`,
});
}
}
return base;
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-scanner",
name: "스캐너",
description: "바코드/QR 카메라 스캔",
category: "input",
icon: "ScanLine",
component: PopScannerComponent,
configPanel: PopScannerConfigPanel,
preview: PopScannerPreview,
defaultProps: DEFAULT_SCANNER_CONFIG,
connectionMeta: {
sendable: buildSendableMeta(),
receivable: [],
},
getDynamicConnectionMeta: (config: Record<string, unknown>) => ({
sendable: buildSendableMeta(config as unknown as PopScannerConfig),
receivable: [],
}),
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -18,12 +18,21 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Search, ChevronRight, Loader2, X } from "lucide-react";
import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
DatePresetOption,
DateSelectionMode,
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
@ -62,9 +71,21 @@ export function PopSearchComponent({
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
const fieldKey = isModalType
? (config.modalConfig?.valueField || config.fieldName || componentId || "search")
: (config.fieldName || componentId || "search");
const resolveFilterMode = useCallback(() => {
if (config.filterMode) return config.filterMode;
if (normalizedType === "date") {
const mode: DateSelectionMode = config.dateSelectionMode || "single";
return mode === "range" ? "range" : "equals";
}
return "contains";
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
const emitFilterChanged = useCallback(
(newValue: unknown) => {
@ -72,15 +93,18 @@ export function PopSearchComponent({
setSharedData(`search_${fieldKey}`, newValue);
if (componentId) {
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
filterColumns,
value: newValue,
filterMode: resolveFilterMode(),
});
}
publish("filter_changed", { [fieldKey]: newValue });
},
[fieldKey, publish, setSharedData, componentId]
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
useEffect(() => {
@ -88,15 +112,40 @@ export function PopSearchComponent({
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const data = payload as { value?: unknown; displayText?: string } | unknown;
const incoming = typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (isModalType && incoming != null) {
setModalDisplayText(String(incoming));
}
emitFilterChanged(incoming);
}
);
return unsub;
}, [componentId, subscribe, emitFilterChanged]);
}, [componentId, subscribe, emitFilterChanged, isModalType]);
useEffect(() => {
const unsub = subscribe("scan_auto_fill", (payload: unknown) => {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const myKey = config.fieldName;
if (!myKey) return;
const targetKeys = config.filterColumns?.length ? config.filterColumns : [myKey];
for (const key of targetKeys) {
if (key in data) {
if (isModalType) setModalDisplayText(String(data[key]));
emitFilterChanged(data[key]);
return;
}
}
if (myKey in data) {
if (isModalType) setModalDisplayText(String(data[myKey]));
emitFilterChanged(data[myKey]);
}
});
return unsub;
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
const handleModalOpen = useCallback(() => {
if (!config.modalConfig) return;
@ -116,29 +165,30 @@ export function PopSearchComponent({
[config.modalConfig, emitFilterChanged]
);
const handleModalClear = useCallback(() => {
setModalDisplayText("");
emitFilterChanged("");
}, [emitFilterChanged]);
const showLabel = config.labelVisible !== false && !!config.labelText;
return (
<div
className={cn(
"flex h-full w-full overflow-hidden",
showLabel && config.labelPosition === "left"
? "flex-row items-center gap-2 p-1.5"
: "flex-col justify-center gap-0.5 p-1.5"
)}
className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5"
>
{showLabel && (
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{config.labelText}
</span>
)}
<div className="min-w-0">
<div className="min-w-0 w-full flex-1 flex flex-col justify-center">
<SearchInputRenderer
config={config}
value={value}
onChange={emitFilterChanged}
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
onModalClear={handleModalClear}
/>
</div>
@ -165,9 +215,10 @@ interface InputRendererProps {
onChange: (v: unknown) => void;
modalDisplayText?: string;
onModalOpen?: () => void;
onModalClear?: () => void;
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
case "text":
@ -175,12 +226,24 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "select":
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "date": {
const dateMode: DateSelectionMode = config.dateSelectionMode || "single";
return dateMode === "range"
? <DateRangeInput config={config} value={value} onChange={onChange} />
: <DateSingleInput config={config} value={String(value ?? "")} onChange={onChange} />;
}
case "date-preset":
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
case "toggle":
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -215,7 +278,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
const isNumber = config.inputType === "number";
return (
<div className="relative">
<div className="relative h-full">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type={isNumber ? "number" : "text"}
@ -224,12 +287,283 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
className="h-8 pl-7 text-xs"
className="h-full min-h-8 pl-7 text-xs"
/>
</div>
);
}
// ========================================
// date 서브타입 - 단일 날짜
// ========================================
function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const selected = value ? new Date(value + "T00:00:00") : undefined;
const handleSelect = useCallback(
(day: Date | undefined) => {
if (!day) return;
onChange(format(day, "yyyy-MM-dd"));
setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange("");
},
[onChange]
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!value && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{value ? format(new Date(value + "T00:00:00"), "yyyy.MM.dd (EEE)", { locale: ko }) : (config.placeholder || "날짜 선택")}
</span>
{value && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="flex justify-center pb-4">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
className="touch-date-calendar"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleSelect}
locale={ko}
defaultMonth={selected || new Date()}
/>
</PopoverContent>
</Popover>
);
}
// ========================================
// date 서브타입 - 기간 선택 (프리셋 + Calendar Range)
// ========================================
interface DateRangeValue { from?: string; to?: string }
const RANGE_PRESETS = [
{ key: "today", label: "오늘" },
{ key: "this-week", label: "이번주" },
{ key: "this-month", label: "이번달" },
] as const;
function computeRangePreset(key: string): DateRangeValue {
const now = new Date();
const fmt = (d: Date) => format(d, "yyyy-MM-dd");
switch (key) {
case "today":
return { from: fmt(now), to: fmt(now) };
case "this-week":
return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) };
case "this-month":
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) };
default:
return {};
}
}
function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
const [open, setOpen] = useState(false);
const useModal = config.calendarDisplay === "modal";
const rangeVal: DateRangeValue = (typeof value === "object" && value !== null)
? value as DateRangeValue
: (typeof value === "string" && value ? { from: value, to: value } : {});
const calendarRange = useMemo(() => {
if (!rangeVal.from) return undefined;
return {
from: new Date(rangeVal.from + "T00:00:00"),
to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined,
};
}, [rangeVal.from, rangeVal.to]);
const activePreset = RANGE_PRESETS.find((p) => {
const preset = computeRangePreset(p.key);
return preset.from === rangeVal.from && preset.to === rangeVal.to;
})?.key ?? null;
const handlePreset = useCallback(
(key: string) => {
const preset = computeRangePreset(key);
onChange(preset);
},
[onChange]
);
const handleRangeSelect = useCallback(
(range: { from?: Date; to?: Date } | undefined) => {
if (!range?.from) return;
const from = format(range.from, "yyyy-MM-dd");
const to = range.to ? format(range.to, "yyyy-MM-dd") : from;
onChange({ from, to });
if (range.to) setOpen(false);
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange({});
},
[onChange]
);
const displayText = rangeVal.from
? rangeVal.from === rangeVal.to
? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko })
: `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}`
: "";
const presetBar = (
<div className="flex gap-1 px-3">
{RANGE_PRESETS.map((p) => (
<Button
key={p.key}
variant={activePreset === p.key ? "default" : "outline"}
size="sm"
className="h-7 flex-1 px-1 text-[10px]"
onClick={() => {
handlePreset(p.key);
setOpen(false);
}}
>
{p.label}
</Button>
))}
</div>
);
const calendarEl = (
<Calendar
mode="range"
selected={calendarRange}
onSelect={handleRangeSelect}
locale={ko}
defaultMonth={calendarRange?.from || new Date()}
numberOfMonths={1}
className={useModal ? "touch-date-calendar" : undefined}
/>
);
const triggerButton = (
<Button
variant="outline"
className={cn(
"h-full min-h-8 w-full justify-start gap-1.5 px-2 text-xs font-normal",
!rangeVal.from && "text-muted-foreground"
)}
onClick={useModal ? () => setOpen(true) : undefined}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">
{displayText || (config.placeholder || "기간 선택")}
</span>
{rangeVal.from && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onKeyDown={(e) => { if (e.key === "Enter") handleClear(e as unknown as React.MouseEvent); }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
)}
</Button>
);
if (useModal) {
return (
<>
{triggerButton}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[360px] p-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="text-sm"> </DialogTitle>
</DialogHeader>
<div className="space-y-2 pb-4">
{presetBar}
<div className="flex justify-center">
{calendarEl}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="space-y-2 pt-2 pb-1">
{presetBar}
{calendarEl}
</div>
</PopoverContent>
</Popover>
);
}
// ========================================
// select 서브타입
// ========================================
@ -237,7 +571,7 @@ function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig;
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
return (
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger className="h-full min-h-8 text-xs">
<SelectValue placeholder={config.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
@ -266,7 +600,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
};
return (
<div className="flex flex-wrap gap-1">
<div className="flex h-full flex-wrap items-center gap-1">
{presets.map((preset) => (
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
{DATE_PRESET_LABELS[preset]}
@ -282,7 +616,7 @@ function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchC
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
return (
<div className="flex items-center gap-2">
<div className="flex h-full items-center gap-2">
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
</div>
@ -293,17 +627,32 @@ function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v:
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
// ========================================
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
function ModalSearchInput({ config, displayText, onClick, onClear }: { config: PopSearchConfig; displayText: string; onClick?: () => void; onClear?: () => void }) {
const hasValue = !!displayText;
return (
<div
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
className="flex h-full min-h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className={`flex-1 truncate text-xs ${hasValue ? "" : "text-muted-foreground"}`}>
{displayText || config.placeholder || "선택..."}
</span>
{hasValue && onClear ? (
<button
type="button"
className="ml-1 shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onClear(); }}
aria-label="선택 해제"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</div>
);
}
@ -314,7 +663,7 @@ function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchC
function PlaceholderInput({ inputType }: { inputType: string }) {
return (
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<div className="flex h-full min-h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
<span className="text-[10px] text-muted-foreground">{inputType} ( )</span>
</div>
);
@ -382,6 +731,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
columnLabels,
displayStyle = "table",
displayField,
distinct,
} = modalConfig;
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
@ -393,13 +743,25 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
setLoading(true);
try {
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
setAllRows(result.data || []);
let rows = result.data || [];
if (distinct && displayField) {
const seen = new Set<string>();
rows = rows.filter((row) => {
const val = String(row[displayField] ?? "");
if (seen.has(val)) return false;
seen.add(val);
return true;
});
}
setAllRows(rows);
} catch {
setAllRows([]);
} finally {
setLoading(false);
}
}, [tableName]);
}, [tableName, distinct, displayField]);
useEffect(() => {
if (open) {

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -13,7 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react";
import {
Popover,
PopoverContent,
@ -30,6 +30,9 @@ import {
import type {
PopSearchConfig,
SearchInputType,
SearchFilterMode,
DateSelectionMode,
CalendarDisplayMode,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
@ -38,6 +41,7 @@ import type {
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
SEARCH_FILTER_MODE_LABELS,
DATE_PRESET_LABELS,
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
@ -57,7 +61,6 @@ const DEFAULT_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@ -69,9 +72,12 @@ const DEFAULT_CONFIG: PopSearchConfig = {
interface ConfigPanelProps {
config: PopSearchConfig | undefined;
onUpdate: (config: PopSearchConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) {
const [step, setStep] = useState(0);
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
const cfg: PopSearchConfig = {
@ -110,7 +116,7 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
</div>
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />}
<div className="flex justify-between pt-2">
<Button
@ -145,6 +151,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
interface StepProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
function StepBasicSettings({ cfg, update }: StepProps) {
@ -189,33 +198,17 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs"> ()</SelectItem>
<SelectItem value="left" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={cfg.labelText || ""}
onChange={(e) => update({ labelText: e.target.value })}
placeholder="예: 거래처명"
className="h-8 text-xs"
/>
</div>
)}
</div>
);
}
@ -224,18 +217,29 @@ function StepBasicSettings({ cfg, update }: StepProps) {
// STEP 2: 타입별 상세 설정
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
return <TextDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "select":
return <SelectDetailSettings cfg={cfg} update={update} />;
return <SelectDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date":
return <DateDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
@ -255,11 +259,278 @@ function StepDetailSettings({ cfg, update }: StepProps) {
}
}
// ========================================
// 공통: 필터 연결 설정 섹션
// ========================================
interface FilterConnectionSectionProps {
cfg: PopSearchConfig;
update: (partial: Partial<PopSearchConfig>) => void;
showFieldName: boolean;
fixedFilterMode?: SearchFilterMode;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
interface ConnectedComponentInfo {
tableNames: string[];
displayedColumns: Set<string>;
}
/**
* tableName과 .
*/
function getConnectedComponentInfo(
componentId?: string,
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[],
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[],
): ConnectedComponentInfo {
const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() };
if (!componentId || !connections || !allComponents) return empty;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const tableNames = new Set<string>();
const displayedColumns = new Set<string>();
for (const tid of targetIds) {
const comp = allComponents.find((c) => c.id === tid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, any>;
const tn = compCfg.dataSource?.tableName;
if (tn) tableNames.add(tn);
// pop-card-list: cardTemplate에서 사용 중인 컬럼 수집
const tpl = compCfg.cardTemplate;
if (tpl) {
if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField);
if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField);
if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn);
if (Array.isArray(tpl.body?.fields)) {
for (const f of tpl.body.fields) {
if (f.columnName) displayedColumns.add(f.columnName);
}
}
}
// pop-string-list: selectedColumns / listColumns
if (Array.isArray(compCfg.selectedColumns)) {
for (const col of compCfg.selectedColumns) displayedColumns.add(col);
}
if (Array.isArray(compCfg.listColumns)) {
for (const lc of compCfg.listColumns) {
if (lc.columnName) displayedColumns.add(lc.columnName);
}
}
}
return { tableNames: Array.from(tableNames), displayedColumns };
}
function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) {
const connInfo = useMemo(
() => getConnectedComponentInfo(componentId, connections, allComponents),
[componentId, connections, allComponents],
);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const connectedTablesKey = connInfo.tableNames.join(",");
useEffect(() => {
if (connInfo.tableNames.length === 0) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
Promise.all(connInfo.tableNames.map((t) => getTableColumns(t)))
.then((results) => {
if (cancelled) return;
const allCols: ColumnTypeInfo[] = [];
const seen = new Set<string>();
for (const res of results) {
if (res.success && res.data?.columns) {
for (const col of res.data.columns) {
if (!seen.has(col.columnName)) {
seen.add(col.columnName);
allCols.push(col);
}
}
}
}
setTargetColumns(allCols);
})
.finally(() => { if (!cancelled) setColumnsLoading(false); });
return () => { cancelled = true; };
}, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps
const hasConnection = connInfo.tableNames.length > 0;
const { displayedCols, otherCols } = useMemo(() => {
if (connInfo.displayedColumns.size === 0) {
return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns };
}
const displayed: ColumnTypeInfo[] = [];
const others: ColumnTypeInfo[] = [];
for (const col of targetColumns) {
if (connInfo.displayedColumns.has(col.columnName)) {
displayed.push(col);
} else {
others.push(col);
}
}
return { displayedCols: displayed, otherCols: others };
}, [targetColumns, connInfo.displayedColumns]);
const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []);
const toggleFilterColumn = (colName: string) => {
const current = new Set(selectedFilterCols);
if (current.has(colName)) {
current.delete(colName);
} else {
current.add(colName);
}
const next = Array.from(current);
update({
filterColumns: next,
fieldName: next[0] || "",
});
};
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`filter_col_${col.columnName}`}
checked={selectedFilterCols.includes(col.columnName)}
onCheckedChange={() => toggleFilterColumn(col.columnName)}
/>
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
</Label>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2 border-t pt-3">
<span className="text-[10px] font-medium text-muted-foreground"> </span>
</div>
{!hasConnection && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
.
</p>
</div>
)}
{hasConnection && showFieldName && (
<div className="space-y-1">
<Label className="text-[10px]">
<span className="text-destructive">*</span>
</Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<div className="max-h-48 space-y-2 overflow-y-auto rounded border p-2">
{displayedCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-primary"> </p>
{displayedCols.map(renderColumnCheckbox)}
</div>
)}
{displayedCols.length > 0 && otherCols.length > 0 && (
<div className="border-t" />
)}
{otherCols.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-muted-foreground"> </p>
{otherCols.map(renderColumnCheckbox)}
</div>
)}
</div>
) : (
<p className="text-[9px] text-muted-foreground">
</p>
)}
{selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
</p>
</div>
)}
{selectedFilterCols.length > 0 && (
<p className="text-[9px] text-muted-foreground">
{selectedFilterCols.length} -
</p>
)}
{selectedFilterCols.length === 0 && (
<p className="text-[9px] text-muted-foreground">
( )
</p>
)}
</div>
)}
{fixedFilterMode ? (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="flex h-8 items-center rounded-md border bg-muted px-3 text-xs text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</div>
<p className="text-[9px] text-muted-foreground">
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}
</p>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.filterMode || "contains"}
onValueChange={(v) => update({ filterMode: v as SearchFilterMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SEARCH_FILTER_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
</div>
);
}
// ========================================
// text/number 상세 설정
// ========================================
function TextDetailSettings({ cfg, update }: StepProps) {
function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
return (
<div className="space-y-3">
<div className="space-y-1">
@ -285,6 +556,8 @@ function TextDetailSettings({ cfg, update }: StepProps) {
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter </Label>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@ -293,7 +566,7 @@ function TextDetailSettings({ cfg, update }: StepProps) {
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update }: StepProps) {
function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const options = cfg.options || [];
const addOption = () => {
@ -329,6 +602,90 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
<Plus className="mr-1 h-3 w-3" />
</Button>
<FilterConnectionSection cfg={cfg} update={update} showFieldName allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
// ========================================
// date 상세 설정
// ========================================
const DATE_SELECTION_MODE_LABELS: Record<DateSelectionMode, string> = {
single: "단일 날짜",
range: "기간 선택",
};
const CALENDAR_DISPLAY_LABELS: Record<CalendarDisplayMode, string> = {
popover: "팝오버 (PC용)",
modal: "모달 (터치/POP용)",
};
function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const mode: DateSelectionMode = cfg.dateSelectionMode || "single";
const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal";
const autoFilterMode = mode === "range" ? "range" : "equals";
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={mode}
onValueChange={(v) => update({ dateSelectionMode: v as DateSelectionMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DATE_SELECTION_MODE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{mode === "single"
? "캘린더에서 날짜 하나를 선택합니다"
: "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}
</p>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={calDisplay}
onValueChange={(v) => update({ calendarDisplay: v as CalendarDisplayMode })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(CALENDAR_DISPLAY_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
{calDisplay === "modal"
? "터치 친화적인 큰 모달로 캘린더가 열립니다"
: "입력란 아래에 작은 팝오버로 열립니다"}
</p>
</div>
<FilterConnectionSection
cfg={cfg}
update={update}
showFieldName
fixedFilterMode={autoFilterMode}
allComponents={allComponents}
connections={connections}
componentId={componentId}
/>
</div>
);
}
@ -337,7 +694,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update }: StepProps) {
function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
@ -366,6 +723,8 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
&quot;&quot; UI가 ( )
</p>
)}
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="range" allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}
@ -647,6 +1006,21 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</p>
</div>
{/* 중복 제거 (Distinct) */}
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Checkbox
id="modal_distinct"
checked={mc.distinct ?? false}
onCheckedChange={(checked) => updateModal({ distinct: !!checked })}
/>
<Label htmlFor="modal_distinct" className="text-[10px]"> (Distinct)</Label>
</div>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
{/* 검색창에 보일 값 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
@ -694,8 +1068,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
(: 회사코드)
</p>
</div>
<FilterConnectionSection cfg={cfg} update={update} showFieldName={false} />
</>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (9종) */
/** 검색 필드 입력 타입 (10종) */
export type SearchInputType =
| "text"
| "number"
@ -11,7 +11,8 @@ export type SearchInputType =
| "multi-select"
| "combo"
| "modal"
| "toggle";
| "toggle"
| "status-chip";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
@ -22,6 +23,12 @@ export function normalizeInputType(t: string): SearchInputType {
return t as SearchInputType;
}
/** 날짜 선택 모드 */
export type DateSelectionMode = "single" | "range";
/** 캘린더 표시 방식 (POP 터치 환경에서는 modal 권장) */
export type CalendarDisplayMode = "popover" | "modal";
/** 날짜 프리셋 옵션 */
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
@ -46,6 +53,9 @@ export type ModalDisplayStyle = "table" | "icon";
/** 모달 검색 방식 */
export type ModalSearchMode = "contains" | "starts-with" | "equals";
/** 검색 값을 대상 리스트에 전달할 때의 필터링 방식 */
export type SearchFilterMode = "contains" | "equals" | "starts_with" | "range";
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
export type ModalFilterTab = "korean" | "alphabet";
@ -64,6 +74,22 @@ export interface ModalSelectConfig {
displayField: string;
valueField: string;
/** displayField 기준 중복 제거 */
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
@ -81,18 +107,28 @@ export interface PopSearchConfig {
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
calendarDisplay?: CalendarDisplayMode;
// date-preset 전용
datePresets?: DatePresetOption[];
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
// 스타일
labelPosition?: "top" | "left";
// 연결된 리스트에 필터를 보낼 때의 매칭 방식
filterMode?: SearchFilterMode;
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@ -102,7 +138,6 @@ export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
placeholder: "검색어 입력",
debounceMs: 500,
triggerOnEnter: true,
labelPosition: "top",
labelText: "",
labelVisible: true,
};
@ -126,6 +161,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */
@ -147,6 +189,14 @@ export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
alphabet: "ABC",
};
/** 검색 필터 방식 라벨 (설정 패널용) */
export const SEARCH_FILTER_MODE_LABELS: Record<SearchFilterMode, string> = {
contains: "포함",
equals: "일치",
starts_with: "시작",
range: "범위",
};
/** 한글 초성 추출 */
const KOREAN_CONSONANTS = [
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",

View File

@ -38,9 +38,23 @@ export function ColumnCombobox({
const filtered = useMemo(() => {
if (!search) return columns;
const q = search.toLowerCase();
return columns.filter((c) => c.name.toLowerCase().includes(q));
return columns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.comment && c.comment.toLowerCase().includes(q))
);
}, [columns, search]);
const selectedCol = useMemo(
() => columns.find((c) => c.name === value),
[columns, value],
);
const displayValue = selectedCol
? selectedCol.comment
? `${selectedCol.name} (${selectedCol.comment})`
: selectedCol.name
: "";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -50,7 +64,7 @@ export function ColumnCombobox({
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value || placeholder}
<span className="truncate">{displayValue || placeholder}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -61,7 +75,7 @@ export function ColumnCombobox({
>
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼명 검색..."
placeholder="컬럼명 또는 한글명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
@ -88,8 +102,15 @@ export function ColumnCombobox({
value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{col.name}</span>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span>{col.name}</span>
{col.comment && (
<span className="text-[11px] text-muted-foreground">
({col.comment})
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{col.type}
</span>

View File

@ -0,0 +1,243 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { usePopEvent } from "@/hooks/pop";
import type { StatusBarConfig, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
interface PopStatusBarComponentProps {
config: StatusBarConfig;
label?: string;
screenId?: string;
componentId?: string;
}
export function PopStatusBarComponent({
config: rawConfig,
label,
screenId,
componentId,
}: PopStatusBarComponentProps) {
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe } = usePopEvent(screenId || "");
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__all_rows`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const inner =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (
typeof inner === "object" &&
inner &&
!Array.isArray(inner) &&
"rows" in inner
) {
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
}
}
);
return unsub;
}, [componentId, subscribe]);
// 외부에서 값 설정 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const incoming =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
setSelectedValue(String(incoming ?? ""));
}
);
return unsub;
}, [componentId, subscribe]);
const emitFilter = useCallback(
(newValue: string) => {
setSelectedValue(newValue);
if (!componentId) return;
const baseColumn = config.filterColumn || config.countColumn || "";
const subActive = config.useSubCount && !!autoSubStatusColumn;
const filterColumns = subActive
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
: [baseColumn].filter(Boolean);
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: baseColumn,
filterColumns,
value: newValue,
filterMode: "equals",
_source: "status-bar",
});
},
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
);
const chipCfg = config;
const showCount = chipCfg.showCount !== false;
const baseCountColumn = chipCfg.countColumn || "";
const useSubCount = chipCfg.useSubCount || false;
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
const allowAll = chipCfg.allowAll !== false;
const allLabel = chipCfg.allLabel || "전체";
const chipStyle = chipCfg.chipStyle || "tab";
const options: StatusChipOption[] = chipCfg.options || [];
// 하위 필터(공정) 활성 여부
const subFilterActive = useSubCount && !!autoSubStatusColumn;
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
const shouldHide = hideUntilSubFilter && !subFilterActive;
const effectiveCountColumn =
subFilterActive ? autoSubStatusColumn : baseCountColumn;
const counts = useMemo(() => {
if (!showCount || !effectiveCountColumn || allRows.length === 0)
return new Map<string, number>();
const map = new Map<string, number>();
for (const row of allRows) {
if (row == null || typeof row !== "object") continue;
const v = String(row[effectiveCountColumn] ?? "");
map.set(v, (map.get(v) || 0) + 1);
}
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];
if (allowAll) {
items.push({ value: "", label: allLabel, count: totalCount });
}
for (const opt of options) {
items.push({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0,
});
}
return items;
}, [options, counts, totalCount, allowAll, allLabel]);
const showLabel = !!label;
if (shouldHide) {
return (
<div className="flex h-full w-full items-center justify-center p-1.5">
<span className="text-[10px] text-muted-foreground/50">
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
</span>
</div>
);
}
if (chipStyle === "pill") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{item.label}
{showCount && (
<span
className={cn(
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
isActive
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-background text-foreground"
)}
>
{item.count}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
// tab 스타일 (기본)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 text-muted-foreground hover:bg-accent"
)}
>
{showCount && (
<span className="text-lg font-bold leading-tight">
{item.count}
</span>
)}
<span className="text-[10px] font-medium leading-tight">
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,489 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
import { getTableColumns } from "@/lib/api/tableManagement";
import { dataApi } from "@/lib/api/data";
import type { ColumnTypeInfo } from "@/lib/api/tableManagement";
import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
interface ConfigPanelProps {
config: StatusBarConfig | undefined;
onUpdate: (config: StatusBarConfig) => void;
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[];
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
componentId?: string;
}
export function PopStatusBarConfigPanel({
config: rawConfig,
onUpdate,
allComponents,
connections,
componentId,
}: ConfigPanelProps) {
const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const update = (partial: Partial<StatusBarConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const options = cfg.options || [];
const removeOption = (index: number) => {
update({ options: options.filter((_, i) => i !== index) });
};
const updateOption = (
index: number,
field: keyof StatusChipOption,
val: string
) => {
update({
options: options.map((opt, i) =>
i === index ? { ...opt, [field]: val } : opt
),
});
};
// 연결된 카드 컴포넌트의 테이블 컬럼 가져오기
const connectedTableName = useMemo(() => {
if (!componentId || !connections || !allComponents) return null;
const targetIds = connections
.filter((c) => c.sourceComponent === componentId)
.map((c) => c.targetComponent);
const sourceIds = connections
.filter((c) => c.targetComponent === componentId)
.map((c) => c.sourceComponent);
const peerIds = [...new Set([...targetIds, ...sourceIds])];
for (const pid of peerIds) {
const comp = allComponents.find((c) => c.id === pid);
if (!comp?.config) continue;
const compCfg = comp.config as Record<string, unknown>;
const ds = compCfg.dataSource as { tableName?: string } | undefined;
if (ds?.tableName) return ds.tableName;
}
return null;
}, [componentId, connections, allComponents]);
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 집계 컬럼의 고유값 (옵션 선택용)
const [distinctValues, setDistinctValues] = useState<string[]>([]);
const [distinctLoading, setDistinctLoading] = useState(false);
useEffect(() => {
if (!connectedTableName) {
setTargetColumns([]);
return;
}
let cancelled = false;
setColumnsLoading(true);
getTableColumns(connectedTableName)
.then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setTargetColumns(res.data.columns);
}
})
.finally(() => {
if (!cancelled) setColumnsLoading(false);
});
return () => {
cancelled = true;
};
}, [connectedTableName]);
const fetchDistinctValues = useCallback(async (tableName: string, column: string) => {
setDistinctLoading(true);
try {
const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 });
const vals = new Set<string>();
for (const row of res.data) {
const v = row[column];
if (v != null && String(v).trim() !== "") {
vals.add(String(v));
}
}
const sorted = [...vals].sort();
setDistinctValues(sorted);
return sorted;
} catch {
setDistinctValues([]);
return [];
} finally {
setDistinctLoading(false);
}
}, []);
// 집계 컬럼 변경 시 고유값 새로 가져오기
useEffect(() => {
const col = cfg.countColumn;
if (!connectedTableName || !col) {
setDistinctValues([]);
return;
}
fetchDistinctValues(connectedTableName, col);
}, [connectedTableName, cfg.countColumn, fetchDistinctValues]);
const handleAutoFill = useCallback(async () => {
if (!connectedTableName || !cfg.countColumn) return;
const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn);
if (vals.length === 0) return;
const newOptions: StatusChipOption[] = vals.map((v) => {
const existing = options.find((o) => o.value === v);
return { value: v, label: existing?.label || v };
});
update({ options: newOptions });
}, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]);
const addOptionFromValue = (value: string) => {
if (options.some((o) => o.value === value)) return;
update({
options: [...options, { value, label: value }],
});
};
return (
<div className="space-y-4">
{/* --- 칩 옵션 목록 --- */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
{connectedTableName && cfg.countColumn && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[9px]"
onClick={handleAutoFill}
disabled={distinctLoading}
>
{distinctLoading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3 w-3" />
)}
DB에서
</Button>
)}
</div>
{cfg.useSubCount && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
. DB .
</p>
</div>
)}
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground">
{connectedTableName && cfg.countColumn
? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요."
: "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."}
</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => updateOption(i, "value", e.target.value)}
placeholder="DB 값"
className="h-7 flex-1 text-[10px]"
/>
<Input
value={opt.label}
onChange={(e) => updateOption(i, "label", e.target.value)}
placeholder="표시 라벨"
className="h-7 flex-1 text-[10px]"
/>
<button
type="button"
onClick={() => removeOption(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{/* 고유값에서 추가 */}
{distinctValues.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground">
(DB에서 )
</Label>
<div className="flex flex-wrap gap-1">
{distinctValues
.filter((dv) => !options.some((o) => o.value === dv))
.map((dv) => (
<button
key={dv}
type="button"
onClick={() => addOptionFromValue(dv)}
className="flex h-6 items-center gap-1 rounded-full border border-dashed px-2 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus className="h-2.5 w-2.5" />
{dv}
</button>
))}
{distinctValues.every((dv) => options.some((o) => o.value === dv)) && (
<p className="text-[9px] text-muted-foreground"> </p>
)}
</div>
</div>
)}
{/* 수동 추가 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={() => {
update({
options: [
...options,
{ value: "", label: "" },
],
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* --- 전체 보기 칩 --- */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Checkbox
id="allowAll"
checked={cfg.allowAll !== false}
onCheckedChange={(checked) => update({ allowAll: Boolean(checked) })}
/>
<Label htmlFor="allowAll" className="text-[10px]">
&quot;&quot;
</Label>
</div>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
{cfg.allowAll !== false && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.allLabel || ""}
onChange={(e) => update({ allLabel: e.target.value })}
placeholder="전체"
className="h-7 text-[10px]"
/>
</div>
)}
</div>
{/* --- 건수 표시 --- */}
<div className="flex items-center gap-2">
<Checkbox
id="showCount"
checked={cfg.showCount !== false}
onCheckedChange={(checked) => update({ showCount: Boolean(checked) })}
/>
<Label htmlFor="showCount" className="text-[10px]">
</Label>
</div>
{cfg.showCount !== false && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.countColumn || ""}
onValueChange={(v) => update({ countColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="집계 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.countColumn || ""}
onChange={(e) => update({ countColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
{cfg.showCount !== false && (
<div className="space-y-2 rounded bg-muted/30 p-2">
<div className="flex items-center gap-2">
<Checkbox
id="useSubCount"
checked={cfg.useSubCount || false}
onCheckedChange={(checked) =>
update({ useSubCount: Boolean(checked) })
}
/>
<Label htmlFor="useSubCount" className="text-[10px]">
</Label>
</div>
{cfg.useSubCount && (
<>
<p className="pl-5 text-[9px] text-muted-foreground">
</p>
<div className="mt-1 flex items-center gap-2 pl-5">
<Checkbox
id="hideUntilSubFilter"
checked={cfg.hideUntilSubFilter || false}
onCheckedChange={(checked) =>
update({ hideUntilSubFilter: Boolean(checked) })
}
/>
<Label htmlFor="hideUntilSubFilter" className="text-[10px]">
</Label>
</div>
{cfg.hideUntilSubFilter && (
<div className="space-y-1 pl-5">
<Label className="text-[9px] text-muted-foreground">
</Label>
<Input
value={cfg.hiddenMessage || ""}
onChange={(e) => update({ hiddenMessage: e.target.value })}
placeholder="조건을 선택하면 상태별 현황이 표시됩니다"
className="h-7 text-[10px]"
/>
</div>
)}
</>
)}
</div>
)}
{/* --- 칩 스타일 --- */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.chipStyle || "tab"}
onValueChange={(v) => update({ chipStyle: v as StatusChipStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS_CHIP_STYLE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
: + / 알약: 작은
</p>
</div>
{/* --- 필터 컬럼 --- */}
<div className="space-y-1 border-t pt-3">
<Label className="text-[10px]"> </Label>
{!connectedTableName && (
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<p className="text-[9px] text-amber-700">
.
</p>
</div>
)}
{connectedTableName && (
<>
{columnsLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : targetColumns.length > 0 ? (
<Select
value={cfg.filterColumn || cfg.countColumn || ""}
onValueChange={(v) => update({ filterColumn: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필터 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">
({col.columnName})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.filterColumn || ""}
onChange={(e) => update({ filterColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
)}
<p className="text-[9px] text-muted-foreground">
(
)
</p>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopStatusBarComponent } from "./PopStatusBarComponent";
import { PopStatusBarConfigPanel } from "./PopStatusBarConfig";
import type { StatusBarConfig } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
function PopStatusBarPreviewComponent({
config,
label,
}: {
config?: StatusBarConfig;
label?: string;
}) {
const cfg = config || DEFAULT_STATUS_BAR_CONFIG;
const options = cfg.options || [];
const displayLabel = label || "상태 바";
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">
{displayLabel}
</span>
<div className="flex items-center gap-1">
{options.length === 0 ? (
<span className="text-[9px] text-muted-foreground">
</span>
) : (
options.slice(0, 4).map((opt) => (
<div
key={opt.value}
className="flex flex-col items-center rounded bg-muted/60 px-2 py-0.5"
>
<span className="text-[10px] font-bold leading-tight">0</span>
<span className="text-[8px] leading-tight text-muted-foreground">
{opt.label}
</span>
</div>
))
)}
</div>
</div>
);
}
PopComponentRegistry.registerComponent({
id: "pop-status-bar",
name: "상태 바",
description: "상태별 건수 대시보드 + 필터",
category: "display",
icon: "BarChart3",
component: PopStatusBarComponent,
configPanel: PopStatusBarConfigPanel,
preview: PopStatusBarPreviewComponent,
defaultProps: DEFAULT_STATUS_BAR_CONFIG,
connectionMeta: {
sendable: [
{
key: "filter_value",
label: "필터 값",
type: "filter_value",
category: "filter",
description: "선택한 상태 칩 값을 카드에 필터로 전달",
},
],
receivable: [
{
key: "all_rows",
label: "전체 데이터",
type: "all_rows",
category: "data",
description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계",
},
{
key: "set_value",
label: "값 설정",
type: "filter_value",
category: "filter",
description: "외부에서 선택 값 설정",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,48 @@
// ===== pop-status-bar 전용 타입 =====
// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행.
/** 상태 칩 표시 스타일 */
export type StatusChipStyle = "tab" | "pill";
/** 개별 옵션 */
export interface StatusChipOption {
value: string;
label: string;
}
/** status-bar 전용 설정 */
export interface StatusBarConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
useSubCount?: boolean;
/** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */
hideUntilSubFilter?: boolean;
/** 칩 숨김 상태일 때 표시할 안내 문구 */
hiddenMessage?: string;
options?: StatusChipOption[];
/** 필터 대상 컬럼명 (기본: countColumn) */
filterColumn?: string;
/** 추가 필터 대상 컬럼 (하위 테이블 등) */
filterColumns?: string[];
}
/** 기본 설정값 */
export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = {
showCount: true,
allowAll: true,
allLabel: "전체",
chipStyle: "tab",
options: [],
};
/** 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};

View File

@ -193,10 +193,9 @@ export function PopStringListComponent({
row: RowData,
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
): boolean => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const mode = fc?.filterMode || "contains";
const columns: string[] =
fc?.targetColumns?.length
? fc.targetColumns
@ -208,17 +207,46 @@ export function PopStringListComponent({
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
// range 모드: { from, to } 객체 또는 단일 날짜 문자열 지원
if (mode === "range") {
const val = filter.value as { from?: string; to?: string } | string;
let from = "";
let to = "";
if (typeof val === "object" && val !== null) {
from = val.from || "";
to = val.to || "";
} else {
from = String(val || "");
to = from;
}
if (!from && !to) return true;
return columns.some((col) => {
const cellDate = String(row[col] ?? "").slice(0, 10);
if (!cellDate) return false;
if (from && cellDate < from) return false;
if (to && cellDate > to) return false;
return true;
});
}
// 문자열 기반 필터 (contains, equals, starts_with)
const searchValue = String(filter.value ?? "").toLowerCase();
if (!searchValue) return true;
// 날짜 패턴 감지 (YYYY-MM-DD): equals 비교 시 ISO 타임스탬프에서 날짜만 추출
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(searchValue);
const matchCell = (cellValue: string) => {
const target = isDateValue && mode === "equals" ? cellValue.slice(0, 10) : cellValue;
switch (mode) {
case "equals":
return cellValue === searchValue;
return target === searchValue;
case "starts_with":
return cellValue.startsWith(searchValue);
return target.startsWith(searchValue);
case "contains":
default:
return cellValue.includes(searchValue);
return target.includes(searchValue);
}
};

View File

@ -722,3 +722,264 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
// =============================================
// pop-card-list-v2 전용 타입 (슬롯 기반 카드)
// =============================================
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button";
export type CardCellType =
| "text"
| "field"
| "image"
| "badge"
| "button"
| "number-input"
| "cart-button"
| "package-summary"
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
seqNo: number;
processName: string;
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
export interface TimelineDataSource {
processTable: string; // 하위 데이터 테이블명 (예: work_order_process)
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
seqColumn: string; // 순서 컬럼 (예: seq_no)
nameColumn: string; // 표시명 컬럼 (예: process_name)
statusColumn: string; // 상태 컬럼 (예: status)
// 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시)
// 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환
statusMappings?: StatusValueMapping[];
}
export type TimelineStatusSemantic = "pending" | "active" | "done";
export interface StatusValueMapping {
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
label: string; // 화면에 보이는 이름
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
}
export interface CardCellDefinitionV2 {
id: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
type: CardCellType;
// 공통
columnName?: string;
label?: string;
labelPosition?: "top" | "left";
fontSize?: "xs" | "sm" | "md" | "lg";
fontWeight?: "normal" | "medium" | "bold";
textColor?: string;
align?: "left" | "center" | "right";
verticalAlign?: "top" | "middle" | "bottom";
// field 타입 전용 (CardFieldBinding 흡수)
valueType?: "column" | "formula";
formulaLeft?: string;
formulaOperator?: "+" | "-" | "*" | "/";
formulaRight?: string;
formulaRightType?: "input" | "column";
unit?: string;
// image 타입 전용
defaultImage?: string;
// button 타입 전용
buttonAction?: ButtonMainAction;
buttonVariant?: ButtonVariant;
buttonConfirm?: ConfirmConfig;
// number-input 타입 전용
inputUnit?: string;
limitColumn?: string;
autoInitMax?: boolean;
// cart-button 타입 전용
cartLabel?: string;
cartCancelLabel?: string;
cartIconType?: "lucide" | "emoji";
cartIconValue?: string;
// status-badge 타입 전용
statusColumn?: string;
statusMap?: Array<{ value: string; label: string; color: string }>;
// timeline 타입 전용: 공정 데이터 소스 설정
timelineSource?: TimelineDataSource;
processColumn?: string;
processStatusColumn?: string;
currentHighlight?: boolean;
visibleCount?: number;
timelinePriority?: "before" | "after";
showDetailModal?: boolean;
// action-buttons 타입 전용 (신규: 버튼 중심 구조)
actionButtons?: ActionButtonDef[];
// action-buttons 타입 전용 (구: 조건 중심 구조, 하위호환)
actionRules?: Array<{
whenStatus: string;
buttons: Array<ActionButtonConfig>;
}>;
// footer-status 타입 전용
footerLabel?: string;
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
}
export interface ActionButtonUpdate {
column: string;
value?: string;
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
}
// 액션 버튼 클릭 시 동작 모드
export type ActionButtonClickMode = "status-change" | "modal-open" | "select-mode";
// 액션 버튼 개별 설정
export interface ActionButtonConfig {
label: string;
variant: ButtonVariant;
taskPreset: string;
confirm?: ConfirmConfig;
targetTable?: string;
confirmMessage?: string;
allowMultiSelect?: boolean;
updates?: ActionButtonUpdate[];
clickMode?: ActionButtonClickMode;
selectModeConfig?: SelectModeConfig;
}
// 선택 모드 설정
export interface SelectModeConfig {
filterStatus?: string;
buttons: Array<SelectModeButtonConfig>;
}
// 선택 모드 하단 버튼 설정
export interface SelectModeButtonConfig {
label: string;
variant: ButtonVariant;
clickMode: "status-change" | "modal-open" | "cancel-select";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
modalScreenId?: string;
}
// ===== 버튼 중심 구조 (신규) =====
export interface ActionButtonShowCondition {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
unmatchBehavior?: "hidden" | "disabled";
}
export interface ActionButtonClickAction {
type: "immediate" | "select-mode" | "modal-open";
targetTable?: string;
updates?: ActionButtonUpdate[];
confirmMessage?: string;
selectModeButtons?: SelectModeButtonConfig[];
modalScreenId?: string;
// 외부 테이블 조인 설정 (DB 직접 선택 시)
joinConfig?: {
sourceColumn: string; // 메인 테이블의 FK 컬럼
targetColumn: string; // 외부 테이블의 매칭 컬럼
};
}
export interface ActionButtonDef {
label: string;
variant: ButtonVariant;
showCondition?: ActionButtonShowCondition;
/** 단일 액션 (하위호환) 또는 다중 액션 체이닝 */
clickAction: ActionButtonClickAction;
clickActions?: ActionButtonClickAction[];
}
export interface CardGridConfigV2 {
rows: number;
cols: number;
colWidths: string[];
rowHeights?: string[];
gap: number;
showCellBorder: boolean;
cells: CardCellDefinitionV2[];
}
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export interface V2CardClickModalConfig {
screenId: string;
modalTitle?: string;
condition?: {
type: "timeline-status" | "column-value" | "always";
value?: string;
column?: string;
};
}
// ----- V2 오버플로우 설정 -----
export interface V2OverflowConfig {
mode: "loadMore" | "pagination";
visibleCount: number;
loadMoreCount?: number;
pageSize?: number;
}
// ----- pop-card-list-v2 전체 설정 -----
export interface PopCardListV2Config {
dataSource: CardListDataSource;
cardGrid: CardGridConfigV2;
selectedColumns?: string[];
gridColumns?: number;
gridRows?: number;
scrollDirection?: CardScrollDirection;
/** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */
cardSize?: CardSize;
cardGap?: number;
overflow?: V2OverflowConfig;
cardClickAction?: V2CardClickAction;
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;

View File

@ -14,6 +14,7 @@ export interface LoginResponse {
token?: string;
userInfo?: any;
firstMenuPath?: string | null;
popLandingPath?: string | null;
};
errorCode?: string;
}