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

585 lines
18 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, 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;
}
// 테이블 행 데이터 타입
type RowData = Record<string, unknown>;
// ===== 메인 컴포넌트 =====
export function PopStringListComponent({
config,
className,
screenId,
}: 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 [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true);
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;
const showExpandButton = overflow?.showExpandButton ?? true;
// 표시할 데이터 슬라이스
const visibleData = expanded
? rows.slice(0, maxExpandRows)
: rows.slice(0, visibleRows);
const hasMore = rows.length > visibleRows;
// 확장/축소 토글
const toggleExpanded = useCallback(() => {
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 (!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>
);
}
return (
<div className={`flex h-full w-full flex-col overflow-hidden ${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 overflow-auto">
{displayMode === "list" ? (
<ListModeView
columns={listColumns}
data={visibleData}
/>
) : (
<CardModeView
cardGrid={cardGrid}
data={visibleData}
handleCardButtonClick={handleCardButtonClick}
loadingRowId={loadingRowIdx}
/>
)}
</div>
{/* 전체보기 버튼 */}
{showExpandButton && hasMore && (
<div className="shrink-0 border-t px-3 py-1.5">
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-7 w-full text-xs text-muted-foreground"
>
{expanded ? (
<>
<ChevronUp className="mr-1 h-3 w-3" />
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
({rows.length})
</>
)}
</Button>
</div>
)}
</div>
);
}
// ===== 리스트 모드 =====
interface ListModeViewProps {
columns: ListColumnConfig[];
data: RowData[];
}
function ListModeView({ columns, data }: 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="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
style={{ display: "grid", gridTemplateColumns: gridCols }}
>
{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>
);
}
}
}