위젯 기능 추가

This commit is contained in:
Macbook
2026-06-14 01:03:51 +09:00
parent 95595f7e9e
commit 6936792ad4
57 changed files with 6728 additions and 540 deletions
@@ -0,0 +1,361 @@
import React, { useCallback, useState } from 'react';
import AppPopup from '../AppPopup';
import type {
WidgetLayoutSchema,
WidgetZoneConfig,
WidgetZoneId,
WidgetZoneMode,
} from '../../types/widgetDashboard';
import {
defaultWidgetLayout,
newWidgetSlot,
normalizeZoneSlots,
slotsForSplitCount,
} from '../../types/widgetDashboard';
import { addTabToZone, removeTabFromZone } from './WidgetZoneView';
interface Props {
open: boolean;
layout: WidgetLayoutSchema;
onClose: () => void;
onApply: (layout: WidgetLayoutSchema) => void;
}
type ZoneKey = 'left' | 'center' | 'right' | 'bottom';
const ZONE_META: Record<ZoneKey, { title: string; desc: string }> = {
left: { title: '좌측 패널', desc: '종목 목록·전략 목록 등 보조 위젯' },
center: { title: '중앙 (메인)', desc: '차트·분석 등 메인 위젯 영역' },
right: { title: '우측 패널', desc: '매수·매도·호가 등 거래 위젯' },
bottom: { title: '하단 패널', desc: '거래 내역·알림 등 하단 위젯' },
};
const MODE_OPTIONS: { value: WidgetZoneMode; label: string; desc: string }[] = [
{ value: 'single', label: '단일', desc: '위젯 1개' },
{ value: 'split', label: '상하 분할', desc: '2~3개 세로 배치' },
{ value: 'tabs', label: '탭', desc: '탭별 위젯 구성' },
];
function setZoneEnabled(
layout: WidgetLayoutSchema,
key: ZoneKey,
enabled: boolean,
): WidgetLayoutSchema {
if (key === 'center') return layout;
const defaults = defaultWidgetLayout();
return {
...layout,
zones: {
...layout.zones,
[key]: enabled ? (layout.zones[key] ?? defaults.zones[key]) : null,
},
};
}
function updateZoneConfig(
layout: WidgetLayoutSchema,
key: ZoneKey,
patch: Partial<WidgetZoneConfig>,
): WidgetLayoutSchema {
const current = layout.zones[key];
if (!current && key !== 'center') return layout;
const base = current ?? defaultWidgetLayout().zones.center;
let next: WidgetZoneConfig = normalizeZoneSlots({ ...base, ...patch });
if (patch.mode === 'split' && patch.splitCount) {
const defaults = slotsForSplitCount(patch.splitCount, (base.slots ?? []).map(s => s.widgetType));
next = normalizeZoneSlots({
...next,
mode: 'split',
splitCount: patch.splitCount,
slots: defaults.map((s, i) => ({
...s,
widgetType: base.slots?.[i]?.widgetType ?? s.widgetType,
config: base.slots?.[i]?.config ?? {},
})),
tabs: undefined,
});
}
if (patch.mode === 'tabs' && !next.tabs?.length) {
next = addTabToZone({ ...next, mode: 'tabs' }, '탭 1');
}
if (patch.mode === 'single') {
next = normalizeZoneSlots({
mode: 'single',
splitCount: 1,
slots: [base.slots?.[0] ?? newWidgetSlot()],
});
}
return {
...layout,
zones: {
...layout.zones,
[key]: next,
},
};
}
const SettingRow: React.FC<{
label: string;
desc?: string;
className?: string;
children: React.ReactNode;
}> = ({ label, desc, className, children }) => (
<div className={`stg-row${className ? ` ${className}` : ''}`}>
<div className="stg-row-label">
<span className="stg-row-title">{label}</span>
{desc && <span className="stg-row-desc">{desc}</span>}
</div>
<div className="stg-row-ctrl">{children}</div>
</div>
);
const LayoutEditorModal: React.FC<Props> = ({
open,
layout,
onClose,
onApply,
}) => {
const [draft, setDraft] = useState(layout);
React.useEffect(() => {
if (open) setDraft(layout);
}, [open, layout]);
const apply = useCallback(() => {
onApply(draft);
onClose();
}, [draft, onApply, onClose]);
if (!open) return null;
const renderZoneEditor = (key: ZoneKey) => {
const enabled = key === 'center' || draft.zones[key] != null;
const zone = key === 'center'
? draft.zones.center
: (draft.zones[key] ?? defaultWidgetLayout().zones[key]);
if (!zone) return null;
const mode = zone.mode;
const splitCount = zone.splitCount ?? 1;
const meta = ZONE_META[key];
return (
<div key={key} className="stg-section wd-layout-section">
<div className="stg-section-title">{meta.title}</div>
<div className="stg-section-body">
{key !== 'center' && (
<SettingRow
label="패널 표시"
desc={meta.desc}
>
<label className="stg-toggle">
<input
type="checkbox"
checked={enabled}
onChange={e => setDraft(setZoneEnabled(draft, key, e.target.checked))}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
)}
{enabled && (
<>
<SettingRow
label="패널 구성"
desc="단일·상하 분할·탭 방식 중 선택"
className="stg-row--block"
>
<div className="stg-radio-row wd-layout-mode-row">
{MODE_OPTIONS.map(opt => (
<label
key={opt.value}
className="stg-radio-option stg-radio-option--blue"
>
<input
type="radio"
name={`wd-zone-mode-${key}`}
checked={mode === opt.value}
onChange={() => setDraft(updateZoneConfig(
draft,
key,
{ mode: opt.value },
))}
/>
<span className="stg-radio-option-text">
<strong>{opt.label}</strong>
<span>{opt.desc}</span>
</span>
</label>
))}
</div>
</SettingRow>
{mode === 'split' && (
<SettingRow
label="분할 수"
desc="상하로 배치할 위젯 칸 수"
>
<select
className="stg-select"
value={splitCount}
onChange={e => setDraft(updateZoneConfig(
draft,
key,
{ mode: 'split', splitCount: Number(e.target.value) as 1 | 2 | 3 },
))}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</SettingRow>
)}
{mode === 'tabs' && (
<>
<div className="stg-subsection-label stg-subsection-label--spaced">
<span className="stg-subsection-desc"> </span>
</div>
{(zone.tabs ?? []).map(tab => (
<div key={tab.id} className="stg-row stg-row--block wd-layout-tab-row">
<div className="wd-layout-tab-fields">
<label className="wd-layout-tab-field">
<span className="wd-layout-tab-field-label"> </span>
<input
type="text"
className="stg-input stg-input--wide"
value={tab.label}
onChange={e => {
const z = draft.zones[key];
if (!z?.tabs) return;
setDraft({
...draft,
zones: {
...draft.zones,
[key]: {
...z,
tabs: z.tabs.map(t =>
t.id === tab.id ? { ...t, label: e.target.value } : t,
),
},
},
});
}}
/>
</label>
<label className="wd-layout-tab-field">
<span className="wd-layout-tab-field-label"></span>
<select
className="stg-select"
value={tab.splitCount}
onChange={e => {
const count = Number(e.target.value) as 1 | 2 | 3;
const z = draft.zones[key];
if (!z?.tabs) return;
setDraft({
...draft,
zones: {
...draft.zones,
[key]: {
...z,
tabs: z.tabs.map(t =>
t.id === tab.id
? {
...t,
splitCount: count,
slots: slotsForSplitCount(count, t.slots.map(s => s.widgetType)),
}
: t,
),
},
},
});
}}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
{(zone.tabs?.length ?? 0) > 1 && (
<button
type="button"
className="stg-btn stg-btn--danger wd-layout-tab-delete"
onClick={() => {
const z = draft.zones[key];
if (!z) return;
setDraft(updateZoneConfig(draft, key, removeTabFromZone(z, tab.id)));
}}
>
</button>
)}
</div>
</div>
))}
<div className="wd-layout-tab-add">
<button
type="button"
className="stg-btn-secondary wd-layout-tab-add-btn"
onClick={() => {
const z = draft.zones[key];
if (!z) return;
const n = (z.tabs?.length ?? 0) + 1;
setDraft(updateZoneConfig(draft, key, addTabToZone(z, `${n}`)));
}}
>
+
</button>
</div>
</>
)}
</>
)}
</div>
</div>
);
};
return (
<AppPopup
onClose={onClose}
title="화면 구성"
titleKo="Layout"
width={680}
maxWidth="96vw"
backdrop
closeOnBackdrop
zIndex={12000}
bodyClassName="wd-layout-popup-body"
footer={(
<div className="stg-footer">
<button
type="button"
className="stg-btn-reset"
onClick={() => setDraft(defaultWidgetLayout())}
>
</button>
<button type="button" className="stg-btn-save" onClick={apply}>
</button>
</div>
)}
>
<p className="wd-layout-intro">
··· .
· .
</p>
{(['left', 'center', 'right', 'bottom'] as ZoneKey[]).map(renderZoneEditor)}
</AppPopup>
);
};
export default LayoutEditorModal;
@@ -0,0 +1,316 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import BuilderPageShell from '../layout/BuilderPageShell';
import type { Theme } from '../../types';
import type {
WidgetLayoutSchema,
WidgetZoneConfig,
WidgetZoneId,
} from '../../types/widgetDashboard';
import { defaultWidgetLayout } from '../../types/widgetDashboard';
import { storeBool, storeSize } from '../strategyEditor/usePanelResize';
import WidgetZoneView, { resolveActiveTabId, WidgetZoneTabBar } from './WidgetZoneView';
import WidgetSidePanel from './WidgetSidePanel';
import WidgetPickerModal from './WidgetPickerModal';
import LayoutEditorModal from './LayoutEditorModal';
interface Props {
theme: Theme;
layout: WidgetLayoutSchema;
onLayoutChange: (layout: WidgetLayoutSchema, opts?: { immediate?: boolean }) => void;
}
interface PickTarget {
slotId: string;
zoneId: WidgetZoneId;
tabId?: string;
}
type SideZoneId = 'left' | 'right';
function zoneUsesTabs(zone: WidgetZoneConfig | null | undefined): boolean {
return zone?.mode === 'tabs' && (zone.tabs?.length ?? 0) > 0;
}
const WidgetDashboardShell: React.FC<Props> = ({
theme,
layout,
onLayoutChange,
}) => {
const [layoutEditorOpen, setLayoutEditorOpen] = useState(false);
const [pickTarget, setPickTarget] = useState<PickTarget | null>(null);
const [activeTabByZone, setActiveTabByZone] = useState<Partial<Record<WidgetZoneId, string>>>({});
const pickCategory = pickTarget?.zoneId === 'center' ? 'center' : 'side';
const getActiveTab = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig | null | undefined) =>
resolveActiveTabId(zone, activeTabByZone[zoneId]),
[activeTabByZone]);
const setActiveTab = useCallback((zoneId: WidgetZoneId, tabId: string) => {
setActiveTabByZone(prev => ({ ...prev, [zoneId]: tabId }));
}, []);
useEffect(() => {
setActiveTabByZone(prev => {
const next = { ...prev };
let changed = false;
(['left', 'center', 'right', 'bottom'] as WidgetZoneId[]).forEach(zoneId => {
const zone = zoneId === 'center' ? layout.zones.center : layout.zones[zoneId];
if (!zoneUsesTabs(zone)) return;
const resolved = resolveActiveTabId(zone, prev[zoneId]);
if (resolved && resolved !== prev[zoneId]) {
next[zoneId] = resolved;
changed = true;
}
});
return changed ? next : prev;
});
}, [layout]);
const updateZone = useCallback((zoneId: WidgetZoneId, zone: WidgetZoneConfig) => {
onLayoutChange({
...layout,
zones: {
...layout.zones,
[zoneId]: zoneId === 'center' ? zone : zone,
},
});
}, [layout, onLayoutChange]);
const pageClass = [
'bps-page--wd',
layout.zones.left ? '' : 'bps-page--wd-no-left',
layout.zones.right ? '' : 'bps-page--wd-no-right',
].filter(Boolean).join(' ');
const applyWidgetType = useCallback((widgetType: string) => {
if (!pickTarget) return;
const { slotId, zoneId, tabId } = pickTarget;
const zone = layout.zones[zoneId];
if (!zone) return;
const patchSlot = (s: { id: string; widgetType: string | null; config?: Record<string, unknown> }) =>
s.id === slotId ? { ...s, widgetType, config: {} } : s;
let nextLayout: WidgetLayoutSchema;
if (zone.mode === 'tabs' && tabId) {
nextLayout = {
...layout,
zones: {
...layout.zones,
[zoneId]: {
...zone,
tabs: (zone.tabs ?? []).map(tab =>
tab.id === tabId
? { ...tab, slots: tab.slots.map(patchSlot) }
: tab,
),
},
},
};
} else {
nextLayout = {
...layout,
zones: {
...layout.zones,
[zoneId]: {
...zone,
slots: (zone.slots ?? []).map(patchSlot),
},
},
};
}
onLayoutChange(nextLayout, { immediate: true });
setPickTarget(null);
}, [pickTarget, layout, onLayoutChange]);
const renderZoneView = useCallback((
zoneId: WidgetZoneId,
zone: WidgetZoneConfig,
options?: { hideTabBar?: boolean },
) => (
<WidgetZoneView
zoneId={zoneId}
zone={zone}
onChange={z => updateZone(zoneId, z)}
onPickWidget={(slotId, zid, tabId) => setPickTarget({ slotId, zoneId: zid, tabId })}
hideTabBar={options?.hideTabBar}
activeTabId={getActiveTab(zoneId, zone)}
onActiveTabChange={tabId => setActiveTab(zoneId, tabId)}
/>
), [getActiveTab, setActiveTab, updateZone]);
const renderSideTabBar = useCallback((
zoneId: SideZoneId,
zone: WidgetZoneConfig,
) => (
<WidgetZoneTabBar
tabs={zone.tabs ?? []}
activeTabId={getActiveTab(zoneId, zone)}
onSelect={tabId => setActiveTab(zoneId, tabId)}
variant="side"
/>
), [getActiveTab, setActiveTab]);
const leftZone = layout.zones.left;
const rightZone = layout.zones.right;
const bottomZone = layout.zones.bottom;
const centerZone = layout.zones.center;
const leftUsesTabs = zoneUsesTabs(leftZone);
const rightUsesTabs = zoneUsesTabs(rightZone);
const bottomUsesTabs = zoneUsesTabs(bottomZone);
const centerUsesTabs = zoneUsesTabs(centerZone);
const leftPanelContent = leftZone
? renderZoneView('left', leftZone, { hideTabBar: leftUsesTabs })
: null;
const rightPanelContent = rightZone
? renderZoneView('right', rightZone, { hideTabBar: rightUsesTabs })
: null;
const leftPanel = leftPanelContent && leftUsesTabs && leftZone
? (
<WidgetSidePanel tabs={renderSideTabBar('left', leftZone)}>
{leftPanelContent}
</WidgetSidePanel>
)
: leftPanelContent;
const rightPanel = rightPanelContent && rightUsesTabs && rightZone
? (
<WidgetSidePanel tabs={renderSideTabBar('right', rightZone)}>
{rightPanelContent}
</WidgetSidePanel>
)
: rightPanelContent;
const centerPanel = renderZoneView('center', centerZone, { hideTabBar: centerUsesTabs });
const footerPanel = bottomZone
? (
<div className={`wd-footer-zone${bottomUsesTabs ? ' wd-footer-zone--tabs' : ''}`}>
{bottomUsesTabs && bottomZone && (
<WidgetZoneTabBar
tabs={bottomZone.tabs ?? []}
activeTabId={getActiveTab('bottom', bottomZone)}
onSelect={tabId => setActiveTab('bottom', tabId)}
variant="panel"
/>
)}
{renderZoneView('bottom', bottomZone, { hideTabBar: bottomUsesTabs })}
</div>
)
: null;
const centerHead = centerUsesTabs && centerZone
? (
<div className="wd-center-head-tabs">
<WidgetZoneTabBar
tabs={centerZone.tabs ?? []}
activeTabId={getActiveTab('center', centerZone)}
onSelect={tabId => setActiveTab('center', tabId)}
variant="panel"
/>
</div>
)
: undefined;
const headerActions = (
<>
<button
type="button"
className="wd-toolbar-btn"
onClick={() => setLayoutEditorOpen(true)}
>
</button>
</>
);
const storageKeys = useMemo(() => ({
left: 'wd-left-width',
right: 'wd-right-width',
footer: 'wd-footer-height',
leftCollapsed: 'wd-left-open',
rightCollapsed: 'wd-right-open',
}), []);
useEffect(() => {
const { panelSizes, collapsed } = layout;
if (panelSizes.leftWidth != null) storeSize(storageKeys.left, panelSizes.leftWidth);
if (panelSizes.rightWidth != null) storeSize(storageKeys.right, panelSizes.rightWidth);
if (panelSizes.footerHeight != null) storeSize(storageKeys.footer, panelSizes.footerHeight);
if (collapsed.left != null) storeBool(storageKeys.leftCollapsed, !collapsed.left);
if (collapsed.right != null) storeBool(storageKeys.rightCollapsed, !collapsed.right);
}, [layout, storageKeys]);
const handlePanelLayoutChange = useCallback((patch: {
leftWidth?: number;
rightWidth?: number;
footerHeight?: number;
leftOpen?: boolean;
rightOpen?: boolean;
}) => {
onLayoutChange({
...layout,
panelSizes: {
...layout.panelSizes,
...(patch.leftWidth != null ? { leftWidth: patch.leftWidth } : {}),
...(patch.rightWidth != null ? { rightWidth: patch.rightWidth } : {}),
...(patch.footerHeight != null ? { footerHeight: patch.footerHeight } : {}),
},
collapsed: {
...layout.collapsed,
...(patch.leftOpen != null ? { left: !patch.leftOpen } : {}),
...(patch.rightOpen != null ? { right: !patch.rightOpen } : {}),
},
});
}, [layout, onLayoutChange]);
return (
<>
<BuilderPageShell
theme={theme}
title="위젯"
subtitle="Widget Dashboard"
pageClassName={pageClass}
headerActions={headerActions}
collapsiblePanels
leftStorageKey={storageKeys.left}
leftDefaultWidth={layout.panelSizes.leftWidth ?? 280}
leftCollapsedStorageKey={storageKeys.leftCollapsed}
rightStorageKey={storageKeys.right}
rightDefaultWidth={layout.panelSizes.rightWidth ?? 320}
rightCollapsedStorageKey={storageKeys.rightCollapsed}
footerStorageKey={storageKeys.footer}
onPanelLayoutChange={handlePanelLayoutChange}
left={leftPanel ?? <span />}
centerHead={centerHead}
center={centerPanel}
right={rightPanel ?? undefined}
footer={footerPanel ?? undefined}
footerLabel={bottomUsesTabs ? undefined : '하단'}
leftTitle={layout.zones.left && !leftUsesTabs ? '좌측' : undefined}
rightTitle={layout.zones.right && !rightUsesTabs ? '우측' : undefined}
/>
<WidgetPickerModal
open={pickTarget != null}
category={pickCategory}
onClose={() => setPickTarget(null)}
onSelect={applyWidgetType}
/>
<LayoutEditorModal
open={layoutEditorOpen}
layout={layout}
onClose={() => setLayoutEditorOpen(false)}
onApply={draft => onLayoutChange(draft, { immediate: true })}
/>
</>
);
};
export default WidgetDashboardShell;
export { defaultWidgetLayout };
@@ -0,0 +1,73 @@
import React from 'react';
import AppPopup from '../AppPopup';
import type { WidgetCategory } from '../../types/widgetDashboard';
import { widgetsByGroup, widgetsByGroupAll } from '../../widgets/widgetRegistry';
export type WidgetPickerCategory = WidgetCategory | 'all';
interface Props {
open: boolean;
category: WidgetPickerCategory;
onClose: () => void;
onSelect: (widgetType: string) => void;
}
const WidgetPickerModal: React.FC<Props> = ({
open,
category,
onClose,
onSelect,
}) => {
if (!open) return null;
const groups = category === 'all' ? widgetsByGroupAll() : widgetsByGroup(category);
const title = category === 'center'
? '메인 위젯'
: category === 'side'
? '패널 위젯'
: '위젯 선택';
const intro = category === 'center'
? '중앙 영역에 표시할 메인 위젯을 선택하세요.'
: category === 'side'
? '좌·우·하단 패널에 추가할 위젯을 선택하세요. 주식 HTS/위젯 프로그램에서 흔히 쓰는 구성을 지원합니다.'
: '추가할 위젯 유형을 선택하세요. 차트·시세·매매·포트폴리오 등 모든 위젯을 사용할 수 있습니다.';
return (
<AppPopup
onClose={onClose}
title={title}
titleKo="Widget"
width={620}
maxWidth="96vw"
backdrop
closeOnBackdrop
zIndex={13200}
bodyClassName="wd-picker-popup-body"
>
<p className="wd-layout-intro">{intro}</p>
{groups.map(g => (
<section key={g.group} className="wd-picker-section">
<h3 className="wd-picker-section-title">{g.label}</h3>
<div className="wd-picker-grid">
{g.items.map(w => (
<button
key={w.type}
type="button"
className="wd-picker-item"
onClick={() => {
onSelect(w.type);
onClose();
}}
>
<span className="wd-picker-item-label">{w.label}</span>
<span className="wd-picker-item-desc">{w.description}</span>
</button>
))}
</div>
</section>
))}
</AppPopup>
);
};
export default WidgetPickerModal;
@@ -0,0 +1,18 @@
import React from 'react';
interface SidePanelProps {
tabs?: React.ReactNode;
children: React.ReactNode;
}
/** 좌·우 side zone — se-palette-panel 과 동일 카드 + 상단 탭 */
export function WidgetSidePanel({ tabs, children }: SidePanelProps) {
return (
<div className="wd-side-panel">
{tabs}
<div className="wd-side-panel-body">{children}</div>
</div>
);
}
export default WidgetSidePanel;
@@ -0,0 +1,98 @@
import React from 'react';
import type { WidgetSlot as WidgetSlotModel } from '../../types/widgetDashboard';
import type { WidgetZoneId } from '../../types/widgetDashboard';
import { getWidgetDefinition, widgetLabel } from '../../widgets/widgetRegistry';
interface Props {
slot: WidgetSlotModel;
zoneId: WidgetZoneId;
onPickWidget: (slotId: string) => void;
onClearWidget?: (slotId: string) => void;
}
const WidgetSlotView: React.FC<Props> = ({
slot,
zoneId,
onPickWidget,
onClearWidget,
}) => {
const def = getWidgetDefinition(slot.widgetType);
const category = zoneId === 'center' ? 'center' : 'side';
const Host = def?.Host;
const filledEmbed = Boolean(Host);
return (
<div
className={[
'wd-slot',
filledEmbed ? 'wd-slot--filled' : '',
filledEmbed && zoneId === 'center' ? 'wd-slot--filled-center' : '',
].filter(Boolean).join(' ')}
>
{!filledEmbed && (
<header className="wd-slot-head">
<span className="wd-slot-title">{widgetLabel(slot.widgetType)}</span>
<div className="wd-slot-actions">
{slot.widgetType && onClearWidget && (
<button
type="button"
className="wd-slot-action"
title="위젯 제거"
aria-label="위젯 제거"
onClick={() => onClearWidget(slot.id)}
>
</button>
)}
</div>
</header>
)}
<div className="wd-slot-body">
{Host ? (
<Host config={slot.config} />
) : (
<div className="wd-slot-empty">
<button
type="button"
className="wd-slot-add"
title="위젯 추가"
aria-label="위젯 추가"
onClick={() => onPickWidget(slot.id)}
>
<span className="wd-slot-add-icon">+</span>
</button>
<p className="wd-slot-empty-hint">
{category === 'center' ? '메인 위젯을 추가하세요' : '패널 위젯을 추가하세요'}
</p>
</div>
)}
{Host && (
<>
{filledEmbed && onClearWidget && (
<button
type="button"
className="wd-slot-overlay-clear"
title="위젯 제거"
aria-label="위젯 제거"
onClick={() => onClearWidget(slot.id)}
>
</button>
)}
<button
type="button"
className="wd-slot-change"
title="위젯 변경"
aria-label="위젯 변경"
onClick={() => onPickWidget(slot.id)}
>
+
</button>
</>
)}
</div>
</div>
);
};
export default WidgetSlotView;
@@ -0,0 +1,172 @@
import React, { useCallback, useMemo } from 'react';
import type {
WidgetSlot,
WidgetTabConfig,
WidgetZoneConfig,
WidgetZoneId,
} from '../../types/widgetDashboard';
import { newWidgetTab } from '../../types/widgetDashboard';
import WidgetSlotView from './WidgetSlotView';
interface Props {
zoneId: WidgetZoneId;
zone: WidgetZoneConfig;
onChange: (zone: WidgetZoneConfig) => void;
onPickWidget: (slotId: string, zoneId: WidgetZoneId, tabId?: string) => void;
/** 패널 헤더 탭 — Shell에서 TabBar를 렌더할 때 true */
hideTabBar?: boolean;
activeTabId?: string | null;
onActiveTabChange?: (tabId: string) => void;
}
type TabBarVariant = 'left' | 'right' | 'panel' | 'side';
interface TabBarProps {
tabs: WidgetTabConfig[];
activeTabId: string | null;
onSelect: (tabId: string) => void;
variant?: TabBarVariant;
}
/** 전략 편집기 se-right-tabs 스타일 (지표 | 템플릿) */
export const WidgetZoneTabBar: React.FC<TabBarProps> = ({
tabs,
activeTabId,
onSelect,
variant = 'left',
}) => {
const isSide = variant === 'left' || variant === 'right' || variant === 'side';
const wrapClass = isSide ? 'se-right-tabs wd-side-tabs' : 'se-right-tabs wd-panel-tabs';
return (
<div className={wrapClass} role="tablist">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTabId === tab.id}
className={`se-right-tab${activeTabId === tab.id ? ' se-right-tab--on' : ''}`}
onClick={() => onSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
};
export function resolveActiveTabId(
zone: WidgetZoneConfig | null | undefined,
preferredId: string | null | undefined,
): string | null {
if (!zone || zone.mode !== 'tabs') return null;
const tabs = zone.tabs ?? [];
if (tabs.length === 0) return null;
if (preferredId && tabs.some(t => t.id === preferredId)) return preferredId;
return tabs[0].id;
}
const WidgetZoneView: React.FC<Props> = ({
zoneId,
zone,
onChange,
onPickWidget,
hideTabBar = false,
activeTabId: activeTabIdProp,
onActiveTabChange,
}) => {
const activeTabId = useMemo(
() => resolveActiveTabId(zone, activeTabIdProp),
[zone, activeTabIdProp],
);
const updateSlot = useCallback((
slotId: string,
updater: (s: WidgetSlot) => WidgetSlot,
tabId?: string,
) => {
if (zone.mode === 'tabs' && tabId) {
onChange({
...zone,
tabs: (zone.tabs ?? []).map(tab =>
tab.id === tabId
? { ...tab, slots: tab.slots.map(s => (s.id === slotId ? updater(s) : s)) }
: tab,
),
});
return;
}
onChange({
...zone,
slots: (zone.slots ?? []).map(s => (s.id === slotId ? updater(s) : s)),
});
}, [zone, onChange]);
const clearWidget = useCallback((slotId: string, tabId?: string) => {
updateSlot(slotId, s => ({ ...s, widgetType: null, config: {} }), tabId);
}, [updateSlot]);
const renderSlots = (slots: WidgetSlot[], tabId?: string) => (
<div
className={`wd-zone-slots wd-zone-slots--${slots.length}`}
data-split={slots.length}
>
{slots.map(slot => (
<WidgetSlotView
key={slot.id}
slot={slot}
zoneId={zoneId}
onPickWidget={id => onPickWidget(id, zoneId, tabId)}
onClearWidget={id => clearWidget(id, tabId)}
/>
))}
</div>
);
if (zone.mode === 'tabs') {
const tabs = zone.tabs ?? [];
const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0];
return (
<div className="wd-zone wd-zone--tabs">
{!hideTabBar && (
<WidgetZoneTabBar
tabs={tabs}
activeTabId={activeTabId}
onSelect={id => onActiveTabChange?.(id)}
variant="side"
/>
)}
{activeTab && renderSlots(activeTab.slots, activeTab.id)}
</div>
);
}
const slots = zone.slots ?? [];
return (
<div className={`wd-zone wd-zone--${zone.mode}`}>
{renderSlots(slots)}
</div>
);
};
export default WidgetZoneView;
export function addTabToZone(zone: WidgetZoneConfig, label = '탭'): WidgetZoneConfig {
const tab = newWidgetTab(label, 1);
return {
...zone,
mode: 'tabs',
tabs: [...(zone.tabs ?? []), tab],
slots: undefined,
};
}
export function removeTabFromZone(zone: WidgetZoneConfig, tabId: string): WidgetZoneConfig {
const tabs = (zone.tabs ?? []).filter(t => t.id !== tabId);
if (tabs.length === 0) {
return { mode: 'single', splitCount: 1, slots: zone.slots ?? [] };
}
return { ...zone, tabs };
}