ERP-node/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx

796 lines
26 KiB
TypeScript
Raw Normal View History

"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 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 {
toast.error(result.error || "작업에 실패했습니다.");
}
} catch {
toast.error("알 수 없는 오류가 발생했습니다.");
} 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>
);
}
}
}