SPMS_WEB/react/src/features/device/pages/DeviceListPage.tsx
SEAN 589a0d67ce feat: 기기 관리 페이지 API 연동 (#37)
- 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
2026-03-02 10:47:04 +09:00

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>
);
}