feat: enhance ResponsiveGridRenderer with row margin calculations

- Added rowMinY and rowMaxBottom properties to ProcessedRow for improved layout calculations.
- Implemented dynamic margin adjustments between rows in the ResponsiveGridRenderer to enhance visual spacing.
- Refactored TabsWidget to streamline the ResponsiveGridRenderer integration, removing unnecessary wrapper divs for cleaner structure.
- Introduced ScaledCustomPanel for better handling of component rendering in split panel layouts.

Made-with: Cursor
This commit is contained in:
kjs 2026-03-12 14:19:48 +09:00
parent 966191786a
commit df47c27b77
7 changed files with 261 additions and 139 deletions

View File

@ -109,6 +109,8 @@ interface ProcessedRow {
mainComponent?: ComponentData;
overlayComps: ComponentData[];
normalComps: ComponentData[];
rowMinY?: number;
rowMaxBottom?: number;
}
function FullWidthOverlayRow({
@ -227,6 +229,10 @@ export function ResponsiveGridRenderer({
}
}
const allComps = [...fullWidthComps, ...normalComps];
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
if (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
@ -234,6 +240,8 @@ export function ResponsiveGridRenderer({
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else if (fullWidthComps.length > 0) {
@ -243,6 +251,8 @@ export function ResponsiveGridRenderer({
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else {
@ -250,6 +260,8 @@ export function ResponsiveGridRenderer({
type: "normal",
overlayComps: [],
normalComps,
rowMinY,
rowMaxBottom,
});
}
}
@ -261,15 +273,26 @@ export function ResponsiveGridRenderer({
style={{ minHeight: "200px" }}
>
{processedRows.map((processedRow, rowIndex) => {
const rowMarginTop = (() => {
if (rowIndex === 0) return 0;
const prevRow = processedRows[rowIndex - 1];
const prevBottom = prevRow.rowMaxBottom ?? 0;
const currTop = processedRow.rowMinY ?? 0;
const designGap = currTop - prevBottom;
if (designGap <= 0) return 0;
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
})();
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
return (
<FullWidthOverlayRow
key={`row-${rowIndex}`}
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
<FullWidthOverlayRow
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
</div>
);
}
@ -290,7 +313,7 @@ export function ResponsiveGridRenderer({
allButtons && "justify-end px-2 py-1",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)}
style={{ gap: `${gap}px` }}
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
@ -337,10 +360,10 @@ export function ResponsiveGridRenderer({
flexGrow: 1,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : undefined,
height: useFlexHeight ? "100%" : (component.size?.height
minHeight: useFlexHeight ? "300px" : (component.size?.height
? `${component.size.height}px`
: "auto"),
: undefined),
height: useFlexHeight ? "100%" : "auto",
}}
>
{renderComponent(component)}

View File

@ -429,8 +429,7 @@ export function TabsWidget({
})) as any;
return (
<div className="flex min-h-0 flex-1 flex-col">
<ResponsiveGridRenderer
<ResponsiveGridRenderer
components={componentDataList}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
@ -453,7 +452,6 @@ export function TabsWidget({
/>
)}
/>
</div>
);
};
@ -498,7 +496,7 @@ export function TabsWidget({
</TabsList>
</div>
<div className="relative flex flex-1 flex-col overflow-auto">
<div className="relative flex-1 overflow-auto">
{visibleTabs.map((tab) => {
const shouldRender = mountedTabs.has(tab.id);
const isActive = selectedTab === tab.id;
@ -508,7 +506,7 @@ export function TabsWidget({
key={tab.id}
value={tab.id}
forceMount
className={cn("flex min-h-0 flex-1 flex-col overflow-auto", !isActive && "hidden")}
className={cn("h-full overflow-auto", !isActive && "hidden")}
>
{shouldRender && renderTabContent(tab)}
</TabsContent>

View File

@ -91,6 +91,103 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value })
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
/**
* 런타임: 디자이너
*/
const ScaledCustomPanel: React.FC<{
components: PanelInlineComponent[];
formData: Record<string, any>;
onFormDataChange: (fieldName: string, value: any) => void;
tableName?: string;
menuObjid?: number;
screenId?: number;
userId?: string;
userName?: string;
companyCode?: string;
allComponents?: any;
selectedRowsData?: any[];
onSelectedRowsChange?: any;
}> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const canvasW = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400,
);
const canvasH = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
200,
);
return (
<div ref={containerRef} className="relative w-full" style={{ height: `${canvasH}px` }}>
{containerWidth > 0 &&
components.map((comp) => {
const x = comp.position?.x || 0;
const y = comp.position?.y || 0;
const w = comp.size?.width || 200;
const h = comp.size?.height || 36;
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: { x, y },
size: { width: undefined, height: h },
componentConfig: comp.componentConfig || {},
style: { ...(comp.style || {}), width: "100%", height: "100%" },
tableName: comp.componentConfig?.tableName,
columnName: comp.componentConfig?.columnName,
webType: comp.componentConfig?.webType,
inputType: (comp as any).inputType || comp.componentConfig?.inputType,
};
return (
<div
key={comp.id}
className="absolute"
style={{
left: `${(x / canvasW) * 100}%`,
top: `${y}px`,
width: `${(w / canvasW) * 100}%`,
minHeight: `${h}px`,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={false}
isInteractive={true}
formData={formData}
onFormDataChange={onFormDataChange}
tableName={tableName}
menuObjid={restProps.menuObjid}
screenId={restProps.screenId}
userId={restProps.userId}
userName={restProps.userName}
companyCode={restProps.companyCode}
allComponents={restProps.allComponents}
selectedRowsData={restProps.selectedRowsData}
onSelectedRowsChange={restProps.onSelectedRowsChange}
/>
</div>
);
})}
</div>
);
};
/**
* SplitPanelLayout
* -
@ -741,8 +838,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
: {
position: "relative",
width: "100%",
height: "100%",
minHeight: getHeightValue(),
height: getHeightValue(),
};
// 계층 구조 빌드 함수 (트리 구조 유지)
@ -3073,59 +3169,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
!isDesignMode ? (
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
(() => {
const leftComps = componentConfig.leftPanel!.components;
const canvasW = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
const canvasH = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
const compDataList = leftComps.map((c: PanelInlineComponent) => ({
id: c.id,
type: "component" as const,
componentType: c.componentType,
label: c.label,
position: c.position || { x: 0, y: 0 },
size: c.size || { width: 400, height: 300 },
componentConfig: c.componentConfig || {},
style: c.style || {},
tableName: c.componentConfig?.tableName,
columnName: c.componentConfig?.columnName,
webType: c.componentConfig?.webType,
inputType: (c as any).inputType || c.componentConfig?.inputType,
})) as any;
return (
<ResponsiveGridRenderer
components={compDataList}
canvasWidth={canvasW}
canvasHeight={canvasH}
renderComponent={(comp) => (
<DynamicComponentRenderer
component={comp as any}
isDesignMode={false}
isInteractive={true}
formData={{}}
tableName={componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
onFormDataChange={(data: any) => {
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
setCustomLeftSelectedData(data.selectedRowsData[0]);
setSelectedLeftItem(data.selectedRowsData[0]);
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
setCustomLeftSelectedData({});
setSelectedLeftItem(null);
}
}}
/>
)}
/>
);
})()
<ScaledCustomPanel
components={componentConfig.leftPanel!.components}
formData={{}}
onFormDataChange={(data: any) => {
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
setCustomLeftSelectedData(data.selectedRowsData[0]);
setSelectedLeftItem(data.selectedRowsData[0]);
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
setCustomLeftSelectedData({});
setSelectedLeftItem(null);
}
}}
tableName={componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
) : (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
@ -3416,7 +3481,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th>
))}
{hasGroupedLeftActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
@ -3452,7 +3517,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasGroupedLeftActions && (
<td className="px-3 py-2 text-right">
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
@ -3514,7 +3579,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th>
))}
{hasLeftTableActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
@ -3550,7 +3615,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasLeftTableActions && (
<td className="px-3 py-2 text-right">
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
@ -4214,53 +4279,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
!isDesignMode ? (
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
(() => {
const rightComps = componentConfig.rightPanel!.components;
const canvasW = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
const canvasH = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
const compDataList = rightComps.map((c: PanelInlineComponent) => ({
id: c.id,
type: "component" as const,
componentType: c.componentType,
label: c.label,
position: c.position || { x: 0, y: 0 },
size: c.size || { width: 400, height: 300 },
componentConfig: c.componentConfig || {},
style: c.style || {},
tableName: c.componentConfig?.tableName,
columnName: c.componentConfig?.columnName,
webType: c.componentConfig?.webType,
inputType: (c as any).inputType || c.componentConfig?.inputType,
})) as any;
return (
<ResponsiveGridRenderer
components={compDataList}
canvasWidth={canvasW}
canvasHeight={canvasH}
renderComponent={(comp) => (
<DynamicComponentRenderer
component={comp as any}
isDesignMode={false}
isInteractive={true}
formData={customLeftSelectedData}
onFormDataChange={(fieldName: string, value: any) => {
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
}}
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
)}
/>
);
})()
<ScaledCustomPanel
components={componentConfig.rightPanel!.components}
formData={customLeftSelectedData}
onFormDataChange={(fieldName: string, value: any) => {
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
}}
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
) : (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {

View File

@ -62,6 +62,7 @@ function SortableColumnRow({
onLabelChange,
onWidthChange,
onFormatChange,
onSuffixChange,
onRemove,
onShowInSummaryChange,
onShowInDetailChange,
@ -87,6 +88,7 @@ function SortableColumnRow({
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onSuffixChange?: (value: string) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
@ -177,15 +179,24 @@ function SortableColumnRow({
className="h-6 w-14 shrink-0 text-xs"
/>
{isNumeric && (
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
<>
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
<Input
value={col.format?.suffix || ""}
onChange={(e) => onSuffixChange?.(e.target.value)}
placeholder="단위"
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
className="h-6 w-10 shrink-0 text-[10px]"
/>
,
</label>
</>
)}
{/* 헤더/상세 표시 토글 */}
{onShowInSummaryChange && (
@ -818,6 +829,18 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
};
updateTab({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateTab({ columns: newColumns });
}}
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
@ -2330,6 +2353,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
};
updateLeftPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateLeftPanel({ columns: newColumns });
}}
onRemove={() =>
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
}
@ -2988,6 +3023,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
};
updateRightPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateRightPanel({ columns: newColumns });
}}
onRemove={() =>
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
}

View File

@ -315,6 +315,11 @@ export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateLeftPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
updateLeftPanel({ columns: newColumns });
}}
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
/>
);

View File

@ -305,6 +305,11 @@ export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateRightPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
updateRightPanel({ columns: newColumns });
}}
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];

View File

@ -13,7 +13,7 @@ import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onSuffixChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: {
id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
@ -23,6 +23,7 @@ export function SortableColumnRow({
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onSuffixChange?: (value: string) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
@ -61,15 +62,24 @@ export function SortableColumnRow({
className="h-6 w-14 shrink-0 text-xs"
/>
{isNumeric && (
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
<>
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
<Input
value={col.format?.suffix || ""}
onChange={(e) => onSuffixChange?.(e.target.value)}
placeholder="단위"
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
className="h-6 w-10 shrink-0 text-[10px]"
/>
,
</label>
</>
)}
{onShowInSummaryChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">