feat: 기기 관리 페이지 API 연동 (#37)
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/38
This commit is contained in:
commit
e37066ce31
43
react/src/api/device.api.ts
Normal file
43
react/src/api/device.api.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
DeviceListRequest,
|
||||||
|
DeviceListResponse,
|
||||||
|
DeviceDeleteRequest,
|
||||||
|
DeviceExportRequest,
|
||||||
|
} from "@/features/device/types";
|
||||||
|
|
||||||
|
/** 기기 목록 조회 (X-Service-Code 선택) */
|
||||||
|
export function fetchDevices(
|
||||||
|
data: DeviceListRequest,
|
||||||
|
serviceCode?: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<DeviceListResponse>>(
|
||||||
|
"/v1/in/device/list",
|
||||||
|
data,
|
||||||
|
serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 삭제 (비활성화) */
|
||||||
|
export function deleteDevice(
|
||||||
|
data: DeviceDeleteRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post<ApiResponse<null>>(
|
||||||
|
"/v1/in/device/admin/delete",
|
||||||
|
data,
|
||||||
|
{ headers: { "X-Service-Code": serviceCode } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기기 내보내기 (xlsx blob) */
|
||||||
|
export function exportDevices(
|
||||||
|
data: DeviceExportRequest,
|
||||||
|
serviceCode: string,
|
||||||
|
) {
|
||||||
|
return apiClient.post("/v1/in/device/export", data, {
|
||||||
|
headers: { "X-Service-Code": serviceCode },
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import CopyButton from "@/components/common/CopyButton";
|
import CopyButton from "@/components/common/CopyButton";
|
||||||
import type { DeviceSummary } from "../types";
|
import { deleteDevice } from "@/api/device.api";
|
||||||
|
import type { DeviceListItem } from "../types";
|
||||||
|
|
||||||
interface DeviceSlidePanelProps {
|
interface DeviceSlidePanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
device: DeviceSummary | null;
|
device: DeviceListItem | null;
|
||||||
|
serviceCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeviceSlidePanel({
|
export default function DeviceSlidePanel({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
device,
|
device,
|
||||||
|
serviceCode,
|
||||||
}: DeviceSlidePanelProps) {
|
}: DeviceSlidePanelProps) {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 패널 열릴 때 스크롤 최상단 리셋
|
// 패널 열릴 때 스크롤 최상단 리셋
|
||||||
|
|
@ -45,6 +49,22 @@ export default function DeviceSlidePanel({
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 기기 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!device || !serviceCode) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteDevice({ device_id: device.device_id }, serviceCode);
|
||||||
|
toast.success("기기가 삭제되었습니다.");
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
toast.error("기기 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 플랫폼 아이콘 렌더링
|
// 플랫폼 아이콘 렌더링
|
||||||
const renderPlatformIcon = () => {
|
const renderPlatformIcon = () => {
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
|
|
@ -98,6 +118,9 @@ export default function DeviceSlidePanel({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 태그명 배열
|
||||||
|
const tagNames = device?.tags?.map((t) => t.tag_name) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 오버레이 */}
|
{/* 오버레이 */}
|
||||||
|
|
@ -120,10 +143,10 @@ export default function DeviceSlidePanel({
|
||||||
{renderPlatformIcon()}
|
{renderPlatformIcon()}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#0f172a]">
|
<h3 className="text-base font-bold text-[#0f172a]">
|
||||||
{device?.deviceModel}
|
{device?.model}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{device?.osVersion}
|
{device?.os_version}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -155,11 +178,11 @@ export default function DeviceSlidePanel({
|
||||||
apps
|
apps
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-[#0f172a]">
|
<span className="text-sm font-semibold text-[#0f172a]">
|
||||||
{device.service}
|
{device.service_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<code className="text-[11px] text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 font-mono">
|
<code className="text-[11px] text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 font-mono">
|
||||||
{device.serviceCode}
|
{device.service_code}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,9 +196,9 @@ export default function DeviceSlidePanel({
|
||||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
{device.id}
|
{String(device.device_id)}
|
||||||
</code>
|
</code>
|
||||||
<CopyButton text={device.id} />
|
<CopyButton text={String(device.device_id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,9 +211,9 @@ export default function DeviceSlidePanel({
|
||||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
<code className="text-xs text-gray-700 font-mono break-all leading-relaxed flex-1">
|
||||||
{device.token}
|
{device.device_token ?? ""}
|
||||||
</code>
|
</code>
|
||||||
<CopyButton text={device.token} />
|
<CopyButton text={device.device_token ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,8 +224,8 @@ export default function DeviceSlidePanel({
|
||||||
수신 동의
|
수신 동의
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<ConsentBox label="푸시 수신" consented={device.push} />
|
<ConsentBox label="푸시 수신" consented={device.push_agreed} />
|
||||||
<ConsentBox label="광고 수신" consented={device.ad} />
|
<ConsentBox label="광고 수신" consented={device.marketing_agreed} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -211,9 +234,9 @@ export default function DeviceSlidePanel({
|
||||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2 block">
|
||||||
태그
|
태그
|
||||||
</label>
|
</label>
|
||||||
{device.tags.length > 0 ? (
|
{tagNames.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{device.tags.map((tag) => (
|
{tagNames.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
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"
|
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"
|
||||||
|
|
@ -238,7 +261,7 @@ export default function DeviceSlidePanel({
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-gray-400 mb-0.5">등록일</p>
|
<p className="text-[11px] text-gray-400 mb-0.5">등록일</p>
|
||||||
<p className="text-sm font-medium text-[#0f172a]">
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
{device.createdAt}
|
{device.created_at}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -246,13 +269,13 @@ export default function DeviceSlidePanel({
|
||||||
마지막 활동
|
마지막 활동
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium text-[#0f172a]">
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
{device.lastActiveAt}
|
{device.last_active_at}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-gray-400 mb-0.5">앱 버전</p>
|
<p className="text-[11px] text-gray-400 mb-0.5">앱 버전</p>
|
||||||
<p className="text-sm font-medium text-[#0f172a]">
|
<p className="text-sm font-medium text-[#0f172a]">
|
||||||
{device.appVersion}
|
{device.app_version}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -327,19 +350,17 @@ export default function DeviceSlidePanel({
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
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"
|
disabled={deleting}
|
||||||
|
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleDelete}
|
||||||
toast.success("기기가 삭제되었습니다.");
|
disabled={deleting}
|
||||||
setShowDeleteConfirm(false);
|
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50"
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
|
||||||
>
|
>
|
||||||
삭제
|
{deleting ? "삭제 중..." : "삭제"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import SearchInput from "@/components/common/SearchInput";
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
import FilterDropdown from "@/components/common/FilterDropdown";
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
|
|
@ -9,13 +10,13 @@ import PlatformBadge from "@/components/common/PlatformBadge";
|
||||||
import SecretToggleCell from "../components/SecretToggleCell";
|
import SecretToggleCell from "../components/SecretToggleCell";
|
||||||
import DeviceSlidePanel from "../components/DeviceSlidePanel";
|
import DeviceSlidePanel from "../components/DeviceSlidePanel";
|
||||||
import { formatDate } from "@/utils/format";
|
import { formatDate } from "@/utils/format";
|
||||||
|
import { fetchDevices, exportDevices } from "@/api/device.api";
|
||||||
|
import { fetchServices } from "@/api/service.api";
|
||||||
import {
|
import {
|
||||||
MOCK_DEVICES,
|
|
||||||
SERVICE_FILTER_OPTIONS,
|
|
||||||
PLATFORM_FILTER_OPTIONS,
|
PLATFORM_FILTER_OPTIONS,
|
||||||
PUSH_CONSENT_FILTER_OPTIONS,
|
PUSH_CONSENT_FILTER_OPTIONS,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { DeviceSummary } from "../types";
|
import type { DeviceListItem } from "../types";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
|
@ -46,26 +47,103 @@ export default function DeviceListPage() {
|
||||||
const [appliedPlatform, setAppliedPlatform] = useState("전체");
|
const [appliedPlatform, setAppliedPlatform] = useState("전체");
|
||||||
const [appliedPush, setAppliedPush] = useState("전체");
|
const [appliedPush, setAppliedPush] = useState("전체");
|
||||||
|
|
||||||
|
// 서비스 목록 (API에서 로드)
|
||||||
|
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
|
||||||
|
"전체 서비스",
|
||||||
|
]);
|
||||||
|
const [serviceCodeMap, setServiceCodeMap] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
const [items, setItems] = useState<DeviceListItem[]>([]);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
// 슬라이드 패널
|
// 슬라이드 패널
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [selectedDevice, setSelectedDevice] = useState<DeviceSummary | null>(
|
const [selectedDevice, setSelectedDevice] = useState<DeviceListItem | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// SecretToggleCell 배타적 관리
|
// SecretToggleCell 배타적 관리
|
||||||
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
|
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 서비스 목록 로드 (초기화 시 1회)
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchServices({ page: 1, pageSize: 100 });
|
||||||
|
const svcItems = res.data.data.items ?? [];
|
||||||
|
const names = svcItems.map((s) => s.serviceName);
|
||||||
|
setServiceFilterOptions(["전체 서비스", ...names]);
|
||||||
|
const codeMap: Record<string, string> = {};
|
||||||
|
svcItems.forEach((s) => {
|
||||||
|
codeMap[s.serviceName] = s.serviceCode;
|
||||||
|
});
|
||||||
|
setServiceCodeMap(codeMap);
|
||||||
|
} catch {
|
||||||
|
// 서비스 목록 로드 실패 시 기본값 유지
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (
|
||||||
|
page: number,
|
||||||
|
keyword: string,
|
||||||
|
serviceName: string,
|
||||||
|
platform: string,
|
||||||
|
push: string,
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const serviceCode =
|
||||||
|
serviceName !== "전체 서비스"
|
||||||
|
? serviceCodeMap[serviceName] || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const res = await fetchDevices(
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
platform: platform !== "전체" ? platform : undefined,
|
||||||
|
push_agreed:
|
||||||
|
push === "동의" ? true : push === "미동의" ? false : undefined,
|
||||||
|
},
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = res.data.data;
|
||||||
|
setItems(data.items ?? []);
|
||||||
|
setTotalItems(data.totalCount ?? 0);
|
||||||
|
setTotalPages(data.totalPages ?? 1);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serviceCodeMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1, "", "전체 서비스", "전체", "전체");
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
// 조회
|
// 조회
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
setLoading(true);
|
setAppliedSearch(search);
|
||||||
setTimeout(() => {
|
setAppliedService(serviceFilter);
|
||||||
setAppliedSearch(search);
|
setAppliedPlatform(platformFilter);
|
||||||
setAppliedService(serviceFilter);
|
setAppliedPush(pushFilter);
|
||||||
setAppliedPlatform(platformFilter);
|
setCurrentPage(1);
|
||||||
setAppliedPush(pushFilter);
|
loadData(1, search, serviceFilter, platformFilter, pushFilter);
|
||||||
setCurrentPage(1);
|
|
||||||
setLoading(false);
|
|
||||||
}, 400);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 초기화
|
// 필터 초기화
|
||||||
|
|
@ -79,50 +157,73 @@ export default function DeviceListPage() {
|
||||||
setAppliedPlatform("전체");
|
setAppliedPlatform("전체");
|
||||||
setAppliedPush("전체");
|
setAppliedPush("전체");
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
loadData(1, "", "전체 서비스", "전체", "전체");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터링
|
// 페이지 변경
|
||||||
const filtered = useMemo(() => {
|
const handlePageChange = (page: number) => {
|
||||||
return MOCK_DEVICES.filter((d) => {
|
setCurrentPage(page);
|
||||||
// 검색 (Device ID / Push Token)
|
loadData(page, appliedSearch, appliedService, appliedPlatform, appliedPush);
|
||||||
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) => {
|
const handleRowClick = (device: DeviceListItem) => {
|
||||||
setOpenDropdownKey(null);
|
setOpenDropdownKey(null);
|
||||||
setSelectedDevice(device);
|
setSelectedDevice(device);
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 패널 닫기 (삭제 후 목록 새로고침)
|
||||||
|
const handlePanelClose = () => {
|
||||||
|
setPanelOpen(false);
|
||||||
|
loadData(
|
||||||
|
currentPage,
|
||||||
|
appliedSearch,
|
||||||
|
appliedService,
|
||||||
|
appliedPlatform,
|
||||||
|
appliedPush,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 내보내기
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (appliedService === "전체 서비스") {
|
||||||
|
toast.error("엑셀 다운로드를 위해 서비스를 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serviceCode = serviceCodeMap[appliedService];
|
||||||
|
if (!serviceCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await exportDevices(
|
||||||
|
{
|
||||||
|
keyword: appliedSearch || undefined,
|
||||||
|
platform:
|
||||||
|
appliedPlatform !== "전체" ? appliedPlatform : undefined,
|
||||||
|
push_agreed:
|
||||||
|
appliedPush === "동의"
|
||||||
|
? true
|
||||||
|
: appliedPush === "미동의"
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
serviceCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([res.data], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `devices_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast.error("엑셀 다운로드에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 체크/취소 아이콘
|
// 체크/취소 아이콘
|
||||||
const StatusIcon = ({ active }: { active: boolean }) => (
|
const StatusIcon = ({ active }: { active: boolean }) => (
|
||||||
<span
|
<span
|
||||||
|
|
@ -177,7 +278,10 @@ export default function DeviceListPage() {
|
||||||
title="기기 관리"
|
title="기기 관리"
|
||||||
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
|
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
|
||||||
action={
|
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">
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
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>
|
<span className="material-symbols-outlined text-lg">download</span>
|
||||||
엑셀 다운로드
|
엑셀 다운로드
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -197,7 +301,7 @@ export default function DeviceListPage() {
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="서비스"
|
label="서비스"
|
||||||
value={serviceFilter}
|
value={serviceFilter}
|
||||||
options={SERVICE_FILTER_OPTIONS}
|
options={serviceFilterOptions}
|
||||||
onChange={setServiceFilter}
|
onChange={setServiceFilter}
|
||||||
className="w-[140px] flex-shrink-0"
|
className="w-[140px] flex-shrink-0"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|
@ -270,22 +374,22 @@ export default function DeviceListPage() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : paged.length > 0 ? (
|
) : items.length > 0 ? (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
{renderTableHead()}
|
{renderTableHead()}
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((device, idx) => (
|
{items.map((device, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={device.id}
|
key={device.device_id}
|
||||||
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
|
||||||
onClick={() => handleRowClick(device)}
|
onClick={() => handleRowClick(device)}
|
||||||
>
|
>
|
||||||
{/* 소속 서비스 */}
|
{/* 소속 서비스 */}
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
{device.service}
|
{device.service_name}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{/* 플랫폼 */}
|
{/* 플랫폼 */}
|
||||||
|
|
@ -300,8 +404,8 @@ export default function DeviceListPage() {
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<SecretToggleCell
|
<SecretToggleCell
|
||||||
label="ID 확인"
|
label="ID 확인"
|
||||||
value={device.id}
|
value={String(device.device_id)}
|
||||||
dropdownKey={`${device.id}-id`}
|
dropdownKey={`${device.device_id}-id`}
|
||||||
openKey={openDropdownKey}
|
openKey={openDropdownKey}
|
||||||
onToggle={setOpenDropdownKey}
|
onToggle={setOpenDropdownKey}
|
||||||
/>
|
/>
|
||||||
|
|
@ -310,28 +414,28 @@ export default function DeviceListPage() {
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<SecretToggleCell
|
<SecretToggleCell
|
||||||
label="토큰 확인"
|
label="토큰 확인"
|
||||||
value={device.token}
|
value={device.device_token ?? ""}
|
||||||
dropdownKey={`${device.id}-token`}
|
dropdownKey={`${device.device_id}-token`}
|
||||||
openKey={openDropdownKey}
|
openKey={openDropdownKey}
|
||||||
onToggle={setOpenDropdownKey}
|
onToggle={setOpenDropdownKey}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{/* 푸시 수신 */}
|
{/* 푸시 수신 */}
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<StatusIcon active={device.push} />
|
<StatusIcon active={device.push_agreed} />
|
||||||
</td>
|
</td>
|
||||||
{/* 광고 수신 */}
|
{/* 광고 수신 */}
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<StatusIcon active={device.ad} />
|
<StatusIcon active={device.marketing_agreed} />
|
||||||
</td>
|
</td>
|
||||||
{/* 태그 */}
|
{/* 태그 */}
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<TagIcon hasTags={device.tags.length > 0} />
|
<TagIcon hasTags={(device.tags?.length ?? 0) > 0} />
|
||||||
</td>
|
</td>
|
||||||
{/* 등록일 */}
|
{/* 등록일 */}
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{formatDate(device.createdAt)}
|
{formatDate(device.created_at ?? "")}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -346,7 +450,7 @@ export default function DeviceListPage() {
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -360,8 +464,14 @@ export default function DeviceListPage() {
|
||||||
{/* 슬라이드 패널 */}
|
{/* 슬라이드 패널 */}
|
||||||
<DeviceSlidePanel
|
<DeviceSlidePanel
|
||||||
isOpen={panelOpen}
|
isOpen={panelOpen}
|
||||||
onClose={() => setPanelOpen(false)}
|
onClose={handlePanelClose}
|
||||||
device={selectedDevice}
|
device={selectedDevice}
|
||||||
|
serviceCode={
|
||||||
|
selectedDevice?.service_code ??
|
||||||
|
(appliedService !== "전체 서비스"
|
||||||
|
? serviceCodeMap[appliedService]
|
||||||
|
: undefined)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,182 +3,67 @@ export const PLATFORM = { IOS: "iOS", ANDROID: "Android" } as const;
|
||||||
export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
|
export type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
|
||||||
|
|
||||||
// 필터 옵션 상수
|
// 필터 옵션 상수
|
||||||
export const SERVICE_FILTER_OPTIONS = [
|
|
||||||
"전체 서비스",
|
|
||||||
"쇼핑몰 앱",
|
|
||||||
"파트너 센터",
|
|
||||||
"물류 시스템",
|
|
||||||
];
|
|
||||||
export const PLATFORM_FILTER_OPTIONS = ["전체", "iOS", "Android"];
|
export const PLATFORM_FILTER_OPTIONS = ["전체", "iOS", "Android"];
|
||||||
export const PUSH_CONSENT_FILTER_OPTIONS = ["전체", "동의", "미동의"];
|
export const PUSH_CONSENT_FILTER_OPTIONS = ["전체", "동의", "미동의"];
|
||||||
|
|
||||||
// 기기 데이터
|
// --- swagger 기준 요청/응답 타입 (snake_case) ---
|
||||||
export interface DeviceSummary {
|
|
||||||
id: string;
|
/** 기기 목록 요청 */
|
||||||
platform: Platform;
|
export interface DeviceListRequest {
|
||||||
deviceModel: string;
|
page: number;
|
||||||
osVersion: string;
|
size: number;
|
||||||
service: string;
|
platform?: string | null;
|
||||||
serviceCode: string;
|
push_agreed?: boolean | null;
|
||||||
token: string;
|
marketing_agreed?: boolean | null;
|
||||||
push: boolean;
|
tags?: number[] | null;
|
||||||
ad: boolean;
|
is_active?: boolean | null;
|
||||||
tags: string[];
|
keyword?: string | null;
|
||||||
createdAt: string;
|
|
||||||
lastActiveAt: string;
|
|
||||||
appVersion: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 목 데이터 (HTML 시안 기반 10건)
|
/** 기기 태그 아이템 */
|
||||||
export const MOCK_DEVICES: DeviceSummary[] = [
|
export interface DeviceTagItem {
|
||||||
{
|
tag_id: number;
|
||||||
id: "a3f1e8b0-7c2d-4a5e-9b1f-6d8c3e2a4f70",
|
tag_name: string;
|
||||||
platform: "iOS",
|
}
|
||||||
deviceModel: "iPhone 15 Pro",
|
|
||||||
osVersion: "iOS 17.2",
|
/** 기기 목록 아이템 */
|
||||||
service: "쇼핑몰 앱",
|
export interface DeviceListItem {
|
||||||
serviceCode: "SVC_MALL",
|
device_id: number;
|
||||||
token: "f83j20dk4829sla92kasdLp3mN7qR1xW5vB8yT0uZ6cH2fJ4gK9eA",
|
device_token: string | null;
|
||||||
push: true,
|
platform: string | null;
|
||||||
ad: true,
|
model: string | null;
|
||||||
tags: ["VIP", "프리미엄"],
|
os_version: string | null;
|
||||||
createdAt: "2023-11-01 14:20",
|
app_version: string | null;
|
||||||
lastActiveAt: "2024-01-15 09:30",
|
service_name: string | null;
|
||||||
appVersion: "3.2.1",
|
service_code: string | null;
|
||||||
},
|
push_agreed: boolean;
|
||||||
{
|
marketing_agreed: boolean;
|
||||||
id: "b7d2c9e1-3f4a-5b6c-8d7e-9f0a1b2c3d4e",
|
tags: DeviceTagItem[] | null;
|
||||||
platform: "Android",
|
created_at: string | null;
|
||||||
deviceModel: "Galaxy S24 Ultra",
|
last_active_at: string | null;
|
||||||
osVersion: "Android 14",
|
is_active: boolean;
|
||||||
service: "파트너 센터",
|
}
|
||||||
serviceCode: "SVC_PARTNER",
|
|
||||||
token: "a1b2c3d4x8y9z0e5f6g7Hj2kL4mN6pQ8rS0tU3vW5xY7zA9bC1dE",
|
/** 기기 목록 응답 */
|
||||||
push: true,
|
export interface DeviceListResponse {
|
||||||
ad: false,
|
items: DeviceListItem[] | null;
|
||||||
tags: ["이벤트", "신규"],
|
totalCount: number;
|
||||||
createdAt: "2023-11-01 13:45",
|
page: number;
|
||||||
lastActiveAt: "2024-01-14 18:20",
|
size: number;
|
||||||
appVersion: "3.1.8",
|
totalPages: number;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "c5e3a1b9-8d7f-4c2e-a6b0-1d9e8f7a5c3b",
|
/** 기기 삭제 요청 */
|
||||||
platform: "iOS",
|
export interface DeviceDeleteRequest {
|
||||||
deviceModel: "iPhone 14",
|
device_id: number;
|
||||||
osVersion: "iOS 16.5",
|
}
|
||||||
service: "쇼핑몰 앱",
|
|
||||||
serviceCode: "SVC_MALL",
|
/** 기기 내보내기 요청 */
|
||||||
token: "x9y8z7w6a1b2v5u4t3Qp7rS9kL2mN4jH6fD8gB0cE1aW3xY5zV",
|
export interface DeviceExportRequest {
|
||||||
push: false,
|
platform?: string | null;
|
||||||
ad: false,
|
push_agreed?: boolean | null;
|
||||||
tags: [],
|
marketing_agreed?: boolean | null;
|
||||||
createdAt: "2023-10-31 09:12",
|
tags?: number[] | null;
|
||||||
lastActiveAt: "2023-12-20 11:05",
|
is_active?: boolean | null;
|
||||||
appVersion: "2.9.4",
|
keyword?: string | null;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
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