feat: 기기 관리 페이지 구현 (#21)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/22
This commit is contained in:
commit
6501676a35
|
|
@ -43,9 +43,9 @@ export default function FilterDropdown({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && setOpen((v) => !v)}
|
onClick={() => !disabled && setOpen((v) => !v)}
|
||||||
disabled={disabled}
|
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"
|
||||||
>
|
>
|
||||||
<span className="truncate">{value}</span>
|
<span className="truncate flex-1">{value}</span>
|
||||||
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
||||||
expand_more
|
expand_more
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -59,7 +59,7 @@ export default function FilterDropdown({
|
||||||
onChange(opt);
|
onChange(opt);
|
||||||
setOpen(false);
|
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" : ""
|
opt === value ? "text-primary font-medium" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,17 @@ interface PlatformBadgeProps {
|
||||||
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
|
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
|
||||||
if (platform === "ios") {
|
if (platform === "ios") {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
|
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
|
||||||
<svg className="size-3" viewBox="0 0 384 512" fill="currentColor">
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
</svg>
|
</svg>
|
||||||
iOS
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
<span className="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||||
<span
|
<span className="material-symbols-outlined text-base">android</span>
|
||||||
className="material-symbols-outlined"
|
|
||||||
style={{ fontSize: "14px" }}
|
|
||||||
>
|
|
||||||
android
|
|
||||||
</span>
|
|
||||||
Android
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
350
react/src/features/device/components/DeviceSlidePanel.tsx
Normal file
350
react/src/features/device/components/DeviceSlidePanel.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="size-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="size-5 text-gray-700" viewBox="0 0 384 512" fill="currentColor">
|
||||||
|
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="size-10 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-green-700 text-xl">
|
||||||
|
android
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수신 동의 박스
|
||||||
|
const ConsentBox = ({
|
||||||
|
label,
|
||||||
|
consented,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
consented: boolean;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-3 border text-center ${
|
||||||
|
consented
|
||||||
|
? "bg-green-50 border-green-200"
|
||||||
|
: "bg-gray-50 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${consented ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
style={{ fontSize: "16px" }}
|
||||||
|
>
|
||||||
|
{consented ? "check_circle" : "cancel"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-600">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${consented ? "text-green-700" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{consented ? "동의" : "미동의"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 오버레이 */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 패널 */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||||
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{renderPlatformIcon()}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-[#0f172a]">
|
||||||
|
{device?.deviceModel}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{device?.osVersion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-5 space-y-5"
|
||||||
|
style={{ overscrollBehavior: "contain" }}
|
||||||
|
>
|
||||||
|
{device ? (
|
||||||
|
<>
|
||||||
|
{/* 소속 서비스 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
소속 서비스
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-[#2563EB] text-lg">
|
||||||
|
apps
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-[#0f172a]">
|
||||||
|
{device.service}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code className="text-[11px] text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 font-mono">
|
||||||
|
{device.serviceCode}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device ID */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
Device ID
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{device.id}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={device.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Push Token */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
Push Token
|
||||||
|
</label>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{device.token}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={device.token} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수신 동의 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
수신 동의
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ConsentBox label="푸시 수신" consented={device.push} />
|
||||||
|
<ConsentBox label="광고 수신" consented={device.ad} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
|
태그
|
||||||
|
</label>
|
||||||
|
{device.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{device.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic">
|
||||||
|
등록된 태그 없음
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기기 정보 */}
|
||||||
|
<div className="border-t border-gray-100 pt-5">
|
||||||
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 block">
|
||||||
|
기기 정보
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">등록일</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">
|
||||||
|
마지막 활동
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.lastActiveAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">앱 버전</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.appVersion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">플랫폼</p>
|
||||||
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
|
{device.platform}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
기기를 선택해주세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
{device && (
|
||||||
|
<div className="flex-shrink-0 border-t border-gray-100 px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full h-10 border border-red-200 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
기기 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
{showDeleteConfirm && device && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-red-600 text-xl">
|
||||||
|
warning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[#0f172a]">기기 삭제</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#0f172a] mb-2">
|
||||||
|
선택한 기기를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>삭제된 기기는 복구할 수 없습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined flex-shrink-0"
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
info
|
||||||
|
</span>
|
||||||
|
<span>해당 기기로의 푸시 발송이 즉시 중단됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toast.success("기기가 삭제되었습니다.");
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
react/src/features/device/components/SecretToggleCell.tsx
Normal file
69
react/src/features/device/components/SecretToggleCell.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative inline-block"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="text-xs text-[#2563EB] hover:text-[#1d4ed8] font-medium transition-colors inline-flex items-center gap-1"
|
||||||
|
onClick={() => onToggle(isOpen ? null : dropdownKey)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||||
|
style={{ fontSize: "14px" }}
|
||||||
|
>
|
||||||
|
keyboard_arrow_down
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 w-[340px] bg-gray-50 border border-gray-200 rounded-lg p-3 shadow-lg z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-[11px] text-gray-600 font-mono break-all leading-relaxed flex-1">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
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<DeviceSummary | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// SecretToggleCell 배타적 관리
|
||||||
|
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(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 }) => (
|
||||||
|
<span
|
||||||
|
className={`material-symbols-outlined ${active ? "text-green-500" : "text-red-400"}`}
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
{active ? "check_circle" : "cancel"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 태그 아이콘 (있으면 체크, 없으면 dash)
|
||||||
|
const TagIcon = ({ hasTags }: { hasTags: boolean }) =>
|
||||||
|
hasTags ? (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-green-500"
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-red-400"
|
||||||
|
style={{ fontSize: "20px" }}
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테이블 헤더 렌더링
|
||||||
|
const renderTableHead = () => (
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className={`px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center ${
|
||||||
|
["푸시 수신", "광고 수신", "태그"].includes(col)
|
||||||
|
? "w-[100px] whitespace-nowrap"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">디바이스 목록</h1>
|
<PageHeader
|
||||||
|
title="기기 관리"
|
||||||
|
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
|
||||||
|
action={
|
||||||
|
<button className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<span className="material-symbols-outlined text-lg">download</span>
|
||||||
|
엑셀 다운로드
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터바 */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder="Device ID 또는 Push Token 검색"
|
||||||
|
label="검색어"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="서비스"
|
||||||
|
value={serviceFilter}
|
||||||
|
options={SERVICE_FILTER_OPTIONS}
|
||||||
|
onChange={setServiceFilter}
|
||||||
|
className="w-[140px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="플랫폼"
|
||||||
|
value={platformFilter}
|
||||||
|
options={PLATFORM_FILTER_OPTIONS}
|
||||||
|
onChange={setPlatformFilter}
|
||||||
|
className="w-[120px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="푸시 수신"
|
||||||
|
value={pushFilter}
|
||||||
|
options={PUSH_CONSENT_FILTER_OPTIONS}
|
||||||
|
onChange={setPushFilter}
|
||||||
|
className="w-[120px] flex-shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
/* 로딩 스켈레톤 */
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
{renderTableHead()}
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={i < 4 ? "border-b border-gray-100" : ""}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-14 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-16 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-6 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : paged.length > 0 ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
{renderTableHead()}
|
||||||
|
<tbody>
|
||||||
|
{paged.map((device, idx) => (
|
||||||
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
|
onClick={() => handleRowClick(device)}
|
||||||
|
>
|
||||||
|
{/* 소속 서비스 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{device.service}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/* 플랫폼 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<PlatformBadge
|
||||||
|
platform={
|
||||||
|
device.platform === "iOS" ? "ios" : "android"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Device ID */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<SecretToggleCell
|
||||||
|
label="ID 확인"
|
||||||
|
value={device.id}
|
||||||
|
dropdownKey={`${device.id}-id`}
|
||||||
|
openKey={openDropdownKey}
|
||||||
|
onToggle={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* Push Token */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<SecretToggleCell
|
||||||
|
label="토큰 확인"
|
||||||
|
value={device.token}
|
||||||
|
dropdownKey={`${device.id}-token`}
|
||||||
|
openKey={openDropdownKey}
|
||||||
|
onToggle={setOpenDropdownKey}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* 푸시 수신 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<StatusIcon active={device.push} />
|
||||||
|
</td>
|
||||||
|
{/* 광고 수신 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<StatusIcon active={device.ad} />
|
||||||
|
</td>
|
||||||
|
{/* 태그 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<TagIcon hasTags={device.tags.length > 0} />
|
||||||
|
</td>
|
||||||
|
{/* 등록일 */}
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{formatDate(device.createdAt)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="search_off"
|
||||||
|
message="검색 결과가 없습니다"
|
||||||
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 슬라이드 패널 */}
|
||||||
|
<DeviceSlidePanel
|
||||||
|
isOpen={panelOpen}
|
||||||
|
onClose={() => setPanelOpen(false)}
|
||||||
|
device={selectedDevice}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user