- types.ts: DeviceSummary/MOCK_DEVICES/SERVICE_FILTER_OPTIONS 삭제, swagger 기준 snake_case 타입 추가 (DeviceListItem, DeviceListRequest 등) - device.api.ts: 신규 생성 (fetchDevices, deleteDevice, exportDevices) - DeviceListPage.tsx: Mock → loadData/useCallback 서버 필터링, fetchServices로 서비스 목록 로드, 엑셀 내보내기 구현 - DeviceSlidePanel.tsx: DeviceListItem 타입 적용, deleteDevice API 호출 연동 Closes #37
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import { toast } from "sonner";
|
|
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 { fetchDevices, exportDevices } from "@/api/device.api";
|
|
import { fetchServices } from "@/api/service.api";
|
|
import {
|
|
PLATFORM_FILTER_OPTIONS,
|
|
PUSH_CONSENT_FILTER_OPTIONS,
|
|
} from "../types";
|
|
import type { DeviceListItem } 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("전체");
|
|
|
|
// 서비스 목록 (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 [selectedDevice, setSelectedDevice] = useState<DeviceListItem | null>(
|
|
null,
|
|
);
|
|
|
|
// SecretToggleCell 배타적 관리
|
|
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 = () => {
|
|
setAppliedSearch(search);
|
|
setAppliedService(serviceFilter);
|
|
setAppliedPlatform(platformFilter);
|
|
setAppliedPush(pushFilter);
|
|
setCurrentPage(1);
|
|
loadData(1, search, serviceFilter, platformFilter, pushFilter);
|
|
};
|
|
|
|
// 필터 초기화
|
|
const handleReset = () => {
|
|
setSearch("");
|
|
setServiceFilter("전체 서비스");
|
|
setPlatformFilter("전체");
|
|
setPushFilter("전체");
|
|
setAppliedSearch("");
|
|
setAppliedService("전체 서비스");
|
|
setAppliedPlatform("전체");
|
|
setAppliedPush("전체");
|
|
setCurrentPage(1);
|
|
loadData(1, "", "전체 서비스", "전체", "전체");
|
|
};
|
|
|
|
// 페이지 변경
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
loadData(page, appliedSearch, appliedService, appliedPlatform, appliedPush);
|
|
};
|
|
|
|
// 행 클릭
|
|
const handleRowClick = (device: DeviceListItem) => {
|
|
setOpenDropdownKey(null);
|
|
setSelectedDevice(device);
|
|
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 }) => (
|
|
<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 (
|
|
<div>
|
|
<PageHeader
|
|
title="기기 관리"
|
|
description="등록된 디바이스 현황을 조회하고 관리할 수 있습니다."
|
|
action={
|
|
<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>
|
|
엑셀 다운로드
|
|
</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={serviceFilterOptions}
|
|
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>
|
|
) : items.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>
|
|
{items.map((device, idx) => (
|
|
<tr
|
|
key={device.device_id}
|
|
className={`${idx < items.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_name}
|
|
</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={String(device.device_id)}
|
|
dropdownKey={`${device.device_id}-id`}
|
|
openKey={openDropdownKey}
|
|
onToggle={setOpenDropdownKey}
|
|
/>
|
|
</td>
|
|
{/* Push Token */}
|
|
<td className="px-6 py-4 text-center">
|
|
<SecretToggleCell
|
|
label="토큰 확인"
|
|
value={device.device_token ?? ""}
|
|
dropdownKey={`${device.device_id}-token`}
|
|
openKey={openDropdownKey}
|
|
onToggle={setOpenDropdownKey}
|
|
/>
|
|
</td>
|
|
{/* 푸시 수신 */}
|
|
<td className="px-6 py-4 text-center">
|
|
<StatusIcon active={device.push_agreed} />
|
|
</td>
|
|
{/* 광고 수신 */}
|
|
<td className="px-6 py-4 text-center">
|
|
<StatusIcon active={device.marketing_agreed} />
|
|
</td>
|
|
{/* 태그 */}
|
|
<td className="px-6 py-4 text-center">
|
|
<TagIcon hasTags={(device.tags?.length ?? 0) > 0} />
|
|
</td>
|
|
{/* 등록일 */}
|
|
<td className="px-6 py-4 text-center">
|
|
<span className="text-sm text-gray-500">
|
|
{formatDate(device.created_at ?? "")}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
icon="search_off"
|
|
message="검색 결과가 없습니다"
|
|
description="다른 검색어를 입력하거나 필터를 변경해보세요."
|
|
/>
|
|
)}
|
|
|
|
{/* 슬라이드 패널 */}
|
|
<DeviceSlidePanel
|
|
isOpen={panelOpen}
|
|
onClose={handlePanelClose}
|
|
device={selectedDevice}
|
|
serviceCode={
|
|
selectedDevice?.service_code ??
|
|
(appliedService !== "전체 서비스"
|
|
? serviceCodeMap[appliedService]
|
|
: undefined)
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|