From bb4d531d8c0df5fdfff1236b1e1bec27bc2197a9 Mon Sep 17 00:00:00 2001 From: SEAN Date: Sun, 1 Mar 2026 10:35:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플랫폼 관리 API 연동 (FCM/APNs 인증서 등록/삭제) - 서비스 상세 통계 카드 대시보드 KPI API 연결 - 통계 서브텍스트 전일 대비 변동 데이터 연결 (변동 없음 표시 포함) - ServiceHeaderCard/ServiceEditPage optional chaining 버그 수정 - 날짜 표기 YYYY-MM-DD 형식 통일 - 페이지네이션 totalCount 필드명 수정 및 패딩 정렬 - 서비스 등록 완료 모달 UI 통일 및 문구 수정 Closes #31 --- react/src/api/service.api.ts | 81 ++++ react/src/components/common/Pagination.tsx | 9 +- react/src/features/dashboard/types.ts | 3 + .../service/components/PlatformManagement.tsx | 428 +++++++++++++++--- .../components/PlatformStatusIndicator.tsx | 2 +- .../service/components/ServiceHeaderCard.tsx | 36 +- .../service/components/ServiceStatsCards.tsx | 44 +- .../service/pages/ServiceDetailPage.tsx | 188 +++++++- .../service/pages/ServiceEditPage.tsx | 209 +++++++-- .../service/pages/ServiceListPage.tsx | 152 ++++--- .../service/pages/ServiceRegisterPage.tsx | 119 ++++- react/src/features/service/types.ts | 329 +++----------- react/src/types/api.ts | 2 +- 13 files changed, 1115 insertions(+), 487 deletions(-) create mode 100644 react/src/api/service.api.ts diff --git a/react/src/api/service.api.ts b/react/src/api/service.api.ts new file mode 100644 index 0000000..2c397ef --- /dev/null +++ b/react/src/api/service.api.ts @@ -0,0 +1,81 @@ +import { apiClient } from "./client"; +import type { ApiResponse, PaginatedResponse } from "@/types/api"; +import type { + ServiceListRequest, + ServiceSummary, + ServiceDetail, + ApiKeyResponse, + CreateServiceRequest, + CreateServiceResponse, + UpdateServiceRequest, + RegisterFcmRequest, + RegisterApnsRequest, +} from "@/features/service/types"; + +/** 서비스 목록 조회 */ +export function fetchServices(data: ServiceListRequest) { + return apiClient.post>( + "/v1/in/service/list", + data, + ); +} + +/** 서비스 상세 조회 */ +export function fetchServiceDetail(serviceCode: string) { + return apiClient.post>( + `/v1/in/service/${serviceCode}`, + ); +} + +/** API Key 전체 조회 (마스킹 해제) */ +export function fetchApiKey(serviceCode: string) { + return apiClient.post>( + `/v1/in/service/${serviceCode}/apikey/view`, + ); +} + +/** 서비스 생성 */ +export function createService(data: CreateServiceRequest) { + return apiClient.post>( + "/v1/in/service/create", + data, + ); +} + +/** 서비스 수정 */ +export function updateService(data: UpdateServiceRequest) { + return apiClient.post>( + "/v1/in/service/update", + data, + ); +} + +/** FCM 인증서 등록 */ +export function registerFcm(serviceCode: string, data: RegisterFcmRequest) { + return apiClient.post>( + `/v1/in/service/${serviceCode}/fcm`, + data, + ); +} + +/** FCM 인증서 삭제 */ +export function deleteFcm(serviceCode: string) { + return apiClient.post>( + `/v1/in/service/${serviceCode}/fcm/delete`, + ); +} + +/** APNs 인증서 등록 */ +export function registerApns(serviceCode: string, data: RegisterApnsRequest) { + return apiClient.post>( + `/v1/in/service/${serviceCode}/apns`, + data, + ); +} + +/** APNs 인증서 삭제 */ +export function deleteApns(serviceCode: string) { + return apiClient.post>( + `/v1/in/service/${serviceCode}/apns/delete`, + ); +} diff --git a/react/src/components/common/Pagination.tsx b/react/src/components/common/Pagination.tsx index 37ac93d..bdc9099 100644 --- a/react/src/components/common/Pagination.tsx +++ b/react/src/components/common/Pagination.tsx @@ -14,8 +14,9 @@ export default function Pagination({ pageSize, onPageChange, }: PaginationProps) { - const start = (currentPage - 1) * pageSize + 1; - const end = Math.min(currentPage * pageSize, totalItems); + const safeTotal = totalItems ?? 0; + const start = safeTotal > 0 ? (currentPage - 1) * pageSize + 1 : 0; + const end = Math.min(currentPage * pageSize, safeTotal); /** 표시할 페이지 번호 목록 (최대 5개) */ const getPageNumbers = () => { @@ -33,9 +34,9 @@ export default function Pagination({ if (totalPages <= 0) return null; return ( -
+
- 총 {totalItems.toLocaleString()}건 중 {start.toLocaleString()}-{end.toLocaleString()} 표시 + 총 {safeTotal.toLocaleString()}건 중 {start.toLocaleString()}-{end.toLocaleString()} 표시
diff --git a/react/src/features/dashboard/types.ts b/react/src/features/dashboard/types.ts index dd9ad9b..5e7eb9d 100644 --- a/react/src/features/dashboard/types.ts +++ b/react/src/features/dashboard/types.ts @@ -19,6 +19,9 @@ export interface DashboardKpi { device_count: number; service_count: number; sent_change_rate: number; // 전일 대비 증감률 (%) + success_rate_change: number; // 성공률 전일 대비 변화분 (pp) + device_count_change: number; // 등록 기기 수 전일 대비 변화량 + today_sent_change_rate: number; // 오늘 발송 전일 대비 증감률 (%) } // 일별 발송 추이 diff --git a/react/src/features/service/components/PlatformManagement.tsx b/react/src/features/service/components/PlatformManagement.tsx index 52a1a66..5950c23 100644 --- a/react/src/features/service/components/PlatformManagement.tsx +++ b/react/src/features/service/components/PlatformManagement.tsx @@ -1,6 +1,37 @@ import { useState, useRef, useEffect } from "react"; import { toast } from "sonner"; import type { ServiceDetail } from "../types"; +import { + registerFcm, + deleteFcm, + registerApns, + deleteApns, +} from "@/api/service.api"; + +// 파일을 텍스트로 읽기 (FCM JSON, APNs p8) +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("파일 읽기 실패")); + reader.readAsText(file); + }); +} + +// 파일을 Base64로 읽기 (APNs p12) +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + // "data:application/...;base64," 접두사 제거 + const base64 = dataUrl.split(",")[1] ?? ""; + resolve(base64); + }; + reader.onerror = () => reject(new Error("파일 읽기 실패")); + reader.readAsDataURL(file); + }); +} // Apple 로고 SVG const AppleLogo = ({ className }: { className?: string }) => ( @@ -112,29 +143,222 @@ function PlatformMenu({ interface PlatformManagementProps { service: ServiceDetail; + onRefresh: () => void; } export default function PlatformManagement({ service, + onRefresh, }: PlatformManagementProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null); const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8"); const [selectedFile, setSelectedFile] = useState(null); const [showPassword, setShowPassword] = useState(false); - const [deletedPlatforms, setDeletedPlatforms] = useState>(new Set()); - const [addedPlatforms, setAddedPlatforms] = useState>(new Set()); const [showAddModal, setShowAddModal] = useState(false); const [addTab, setAddTab] = useState<"android" | "ios">("android"); const [addIosAuthType, setAddIosAuthType] = useState<"p8" | "p12">("p8"); const [addFile, setAddFile] = useState(null); const [addShowPassword, setAddShowPassword] = useState(false); + const [submitting, setSubmitting] = useState(false); - const androidVisible = (service.platforms.android.registered || addedPlatforms.has("android")) && !deletedPlatforms.has("android"); - const iosVisible = (service.platforms.ios.registered || addedPlatforms.has("ios")) && !deletedPlatforms.has("ios"); + // 추가 모달 폼 상태 + const [addBundleId, setAddBundleId] = useState(""); + const [addKeyId, setAddKeyId] = useState(""); + const [addTeamId, setAddTeamId] = useState(""); + const [addCertPassword, setAddCertPassword] = useState(""); + + // 수정 모달 폼 상태 + const [editBundleId, setEditBundleId] = useState(""); + const [editKeyId, setEditKeyId] = useState(""); + const [editTeamId, setEditTeamId] = useState(""); + const [editCertPassword, setEditCertPassword] = useState(""); + + const androidVisible = !!service.platforms?.android?.registered; + const iosVisible = !!service.platforms?.ios?.registered; const allRegistered = androidVisible && iosVisible; const noneRegistered = !androidVisible && !iosVisible; + // 추가 모달 폼 초기화 + const resetAddForm = () => { + setAddFile(null); + setAddShowPassword(false); + setAddBundleId(""); + setAddKeyId(""); + setAddTeamId(""); + setAddCertPassword(""); + }; + + // 수정 모달 폼 초기화 + const resetEditForm = () => { + setSelectedFile(null); + setShowPassword(false); + setEditBundleId(""); + setEditKeyId(""); + setEditTeamId(""); + setEditCertPassword(""); + }; + + // 플랫폼 추가 핸들러 + const handleAdd = async () => { + if (!addFile) { + toast.error("인증서 파일을 업로드해주세요."); + return; + } + + const isAndroidTab = addTab === "android"; + setSubmitting(true); + + try { + if (isAndroidTab) { + // Android: FCM JSON + const text = await readFileAsText(addFile); + await registerFcm(service.serviceCode, { serviceAccountJson: text }); + } else if (addIosAuthType === "p8") { + // iOS p8 + if (!addBundleId.trim()) { + toast.error("Bundle ID를 입력해주세요."); + setSubmitting(false); + return; + } + if (!addKeyId.trim() || addKeyId.trim().length !== 10) { + toast.error("Key ID는 정확히 10자여야 합니다."); + setSubmitting(false); + return; + } + if (!addTeamId.trim() || addTeamId.trim().length !== 10) { + toast.error("Team ID는 정확히 10자여야 합니다."); + setSubmitting(false); + return; + } + const privateKey = await readFileAsText(addFile); + await registerApns(service.serviceCode, { + authType: "p8", + bundleId: addBundleId.trim(), + keyId: addKeyId.trim(), + teamId: addTeamId.trim(), + privateKey, + }); + } else { + // iOS p12 + if (!addBundleId.trim()) { + toast.error("Bundle ID를 입력해주세요."); + setSubmitting(false); + return; + } + const certificateBase64 = await readFileAsBase64(addFile); + await registerApns(service.serviceCode, { + authType: "p12", + bundleId: addBundleId.trim(), + certificateBase64, + certPassword: addCertPassword, + }); + } + + const name = isAndroidTab ? "Android" : "iOS"; + toast.success(`${name} 플랫폼이 추가되었습니다.`); + setShowAddModal(false); + resetAddForm(); + onRefresh(); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "플랫폼 추가에 실패했습니다."; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + // 인증서 수정(덮어쓰기) 핸들러 + const handleEdit = async () => { + if (!editTarget || !selectedFile) { + toast.error("인증서 파일을 업로드해주세요."); + return; + } + + const isAndroid = editTarget === "android"; + setSubmitting(true); + + try { + if (isAndroid) { + const text = await readFileAsText(selectedFile); + await registerFcm(service.serviceCode, { serviceAccountJson: text }); + } else if (iosAuthType === "p8") { + if (!editBundleId.trim()) { + toast.error("Bundle ID를 입력해주세요."); + setSubmitting(false); + return; + } + if (!editKeyId.trim() || editKeyId.trim().length !== 10) { + toast.error("Key ID는 정확히 10자여야 합니다."); + setSubmitting(false); + return; + } + if (!editTeamId.trim() || editTeamId.trim().length !== 10) { + toast.error("Team ID는 정확히 10자여야 합니다."); + setSubmitting(false); + return; + } + const privateKey = await readFileAsText(selectedFile); + await registerApns(service.serviceCode, { + authType: "p8", + bundleId: editBundleId.trim(), + keyId: editKeyId.trim(), + teamId: editTeamId.trim(), + privateKey, + }); + } else { + if (!editBundleId.trim()) { + toast.error("Bundle ID를 입력해주세요."); + setSubmitting(false); + return; + } + const certificateBase64 = await readFileAsBase64(selectedFile); + await registerApns(service.serviceCode, { + authType: "p12", + bundleId: editBundleId.trim(), + certificateBase64, + certPassword: editCertPassword, + }); + } + + toast.success("인증서가 저장되었습니다."); + setEditTarget(null); + resetEditForm(); + onRefresh(); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "인증서 저장에 실패했습니다."; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + // 플랫폼 삭제 핸들러 + const handleDelete = async () => { + if (!deleteTarget) return; + + setSubmitting(true); + try { + if (deleteTarget === "Android") { + await deleteFcm(service.serviceCode); + } else { + await deleteApns(service.serviceCode); + } + + toast.success(`${deleteTarget} 플랫폼이 삭제되었습니다.`); + setDeleteTarget(null); + onRefresh(); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "플랫폼 삭제에 실패했습니다."; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + return ( <>
@@ -144,11 +368,9 @@ export default function PlatformManagement({
{ setEditTarget("android"); - setSelectedFile(null); + resetEditForm(); }} onDelete={() => setDeleteTarget("Android")} /> @@ -210,16 +432,20 @@ export default function PlatformManagement({ Apple APNs를 통한 iOS 기기 관리

{ setEditTarget("ios"); - setSelectedFile(null); - setShowPassword(false); + resetEditForm(); + // 기존 인증 방식이 있으면 프리셋 + if (service.apnsAuthType) setIosAuthType(service.apnsAuthType); + if (service.apnsBundleId) setEditBundleId(service.apnsBundleId); + if (service.apnsKeyId) setEditKeyId(service.apnsKeyId); + if (service.apnsTeamId) setEditTeamId(service.apnsTeamId); }} onDelete={() => setDeleteTarget("iOS")} /> @@ -260,7 +486,7 @@ export default function PlatformManagement({
setShowAddModal(false)} + onClick={() => !submitting && setShowAddModal(false)} />
{/* 헤더 */} @@ -279,7 +505,7 @@ export default function PlatformManagement({
@@ -538,7 +794,7 @@ export default function PlatformManagement({
setDeleteTarget(null)} + onClick={() => !submitting && setDeleteTarget(null)} />
@@ -568,28 +824,29 @@ export default function PlatformManagement({
@@ -599,27 +856,27 @@ export default function PlatformManagement({ {/* 인증서 수정 모달 */} {editTarget && (() => { const cred = editTarget === "android" - ? service.platforms.android - : service.platforms.ios; + ? service.platforms?.android + : service.platforms?.ios; const isAndroid = editTarget === "android"; const statusLabel = - cred.credentialStatus === "error" ? "미인증 상태" - : cred.credentialStatus === "warn" ? "조치 필요" + cred?.credentialStatus === "error" ? "미인증 상태" + : cred?.credentialStatus === "warn" ? "조치 필요" : "인증됨"; const statusDesc = - cred.credentialStatus === "error" + cred?.credentialStatus === "error" ? "인증서가 등록되지 않았습니다. 인증서를 업로드해주세요." - : cred.statusReason ?? ""; + : cred?.statusReason ?? ""; const statusColor = - cred.credentialStatus === "error" ? "red" - : cred.credentialStatus === "warn" ? "amber" + cred?.credentialStatus === "error" ? "red" + : cred?.credentialStatus === "warn" ? "amber" : "green"; return (
setEditTarget(null)} + onClick={() => !submitting && setEditTarget(null)} />
{/* 헤더 */} @@ -648,7 +905,7 @@ export default function PlatformManagement({
+ {/* iOS Bundle ID */} + {!isAndroid && ( +
+ + setEditBundleId(e.target.value)} + placeholder="예: com.example.app" + disabled={submitting} + className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" + /> +
+ )} + {/* iOS P8: Key ID + Team ID */} {!isAndroid && iosAuthType === "p8" && (
@@ -808,7 +1085,10 @@ export default function PlatformManagement({ setEditKeyId(e.target.value)} placeholder="예: ABC123DEFG" + disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
@@ -818,7 +1098,10 @@ export default function PlatformManagement({ setEditTeamId(e.target.value)} placeholder="예: 9ABCDEFGH1" + disabled={submitting} className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
@@ -844,7 +1127,10 @@ export default function PlatformManagement({
setEditCertPassword(e.target.value)} placeholder="P12 인증서 비밀번호를 입력하세요" + disabled={submitting} className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400" />
diff --git a/react/src/features/service/components/PlatformStatusIndicator.tsx b/react/src/features/service/components/PlatformStatusIndicator.tsx index db296a4..2b78daa 100644 --- a/react/src/features/service/components/PlatformStatusIndicator.tsx +++ b/react/src/features/service/components/PlatformStatusIndicator.tsx @@ -46,7 +46,7 @@ export default function PlatformStatusIndicator({ platform, credential, }: PlatformStatusIndicatorProps) { - if (!credential.registered) return null; + if (!credential?.registered) return null; const hasIssue = credential.credentialStatus === "warn" || diff --git a/react/src/features/service/components/ServiceHeaderCard.tsx b/react/src/features/service/components/ServiceHeaderCard.tsx index 6786798..87f9351 100644 --- a/react/src/features/service/components/ServiceHeaderCard.tsx +++ b/react/src/features/service/components/ServiceHeaderCard.tsx @@ -22,7 +22,7 @@ export default function ServiceHeaderCard({
- {service.serviceIcon} + {service.serviceIcon || "hub"}
@@ -78,18 +78,24 @@ export default function ServiceHeaderCard({ 플랫폼

- - - {!service.platforms.android.registered && - !service.platforms.ios.registered && ( - 없음 - )} + {service.platforms ? ( + <> + + + {!service.platforms?.android?.registered && + !service.platforms?.ios?.registered && ( + 없음 + )} + + ) : ( + 없음 + )}
@@ -105,7 +111,7 @@ export default function ServiceHeaderCard({ 생성일

- {service.createdAt} + {service.createdAt?.slice(0, 10)}

@@ -113,7 +119,7 @@ export default function ServiceHeaderCard({ 마지막 업데이트

- {service.updatedAt ?? "-"} + {service.updatedAt?.slice(0, 10) ?? "-"}

diff --git a/react/src/features/service/components/ServiceStatsCards.tsx b/react/src/features/service/components/ServiceStatsCards.tsx index b59a5a2..1e392dc 100644 --- a/react/src/features/service/components/ServiceStatsCards.tsx +++ b/react/src/features/service/components/ServiceStatsCards.tsx @@ -1,7 +1,7 @@ interface StatCard { label: string; value: string; - sub: { type: "trend" | "stable" | "live"; text: string; color?: string }; + sub: { type: "trend" | "stable"; text: string; color?: string }; icon: string; iconBg: string; iconColor: string; @@ -12,6 +12,10 @@ interface ServiceStatsCardsProps { successRate: number; deviceCount: number; todaySent: number; + sentChangeRate?: number; + successRateChange?: number; + deviceCountChange?: number; + todaySentChangeRate?: number; } export default function ServiceStatsCards({ @@ -19,12 +23,22 @@ export default function ServiceStatsCards({ successRate, deviceCount, todaySent, + sentChangeRate = 0, + successRateChange = 0, + deviceCountChange = 0, + todaySentChangeRate = 0, }: ServiceStatsCardsProps) { + const noChange: StatCard["sub"] = { type: "stable", text: "변동 없음" }; + const cards: StatCard[] = [ { label: "총 발송 수", value: totalSent.toLocaleString(), - sub: { type: "trend", text: "+12.5%", color: "text-indigo-600" }, + sub: sentChangeRate === 0 + ? noChange + : sentChangeRate > 0 + ? { type: "trend", text: `+${sentChangeRate.toLocaleString()}`, color: "text-indigo-600" } + : { type: "trend", text: `${sentChangeRate.toLocaleString()}`, color: "text-red-500" }, icon: "equalizer", iconBg: "bg-indigo-50", iconColor: "text-indigo-600", @@ -32,7 +46,11 @@ export default function ServiceStatsCards({ { label: "성공률", value: `${successRate}%`, - sub: { type: "stable", text: "Stable" }, + sub: successRateChange === 0 + ? noChange + : successRateChange > 0 + ? { type: "trend", text: `+${successRateChange.toFixed(1)}%`, color: "text-emerald-600" } + : { type: "trend", text: `${successRateChange.toFixed(1)}%`, color: "text-red-500" }, icon: "check_circle", iconBg: "bg-emerald-50", iconColor: "text-emerald-600", @@ -40,7 +58,11 @@ export default function ServiceStatsCards({ { label: "등록 기기 수", value: deviceCount.toLocaleString(), - sub: { type: "trend", text: "+82 today", color: "text-amber-600" }, + sub: deviceCountChange === 0 + ? noChange + : deviceCountChange > 0 + ? { type: "trend", text: `+${deviceCountChange.toLocaleString()} today`, color: "text-amber-600" } + : { type: "trend", text: `${deviceCountChange.toLocaleString()} today`, color: "text-red-500" }, icon: "devices", iconBg: "bg-amber-50", iconColor: "text-amber-600", @@ -48,7 +70,11 @@ export default function ServiceStatsCards({ { label: "오늘 발송", value: todaySent.toLocaleString(), - sub: { type: "live", text: "Live" }, + sub: todaySentChangeRate === 0 + ? noChange + : todaySentChangeRate > 0 + ? { type: "trend", text: `+${todaySentChangeRate.toLocaleString()}`, color: "text-[#2563EB]" } + : { type: "trend", text: `${todaySentChangeRate.toLocaleString()}`, color: "text-red-500" }, icon: "today", iconBg: "bg-[#2563EB]/5", iconColor: "text-[#2563EB]", @@ -77,7 +103,7 @@ export default function ServiceStatsCards({ className="material-symbols-outlined" style={{ fontSize: "14px" }} > - trending_up + {card.sub.text.startsWith("-") ? "trending_down" : "trending_up"} {card.sub.text}

@@ -88,12 +114,6 @@ export default function ServiceStatsCards({ {card.sub.text}

)} - {card.sub.type === "live" && ( -

- - {card.sub.text} -

- )}
(); - const [showApiKey, setShowApiKey] = useState(false); - // 목 데이터에서 서비스 조회 (목록에서 클릭한 ID로) - const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id); - const service = listItem - ? { ...MOCK_SERVICE_DETAIL, ...listItem, serviceCode: listItem.serviceCode } - : MOCK_SERVICE_DETAIL; + // 데이터 상태 + const [service, setService] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + // 통계 상태 + const [stats, setStats] = useState(null); + + // API 키 모달 상태 + const [showApiKey, setShowApiKey] = useState(false); + const [fullApiKey, setFullApiKey] = useState(null); + const [apiKeyLoading, setApiKeyLoading] = useState(false); + + // 서비스 상세 + 통계 로드 + const loadData = useCallback(async (serviceCode: string) => { + setLoading(true); + setError(false); + + // 오늘 날짜 기준 통계 요청 + const today = new Date().toISOString().slice(0, 10); + + try { + const [serviceRes, dashboardRes] = await Promise.allSettled([ + fetchServiceDetail(serviceCode), + fetchDashboard({ start_date: today, end_date: today }, serviceCode), + ]); + + if (serviceRes.status === "fulfilled") { + setService(serviceRes.value.data.data); + } else { + setError(true); + return; + } + + if (dashboardRes.status === "fulfilled") { + setStats(dashboardRes.value.data.data.kpi); + } + // 통계 실패 시 null 유지 → 0으로 폴백 + } catch { + setError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!id) return; + loadData(id); + }, [id, loadData]); + + // 플랫폼 변경 후 서비스 데이터 갱신 (로딩 스켈레톤 없이 조용히) + const handleRefresh = useCallback(async () => { + if (!id) return; + const today = new Date().toISOString().slice(0, 10); + try { + const [serviceRes, dashboardRes] = await Promise.allSettled([ + fetchServiceDetail(id), + fetchDashboard({ start_date: today, end_date: today }, id), + ]); + if (serviceRes.status === "fulfilled") { + setService(serviceRes.value.data.data); + } + if (dashboardRes.status === "fulfilled") { + setStats(dashboardRes.value.data.data.kpi); + } + } catch { + // 리프레시 실패는 무시 (기존 데이터 유지) + } + }, [id]); + + // API 키 조회 + const handleShowApiKey = async () => { + if (!id) return; + setApiKeyLoading(true); + try { + const res = await fetchApiKey(id); + setFullApiKey(res.data.data.apiKey); + setShowApiKey(true); + } catch { + // 실패 시 마스킹된 키라도 보여줌 + setFullApiKey(service?.apiKey ?? null); + setShowApiKey(true); + } finally { + setApiKeyLoading(false); + } + }; + + const handleCloseApiKey = () => { + setShowApiKey(false); + setFullApiKey(null); + }; + + // 로딩 스켈레톤 + if (loading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); + } + + // 에러 상태 + if (error || !service) { + return ( +
+ +
+ + cloud_off + +

+ 서비스 정보를 불러오지 못했습니다. +

+ +
+
+ ); + } + + // 오늘 발송 = daily_trend의 오늘 데이터 또는 kpi.total_sent (서비스별 조회 시 오늘 기간) + const todaySent = stats?.total_sent ?? 0; return (
@@ -23,24 +163,28 @@ export default function ServiceDetailPage() { setShowApiKey(true)} + onShowApiKey={handleShowApiKey} /> - + {/* API 키 확인 모달 */} {showApiKey && (
setShowApiKey(false)} + onClick={handleCloseApiKey} />
@@ -53,7 +197,7 @@ export default function ServiceDetailPage() {

API 키

- - {service.apiKey} - - + {apiKeyLoading ? ( +
+ ) : ( + <> + + {fullApiKey ?? service.apiKey} + + + + )}
diff --git a/react/src/features/service/pages/ServiceEditPage.tsx b/react/src/features/service/pages/ServiceEditPage.tsx index 8aa41cf..21ae113 100644 --- a/react/src/features/service/pages/ServiceEditPage.tsx +++ b/react/src/features/service/pages/ServiceEditPage.tsx @@ -1,11 +1,15 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; +import { AxiosError } from "axios"; import PageHeader from "@/components/common/PageHeader"; import PlatformStatusIndicator from "../components/PlatformStatusIndicator"; import useShake from "@/hooks/useShake"; import { formatNumber } from "@/utils/format"; -import { MOCK_SERVICE_DETAIL, MOCK_SERVICES, SERVICE_STATUS } from "../types"; +import { fetchServiceDetail, updateService } from "@/api/service.api"; +import type { ApiError } from "@/types/api"; +import type { ServiceDetail } from "../types"; +import { SERVICE_STATUS } from "../types"; // Apple 로고 SVG const AppleLogo = ({ className }: { className?: string }) => ( @@ -18,43 +22,162 @@ export default function ServiceEditPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - // 목 데이터에서 서비스 조회 - const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id); - const service = listItem - ? { ...MOCK_SERVICE_DETAIL, ...listItem } - : MOCK_SERVICE_DETAIL; + // 데이터 상태 + const [service, setService] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); - // 폼 상태 - const [serviceName, setServiceName] = useState(service.serviceName); - const [isActive, setIsActive] = useState( - service.status === SERVICE_STATUS.ACTIVE - ); - const [description, setDescription] = useState(service.description ?? ""); + // 폼 상태 (service 로드 후 초기화) + const [serviceName, setServiceName] = useState(""); + const [isActive, setIsActive] = useState(true); + const [description, setDescription] = useState(""); // 에러 상태 const [nameError, setNameError] = useState(false); + const [nameErrorMsg, setNameErrorMsg] = useState("필수 입력 항목입니다."); const { triggerShake, cls } = useShake(); + // 제출 상태 + const [submitting, setSubmitting] = useState(false); + // 모달 상태 const [showSaveModal, setShowSaveModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false); + // 서비스 상세 로드 + useEffect(() => { + if (!id) return; + let cancelled = false; + + async function load() { + setLoading(true); + setError(false); + try { + const res = await fetchServiceDetail(id!); + if (!cancelled) { + setService(res.data.data); + } + } catch { + if (!cancelled) { + setError(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + load(); + return () => { + cancelled = true; + }; + }, [id]); + + // service 로드 후 폼 초기화 + useEffect(() => { + if (service) { + setServiceName(service.serviceName); + setIsActive(service.status === SERVICE_STATUS.ACTIVE); + setDescription(service.description ?? ""); + } + }, [service]); + // 저장 const handleSave = () => { - if (!serviceName.trim()) { + const trimmed = serviceName.trim(); + if (!trimmed || trimmed.length < 2) { setNameError(true); + setNameErrorMsg( + !trimmed ? "필수 입력 항목입니다." : "서비스 명은 2자 이상이어야 합니다.", + ); triggerShake(["name"]); return; } setShowSaveModal(true); }; - const handleSaveConfirm = () => { + const handleSaveConfirm = async () => { setShowSaveModal(false); - toast.success("변경사항이 저장되었습니다."); - navigate(`/services/${id}`); + setSubmitting(true); + + try { + await updateService({ + serviceCode: id!, + serviceName: serviceName.trim(), + description: description.trim() || null, + status: isActive ? 0 : 1, + }); + toast.success("변경사항이 저장되었습니다."); + navigate(`/services/${id}`); + } catch (err) { + const axiosErr = err as AxiosError; + const status = axiosErr.response?.status; + const msg = axiosErr.response?.data?.msg; + + if (status === 409) { + toast.error(msg ?? "이미 사용 중인 서비스 명입니다."); + setNameError(true); + setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다."); + triggerShake(["name"]); + } else if (status === 400) { + toast.error(msg ?? "변경된 내용이 없습니다."); + } else { + toast.error(msg ?? "저장에 실패했습니다. 다시 시도해주세요."); + } + } finally { + setSubmitting(false); + } }; + // 로딩 스켈레톤 + if (loading) { + return ( +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + // 에러 상태 + if (error || !service) { + return ( +
+ +
+ + error_outline + +

+ 서비스 정보를 불러올 수 없습니다. +

+ +
+
+ ); + } + return (
error - 필수 입력 항목입니다. + {nameErrorMsg}
)}
@@ -160,18 +283,24 @@ export default function ServiceEditPage() { 플랫폼

- - - {!service.platforms.android.registered && - !service.platforms.ios.registered && ( - 없음 - )} + {service.platforms ? ( + <> + + + {!service.platforms?.android?.registered && + !service.platforms?.ios?.registered && ( + 없음 + )} + + ) : ( + 없음 + )}
@@ -187,7 +316,7 @@ export default function ServiceEditPage() { 생성일

- {service.createdAt} + {service.createdAt?.slice(0, 10)}

@@ -195,7 +324,7 @@ export default function ServiceEditPage() { 마지막 업데이트

- {service.updatedAt ?? "-"} + {service.updatedAt?.slice(0, 10) ?? "-"}

@@ -212,9 +341,10 @@ export default function ServiceEditPage() {
@@ -235,7 +365,7 @@ export default function ServiceEditPage() {
{/* Android */} - {service.platforms.android.registered && ( + {service.platforms?.android?.registered && (
@@ -279,7 +409,7 @@ export default function ServiceEditPage() { )} {/* iOS */} - {service.platforms.ios.registered && ( + {service.platforms?.ios?.registered && (
@@ -321,8 +451,8 @@ export default function ServiceEditPage() { )} {/* 빈 상태 */} - {!service.platforms.android.registered && - !service.platforms.ios.registered && ( + {!service.platforms?.android?.registered && + !service.platforms?.ios?.registered && (
devices @@ -375,7 +505,8 @@ export default function ServiceEditPage() { diff --git a/react/src/features/service/pages/ServiceListPage.tsx b/react/src/features/service/pages/ServiceListPage.tsx index 5c21db9..cefad65 100644 --- a/react/src/features/service/pages/ServiceListPage.tsx +++ b/react/src/features/service/pages/ServiceListPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Link, useNavigate } from "react-router-dom"; import PageHeader from "@/components/common/PageHeader"; import SearchInput from "@/components/common/SearchInput"; @@ -10,13 +10,21 @@ import EmptyState from "@/components/common/EmptyState"; import CopyButton from "@/components/common/CopyButton"; import PlatformStatusIndicator from "../components/PlatformStatusIndicator"; import { formatDate, formatNumber } from "@/utils/format"; -import { MOCK_SERVICES, SERVICE_STATUS } from "../types"; +import { fetchServices } from "@/api/service.api"; +import { SERVICE_STATUS } from "../types"; import type { ServiceSummary } from "../types"; const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"]; const PAGE_SIZE = 10; -// 상태값 매핑 +// 상태 필터 → API status 값 매핑 +function mapStatusFilter(filter: string): number | undefined { + if (filter === "활성") return 0; + if (filter === "비활성") return 1; + return undefined; +} + +// 상태값 배지 function getStatusBadge(status: ServiceSummary["status"]) { if (status === SERVICE_STATUS.ACTIVE) { return ; @@ -31,21 +39,57 @@ export default function ServiceListPage() { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("전체 상태"); const [currentPage, setCurrentPage] = useState(1); + + // 데이터 상태 + const [items, setItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); // 실제 적용될 필터 (조회 버튼 클릭 시 반영) const [appliedSearch, setAppliedSearch] = useState(""); const [appliedStatus, setAppliedStatus] = useState("전체 상태"); - // 조회 버튼 (로딩 인디케이터 포함) + // API 호출 + const loadData = useCallback( + async (page: number, searchKeyword: string, statusFilterValue: string) => { + setLoading(true); + setError(false); + try { + const res = await fetchServices({ + page, + pageSize: PAGE_SIZE, + searchKeyword: searchKeyword || undefined, + status: mapStatusFilter(statusFilterValue), + }); + const data = res.data.data; + setItems(data.items ?? []); + setTotalItems(data.totalCount ?? 0); + setTotalPages(data.totalPages ?? 1); + } catch { + setError(true); + setItems([]); + setTotalItems(0); + setTotalPages(1); + } finally { + setLoading(false); + } + }, + [], + ); + + // 초기 로드 + useEffect(() => { + loadData(1, "", "전체 상태"); + }, [loadData]); + + // 조회 버튼 const handleQuery = () => { - setLoading(true); - setTimeout(() => { - setAppliedSearch(search); - setAppliedStatus(statusFilter); - setCurrentPage(1); - setLoading(false); - }, 400); + setAppliedSearch(search); + setAppliedStatus(statusFilter); + setCurrentPage(1); + loadData(1, search, statusFilter); }; // 필터 초기화 @@ -55,37 +99,14 @@ export default function ServiceListPage() { setAppliedSearch(""); setAppliedStatus("전체 상태"); setCurrentPage(1); + loadData(1, "", "전체 상태"); }; - // 필터링된 데이터 - const filtered = useMemo(() => { - return MOCK_SERVICES.filter((svc) => { - // 검색 - if (appliedSearch) { - const q = appliedSearch.toLowerCase(); - if ( - !svc.serviceName.toLowerCase().includes(q) && - !svc.serviceCode.toLowerCase().includes(q) - ) { - return false; - } - } - // 상태 필터 - if (appliedStatus === "활성" && svc.status !== SERVICE_STATUS.ACTIVE) - return false; - if (appliedStatus === "비활성" && svc.status !== SERVICE_STATUS.SUSPENDED) - return false; - return true; - }); - }, [appliedSearch, appliedStatus]); - - // 페이지네이션 - 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, appliedStatus); + }; return (
@@ -132,8 +153,23 @@ export default function ServiceListPage() {
- {/* 테이블 */} - {loading ? ( + {/* 에러 상태 */} + {error ? ( +
+ + cloud_off + +

+ 데이터를 불러오지 못했습니다. +

+ +
+ ) : loading ? ( /* 로딩 스켈레톤 */
@@ -194,7 +230,7 @@ export default function ServiceListPage() {
- ) : paged.length > 0 ? ( + ) : items.length > 0 ? (
@@ -220,10 +256,10 @@ export default function ServiceListPage() { - {paged.map((svc, idx) => ( + {items.map((svc, idx) => ( navigate(`/services/${svc.serviceCode}`)} > {/* 서비스명 */} @@ -231,7 +267,7 @@ export default function ServiceListPage() {
- {svc.serviceIcon} + {svc.serviceIcon || "hub"}
@@ -254,14 +290,20 @@ export default function ServiceListPage() { {/* 플랫폼 */}
{/* 기기 수 */} @@ -287,7 +329,7 @@ export default function ServiceListPage() { totalPages={totalPages} totalItems={totalItems} pageSize={PAGE_SIZE} - onPageChange={setCurrentPage} + onPageChange={handlePageChange} /> ) : ( diff --git a/react/src/features/service/pages/ServiceRegisterPage.tsx b/react/src/features/service/pages/ServiceRegisterPage.tsx index 7f1ac84..cec50aa 100644 --- a/react/src/features/service/pages/ServiceRegisterPage.tsx +++ b/react/src/features/service/pages/ServiceRegisterPage.tsx @@ -1,9 +1,14 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { AxiosError } from "axios"; import PageHeader from "@/components/common/PageHeader"; +import CopyButton from "@/components/common/CopyButton"; import PlatformSelector from "../components/PlatformSelector"; import useShake from "@/hooks/useShake"; +import { createService } from "@/api/service.api"; +import type { ApiError } from "@/types/api"; +import type { CreateServiceResponse } from "../types"; export default function ServiceRegisterPage() { const navigate = useNavigate(); @@ -18,15 +23,27 @@ export default function ServiceRegisterPage() { // 에러 상태 const [nameError, setNameError] = useState(false); + const [nameErrorMsg, setNameErrorMsg] = useState("서비스 명을 입력해주세요."); const { triggerShake, cls } = useShake(); + // 제출 상태 + const [submitting, setSubmitting] = useState(false); + // 모달 const [showConfirm, setShowConfirm] = useState(false); + // API Key 모달 + const [apiKeyResult, setApiKeyResult] = useState(null); + const [showApiKeyModal, setShowApiKeyModal] = useState(false); + // 등록 버튼 클릭 const handleRegister = () => { - if (!serviceName.trim()) { + const trimmed = serviceName.trim(); + if (!trimmed || trimmed.length < 2) { setNameError(true); + setNameErrorMsg( + !trimmed ? "서비스 명을 입력해주세요." : "서비스 명은 2자 이상이어야 합니다.", + ); triggerShake(["name"]); return; } @@ -34,10 +51,41 @@ export default function ServiceRegisterPage() { }; // 등록 확인 - const handleConfirm = () => { + const handleConfirm = async () => { setShowConfirm(false); - toast.success("서비스가 등록되었습니다."); - navigate("/services"); + setSubmitting(true); + + try { + const res = await createService({ + serviceName: serviceName.trim(), + description: description.trim() || null, + }); + setApiKeyResult(res.data.data); + setShowApiKeyModal(true); + } catch (err) { + const axiosErr = err as AxiosError; + const status = axiosErr.response?.status; + const msg = axiosErr.response?.data?.msg; + + if (status === 409) { + toast.error(msg ?? "이미 사용 중인 서비스 명입니다."); + setNameError(true); + setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다."); + triggerShake(["name"]); + } else { + toast.error(msg ?? "서비스 등록에 실패했습니다. 다시 시도해주세요."); + } + } finally { + setSubmitting(false); + } + }; + + // API Key 모달 닫기 → 상세 페이지 이동 + const handleApiKeyModalClose = () => { + setShowApiKeyModal(false); + if (apiKeyResult) { + navigate(`/services/${apiKeyResult.serviceCode}`); + } }; return ( @@ -77,7 +125,7 @@ export default function ServiceRegisterPage() { > error - 서비스 명을 입력해주세요. + {nameErrorMsg} )} @@ -140,9 +188,10 @@ export default function ServiceRegisterPage() { @@ -199,6 +248,62 @@ export default function ServiceRegisterPage() { )} + + {/* API Key 결과 모달 */} + {showApiKeyModal && apiKeyResult && ( +
+
+
+
+
+
+ + check_circle + +
+

+ 서비스 등록 완료 +

+
+ +
+
+
+ + warning + + + API 키는 외부에 노출되지 않도록 주의해 주세요. + +
+
+
+ + {apiKeyResult.apiKey} + + +
+
+ +
+
+
+ )}
); } diff --git a/react/src/features/service/types.ts b/react/src/features/service/types.ts index 5fabce2..1af9996 100644 --- a/react/src/features/service/types.ts +++ b/react/src/features/service/types.ts @@ -28,7 +28,7 @@ export interface PlatformCredentialSummary { export interface ServiceSummary { serviceCode: string; serviceName: string; - serviceIcon: string; + serviceIcon?: string; description: string | null; status: ServiceStatus; createdAt: string; @@ -36,7 +36,7 @@ export interface ServiceSummary { platforms: { android: PlatformCredentialSummary; ios: PlatformCredentialSummary; - }; + } | null; } // 서비스 상세 @@ -48,271 +48,66 @@ export interface ServiceDetail extends ServiceSummary { hasFcmCredentials: boolean; createdByName: string | null; updatedAt: string | null; + apnsBundleId?: string | null; + apnsKeyId?: string | null; + apnsTeamId?: string | null; + webhookUrl?: string | null; + tags?: string | null; + subTier?: string | null; + subStartedAt?: string | null; + allowedIps?: string[] | null; } -// 목 데이터 - 서비스 목록 -export const MOCK_SERVICES: ServiceSummary[] = [ - { - serviceCode: "svc-factory-01", - serviceName: "스마트 팩토리 모니터링", - serviceIcon: "smart_toy", - description: "공장 모니터링 시스템을 위한 푸시 알림 서비스", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-11-01", - deviceCount: 12540, - platforms: { - android: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - ios: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - }, - }, - { - serviceCode: "svc-logistics-02", - serviceName: "물류 배송 추적", - serviceIcon: "local_shipping", - description: "배송 추적 알림 서비스", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-10-28", - deviceCount: 3205, - platforms: { - android: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - }, - }, - { - serviceCode: "svc-messenger-03", - serviceName: "사내 메신저 Pro", - serviceIcon: "forum", - description: "사내 메신저 푸시 알림", - status: SERVICE_STATUS.SUSPENDED, - createdAt: "2023-10-15", - deviceCount: 850, - platforms: { - android: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "warn", - statusReason: "인증서 만료 30일 전", - expiresAt: "2024-06-15", - }, - }, - }, - { - serviceCode: "svc-inventory-04", - serviceName: "매장 재고 관리", - serviceIcon: "inventory_2", - description: "매장 재고 변동 알림", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-10-10", - deviceCount: 4100, - platforms: { - android: { - registered: true, - credentialStatus: "warn", - statusReason: "FCM 키 갱신 필요", - expiresAt: "2024-07-01", - }, - ios: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - }, - }, - { - serviceCode: "svc-access-05", - serviceName: "방문자 출입 통제", - serviceIcon: "door_front", - description: "출입 통제 시스템 알림", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-10-05", - deviceCount: 18, - platforms: { - android: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "error", - statusReason: "인증서 만료됨", - expiresAt: "2024-01-01", - }, - }, - }, - { - serviceCode: "svc-shop-06", - serviceName: "스마트 쇼핑몰", - serviceIcon: "shopping_bag", - description: "쇼핑몰 알림 서비스", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-09-20", - deviceCount: 8320, - platforms: { - android: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - }, - }, - { - serviceCode: "svc-health-07", - serviceName: "헬스케어 알림", - serviceIcon: "health_and_safety", - description: "건강 관리 알림 서비스", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-09-15", - deviceCount: 1560, - platforms: { - android: { - registered: true, - credentialStatus: "error", - statusReason: "FCM 서비스 계정 만료", - expiresAt: "2024-02-01", - }, - ios: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - }, - }, - { - serviceCode: "svc-edu-08", - serviceName: "교육 플랫폼", - serviceIcon: "school", - description: "교육 콘텐츠 알림", - status: SERVICE_STATUS.SUSPENDED, - createdAt: "2023-09-01", - deviceCount: 5430, - platforms: { - android: { - registered: true, - credentialStatus: "warn", - statusReason: "FCM 키 갱신 권장", - expiresAt: "2024-08-01", - }, - ios: { - registered: true, - credentialStatus: "error", - statusReason: "인증서 만료됨", - expiresAt: "2024-03-01", - }, - }, - }, - { - serviceCode: "svc-food-09", - serviceName: "푸드 딜리버리", - serviceIcon: "restaurant", - description: "음식 배달 알림", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-08-25", - deviceCount: 22180, - platforms: { - android: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "warn", - statusReason: "인증서 만료 60일 전", - expiresAt: "2024-09-01", - }, - }, - }, - { - serviceCode: "svc-fleet-10", - serviceName: "차량 관제 시스템", - serviceIcon: "directions_car", - description: "차량 관제 알림", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-08-10", - deviceCount: 640, - platforms: { - android: { - registered: true, - credentialStatus: "ok", - statusReason: null, - expiresAt: null, - }, - ios: { - registered: false, - credentialStatus: null, - statusReason: null, - expiresAt: null, - }, - }, - }, -]; +// 서비스 목록 요청 +export interface ServiceListRequest { + page: number; + pageSize: number; + searchKeyword?: string; + status?: number; // 0=Active, 1=Suspended, undefined=전체 +} -// 목 데이터 - 서비스 상세 -export const MOCK_SERVICE_DETAIL: ServiceDetail = { - serviceCode: "svc-factory-monitoring-01", - serviceName: "(주) 미래테크", - serviceIcon: "corporate_fare", - description: - "공장 모니터링 시스템을 위한 푸시 알림 서비스입니다. 설비 이상 감지, 작업 지시, 안전 알림 등을 실시간으로 전달합니다.", - status: SERVICE_STATUS.ACTIVE, - createdAt: "2023-10-15", - deviceCount: 1240, - platforms: { - android: { - registered: true, - credentialStatus: "error", - statusReason: "인증서를 등록해주세요", - expiresAt: null, - }, - ios: { - registered: true, - credentialStatus: "warn", - statusReason: ".p12 인증서 만료 30일 전", - expiresAt: "2024-06-15", - }, - }, - apiKey: "a3f8k2m1x9c4b7e0d5g6h8j2l4n7p0q3r5s8t1u6v9w2y4z0A3B7C1D5E9F2G6H0", - apiKeyCreatedAt: "2023-10-15", - apnsAuthType: "p12", - hasApnsKey: true, - hasFcmCredentials: false, - createdByName: "Admin User", - updatedAt: "2024-05-12", -}; +// API 키 응답 (view/refresh 공용) +export interface ApiKeyResponse { + serviceCode: string; + apiKey: string; + apiKeyCreatedAt: string; +} + +// 서비스 생성 요청 +export interface CreateServiceRequest { + serviceName: string; + description?: string | null; +} + +// 서비스 생성 응답 (API Key 1회 표시) +export interface CreateServiceResponse { + serviceCode: string; + apiKey: string; + apiKeyCreatedAt: string; +} + +// 서비스 수정 요청 +export interface UpdateServiceRequest { + serviceCode: string; + serviceName?: string | null; + description?: string | null; + status?: number | null; // 0=Active, 1=Suspended +} + +// FCM 인증서 등록 요청 +export interface RegisterFcmRequest { + serviceAccountJson: string; // JSON 파일 내용 (문자열) +} + +// APNs 인증서 등록 요청 +export interface RegisterApnsRequest { + authType: "p8" | "p12"; + bundleId: string; + // p8 전용 + keyId?: string; + teamId?: string; + privateKey?: string; + // p12 전용 + certificateBase64?: string; + certPassword?: string; +} diff --git a/react/src/types/api.ts b/react/src/types/api.ts index d5160ab..4070366 100644 --- a/react/src/types/api.ts +++ b/react/src/types/api.ts @@ -13,7 +13,7 @@ export interface PaginatedResponse { msg: string | null; data: { items: T[]; - total: number; + totalCount: number; page: number; pageSize: number; totalPages: number; -- 2.45.1
- - + {svc.platforms ? ( + <> + + + + ) : ( + - + )}