diff --git a/react/src/components/common/FilterDropdown.tsx b/react/src/components/common/FilterDropdown.tsx index 73ea4ff..b4a35e2 100644 --- a/react/src/components/common/FilterDropdown.tsx +++ b/react/src/components/common/FilterDropdown.tsx @@ -43,9 +43,9 @@ export default function FilterDropdown({ type="button" onClick={() => !disabled && setOpen((v) => !v)} disabled={disabled} - className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300" + className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-center flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300" > - {value} + {value} expand_more @@ -59,7 +59,7 @@ export default function FilterDropdown({ onChange(opt); setOpen(false); }} - className={`px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${ + className={`px-3 py-2 text-sm text-center hover:bg-gray-50 cursor-pointer transition-colors ${ opt === value ? "text-primary font-medium" : "" }`} > diff --git a/react/src/components/common/PlatformBadge.tsx b/react/src/components/common/PlatformBadge.tsx index 464ac56..0606264 100644 --- a/react/src/components/common/PlatformBadge.tsx +++ b/react/src/components/common/PlatformBadge.tsx @@ -6,24 +6,17 @@ interface PlatformBadgeProps { export default function PlatformBadge({ platform }: PlatformBadgeProps) { if (platform === "ios") { return ( - - - + + + - iOS ); } return ( - - - android - - Android + + android ); } diff --git a/react/src/features/device/components/.gitkeep b/react/src/features/device/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/react/src/features/device/components/DeviceSlidePanel.tsx b/react/src/features/device/components/DeviceSlidePanel.tsx new file mode 100644 index 0000000..9fcd45b --- /dev/null +++ b/react/src/features/device/components/DeviceSlidePanel.tsx @@ -0,0 +1,350 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import CopyButton from "@/components/common/CopyButton"; +import type { DeviceSummary } from "../types"; + +interface DeviceSlidePanelProps { + isOpen: boolean; + onClose: () => void; + device: DeviceSummary | null; +} + +export default function DeviceSlidePanel({ + isOpen, + onClose, + device, +}: DeviceSlidePanelProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const bodyRef = useRef(null); + + // 패널 열릴 때 스크롤 최상단 리셋 + useEffect(() => { + if (isOpen) { + bodyRef.current?.scrollTo(0, 0); + } + }, [isOpen, device]); + + // ESC 닫기 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // body 스크롤 잠금 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + // 플랫폼 아이콘 렌더링 + const renderPlatformIcon = () => { + if (!device) return null; + if (device.platform === "iOS") { + return ( +
+ + + +
+ ); + } + return ( +
+ + android + +
+ ); + }; + + // 수신 동의 박스 + const ConsentBox = ({ + label, + consented, + }: { + label: string; + consented: boolean; + }) => ( +
+
+ + {consented ? "check_circle" : "cancel"} + + {label} +
+ + {consented ? "동의" : "미동의"} + +
+ ); + + return ( + <> + {/* 오버레이 */} +
+ + {/* 패널 */} + + + {/* 삭제 확인 모달 */} + {showDeleteConfirm && device && ( +
+
setShowDeleteConfirm(false)} + /> +
+
+
+ + warning + +
+

기기 삭제

+
+

+ 선택한 기기를 삭제하시겠습니까? +

+
+
+ + info + + 삭제된 기기는 복구할 수 없습니다. +
+
+ + info + + 해당 기기로의 푸시 발송이 즉시 중단됩니다. +
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/react/src/features/device/components/SecretToggleCell.tsx b/react/src/features/device/components/SecretToggleCell.tsx new file mode 100644 index 0000000..a7ce7d6 --- /dev/null +++ b/react/src/features/device/components/SecretToggleCell.tsx @@ -0,0 +1,69 @@ +import { useRef, useEffect } from "react"; +import CopyButton from "@/components/common/CopyButton"; + +interface SecretToggleCellProps { + label: string; + value: string; + dropdownKey: string; + openKey: string | null; + onToggle: (key: string | null) => void; +} + +/** Device ID / Push Token 토글 팝오버 셀 */ +export default function SecretToggleCell({ + label, + value, + dropdownKey, + openKey, + onToggle, +}: SecretToggleCellProps) { + const containerRef = useRef(null); + const isOpen = openKey === dropdownKey; + + // 외부 클릭 시 닫힘 + useEffect(() => { + if (!isOpen) return; + const handleClick = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onToggle(null); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [isOpen, onToggle]); + + return ( +
e.stopPropagation()} + > + + + {isOpen && ( +
+
+ + {value} + + +
+
+ )} +
+ ); +} diff --git a/react/src/features/device/pages/DeviceListPage.tsx b/react/src/features/device/pages/DeviceListPage.tsx index bb60a18..b2884d7 100644 --- a/react/src/features/device/pages/DeviceListPage.tsx +++ b/react/src/features/device/pages/DeviceListPage.tsx @@ -1,7 +1,368 @@ +import { useState, useMemo } from "react"; +import PageHeader from "@/components/common/PageHeader"; +import SearchInput from "@/components/common/SearchInput"; +import FilterDropdown from "@/components/common/FilterDropdown"; +import FilterResetButton from "@/components/common/FilterResetButton"; +import Pagination from "@/components/common/Pagination"; +import EmptyState from "@/components/common/EmptyState"; +import PlatformBadge from "@/components/common/PlatformBadge"; +import SecretToggleCell from "../components/SecretToggleCell"; +import DeviceSlidePanel from "../components/DeviceSlidePanel"; +import { formatDate } from "@/utils/format"; +import { + MOCK_DEVICES, + SERVICE_FILTER_OPTIONS, + PLATFORM_FILTER_OPTIONS, + PUSH_CONSENT_FILTER_OPTIONS, +} from "../types"; +import type { DeviceSummary } from "../types"; + +const PAGE_SIZE = 10; + +// 테이블 컬럼 헤더 +const COLUMNS = [ + "소속 서비스", + "플랫폼", + "Device ID", + "Push Token", + "푸시 수신", + "광고 수신", + "태그", + "등록일", +]; + export default function DeviceListPage() { + // 필터 입력 상태 + const [search, setSearch] = useState(""); + const [serviceFilter, setServiceFilter] = useState("전체 서비스"); + const [platformFilter, setPlatformFilter] = useState("전체"); + const [pushFilter, setPushFilter] = useState("전체"); + const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(false); + + // 적용된 필터 + const [appliedSearch, setAppliedSearch] = useState(""); + const [appliedService, setAppliedService] = useState("전체 서비스"); + const [appliedPlatform, setAppliedPlatform] = useState("전체"); + const [appliedPush, setAppliedPush] = useState("전체"); + + // 슬라이드 패널 + const [panelOpen, setPanelOpen] = useState(false); + const [selectedDevice, setSelectedDevice] = useState( + null + ); + + // SecretToggleCell 배타적 관리 + const [openDropdownKey, setOpenDropdownKey] = useState(null); + + // 조회 + const handleQuery = () => { + setLoading(true); + setTimeout(() => { + setAppliedSearch(search); + setAppliedService(serviceFilter); + setAppliedPlatform(platformFilter); + setAppliedPush(pushFilter); + setCurrentPage(1); + setLoading(false); + }, 400); + }; + + // 필터 초기화 + const handleReset = () => { + setSearch(""); + setServiceFilter("전체 서비스"); + setPlatformFilter("전체"); + setPushFilter("전체"); + setAppliedSearch(""); + setAppliedService("전체 서비스"); + setAppliedPlatform("전체"); + setAppliedPush("전체"); + setCurrentPage(1); + }; + + // 필터링 + const filtered = useMemo(() => { + return MOCK_DEVICES.filter((d) => { + // 검색 (Device ID / Push Token) + if (appliedSearch) { + const q = appliedSearch.toLowerCase(); + if ( + !d.id.toLowerCase().includes(q) && + !d.token.toLowerCase().includes(q) + ) + return false; + } + // 서비스 + if (appliedService !== "전체 서비스" && d.service !== appliedService) + return false; + // 플랫폼 + if (appliedPlatform !== "전체" && d.platform !== appliedPlatform) + return false; + // 푸시 수신 + if (appliedPush !== "전체") { + if (appliedPush === "동의" && !d.push) return false; + if (appliedPush === "미동의" && d.push) return false; + } + return true; + }); + }, [appliedSearch, appliedService, appliedPlatform, appliedPush]); + + // 페이지네이션 + const totalItems = filtered.length; + const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE)); + const paged = filtered.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE + ); + + // 행 클릭 + const handleRowClick = (device: DeviceSummary) => { + setOpenDropdownKey(null); + setSelectedDevice(device); + setPanelOpen(true); + }; + + // 체크/취소 아이콘 + const StatusIcon = ({ active }: { active: boolean }) => ( + + {active ? "check_circle" : "cancel"} + + ); + + // 태그 아이콘 (있으면 체크, 없으면 dash) + const TagIcon = ({ hasTags }: { hasTags: boolean }) => + hasTags ? ( + + check_circle + + ) : ( + + cancel + + ); + + // 테이블 헤더 렌더링 + const renderTableHead = () => ( + + + {COLUMNS.map((col) => ( + + {col} + + ))} + + + ); + return ( -
-

디바이스 목록

+
+ + download + 엑셀 다운로드 + + } + /> + + {/* 필터바 */} +
+
+ + + + + + +
+
+ + {/* 테이블 */} + {loading ? ( + /* 로딩 스켈레톤 */ +
+ + {renderTableHead()} + + {Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : paged.length > 0 ? ( +
+
+ + {renderTableHead()} + + {paged.map((device, idx) => ( + handleRowClick(device)} + > + {/* 소속 서비스 */} + + {/* 플랫폼 */} + + {/* Device ID */} + + {/* Push Token */} + + {/* 푸시 수신 */} + + {/* 광고 수신 */} + + {/* 태그 */} + + {/* 등록일 */} + + + ))} + +
+ + {device.service} + + + + + + + + + + + + + 0} /> + + + {formatDate(device.createdAt)} + +
+
+ + {/* 페이지네이션 */} + +
+ ) : ( + + )} + + {/* 슬라이드 패널 */} + setPanelOpen(false)} + device={selectedDevice} + />
); } diff --git a/react/src/features/device/types.ts b/react/src/features/device/types.ts index 311610f..cbdc7b0 100644 --- a/react/src/features/device/types.ts +++ b/react/src/features/device/types.ts @@ -1 +1,184 @@ -// Device feature 타입 정의 +// 플랫폼 타입 +export const PLATFORM = { IOS: "iOS", ANDROID: "Android" } as const; +export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM]; + +// 필터 옵션 상수 +export const SERVICE_FILTER_OPTIONS = [ + "전체 서비스", + "쇼핑몰 앱", + "파트너 센터", + "물류 시스템", +]; +export const PLATFORM_FILTER_OPTIONS = ["전체", "iOS", "Android"]; +export const PUSH_CONSENT_FILTER_OPTIONS = ["전체", "동의", "미동의"]; + +// 기기 데이터 +export interface DeviceSummary { + id: string; + platform: Platform; + deviceModel: string; + osVersion: string; + service: string; + serviceCode: string; + token: string; + push: boolean; + ad: boolean; + tags: string[]; + createdAt: string; + lastActiveAt: string; + appVersion: string; +} + +// 목 데이터 (HTML 시안 기반 10건) +export const MOCK_DEVICES: DeviceSummary[] = [ + { + id: "a3f1e8b0-7c2d-4a5e-9b1f-6d8c3e2a4f70", + platform: "iOS", + deviceModel: "iPhone 15 Pro", + osVersion: "iOS 17.2", + service: "쇼핑몰 앱", + serviceCode: "SVC_MALL", + token: "f83j20dk4829sla92kasdLp3mN7qR1xW5vB8yT0uZ6cH2fJ4gK9eA", + push: true, + ad: true, + tags: ["VIP", "프리미엄"], + createdAt: "2023-11-01 14:20", + lastActiveAt: "2024-01-15 09:30", + appVersion: "3.2.1", + }, + { + id: "b7d2c9e1-3f4a-5b6c-8d7e-9f0a1b2c3d4e", + platform: "Android", + deviceModel: "Galaxy S24 Ultra", + osVersion: "Android 14", + service: "파트너 센터", + serviceCode: "SVC_PARTNER", + token: "a1b2c3d4x8y9z0e5f6g7Hj2kL4mN6pQ8rS0tU3vW5xY7zA9bC1dE", + push: true, + ad: false, + tags: ["이벤트", "신규"], + createdAt: "2023-11-01 13:45", + lastActiveAt: "2024-01-14 18:20", + appVersion: "3.1.8", + }, + { + id: "c5e3a1b9-8d7f-4c2e-a6b0-1d9e8f7a5c3b", + platform: "iOS", + deviceModel: "iPhone 14", + osVersion: "iOS 16.5", + service: "쇼핑몰 앱", + serviceCode: "SVC_MALL", + token: "x9y8z7w6a1b2v5u4t3Qp7rS9kL2mN4jH6fD8gB0cE1aW3xY5zV", + push: false, + ad: false, + tags: [], + createdAt: "2023-10-31 09:12", + lastActiveAt: "2023-12-20 11:05", + appVersion: "2.9.4", + }, + { + id: "d9f4b2a7-1e6c-4d8f-b3a5-7c0e9d2f1a8b", + platform: "Android", + deviceModel: "Galaxy Z Flip5", + osVersion: "Android 13", + service: "물류 시스템", + serviceCode: "SVC_LOGISTICS", + token: "k1l2m3n4r8s9o5p6q7Xa3bC5dE7fG9hJ1kL3mN5pQ7rS9tU1vW3x", + push: true, + ad: true, + tags: ["임직원"], + createdAt: "2023-10-30 18:30", + lastActiveAt: "2024-01-10 07:45", + appVersion: "3.0.2", + }, + { + id: "e2a8c6d4-5b3f-4e1a-9c7d-0f8b2e6a4d1c", + platform: "iOS", + deviceModel: "iPad Pro 12.9", + osVersion: "iPadOS 17.1", + service: "파트너 센터", + serviceCode: "SVC_PARTNER", + token: "r8s9t0u1y5z6v2w3x4Kb7cD9eF1gH3jK5lM7nP9qR1sT3uV5wX7z", + push: true, + ad: false, + tags: ["Test"], + createdAt: "2023-10-28 11:15", + lastActiveAt: "2024-01-12 15:00", + appVersion: "3.2.0", + }, + { + id: "f1b7a3c5-9d2e-4f8a-b6c0-3e7d1a9f5b2c", + platform: "Android", + deviceModel: "Pixel 8 Pro", + osVersion: "Android 14", + service: "쇼핑몰 앱", + serviceCode: "SVC_MALL", + token: "Qw3eR5tY7uI9oP1aS3dF5gH7jK9lZ1xC3vB5nM7qW9eR1tY3uI5", + push: true, + ad: true, + tags: [], + createdAt: "2023-10-27 16:40", + lastActiveAt: "2024-01-08 12:10", + appVersion: "2.8.5", + }, + { + id: "0a9b8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d", + platform: "iOS", + deviceModel: "iPhone 13 mini", + osVersion: "iOS 16.1", + service: "물류 시스템", + serviceCode: "SVC_LOGISTICS", + token: "mN4pQ6rS8tU0vW2xY4zA6bC8dE0fG2hJ4kL6mN8pQ0rS2tU4vW6x", + push: false, + ad: false, + tags: [], + createdAt: "2023-10-25 10:05", + lastActiveAt: "2023-11-30 14:50", + appVersion: "2.7.0", + }, + { + id: "7e6d5c4b-3a2f-1e0d-9c8b-7a6f5e4d3c2b", + platform: "Android", + deviceModel: "Galaxy A54", + osVersion: "Android 13", + service: "쇼핑몰 앱", + serviceCode: "SVC_MALL", + token: "yZ1aB3cD5eF7gH9iJ1kL3mN5oP7qR9sT1uV3wX5yZ7aB9cD1eF3g", + push: true, + ad: false, + tags: ["이벤트"], + createdAt: "2023-10-23 08:30", + lastActiveAt: "2024-01-05 16:40", + appVersion: "3.1.0", + }, + { + id: "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + platform: "iOS", + deviceModel: "iPhone 15", + osVersion: "iOS 17.0", + service: "쇼핑몰 앱", + serviceCode: "SVC_MALL", + token: "Hg5fE3dC1bA9zY7xW5vU3tS1rQ9pO7nM5lK3jH1gF9eD7cB5aZ3", + push: true, + ad: true, + tags: ["VIP", "임직원"], + createdAt: "2023-10-20 14:55", + lastActiveAt: "2024-01-13 20:15", + appVersion: "3.2.1", + }, + { + id: "8f9e0d1c-2b3a-4f5e-6d7c-8b9a0f1e2d3c", + platform: "Android", + deviceModel: "Galaxy Tab S9", + osVersion: "Android 14", + service: "파트너 센터", + serviceCode: "SVC_PARTNER", + token: "Tp8sR6qP4oN2mL0kJ8iH6gF4eD2cB0aZ8yX6wV4uT2sR0qP8oN6m", + push: true, + ad: false, + tags: ["신규"], + createdAt: "2023-10-18 09:20", + lastActiveAt: "2024-01-02 10:30", + appVersion: "3.0.0", + }, +];