Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-12 14:31:31 +09:00
commit dc37ad4471
6 changed files with 219 additions and 33 deletions

View File

@ -204,6 +204,66 @@ function FullWidthOverlayRow({
); );
} }
function ProportionalRenderer({
components,
canvasWidth,
canvasHeight,
renderComponent,
}: ResponsiveGridRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const topLevel = components.filter((c) => !c.parentId);
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
const maxBottom = topLevel.reduce((max, c) => {
const bottom = c.position.y + (c.size?.height || 40);
return Math.max(max, bottom);
}, 0);
return (
<div
ref={containerRef}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
>
{containerW > 0 &&
topLevel.map((component) => {
const typeId = getComponentTypeId(component);
return (
<div
key={component.id}
data-component-id={component.id}
data-component-type={typeId}
style={{
position: "absolute",
left: `${(component.position.x / canvasWidth) * 100}%`,
top: `${component.position.y * ratio}px`,
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
height: `${(component.size?.height || 40) * ratio}px`,
zIndex: component.position.z || 1,
}}
>
{renderComponent(component)}
</div>
);
})}
</div>
);
}
export function ResponsiveGridRenderer({ export function ResponsiveGridRenderer({
components, components,
canvasWidth, canvasWidth,
@ -213,6 +273,18 @@ export function ResponsiveGridRenderer({
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const topLevel = components.filter((c) => !c.parentId); const topLevel = components.filter((c) => !c.parentId);
const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c));
if (!isMobile && !hasFullWidthComponent) {
return (
<ProportionalRenderer
components={components}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={renderComponent}
/>
);
}
const rows = groupComponentsIntoRows(topLevel); const rows = groupComponentsIntoRows(topLevel);
const processedRows: ProcessedRow[] = []; const processedRows: ProcessedRow[] = [];
@ -357,7 +429,7 @@ export function ResponsiveGridRenderer({
style={{ style={{
width: isFullWidth ? "100%" : undefined, width: isFullWidth ? "100%" : undefined,
flexBasis: useFlexHeight ? undefined : flexBasis, flexBasis: useFlexHeight ? undefined : flexBasis,
flexGrow: 1, flexGrow: percentWidth,
flexShrink: 1, flexShrink: 1,
minWidth: isMobile ? "100%" : undefined, minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : (component.size?.height minHeight: useFlexHeight ? "300px" : (component.size?.height

View File

@ -194,7 +194,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
operator: "contains", // 기본 연산자 operator: "contains", // 기본 연산자
value: "", value: "",
filterType: cf.filterType, filterType: cf.filterType,
width: cf.width || 200, // 너비 포함 (기본 200px) width: cf.width && cf.width >= 10 && cf.width <= 100 ? cf.width : 25,
})); }));
// localStorage에 저장 (화면별로 독립적) // localStorage에 저장 (화면별로 독립적)
@ -334,20 +334,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
{/* 너비 입력 */} {/* 너비 입력 */}
<Input <Input
type="number" type="number"
value={filter.width || 200} value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
onChange={(e) => { onChange={(e) => {
const newWidth = parseInt(e.target.value) || 200; const newWidth = Math.min(100, Math.max(10, parseInt(e.target.value) || 25));
setColumnFilters((prev) => setColumnFilters((prev) =>
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)), prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
); );
}} }}
disabled={!filter.enabled} disabled={!filter.enabled}
placeholder="너비" placeholder="25"
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm" className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
min={50} min={10}
max={500} max={100}
/> />
<span className="text-muted-foreground text-xs">px</span> <span className="text-muted-foreground text-xs">%</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -136,7 +136,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
inputType, inputType,
enabled: false, enabled: false,
filterType, filterType,
width: 200, width: 25,
}; };
}); });
@ -271,7 +271,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
operator: "contains", operator: "contains",
value: "", value: "",
filterType: f.filterType, filterType: f.filterType,
width: f.width || 200, width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
})); }));
onFiltersApplied?.(activeFilters); onFiltersApplied?.(activeFilters);
@ -498,15 +498,15 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Select> </Select>
<Input <Input
type="number" type="number"
min={100} min={10}
max={400} max={100}
value={filter.width || 200} value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
onChange={(e) => onChange={(e) =>
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200) handleFilterWidthChange(filter.columnName, Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))
} }
className="h-7 w-16 text-center text-xs" className="h-7 w-16 text-center text-xs"
/> />
<span className="text-muted-foreground text-xs">px</span> <span className="text-muted-foreground text-xs">%</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -648,12 +648,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const renderFilterInput = (filter: TableFilter) => { const renderFilterInput = (filter: TableFilter) => {
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName); const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
const value = filterValues[filter.columnName] || ""; const value = filterValues[filter.columnName] || "";
const width = filter.width || 200; // 기본 너비 200px
switch (filter.filterType) { switch (filter.filterType) {
case "date": case "date":
return ( return (
<div className="w-full sm:w-auto" style={{ maxWidth: `${width}px` }}> <div className="w-full">
<ModernDatePicker <ModernDatePicker
label={column?.columnLabel || filter.columnName} label={column?.columnLabel || filter.columnName}
value={value ? (typeof value === "string" ? { from: new Date(value), to: new Date(value) } : value) : {}} value={value ? (typeof value === "string" ? { from: new Date(value), to: new Date(value) } : value) : {}}
@ -676,8 +675,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
type="number" type="number"
value={value} value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:w-auto sm:text-sm" className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel} placeholder={column?.columnLabel}
/> />
); );
@ -726,10 +725,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
variant="outline" variant="outline"
role="combobox" role="combobox"
className={cn( className={cn(
"h-9 min-h-9 w-full justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:w-auto sm:text-sm", "h-9 min-h-9 w-full justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
selectedValues.length === 0 && "text-muted-foreground", selectedValues.length === 0 && "text-muted-foreground",
)} )}
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
> >
<span className="truncate">{getDisplayText()}</span> <span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -781,8 +780,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
type="text" type="text"
value={value} value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:w-auto sm:text-sm" className="h-9 w-full text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ maxWidth: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }} style={{ height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel} placeholder={column?.columnLabel}
/> />
); );
@ -802,9 +801,18 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
{/* 필터 입력 필드들 */} {/* 필터 입력 필드들 */}
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> <div className="flex flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{activeFilters.map((filter) => ( {activeFilters.map((filter) => {
<div key={filter.columnName}>{renderFilterInput(filter)}</div> const widthPercent = filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25;
))} return (
<div
key={filter.columnName}
className="w-full sm:w-auto"
style={{ flex: `0 1 ${widthPercent}%`, minWidth: "120px" }}
>
{renderFilterInput(filter)}
</div>
);
})}
{/* 초기화 버튼 */} {/* 초기화 버튼 */}
<Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm"> <Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">

