diff --git a/react/src/api/device.api.ts b/react/src/api/device.api.ts new file mode 100644 index 0000000..ee14ef4 --- /dev/null +++ b/react/src/api/device.api.ts @@ -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>( + "/v1/in/device/list", + data, + serviceCode ? { headers: { "X-Service-Code": serviceCode } } : undefined, + ); +} + +/** 기기 삭제 (비활성화) */ +export function deleteDevice( + data: DeviceDeleteRequest, + serviceCode: string, +) { + return apiClient.post>( + "/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", + }); +} diff --git a/react/src/features/device/components/DeviceSlidePanel.tsx b/react/src/features/device/components/DeviceSlidePanel.tsx index 9fcd45b..7fc5a45 100644 --- a/react/src/features/device/components/DeviceSlidePanel.tsx +++ b/react/src/features/device/components/DeviceSlidePanel.tsx @@ -1,20 +1,24 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import CopyButton from "@/components/common/CopyButton"; -import type { DeviceSummary } from "../types"; +import { deleteDevice } from "@/api/device.api"; +import type { DeviceListItem } from "../types"; interface DeviceSlidePanelProps { isOpen: boolean; onClose: () => void; - device: DeviceSummary | null; + device: DeviceListItem | null; + serviceCode?: string; } export default function DeviceSlidePanel({ isOpen, onClose, device, + serviceCode, }: DeviceSlidePanelProps) { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); const bodyRef = useRef(null); // 패널 열릴 때 스크롤 최상단 리셋 @@ -45,6 +49,22 @@ export default function DeviceSlidePanel({ }; }, [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 = () => { if (!device) return null; @@ -98,6 +118,9 @@ export default function DeviceSlidePanel({ ); + // 태그명 배열 + const tagNames = device?.tags?.map((t) => t.tag_name) ?? []; + return ( <> {/* 오버레이 */} @@ -120,10 +143,10 @@ export default function DeviceSlidePanel({ {renderPlatformIcon()}

- {device?.deviceModel} + {device?.model}

- {device?.osVersion} + {device?.os_version}

