feat: 플로우 위젯 디자인 개선 및 검색 필터 기능 강화
- 플로우 위젯 단계 박스 미니멀 디자인 적용 - 테두리와 배경 제거, 하단 선만 표시 - STEP 배지 제거, 단계명과 건수 상하 배치 - 선택 인디케이터(ChevronUp) 제거 - 건수 폰트 굵기 조정 (font-medium) - 검색 필터 기능 개선 - 그리드 컬럼 수 확장 (최대 6개까지) - 상단 타이틀과 검색 필터 사이 여백 조정 - 검색 필터 설정 시 표시되는 컬럼만 선택 가능하도록 변경 - 필터 설정을 사용자별로 저장하도록 변경 - 이전 사용자의 필터 설정 자동 정리 로직 추가 - 기본 버튼 컴포넌트 스타일 변경 - 배경 흰색, 검정 테두리로 변경
This commit is contained in:
parent
148155e6fe
commit
a819ea6bfa
|
|
@ -38,6 +38,7 @@ import {
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface FlowWidgetProps {
|
||||
component: FlowComponent;
|
||||
|
|
@ -55,6 +56,7 @@ export function FlowWidget({
|
|||
onFlowRefresh,
|
||||
}: FlowWidgetProps) {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||
|
||||
// 🆕 전역 상태 관리
|
||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||
|
|
@ -117,30 +119,64 @@ export function FlowWidget({
|
|||
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
|
||||
const flowComponentId = component.id;
|
||||
|
||||
// 🆕 localStorage 키 생성
|
||||
// 🆕 localStorage 키 생성 (사용자별로 저장)
|
||||
const filterSettingKey = useMemo(() => {
|
||||
if (!flowId || selectedStepId === null) return null;
|
||||
return `flowWidget_searchFilters_${flowId}_${selectedStepId}`;
|
||||
}, [flowId, selectedStepId]);
|
||||
if (!flowId || selectedStepId === null || !user?.userId) return null;
|
||||
return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`;
|
||||
}, [flowId, selectedStepId, user?.userId]);
|
||||
|
||||
// 🆕 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!filterSettingKey || allAvailableColumns.length === 0) return;
|
||||
if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return;
|
||||
|
||||
try {
|
||||
// 현재 사용자의 필터 설정만 불러오기
|
||||
const saved = localStorage.getItem(filterSettingKey);
|
||||
if (saved) {
|
||||
const savedFilters = JSON.parse(saved);
|
||||
setSearchFilterColumns(new Set(savedFilters));
|
||||
// 현재 단계에 표시되는 컬럼만 필터링
|
||||
const validFilters = savedFilters.filter((col: string) => stepDataColumns.includes(col));
|
||||
setSearchFilterColumns(new Set(validFilters));
|
||||
} else {
|
||||
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
|
||||
// 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUserId = user.userId;
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// localStorage의 모든 키를 확인
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("flowWidget_searchFilters_")) {
|
||||
// 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId}
|
||||
// split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"]
|
||||
// 따라서 userId는 parts[2]입니다
|
||||
const parts = key.split("_");
|
||||
if (parts.length >= 3) {
|
||||
const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId
|
||||
// 현재 사용자 ID와 다른 사용자의 설정은 제거
|
||||
if (userIdFromKey !== currentUserId) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 이전 사용자의 설정 제거
|
||||
if (keysToRemove.length > 0) {
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필터 설정 불러오기 실패:", error);
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
}, [filterSettingKey, allAvailableColumns]);
|
||||
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
||||
|
||||
// 🆕 필터 설정 저장
|
||||
const saveFilterSettings = useCallback(() => {
|
||||
|
|
@ -174,14 +210,14 @@ export function FlowWidget({
|
|||
|
||||
// 🆕 전체 선택/해제
|
||||
const toggleAllFilters = useCallback(() => {
|
||||
if (searchFilterColumns.size === allAvailableColumns.length) {
|
||||
if (searchFilterColumns.size === stepDataColumns.length) {
|
||||
// 전체 해제
|
||||
setSearchFilterColumns(new Set());
|
||||
} else {
|
||||
// 전체 선택
|
||||
setSearchFilterColumns(new Set(allAvailableColumns));
|
||||
setSearchFilterColumns(new Set(stepDataColumns));
|
||||
}
|
||||
}, [searchFilterColumns, allAvailableColumns]);
|
||||
}, [searchFilterColumns, stepDataColumns]);
|
||||
|
||||
// 🆕 검색 초기화
|
||||
const handleClearSearch = useCallback(() => {
|
||||
|
|
@ -638,59 +674,76 @@ export function FlowWidget({
|
|||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
<div
|
||||
className={`group bg-card relative w-full cursor-pointer rounded-lg border-2 p-4 shadow-sm transition-all duration-200 sm:w-auto sm:min-w-[180px] sm:rounded-xl sm:p-5 lg:min-w-[220px] lg:p-6 ${
|
||||
selectedStepId === step.id
|
||||
? "border-primary bg-primary/5 shadow-md"
|
||||
: "border-border hover:border-primary/50 hover:shadow-md"
|
||||
}`}
|
||||
className="group relative w-full cursor-pointer pb-4 transition-all duration-300 sm:w-auto sm:min-w-[200px] lg:min-w-[240px]"
|
||||
onClick={() => handleStepClick(step.id, step.stepName)}
|
||||
>
|
||||
{/* 단계 번호 배지 */}
|
||||
<div className="bg-primary/10 text-primary mb-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium sm:mb-3 sm:px-3">
|
||||
Step {step.stepOrder}
|
||||
</div>
|
||||
{/* 콘텐츠 */}
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 pb-5 sm:gap-2.5 sm:pb-6">
|
||||
{/* 스텝 이름 */}
|
||||
<h4
|
||||
className={`text-base font-semibold leading-tight transition-colors duration-300 sm:text-lg lg:text-xl ${
|
||||
selectedStepId === step.id ? "text-primary" : "text-foreground group-hover:text-primary/80"
|
||||
}`}
|
||||
>
|
||||
{step.stepName}
|
||||
</h4>
|
||||
|
||||
{/* 스텝 이름 */}
|
||||
<h4 className="text-foreground mb-2 pr-8 text-base leading-tight font-semibold sm:text-lg">
|
||||
{step.stepName}
|
||||
</h4>
|
||||
|
||||
{/* 데이터 건수 */}
|
||||
{showStepCount && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-2 text-xs sm:mt-3 sm:text-sm">
|
||||
<div className="bg-muted flex h-7 items-center rounded-md px-2 sm:h-8 sm:px-3">
|
||||
<span className="text-foreground text-sm font-semibold sm:text-base">
|
||||
{/* 데이터 건수 */}
|
||||
{showStepCount && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 transition-all duration-300 ${
|
||||
selectedStepId === step.id
|
||||
? "text-primary"
|
||||
: "text-muted-foreground group-hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium sm:text-base">
|
||||
{stepCounts[step.id] || 0}
|
||||
</span>
|
||||
<span className="ml-1">건</span>
|
||||
<span className="text-xs font-normal sm:text-sm">건</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택 인디케이터 */}
|
||||
{selectedStepId === step.id && (
|
||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4">
|
||||
<ChevronUp className="text-primary h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
)}
|
||||
{/* 하단 선 */}
|
||||
<div
|
||||
className={`h-0.5 transition-all duration-300 ${
|
||||
selectedStepId === step.id
|
||||
? "bg-primary"
|
||||
: "bg-border group-hover:bg-primary/50"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 화살표 (마지막 스텝 제외) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="text-muted-foreground/40 flex shrink-0 items-center justify-center py-2 sm:py-0">
|
||||
<div className="flex shrink-0 items-center justify-center py-2 sm:py-0">
|
||||
{displayMode === "horizontal" ? (
|
||||
<svg
|
||||
className="h-5 w-5 rotate-90 sm:h-6 sm:w-6 sm:rotate-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-0.5 w-6 bg-border sm:w-8" />
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="h-0.5 w-6 bg-border sm:w-8" />
|
||||
</div>
|
||||
) : (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="h-6 w-0.5 bg-border sm:h-8" />
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<div className="h-6 w-0.5 bg-border sm:h-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -720,7 +773,7 @@ export function FlowWidget({
|
|||
</div>
|
||||
|
||||
{/* 🆕 필터 설정 버튼 */}
|
||||
{allAvailableColumns.length > 0 && (
|
||||
{stepDataColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -746,7 +799,7 @@ export function FlowWidget({
|
|||
|
||||
{/* 🆕 검색 필터 입력 영역 */}
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<div className="mt-4 space-y-3 p-4">
|
||||
<div className="mt-2 space-y-3 p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{Object.keys(searchValues).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-7 text-xs">
|
||||
|
|
@ -756,7 +809,7 @@ export function FlowWidget({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{Array.from(searchFilterColumns).map((col) => (
|
||||
<div key={col} className="space-y-1.5">
|
||||
<Label htmlFor={`search-${col}`} className="text-xs">
|
||||
|
|
@ -1043,20 +1096,20 @@ export function FlowWidget({
|
|||
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
||||
<Checkbox
|
||||
id="select-all-filters"
|
||||
checked={searchFilterColumns.size === allAvailableColumns.length && allAvailableColumns.length > 0}
|
||||
checked={searchFilterColumns.size === stepDataColumns.length && stepDataColumns.length > 0}
|
||||
onCheckedChange={toggleAllFilters}
|
||||
/>
|
||||
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
||||
전체 선택/해제
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{searchFilterColumns.size} / {allAvailableColumns.length}개
|
||||
{searchFilterColumns.size} / {stepDataColumns.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{allAvailableColumns.map((col) => (
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`filter-${col}`}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
|
|
|
|||
Loading…
Reference in New Issue