351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|