jskim-node #406
|
|
@ -929,6 +929,42 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
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(
|
||||
(
|
||||
|
|
@ -3950,12 +3986,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
|
|
@ -4064,12 +4102,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
|
|
@ -4486,12 +4526,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
{col.type === "progress"
|
||||
? renderProgressCell(col, item, selectedLeftItem)
|
||||
: formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
|
||||
// 드래그 가능한 컬럼 아이템
|
||||
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;
|
||||
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;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
|
|
@ -41,6 +41,9 @@ function SortableColumnRow({
|
|||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (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 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",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
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">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</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 조인 컬럼" />
|
||||
) : (
|
||||
<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 };
|
||||
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>
|
||||
|
||||
{/* 프로그레스 컬럼 추가 */}
|
||||
{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 조인 컬럼 - 아코디언 (접기/펼치기) */}
|
||||
{(() => {
|
||||
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
|
||||
|
|
|
|||
|
|
@ -42,9 +42,13 @@ export const StatusCountComponent: React.FC<StatusCountComponentProps> = ({
|
|||
});
|
||||
|
||||
const responseData = res.data?.data;
|
||||
const rows: any[] = Array.isArray(responseData)
|
||||
? responseData
|
||||
: (responseData?.data || responseData?.rows || []);
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(responseData)) {
|
||||
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> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue