feat(pop): 액션 아키텍처 + 모달 시스템 구현 (STEP 0~7)

- executePopAction / usePopAction 훅 신규 생성
- pop-button을 usePopAction 기반으로 리팩토링
- PopModalDefinition 타입 + MODAL_SIZE_PRESETS 정의
- PopDesignerContext 신규 생성 (모달 탭 상태 공유)
- PopDesigner에 모달 탭 UI 추가 (메인 캔버스 / 모달 캔버스 전환)
- PopCanvas에 접이식 ModalSizeSettingsPanel + ModalThumbnailPreview 구현
- PopViewerWithModals 신규 생성 (뷰어 모달 렌더링 + 스택 관리)
- FULL 모달 전체화면 지원 (h-dvh, w-screen, rounded-none)
- pop-string-list 카드 버튼 액션 연동
- pop-icon / SelectedItemsDetailInput lucide import 최적화
- tsconfig skipLibCheck 설정 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim
2026-02-23 13:54:49 +09:00
parent 51e1392640
commit df8cbb3e80
16 changed files with 1492 additions and 220 deletions

View File

@@ -8,7 +8,7 @@
* 오버플로우: visibleRows 제한 + "전체보기" 확장
*/
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -18,6 +18,9 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { dataApi } from "@/lib/api/data";
import { executePopAction } from "@/hooks/pop/executePopAction";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { toast } from "sonner";
import type {
PopStringListConfig,
CardGridConfig,
@@ -43,6 +46,7 @@ function resolveColumnName(name: string): string {
interface PopStringListComponentProps {
config?: PopStringListConfig;
className?: string;
screenId?: string;
}
// 테이블 행 데이터 타입
@@ -53,6 +57,7 @@ type RowData = Record<string, unknown>;
export function PopStringListComponent({
config,
className,
screenId,
}: PopStringListComponentProps) {
const displayMode = config?.displayMode || "list";
const header = config?.header;
@@ -67,6 +72,46 @@ export function PopStringListComponent({
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1);
// 이벤트 발행 (카드 버튼 액션에서 사용)
const { publish } = usePopEvent(screenId || "");
// 카드 버튼 클릭 핸들러
const handleCardButtonClick = useCallback(
async (cell: CardCellDefinition, row: RowData) => {
if (!cell.buttonAction) return;
// 확인 다이얼로그 (간단 구현: window.confirm)
if (cell.buttonConfirm?.enabled) {
const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?";
if (!window.confirm(msg)) return;
}
const rowIndex = rows.indexOf(row);
setLoadingRowIdx(rowIndex);
try {
const result = await executePopAction(cell.buttonAction, row as Record<string, unknown>, {
publish,
screenId,
});
if (result.success) {
toast.success("작업이 완료되었습니다.");
} else {
toast.error(result.error || "작업에 실패했습니다.");
}
} catch {
toast.error("알 수 없는 오류가 발생했습니다.");
} finally {
setLoadingRowIdx(-1);
}
},
[rows, publish, screenId]
);
// 오버플로우 계산 (JSON 복원 시 string 유입 방어)
const visibleRows = Number(overflow?.visibleRows) || 5;
const maxExpandRows = Number(overflow?.maxExpandRows) || 20;
@@ -83,9 +128,20 @@ export function PopStringListComponent({
setExpanded((prev) => !prev);
}, []);
// dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
const dsTableName = dataSource?.tableName;
const dsSortColumn = dataSource?.sort?.column;
const dsSortDirection = dataSource?.sort?.direction;
const dsLimitMode = dataSource?.limit?.mode;
const dsLimitCount = dataSource?.limit?.count;
const dsFiltersKey = useMemo(
() => JSON.stringify(dataSource?.filters || []),
[dataSource?.filters]
);
// 데이터 조회
useEffect(() => {
if (!dataSource?.tableName) {
if (!dsTableName) {
setLoading(false);
setRows([]);
return;
@@ -98,8 +154,9 @@ export function PopStringListComponent({
try {
// 필터 조건 구성
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
if (parsedFilters.length > 0) {
parsedFilters.forEach((f) => {
if (f.column && f.value) {
filters[f.column] = f.value;
}
@@ -107,16 +164,16 @@ export function PopStringListComponent({
}
// 정렬 조건
const sortBy = dataSource.sort?.column;
const sortOrder = dataSource.sort?.direction;
const sortBy = dsSortColumn;
const sortOrder = dsSortDirection;
// 개수 제한 (string 유입 방어: Number 캐스팅)
const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count
? Number(dataSource.limit.count)
dsLimitMode === "limited" && dsLimitCount
? Number(dsLimitCount)
: maxExpandRows;
const result = await dataApi.getTableData(dataSource.tableName, {
const result = await dataApi.getTableData(dsTableName, {
page: 1,
size,
sortBy: sortOrder ? sortBy : undefined,
@@ -136,7 +193,7 @@ export function PopStringListComponent({
};
fetchData();
}, [dataSource, maxExpandRows]);
}, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]);
// 로딩 상태
if (loading) {
@@ -199,6 +256,8 @@ export function PopStringListComponent({
<CardModeView
cardGrid={cardGrid}
data={visibleData}
handleCardButtonClick={handleCardButtonClick}
loadingRowId={loadingRowIdx}
/>
)}
</div>
@@ -376,9 +435,11 @@ function ListModeView({ columns, data }: ListModeViewProps) {
interface CardModeViewProps {
cardGrid?: CardGridConfig;
data: RowData[];
handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void;
loadingRowId?: number;
}
function CardModeView({ cardGrid, data }: CardModeViewProps) {
function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) {
if (!cardGrid || (cardGrid.cells || []).length === 0) {
return (
<div className="flex h-full items-center justify-center p-2">
@@ -439,7 +500,7 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) {
: "none",
}}
>
{renderCellContent(cell, row)}
{renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
</div>
);
})}
@@ -451,7 +512,12 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) {
// ===== 셀 컨텐츠 렌더링 =====
function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode {
function renderCellContent(
cell: CardCellDefinition,
row: RowData,
onButtonClick?: (cell: CardCellDefinition, row: RowData) => void,
isButtonLoading?: boolean,
): React.ReactNode {
const value = row[cell.columnName];
const displayValue = value != null ? String(value) : "";
@@ -478,7 +544,16 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN
case "button":
return (
<Button variant="outline" size="sm" className="h-6 text-[10px]">
<Button
variant={cell.buttonVariant || "outline"}
size="sm"
className="h-6 text-[10px]"
disabled={isButtonLoading}
onClick={(e) => {
e.stopPropagation();
onButtonClick?.(cell, row);
}}
>
{cell.label || displayValue}
</Button>
);