- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
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>
|
|
);
|
|
}
|
|
}
|
|
}
|