797 lines
27 KiB
TypeScript
797 lines
27 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* pop-string-list 런타임 컴포넌트
|
|
*
|
|
* 리스트 모드: 엑셀형 행/열 (CSS Grid)
|
|
* 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan)
|
|
* 오버플로우: visibleRows 제한 + "더보기" 점진 확장
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import type {
|
|
PopStringListConfig,
|
|
CardGridConfig,
|
|
ListColumnConfig,
|
|
CardCellDefinition,
|
|
} from "./types";
|
|
|
|
// ===== 유틸리티 =====
|
|
|
|
/**
|
|
* 컬럼명에서 실제 데이터 키를 추출
|
|
* 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출
|
|
* 일반 컬럼은 그대로 반환
|
|
*/
|
|
function resolveColumnName(name: string): string {
|
|
if (!name) return name;
|
|
const dotIdx = name.lastIndexOf(".");
|
|
return dotIdx >= 0 ? name.substring(dotIdx + 1) : name;
|
|
}
|
|
|
|
// ===== Props =====
|
|
|
|
interface PopStringListComponentProps {
|
|
config?: PopStringListConfig;
|
|
className?: string;
|
|
screenId?: string;
|
|
componentId?: string;
|
|
}
|
|
|
|
// 테이블 행 데이터 타입
|
|
type RowData = Record<string, unknown>;
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
export function PopStringListComponent({
|
|
config,
|
|
className,
|
|
screenId,
|
|
componentId,
|
|
}: PopStringListComponentProps) {
|
|
const displayMode = config?.displayMode || "list";
|
|
const header = config?.header;
|
|
const overflow = config?.overflow;
|
|
const dataSource = config?.dataSource;
|
|
const listColumns = config?.listColumns || [];
|
|
const cardGrid = config?.cardGrid;
|
|
const rowClickAction = config?.rowClickAction || "none";
|
|
|
|
// 데이터 상태
|
|
const [rows, setRows] = useState<RowData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// 더보기 모드: 현재 표시 중인 행 수
|
|
const [displayCount, setDisplayCount] = useState<number>(0);
|
|
// 페이지네이션 모드: 현재 페이지 (1부터 시작)
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
|
|
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1);
|
|
|
|
// 이벤트 버스
|
|
const { publish, subscribe } = usePopEvent(screenId || "");
|
|
|
|
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
|
|
const [externalFilters, setExternalFilters] = useState<
|
|
Map<string, {
|
|
fieldName: string;
|
|
value: unknown;
|
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
|
}>
|
|
>(new Map());
|
|
|
|
// 표준 입력 이벤트 구독
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__filter_condition`,
|
|
(payload: unknown) => {
|
|
const data = payload as {
|
|
value?: { fieldName?: string; value?: unknown };
|
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
|
_connectionId?: string;
|
|
};
|
|
const connId = data?._connectionId || "default";
|
|
setExternalFilters(prev => {
|
|
const next = new Map(prev);
|
|
if (data?.value?.value) {
|
|
next.set(connId, {
|
|
fieldName: data.value.fieldName || "",
|
|
value: data.value.value,
|
|
filterConfig: data.filterConfig,
|
|
});
|
|
} else {
|
|
next.delete(connId);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe]);
|
|
|
|
// 카드 버튼 클릭 핸들러
|
|
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 {
|
|
showErrorToast("작업에 실패했습니다", result.error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
} catch {
|
|
showErrorToast("예기치 않은 오류가 발생했습니다", null, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
} finally {
|
|
setLoadingRowIdx(-1);
|
|
}
|
|
},
|
|
[rows, publish, screenId]
|
|
);
|
|
|
|
// 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션)
|
|
const handleRowClick = useCallback(
|
|
(row: RowData) => {
|
|
if (rowClickAction === "none") return;
|
|
|
|
// selected_row 이벤트 발행
|
|
if (componentId) {
|
|
publish(`__comp_output__${componentId}__selected_row`, row);
|
|
}
|
|
|
|
// 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환
|
|
if (rowClickAction === "select-and-close-modal") {
|
|
publish("__pop_modal_close__", { selectedRow: row });
|
|
}
|
|
},
|
|
[rowClickAction, componentId, publish]
|
|
);
|
|
|
|
// 오버플로우 설정 (JSON 복원 시 string 유입 방어)
|
|
const overflowMode = overflow?.mode || "loadMore";
|
|
const visibleRows = Number(overflow?.visibleRows) || 5;
|
|
const loadMoreCount = Number(overflow?.loadMoreCount) || 5;
|
|
const maxExpandRows = Number(overflow?.maxExpandRows) || 50;
|
|
const showExpandButton = overflow?.showExpandButton ?? true;
|
|
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
|
const paginationStyle = overflow?.paginationStyle || "bottom";
|
|
|
|
// --- 외부 필터 적용 (복수 필터 AND 결합) ---
|
|
const filteredRows = useMemo(() => {
|
|
if (externalFilters.size === 0) return rows;
|
|
|
|
const matchSingleFilter = (
|
|
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 columns: string[] =
|
|
fc?.targetColumns?.length
|
|
? fc.targetColumns
|
|
: fc?.targetColumn
|
|
? [fc.targetColumn]
|
|
: filter.fieldName
|
|
? [filter.fieldName]
|
|
: [];
|
|
|
|
if (columns.length === 0) return true;
|
|
|
|
const mode = fc?.filterMode || "contains";
|
|
|
|
const matchCell = (cellValue: string) => {
|
|
switch (mode) {
|
|
case "equals":
|
|
return cellValue === searchValue;
|
|
case "starts_with":
|
|
return cellValue.startsWith(searchValue);
|
|
case "contains":
|
|
default:
|
|
return cellValue.includes(searchValue);
|
|
}
|
|
};
|
|
|
|
return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
|
|
};
|
|
|
|
const allFilters = [...externalFilters.values()];
|
|
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
|
|
}, [rows, externalFilters]);
|
|
|
|
// --- 더보기 모드 ---
|
|
useEffect(() => {
|
|
setDisplayCount(visibleRows);
|
|
}, [visibleRows]);
|
|
|
|
const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length);
|
|
const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows;
|
|
const isExpanded = effectiveLimit > visibleRows;
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
setDisplayCount((prev) => {
|
|
const current = prev || visibleRows;
|
|
return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length);
|
|
});
|
|
}, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]);
|
|
|
|
const handleCollapse = useCallback(() => {
|
|
setDisplayCount(visibleRows);
|
|
}, [visibleRows]);
|
|
|
|
// --- 페이지네이션 모드 ---
|
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [pageSize, filteredRows.length]);
|
|
|
|
const handlePageChange = useCallback((page: number) => {
|
|
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
|
}, [totalPages]);
|
|
|
|
// --- 모드별 visibleData 결정 ---
|
|
const visibleData = useMemo(() => {
|
|
if (overflowMode === "pagination") {
|
|
const start = (currentPage - 1) * pageSize;
|
|
return filteredRows.slice(start, start + pageSize);
|
|
}
|
|
return filteredRows.slice(0, effectiveLimit);
|
|
}, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]);
|
|
|
|
// 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 (!dsTableName) {
|
|
setLoading(false);
|
|
setRows([]);
|
|
return;
|
|
}
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터)
|
|
const filters: Record<string, unknown> = {};
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 정렬 조건
|
|
const sortBy = dsSortColumn;
|
|
const sortOrder = dsSortDirection;
|
|
|
|
// 개수 제한 (string 유입 방어: Number 캐스팅)
|
|
const size =
|
|
dsLimitMode === "limited" && dsLimitCount
|
|
? Number(dsLimitCount)
|
|
: maxExpandRows;
|
|
|
|
const result = await dataApi.getTableData(dsTableName, {
|
|
page: 1,
|
|
size,
|
|
sortBy: sortOrder ? sortBy : undefined,
|
|
sortOrder,
|
|
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
});
|
|
|
|
setRows(result.data || []);
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "데이터 조회 실패";
|
|
setError(message);
|
|
setRows([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]);
|
|
|
|
// 로딩 상태
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 에러 상태
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
<span className="text-xs text-destructive">{error}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 테이블 미선택
|
|
if (!dataSource?.tableName) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center p-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
테이블을 선택하세요
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 데이터 없음
|
|
if (rows.length === 0) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center p-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
데이터가 없습니다
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1;
|
|
|
|
return (
|
|
<div className={`flex w-full flex-col ${className || ""}`}>
|
|
{/* 헤더 */}
|
|
{header?.enabled && header.label && (
|
|
<div className="shrink-0 border-b px-3 py-2">
|
|
<span className="text-sm font-medium">{header.label}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 컨텐츠 */}
|
|
<div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
|
|
{displayMode === "list" ? (
|
|
<ListModeView columns={listColumns} data={visibleData} onRowClick={rowClickAction !== "none" ? handleRowClick : undefined} />
|
|
) : (
|
|
<CardModeView
|
|
cardGrid={cardGrid}
|
|
data={visibleData}
|
|
handleCardButtonClick={handleCardButtonClick}
|
|
loadingRowId={loadingRowIdx}
|
|
/>
|
|
)}
|
|
{isPaginationSide && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage <= 1}
|
|
className="absolute left-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages}
|
|
className="absolute right-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */}
|
|
{isPaginationSide && (
|
|
<div className="shrink-0 flex justify-center py-1">
|
|
<span className="rounded-full bg-muted/50 px-2.5 py-0.5 text-[10px] tabular-nums text-muted-foreground">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 더보기 모드 컨트롤 */}
|
|
{overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && (
|
|
<div className="shrink-0 border-t px-3 py-1.5 flex gap-2">
|
|
{hasMore && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleLoadMore}
|
|
className="h-7 flex-1 text-xs text-muted-foreground"
|
|
>
|
|
<ChevronDown className="mr-1 h-3 w-3" />
|
|
더보기 ({rows.length - effectiveLimit}건 남음)
|
|
</Button>
|
|
)}
|
|
{isExpanded && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleCollapse}
|
|
className="h-7 text-xs text-muted-foreground"
|
|
>
|
|
<ChevronUp className="mr-1 h-3 w-3" />
|
|
접기
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 페이지네이션 bottom 모드 컨트롤 */}
|
|
{overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && (
|
|
<div className="shrink-0 border-t px-3 py-1.5 flex items-center justify-between">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{rows.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage <= 1}
|
|
className="h-6 w-6"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</Button>
|
|
<span className="text-xs tabular-nums px-1">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages}
|
|
className="h-6 w-6"
|
|
>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 리스트 모드 =====
|
|
|
|
interface ListModeViewProps {
|
|
columns: ListColumnConfig[];
|
|
data: RowData[];
|
|
onRowClick?: (row: RowData) => void;
|
|
}
|
|
|
|
function ListModeView({ columns, data, onRowClick }: ListModeViewProps) {
|
|
// 런타임 컬럼 전환 상태
|
|
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
|
|
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
|
|
|
|
if (columns.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center p-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
컬럼을 설정하세요
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const gridCols = columns.map((c) => c.width || "1fr").join(" ");
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* 헤더 행 */}
|
|
<div
|
|
className="border-b bg-muted/50"
|
|
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
|
>
|
|
{columns.map((col, colIdx) => {
|
|
const hasAlternates = (col.alternateColumns || []).length > 0;
|
|
const currentColName = activeColumns[colIdx] || col.columnName;
|
|
// 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시
|
|
const currentLabel =
|
|
currentColName === col.columnName
|
|
? col.label
|
|
: resolveColumnName(currentColName);
|
|
|
|
if (hasAlternates) {
|
|
// 전환 가능한 헤더: Popover 드롭다운
|
|
return (
|
|
<Popover key={col.columnName}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 transition-colors"
|
|
style={{ textAlign: col.align || "left" }}
|
|
>
|
|
<span className="truncate">{currentLabel}</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto min-w-[120px] p-1" align="start">
|
|
<div className="flex flex-col">
|
|
{/* 원래 컬럼 */}
|
|
<button
|
|
className={cn(
|
|
"rounded px-2 py-1 text-left text-xs transition-colors",
|
|
currentColName === col.columnName
|
|
? "bg-primary/10 text-primary font-medium"
|
|
: "hover:bg-muted"
|
|
)}
|
|
onClick={() => {
|
|
setActiveColumns((prev) => {
|
|
const next = { ...prev };
|
|
delete next[colIdx];
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
{col.label} (기본)
|
|
</button>
|
|
{/* 대체 컬럼들 */}
|
|
{(col.alternateColumns || []).map((altCol) => {
|
|
const altLabel = resolveColumnName(altCol);
|
|
return (
|
|
<button
|
|
key={altCol}
|
|
className={cn(
|
|
"rounded px-2 py-1 text-left text-xs transition-colors",
|
|
currentColName === altCol
|
|
? "bg-primary/10 text-primary font-medium"
|
|
: "hover:bg-muted"
|
|
)}
|
|
onClick={() => {
|
|
setActiveColumns((prev) => ({
|
|
...prev,
|
|
[colIdx]: altCol,
|
|
}));
|
|
}}
|
|
>
|
|
{altLabel}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 전환 없는 일반 헤더
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
|
style={{ textAlign: col.align || "left" }}
|
|
>
|
|
{col.label}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 데이터 행 */}
|
|
{data.map((row, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
|
onRowClick && "cursor-pointer"
|
|
)}
|
|
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
|
onClick={() => onRowClick?.(row)}
|
|
>
|
|
{columns.map((col, colIdx) => {
|
|
const currentColName = activeColumns[colIdx] || col.columnName;
|
|
const resolvedKey = resolveColumnName(currentColName);
|
|
return (
|
|
<div
|
|
key={`${col.columnName}-${colIdx}`}
|
|
className="px-2 py-1.5 text-xs truncate"
|
|
style={{ textAlign: col.align || "left" }}
|
|
>
|
|
{String(row[resolvedKey] ?? "")}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 카드 모드 =====
|
|
|
|
interface CardModeViewProps {
|
|
cardGrid?: CardGridConfig;
|
|
data: RowData[];
|
|
handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void;
|
|
loadingRowId?: number;
|
|
}
|
|
|
|
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">
|
|
<span className="text-xs text-muted-foreground">
|
|
카드 레이아웃을 설정하세요
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2 p-2">
|
|
{data.map((row, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-md border"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
cardGrid.colWidths && cardGrid.colWidths.length > 0
|
|
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
|
: "1fr",
|
|
gridTemplateRows:
|
|
cardGrid.rowHeights && cardGrid.rowHeights.length > 0
|
|
? cardGrid.rowHeights
|
|
.map((h) => {
|
|
if (!h) return "minmax(32px, auto)";
|
|
// px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장
|
|
if (h.endsWith("px")) {
|
|
return `minmax(${h}, auto)`;
|
|
}
|
|
// fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용
|
|
const px = Math.round(parseFloat(h) * 32) || 32;
|
|
return `minmax(${px}px, auto)`;
|
|
})
|
|
.join(" ")
|
|
: `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`,
|
|
gap: `${Number(cardGrid.gap) || 0}px`,
|
|
}}
|
|
>
|
|
{(cardGrid.cells || []).map((cell) => {
|
|
// 가로 정렬 매핑
|
|
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
|
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
|
return (
|
|
<div
|
|
key={cell.id}
|
|
className="overflow-hidden p-1.5"
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
|
alignItems: justifyMap[cell.align || "left"],
|
|
gridColumn: `${Number(cell.col) || 1} / span ${Number(cell.colSpan) || 1}`,
|
|
gridRow: `${Number(cell.row) || 1} / span ${Number(cell.rowSpan) || 1}`,
|
|
border: cardGrid.showBorder
|
|
? "1px solid hsl(var(--border))"
|
|
: "none",
|
|
}}
|
|
>
|
|
{renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 셀 컨텐츠 렌더링 =====
|
|
|
|
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) : "";
|
|
|
|
switch (cell.type) {
|
|
case "image":
|
|
return displayValue ? (
|
|
<img
|
|
src={displayValue}
|
|
alt={cell.label || cell.columnName}
|
|
className="h-full max-h-[200px] w-full object-cover rounded"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center bg-muted rounded">
|
|
<span className="text-[10px] text-muted-foreground">No Image</span>
|
|
</div>
|
|
);
|
|
|
|
case "badge":
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
|
{displayValue}
|
|
</span>
|
|
);
|
|
|
|
case "button":
|
|
return (
|
|
<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>
|
|
);
|
|
|
|
case "text":
|
|
default: {
|
|
// 글자 크기 매핑
|
|
const fontSizeClass =
|
|
cell.fontSize === "sm"
|
|
? "text-[10px]"
|
|
: cell.fontSize === "lg"
|
|
? "text-sm"
|
|
: "text-xs"; // md (기본)
|
|
const isLabelLeft = cell.labelPosition === "left";
|
|
|
|
return (
|
|
<div className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}>
|
|
{cell.label && (
|
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
{cell.label}{isLabelLeft ? ":" : ""}
|
|
</span>
|
|
)}
|
|
<span className={`${fontSizeClass} truncate`}>{displayValue}</span>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
}
|