feat: Add progress bar functionality to SplitPanelLayoutComponent and configuration options

- Implemented a new progress bar rendering function in the SplitPanelLayoutComponent to visually represent the ratio of child to parent values.
- Enhanced the SortableColumnRow component to support progress column configuration, allowing users to set current and maximum values through a popover interface.
- Updated the AdditionalTabConfigPanel to include options for adding progress columns, improving user experience in managing data visualization.

These changes significantly enhance the functionality and usability of the split panel layout by providing visual progress indicators and configuration options for users.
This commit is contained in:
kjs 2026-03-09 18:05:00 +09:00
parent 4d6783e508
commit c98b2ccb43
3 changed files with 210 additions and 24 deletions

View File

@ -929,6 +929,42 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return result; return result;
}, []); }, []);
// 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율)
const renderProgressCell = useCallback(
(col: any, item: any, parentData: any) => {
const current = Number(item[col.numerator] || 0);
const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0);
const percentage = max > 0 ? Math.round((current / max) * 100) : 0;
const barWidth = Math.min(percentage, 100);
const barColor =
percentage > 100
? "bg-red-600"
: percentage >= 90
? "bg-red-500"
: percentage >= 70
? "bg-amber-500"
: "bg-emerald-500";
return (
<div className="flex min-w-[120px] items-center gap-2">
<div className="flex-1">
<div className="bg-muted h-2 w-full rounded-full">
<div
className={`h-2 rounded-full transition-all ${barColor}`}
style={{ width: `${barWidth}%` }}
/>
</div>
<div className="text-muted-foreground mt-0.5 text-[10px]">
{current.toLocaleString()} / {max.toLocaleString()}
</div>
</div>
<span className="shrink-0 text-xs font-medium">{percentage}%</span>
</div>
);
},
[],
);
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
const formatCellValue = useCallback( const formatCellValue = useCallback(
( (
@ -3950,7 +3986,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
{tabSummaryColumns.map((col: any) => ( {tabSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs"> <td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,
@ -4064,7 +4102,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
{listSummaryColumns.map((col: any) => ( {listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs"> <td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,
@ -4486,7 +4526,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-xs whitespace-nowrap" className="px-3 py-2 text-xs whitespace-nowrap"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue( {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
rightCategoryMappings, rightCategoryMappings,

View File

@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
// 드래그 가능한 컬럼 아이템 // 드래그 가능한 컬럼 아이템
function SortableColumnRow({ function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns,
}: { }: {
id: string; id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean }; col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string };
index: number; index: number;
isNumeric: boolean; isNumeric: boolean;
isEntityJoin?: boolean; isEntityJoin?: boolean;
@ -41,6 +41,9 @@ function SortableColumnRow({
onRemove: () => void; onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void; onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void; onShowInDetailChange?: (checked: boolean) => void;
onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void;
availableChildColumns?: Array<{ columnName: string; columnLabel: string }>;
availableParentColumns?: Array<{ columnName: string; columnLabel: string }>;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition }; const style = { transform: CSS.Transform.toString(transform), transition };
@ -53,12 +56,44 @@ function SortableColumnRow({
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5", "flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md", isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30", isEntityJoin && "border-blue-200 bg-blue-50/30",
col.type === "progress" && "border-emerald-200 bg-emerald-50/30",
)} )}
> >
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none"> <div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" /> <GripVertical className="h-3 w-3" />
</div> </div>
{isEntityJoin ? ( {col.type === "progress" ? (
<Popover>
<PopoverTrigger asChild>
<button className="shrink-0 cursor-pointer rounded bg-emerald-100 px-1 text-[9px] font-medium text-emerald-700 hover:bg-emerald-200" title="클릭하여 설정 변경">BAR</button>
</PopoverTrigger>
<PopoverContent className="w-56 space-y-2 p-3" align="start">
<p className="text-xs font-medium"> </p>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select value={col.numerator || ""} onValueChange={(v) => onProgressChange?.({ numerator: v })}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>
{(availableChildColumns || []).map((c) => (
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select value={col.denominator || ""} onValueChange={(v) => onProgressChange?.({ denominator: v })}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="컬럼 선택" /></SelectTrigger>
<SelectContent>
{(availableParentColumns || []).map((c) => (
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">{c.columnLabel || c.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
) : isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" /> <Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
) : ( ) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span> <span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
@ -656,6 +691,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
newColumns[index] = { ...newColumns[index], showInDetail: checked }; newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateTab({ columns: newColumns }); updateTab({ columns: newColumns });
}} }}
onProgressChange={(updates) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], ...updates };
updateTab({ columns: newColumns });
}}
availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))}
/> />
); );
})} })}
@ -685,6 +727,104 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
))} ))}
</div> </div>
{/* 프로그레스 컬럼 추가 */}
{tab.tableName && (
<div className="border-border/60 my-2 border-t pt-2">
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-2 select-none">
<ChevronRight className="h-3 w-3 shrink-0 text-emerald-500 transition-transform group-open:rotate-90" />
<span className="text-[10px] font-medium text-emerald-600"> </span>
</summary>
<div className="mt-2 space-y-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
id={`tab-${tabIndex}-progress-label`}
placeholder="예: 샷수 현황"
className="h-7 text-xs"
defaultValue=""
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select
onValueChange={(v) => {
const el = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
if (el) el.value = v;
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" id={`tab-${tabIndex}-progress-numerator`} />
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ( )</Label>
<Select
onValueChange={(v) => {
const el = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
if (el) el.value = v;
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" id={`tab-${tabIndex}-progress-denominator`} />
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 w-full text-xs text-emerald-700 border-emerald-300 hover:bg-emerald-100"
onClick={() => {
const labelEl = document.getElementById(`tab-${tabIndex}-progress-label`) as HTMLInputElement;
const numEl = document.getElementById(`tab-${tabIndex}-progress-numerator`) as HTMLInputElement;
const denEl = document.getElementById(`tab-${tabIndex}-progress-denominator`) as HTMLInputElement;
const label = labelEl?.value || "프로그레스";
const numerator = numEl?.value;
const denominator = denEl?.value;
if (!numerator || !denominator) return;
updateTab({
columns: [
...selectedColumns,
{
name: `progress_${numerator}_${denominator}`,
label,
width: 200,
type: "progress",
numerator,
denominator,
} as any,
],
});
if (labelEl) labelEl.value = "";
}}
>
</Button>
</div>
</details>
</div>
)}
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
{(() => { {(() => {
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;

View File

@ -42,9 +42,13 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
}); });
const responseData = res.data?.data; const responseData = res.data?.data;
const rows: any[] = Array.isArray(responseData) let rows: any[] = [];
? responseData if (Array.isArray(responseData)) {
: (responseData?.data || responseData?.rows || []); rows = responseData;
} else if (responseData && typeof responseData === "object") {
rows = Array.isArray(responseData.data) ? responseData.data :
Array.isArray(responseData.rows) ? responseData.rows : [];
}
const grouped: Record<string, number> = {}; const grouped: Record<string, number> = {};
for (const row of rows) { for (const row of rows) {