feat: 플로우 위젯 디자인 개선 및 검색 필터 기능 강화

- 플로우 위젯 단계 박스 미니멀 디자인 적용
  - 테두리와 배경 제거, 하단 선만 표시
  - STEP 배지 제거, 단계명과 건수 상하 배치
  - 선택 인디케이터(ChevronUp) 제거
  - 건수 폰트 굵기 조정 (font-medium)

- 검색 필터 기능 개선
  - 그리드 컬럼 수 확장 (최대 6개까지)
  - 상단 타이틀과 검색 필터 사이 여백 조정
  - 검색 필터 설정 시 표시되는 컬럼만 선택 가능하도록 변경
  - 필터 설정을 사용자별로 저장하도록 변경
  - 이전 사용자의 필터 설정 자동 정리 로직 추가

- 기본 버튼 컴포넌트 스타일 변경
  - 배경 흰색, 검정 테두리로 변경
This commit is contained in:
kjs 2025-10-30 18:30:39 +09:00
parent 148155e6fe
commit a819ea6bfa
2 changed files with 110 additions and 57 deletions

View File

@ -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}`}

View File

@ -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: