feat(pop): 설정 패널 아코디언 접기/펼치기 일관성 + sessionStorage 상태 기억

설정 패널을 열 때 섹션이 일부는 펼쳐져 있고 일부는 접혀 있어
일관성이 없던 UX를 개선하고, 사용자가 펼친 섹션을 탭 세션 내에서 기억한다.
- useCollapsibleSections 커스텀 훅 생성 (sessionStorage 기반, 초기 모두 접힘)
- PopCardListConfig: CollapsibleSection에 sectionKey/sections prop 패턴 적용
- PopFieldConfig: SaveTabContent 5개 고정 섹션 훅 적용,
  SectionEditor 초기값 접힘으로 변경
- PopDashboardConfig: PageEditor 초기값 접힘으로 변경
This commit is contained in:
SeongHyun Kim 2026-03-05 18:54:29 +09:00
parent 7a9a705f19
commit 12a8290873
5 changed files with 125 additions and 46 deletions

View File

@ -26,5 +26,8 @@ export { useConnectionResolver } from "./useConnectionResolver";
export { useCartSync } from "./useCartSync"; export { useCartSync } from "./useCartSync";
export type { UseCartSyncReturn } from "./useCartSync"; export type { UseCartSyncReturn } from "./useCartSync";
// 설정 패널 접기/펼치기 상태 관리
export { useCollapsibleSections } from "./useCollapsibleSections";
// SQL 빌더 유틸 (고급 사용 시) // SQL 빌더 유틸 (고급 사용 시)
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";

View File

@ -0,0 +1,58 @@
import { useState, useCallback, useRef } from "react";
/**
* / sessionStorage로
*
* - 상태: 모든
* -
* -
*
* @param storageKey sessionStorage (: "pop-card-list")
*/
export function useCollapsibleSections(storageKey: string) {
const fullKey = `pop-config-sections-${storageKey}`;
const [openSections, setOpenSections] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set<string>();
try {
const saved = sessionStorage.getItem(fullKey);
if (saved) return new Set<string>(JSON.parse(saved));
} catch {}
return new Set<string>();
});
const openSectionsRef = useRef(openSections);
openSectionsRef.current = openSections;
const persist = useCallback(
(next: Set<string>) => {
try {
sessionStorage.setItem(fullKey, JSON.stringify([...next]));
} catch {}
},
[fullKey],
);
const isOpen = useCallback(
(key: string) => openSectionsRef.current.has(key),
[],
);
const toggle = useCallback(
(key: string) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
persist(next);
return next;
});
},
[persist],
);
return { isOpen, toggle };
}

View File