View File

@ -106,7 +106,7 @@ export function TableSearchWidgetConfigPanel({
columnName: "", columnName: "",
columnLabel: "", columnLabel: "",
filterType: "text", filterType: "text",
width: 200, width: 25,
}; };
const updatedFilters = [...localPresetFilters, newFilter]; const updatedFilters = [...localPresetFilters, newFilter];
setLocalPresetFilters(updatedFilters); setLocalPresetFilters(updatedFilters);
@ -346,15 +346,15 @@ export function TableSearchWidgetConfigPanel({
{/* 너비 */} {/* 너비 */}
<div> <div>
<Label className="text-[10px] sm:text-xs mb-1"> (px)</Label> <Label className="text-[10px] sm:text-xs mb-1"> (%)</Label>
<Input <Input
type="number" type="number"
value={filter.width || 200} value={filter.width && filter.width >= 10 && filter.width <= 100 ? filter.width : 25}
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))} onChange={(e) => updateFilter(filter.id, "width", Math.min(100, Math.max(10, parseInt(e.target.value) || 25)))}
placeholder="200" placeholder="25"
className="h-7 text-xs" className="h-7 text-xs"
min={100} min={10}
max={500} max={100}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,106 @@
/**
* 회사 기본정보 화면 - 컴포넌트 렌더링 비율 정밀 분석
*
* 사용법: 브라우저에서 회사 기본정보 화면을 상태에서
* F12 Console 스크립트 전체를 붙여넣고 Enter
*/
(function analyzeLayout() {
const results = { part1: null, part2: null };
// ========== Part 1: DesktopCanvasRenderer 구조 확인 ==========
const runtime = document.querySelector('[data-screen-runtime="true"]');
if (!runtime) {
console.warn('⚠️ [data-screen-runtime="true"] 요소를 찾을 수 없습니다.');
console.log('대안: ScreenModal 기반 렌더링이거나 다른 구조일 수 있습니다.');
results.part1 = { error: 'Runtime not found' };
} else {
const rect = runtime.getBoundingClientRect();
const inner = runtime.firstElementChild;
results.part1 = {
runtimeContainer: { width: rect.width, height: rect.height },
innerDiv: null,
components: [],
};
if (inner) {
const style = inner.style;
results.part1.innerDiv = {
width: style.width,
height: style.height,
transform: style.transform,
transformOrigin: style.transformOrigin,
position: style.position,
};
const comps = inner.querySelectorAll('[data-component-id]');
comps.forEach((comp) => {
const s = comp.style;
const r = comp.getBoundingClientRect();
results.part1.components.push({
type: comp.getAttribute('data-component-type'),
id: comp.getAttribute('data-component-id'),
stylePos: `(${s.left}, ${s.top})`,
styleSize: `${s.width} x ${s.height}`,
renderedSize: `${Math.round(r.width)} x ${Math.round(r.height)}`,
});
});
} else {
// ResponsiveGridRenderer (flex 기반) 구조일 수 있음 - 행 단위로 확인
const rows = runtime.querySelectorAll(':scope > div');
results.part1.rows = [];
rows.forEach((row, i) => {
const children = row.children;
const rowData = { rowIndex: i, childCount: children.length, children: [] };
Array.from(children).forEach((child, j) => {
const cs = window.getComputedStyle(child);
const r = child.getBoundingClientRect();
rowData.children.push({
type: child.getAttribute('data-component-type') || 'unknown',
width: Math.round(r.width),
height: Math.round(r.height),
flexGrow: cs.flexGrow,
flexBasis: cs.flexBasis,
});
});
results.part1.rows.push(rowData);
});
}
}
// ========== Part 2: wrapper vs child 크기 확인 ==========
const comps = document.querySelectorAll('[data-component-id]');
results.part2 = [];
comps.forEach((comp) => {
const type = comp.getAttribute('data-component-type');
const child = comp.firstElementChild;
if (child) {
const childRect = child.getBoundingClientRect();
const compRect = comp.getBoundingClientRect();
results.part2.push({
type,
wrapper: `${Math.round(compRect.width)}x${Math.round(compRect.height)}`,
child: `${Math.round(childRect.width)}x${Math.round(childRect.height)}`,
overflow: childRect.width > compRect.width ? 'YES' : 'no',
});
}
});
// ========== 결과 출력 ==========
console.log('========== Part 1: Runtime 구조 ==========');
console.log(JSON.stringify(results.part1, null, 2));
console.log('\n========== Part 2: Wrapper vs Child ==========');
results.part2.forEach((r) => {
console.log(`${r.type}: wrapper=${r.wrapper}, child=${r.child}, overflow=${r.overflow}`);
});
// scale 값 추출 (transform에서)
if (results.part1?.innerDiv?.transform) {
const m = results.part1.innerDiv.transform.match(/scale\(([^)]+)\)/);
if (m) console.log('\n📐 Scale 값:', m[1]);
}
return results;
})();