@@ -155,11 +178,11 @@ export default function DeviceSlidePanel({ apps - {device.service} + {device.service_name} - {device.serviceCode} + {device.service_code} @@ -173,9 +196,9 @@ export default function DeviceSlidePanel({
- {device.id} + {String(device.device_id)} - +
@@ -188,9 +211,9 @@ export default function DeviceSlidePanel({
- {device.token} + {device.device_token ?? ""} - +
@@ -201,8 +224,8 @@ export default function DeviceSlidePanel({ 수신 동의
- - + +
@@ -211,9 +234,9 @@ export default function DeviceSlidePanel({ - {device.tags.length > 0 ? ( + {tagNames.length > 0 ? (
- {device.tags.map((tag) => ( + {tagNames.map((tag) => (

등록일

- {device.createdAt} + {device.created_at}

@@ -246,13 +269,13 @@ export default function DeviceSlidePanel({ 마지막 활동

- {device.lastActiveAt} + {device.last_active_at}

앱 버전

- {device.appVersion} + {device.app_version}

@@ -327,19 +350,17 @@ export default function DeviceSlidePanel({
diff --git a/react/src/features/device/pages/DeviceListPage.tsx b/react/src/features/device/pages/DeviceListPage.tsx index b2884d7..a887488 100644 --- a/react/src/features/device/pages/DeviceListPage.tsx +++ b/react/src/features/device/pages/DeviceListPage.tsx @@ -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 SearchInput from "@/components/common/SearchInput"; import FilterDropdown from "@/components/common/FilterDropdown"; @@ -9,13 +10,13 @@ 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 { - MOCK_DEVICES, - SERVICE_FILTER_OPTIONS, PLATFORM_FILTER_OPTIONS, PUSH_CONSENT_FILTER_OPTIONS, } from "../types"; -import type { DeviceSummary } from "../types"; +import type { DeviceListItem } from "../types"; const PAGE_SIZE = 10; @@ -46,26 +47,103 @@ export default function DeviceListPage() { const [appliedPlatform, setAppliedPlatform] = useState("전체"); const [appliedPush, setAppliedPush] = useState("전체"); + // 서비스 목록 (API에서 로드) + const [serviceFilterOptions, setServiceFilterOptions] = useState([ + "전체 서비스", + ]); + const [serviceCodeMap, setServiceCodeMap] = useState< + Record + >({}); + + // 데이터 + const [items, setItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); + // 슬라이드 패널 const [panelOpen, setPanelOpen] = useState(false); - const [selectedDevice, setSelectedDevice] = useState( - null + const [selectedDevice, setSelectedDevice] = useState( + null, ); // SecretToggleCell 배타적 관리 const [openDropdownKey, setOpenDropdownKey] = useState(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 = {}; + 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 = () => { - setLoading(true); - setTimeout(() => { - setAppliedSearch(search); - setAppliedService(serviceFilter); - setAppliedPlatform(platformFilter); - setAppliedPush(pushFilter); - setCurrentPage(1); - setLoading(false); - }, 400); + setAppliedSearch(search); + setAppliedService(serviceFilter); + setAppliedPlatform(platformFilter); + setAppliedPush(pushFilter); + setCurrentPage(1); + loadData(1, search, serviceFilter, platformFilter, pushFilter); }; // 필터 초기화 @@ -79,50 +157,73 @@ export default function DeviceListPage() { setAppliedPlatform("전체"); setAppliedPush("전체"); setCurrentPage(1); + loadData(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 handlePageChange = (page: number) => { + setCurrentPage(page); + loadData(page, appliedSearch, appliedService, appliedPlatform, appliedPush); + }; // 행 클릭 - const handleRowClick = (device: DeviceSummary) => { + 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 }) => ( + @@ -197,7 +301,7 @@ export default function DeviceListPage() { - ) : paged.length > 0 ? ( + ) : items.length > 0 ? (
{renderTableHead()} - {paged.map((device, idx) => ( + {items.map((device, idx) => ( handleRowClick(device)} > {/* 소속 서비스 */} {/* 플랫폼 */} @@ -300,8 +404,8 @@ export default function DeviceListPage() { {/* 푸시 수신 */} {/* 광고 수신 */} {/* 태그 */} {/* 등록일 */} @@ -346,7 +450,7 @@ export default function DeviceListPage() { totalPages={totalPages} totalItems={totalItems} pageSize={PAGE_SIZE} - onPageChange={setCurrentPage} + onPageChange={handlePageChange} /> ) : ( @@ -360,8 +464,14 @@ export default function DeviceListPage() { {/* 슬라이드 패널 */} setPanelOpen(false)} + onClose={handlePanelClose} device={selectedDevice} + serviceCode={ + selectedDevice?.service_code ?? + (appliedService !== "전체 서비스" + ? serviceCodeMap[appliedService] + : undefined) + } /> ); diff --git a/react/src/features/device/types.ts b/react/src/features/device/types.ts index cbdc7b0..49961d3 100644 --- a/react/src/features/device/types.ts +++ b/react/src/features/device/types.ts @@ -3,182 +3,67 @@ 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; +// --- swagger 기준 요청/응답 타입 (snake_case) --- + +/** 기기 목록 요청 */ +export interface DeviceListRequest { + page: number; + size: number; + platform?: string | null; + push_agreed?: boolean | null; + marketing_agreed?: boolean | null; + tags?: number[] | null; + is_active?: boolean | null; + keyword?: string | null; } -// 목 데이터 (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", - }, -]; +/** 기기 태그 아이템 */ +export interface DeviceTagItem { + tag_id: number; + tag_name: string; +} + +/** 기기 목록 아이템 */ +export interface DeviceListItem { + device_id: number; + device_token: string | null; + platform: string | null; + model: string | null; + os_version: string | null; + app_version: string | null; + service_name: string | null; + service_code: string | null; + push_agreed: boolean; + marketing_agreed: boolean; + tags: DeviceTagItem[] | null; + created_at: string | null; + last_active_at: string | null; + is_active: boolean; +} + +/** 기기 목록 응답 */ +export interface DeviceListResponse { + items: DeviceListItem[] | null; + totalCount: number; + page: number; + size: number; + totalPages: number; +} + +/** 기기 삭제 요청 */ +export interface DeviceDeleteRequest { + device_id: number; +} + +/** 기기 내보내기 요청 */ +export interface DeviceExportRequest { + platform?: string | null; + push_agreed?: boolean | null; + marketing_agreed?: boolean | null; + tags?: number[] | null; + is_active?: boolean | null; + keyword?: string | null; +}
- {device.service} + {device.service_name} @@ -310,28 +414,28 @@ export default function DeviceListPage() { - + - + - 0} /> + 0} /> - {formatDate(device.createdAt)} + {formatDate(device.created_at ?? "")}