@ -10,6 +10,7 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -135,6 +136,7 @@ const COLOR_OPTIONS = [
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
const sections = useCollapsibleSections("pop-card-list");
const cfg: PopCardListConfig = config || DEFAULT_CONFIG; const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
@ -184,6 +186,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
onUpdate={updateConfig} onUpdate={updateConfig}
currentMode={currentMode} currentMode={currentMode}
currentColSpan={currentColSpan} currentColSpan={currentColSpan}
sections={sections}
/> />
)} )}
{activeTab === "template" && ( {activeTab === "template" && (
@ -195,7 +198,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
</div> </div>
</div> </div>
) : ( ) : (
<CardTemplateTab config={cfg} onUpdate={updateConfig} /> <CardTemplateTab config={cfg} onUpdate={updateConfig} sections={sections} />
) )
)} )}
</div> </div>
@ -205,16 +208,20 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== // ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
type SectionsApi = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
function BasicSettingsTab({ function BasicSettingsTab({
config, config,
onUpdate, onUpdate,
currentMode, currentMode,
currentColSpan, currentColSpan,
sections,
}: { }: {
config: PopCardListConfig; config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void; onUpdate: (partial: Partial<PopCardListConfig>) => void;
currentMode?: GridMode; currentMode?: GridMode;
currentColSpan?: number; currentColSpan?: number;
sections: SectionsApi;
}) { }) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
@ -321,7 +328,7 @@ function BasicSettingsTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 장바구니 목록 모드 */} {/* 장바구니 목록 모드 */}
<CollapsibleSection title="장바구니 목록 모드" defaultOpen={isCartListMode}> <CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection <CartListModeSection
cartListMode={config.cartListMode} cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })} onUpdate={(cartListMode) => onUpdate({ cartListMode })}
@ -330,7 +337,7 @@ function BasicSettingsTab({
{/* 테이블 선택 (장바구니 모드 시 숨김) */} {/* 테이블 선택 (장바구니 모드 시 숨김) */}
{!isCartListMode && ( {!isCartListMode && (
<CollapsibleSection title="테이블 선택" defaultOpen> <CollapsibleSection sectionKey="basic-table" title="테이블 선택" sections={sections}>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label className="text-[10px] text-muted-foreground"> </Label> <Label className="text-[10px] text-muted-foreground"> </Label>
@ -365,7 +372,9 @@ function BasicSettingsTab({
{/* 조인 설정 (장바구니 모드 시 숨김) */} {/* 조인 설정 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-join"
title="조인 설정" title="조인 설정"
sections={sections}
badge={ badge={
dataSource.joins && dataSource.joins.length > 0 dataSource.joins && dataSource.joins.length > 0
? `${dataSource.joins.length}` ? `${dataSource.joins.length}`
@ -383,7 +392,9 @@ function BasicSettingsTab({
{/* 정렬 기준 (장바구니 모드 시 숨김) */} {/* 정렬 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-sort"
title="정렬 기준" title="정렬 기준"
sections={sections}
badge={ badge={
dataSource.sort dataSource.sort
? Array.isArray(dataSource.sort) ? Array.isArray(dataSource.sort)
@ -403,7 +414,9 @@ function BasicSettingsTab({
{/* 필터 기준 (장바구니 모드 시 숨김) */} {/* 필터 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && ( {!isCartListMode && dataSource.tableName && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-filter"
title="필터 기준" title="필터 기준"
sections={sections}
badge={ badge={
dataSource.filters && dataSource.filters.length > 0 dataSource.filters && dataSource.filters.length > 0
? `${dataSource.filters.length}` ? `${dataSource.filters.length}`
@ -421,7 +434,9 @@ function BasicSettingsTab({
{/* 저장 매핑 (장바구니 모드일 때만) */} {/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && ( {isCartListMode && (
<CollapsibleSection <CollapsibleSection
sectionKey="basic-save-mapping"
title="저장 매핑" title="저장 매핑"
sections={sections}
badge={ badge={
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0 config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
? `${config.saveMapping.mappings.length}` ? `${config.saveMapping.mappings.length}`
@ -437,7 +452,7 @@ function BasicSettingsTab({
)} )}
{/* 레이아웃 설정 */} {/* 레이아웃 설정 */}
<CollapsibleSection title="레이아웃 설정" defaultOpen> <CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
<div className="space-y-3"> <div className="space-y-3">
{modeLabel && ( {modeLabel && (
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5"> <div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
@ -526,9 +541,11 @@ function BasicSettingsTab({
function CardTemplateTab({ function CardTemplateTab({
config, config,
onUpdate, onUpdate,
sections,
}: { }: {
config: PopCardListConfig; config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void; onUpdate: (partial: Partial<PopCardListConfig>) => void;
sections: SectionsApi;
}) { }) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const template = config.cardTemplate || DEFAULT_TEMPLATE; const template = config.cardTemplate || DEFAULT_TEMPLATE;
@ -634,7 +651,7 @@ function CardTemplateTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 헤더 설정 */} {/* 헤더 설정 */}
<CollapsibleSection title="헤더 설정" defaultOpen> <CollapsibleSection sectionKey="tpl-header" title="헤더 설정" sections={sections}>
<HeaderSettingsSection <HeaderSettingsSection
header={template.header || DEFAULT_HEADER} header={template.header || DEFAULT_HEADER}
columnGroups={columnGroups} columnGroups={columnGroups}
@ -643,7 +660,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 이미지 설정 */} {/* 이미지 설정 */}
<CollapsibleSection title="이미지 설정" defaultOpen> <CollapsibleSection sectionKey="tpl-image" title="이미지 설정" sections={sections}>
<ImageSettingsSection <ImageSettingsSection
image={template.image || DEFAULT_IMAGE} image={template.image || DEFAULT_IMAGE}
columnGroups={columnGroups} columnGroups={columnGroups}
@ -653,9 +670,10 @@ function CardTemplateTab({
{/* 본문 필드 */} {/* 본문 필드 */}
<CollapsibleSection <CollapsibleSection
sectionKey="tpl-body"
title="본문 필드" title="본문 필드"
sections={sections}
badge={`${template.body?.fields?.length || 0}`} badge={`${template.body?.fields?.length || 0}`}
defaultOpen
> >
<BodyFieldsSection <BodyFieldsSection
body={template.body || DEFAULT_BODY} body={template.body || DEFAULT_BODY}
@ -665,7 +683,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 입력 필드 설정 */} {/* 입력 필드 설정 */}
<CollapsibleSection title="입력 필드" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-input" title="입력 필드" sections={sections}>
<InputFieldSettingsSection <InputFieldSettingsSection
inputField={config.inputField} inputField={config.inputField}
columns={columns} columns={columns}
@ -675,7 +693,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 포장등록 설정 */} {/* 포장등록 설정 */}
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-package" title="포장등록 (계산기)" sections={sections}>
<PackageSettingsSection <PackageSettingsSection
packageConfig={config.packageConfig} packageConfig={config.packageConfig}
onUpdate={(packageConfig) => onUpdate({ packageConfig })} onUpdate={(packageConfig) => onUpdate({ packageConfig })}
@ -683,7 +701,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 담기 버튼 설정 */} {/* 담기 버튼 설정 */}
<CollapsibleSection title="담기 버튼" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-cart" title="담기 버튼" sections={sections}>
<CartActionSettingsSection <CartActionSettingsSection
cartAction={config.cartAction} cartAction={config.cartAction}
onUpdate={(cartAction) => onUpdate({ cartAction })} onUpdate={(cartAction) => onUpdate({ cartAction })}
@ -693,7 +711,7 @@ function CardTemplateTab({
</CollapsibleSection> </CollapsibleSection>
{/* 반응형 표시 설정 */} {/* 반응형 표시 설정 */}
<CollapsibleSection title="반응형 표시" defaultOpen={false}> <CollapsibleSection sectionKey="tpl-responsive" title="반응형 표시" sections={sections}>
<ResponsiveDisplaySection <ResponsiveDisplaySection
config={config} config={config}
onUpdate={onUpdate} onUpdate={onUpdate}
@ -769,24 +787,26 @@ function GroupedColumnSelect({
// ===== 접기/펴기 섹션 컴포넌트 ===== // ===== 접기/펴기 섹션 컴포넌트 =====
function CollapsibleSection({ function CollapsibleSection({
sectionKey,
title, title,
badge, badge,
defaultOpen = false, sections,
children, children,
}: { }: {
sectionKey: string;
title: string; title: string;
badge?: string; badge?: string;
defaultOpen?: boolean; sections: SectionsApi;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const open = sections.isOpen(sectionKey);
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<button <button
type="button" type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50" className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen(!open)} onClick={() => sections.toggle(sectionKey)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{open ? ( {open ? (

View File

@ -1937,7 +1937,7 @@ function PageEditor({
isPreviewing?: boolean; isPreviewing?: boolean;
onUpdateItem?: (updatedItem: DashboardItem) => void; onUpdateItem?: (updatedItem: DashboardItem) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(false);
return ( return (
<div className="rounded-md border p-2"> <div className="rounded-md border p-2">

View File

@ -9,6 +9,7 @@
*/ */
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@ -648,10 +649,7 @@ function SaveTabContent({
const noFields = allFields.length === 0; const noFields = allFields.length === 0;
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}); const sections = useCollapsibleSections("pop-field");
const toggleSection = useCallback((key: string) => {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -666,16 +664,16 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("table")} onClick={() => sections.toggle("table")}
> >
{collapsed["table"] ? ( {sections.isOpen("table") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
</div> </div>
{!collapsed["table"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("table") && <div className="space-y-3 border-t p-3">
{/* 읽기 테이블 (display 섹션이 있을 때만) */} {/* 읽기 테이블 (display 섹션이 있을 때만) */}
{hasDisplayFields && ( {hasDisplayFields && (
<> <>
@ -839,19 +837,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("read")} onClick={() => sections.toggle("read")}
> >
{collapsed["read"] ? ( {sections.isOpen("read") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["read"] && <div className="space-y-2 border-t p-3"> {sections.isOpen("read") && <div className="space-y-2 border-t p-3">
{readColumns.length === 0 ? ( {readColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground"> <p className="py-2 text-xs text-muted-foreground">
... ...
@ -988,19 +986,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("input")} onClick={() => sections.toggle("input")}
> >
{collapsed["input"] ? ( {sections.isOpen("input") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["input"] && <div className="space-y-2 border-t p-3"> {sections.isOpen("input") && <div className="space-y-2 border-t p-3">
{saveColumns.length === 0 ? ( {saveColumns.length === 0 ? (
<p className="py-2 text-xs text-muted-foreground"> <p className="py-2 text-xs text-muted-foreground">
... ...
@ -1050,19 +1048,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("hidden")} onClick={() => sections.toggle("hidden")}
> >
{collapsed["hidden"] ? ( {sections.isOpen("hidden") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
(UI , ) (UI , )
</span> </span>
</div> </div>
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("hidden") && <div className="space-y-3 border-t p-3">
{hiddenMappings.map((m) => { {hiddenMappings.map((m) => {
const isJson = m.valueSource === "json_extract"; const isJson = m.valueSource === "json_extract";
const isStatic = m.valueSource === "static"; const isStatic = m.valueSource === "static";
@ -1215,19 +1213,19 @@ function SaveTabContent({
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<div <div
className="flex cursor-pointer items-center gap-1.5 px-3 py-2" className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
onClick={() => toggleSection("autogen")} onClick={() => sections.toggle("autogen")}
> >
{collapsed["autogen"] ? ( {sections.isOpen("autogen") ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" /> <ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)} )}
<span className="text-xs font-medium"> </span> <span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
( ) ( )
</span> </span>
</div> </div>
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3"> {sections.isOpen("autogen") && <div className="space-y-3 border-t p-3">
{autoGenMappings.map((m) => { {autoGenMappings.map((m) => {
const isLinked = !!m.linkedFieldId; const isLinked = !!m.linkedFieldId;
return ( return (
@ -1396,7 +1394,7 @@ function SectionEditor({
onMoveUp, onMoveUp,
allSections, allSections,
}: SectionEditorProps) { }: SectionEditorProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(true);
const resolvedStyle = migrateStyle(section.style); const resolvedStyle = migrateStyle(section.style);
const sectionFields = section.fields || []; const sectionFields = section.fields || [];