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

View File

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

View File

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