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({
{
- // 삭제된(미등록) 플랫폼 중 첫 번째를 기본 탭으로 선택
const androidAvail = !androidVisible;
setAddTab(androidAvail ? "android" : "ios");
- setAddFile(null);
- setAddShowPassword(false);
+ resetAddForm();
setShowAddModal(true);
}}
className={`border px-4 py-2 rounded text-sm font-medium flex items-center gap-2 transition-colors ${
@@ -182,15 +404,15 @@ export default function PlatformManagement({
Google FCM을 통한 Android 기기 관리
{
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({
setShowAddModal(false)}
+ onClick={() => !submitting && setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
close
@@ -295,7 +521,7 @@ export default function PlatformManagement({
setAddFile(null);
}
}}
- disabled={!androidAvail}
+ disabled={!androidAvail || submitting}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!androidAvail
? "text-gray-300 border-transparent cursor-not-allowed"
@@ -317,7 +543,7 @@ export default function PlatformManagement({
setAddFile(null);
}
}}
- disabled={!iosAvail}
+ disabled={!iosAvail || submitting}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!iosAvail
? "text-gray-300 border-transparent cursor-not-allowed"
@@ -344,6 +570,7 @@ export default function PlatformManagement({
{ setAddIosAuthType("p8"); setAddFile(null); }}
+ disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5"
@@ -368,6 +595,7 @@ export default function PlatformManagement({
{ setAddIosAuthType("p12"); setAddFile(null); }}
+ disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5"
@@ -432,6 +660,7 @@ export default function PlatformManagement({
setAddFile(null)}
+ disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
close
@@ -440,6 +669,23 @@ export default function PlatformManagement({
)}
+ {/* iOS Bundle ID */}
+ {!isAndroidTab && (
+
+
+ Bundle ID
+
+ setAddBundleId(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 */}
{!isAndroidTab && addIosAuthType === "p8" && (
@@ -450,7 +696,10 @@ export default function PlatformManagement({
setAddKeyId(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"
/>
@@ -460,7 +709,10 @@ export default function PlatformManagement({
setAddTeamId(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"
/>
@@ -486,7 +738,10 @@ export default function PlatformManagement({
setAddCertPassword(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"
/>
setShowAddModal(false)}
+ disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
취소
{
- setAddedPlatforms((prev) => new Set(prev).add(addTab));
- setDeletedPlatforms((prev) => {
- const next = new Set(prev);
- next.delete(addTab);
- return next;
- });
- const name = isAndroidTab ? "Android" : "iOS";
- toast.success(`${name} 플랫폼이 추가되었습니다.`);
- setShowAddModal(false);
- }}
- className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
+ onClick={handleAdd}
+ disabled={submitting}
+ className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
- check
- 추가
+ {submitting ? (
+ <>
+ progress_activity
+ 처리 중...
+ >
+ ) : (
+ <>
+ check
+ 추가
+ >
+ )}
@@ -538,7 +794,7 @@ export default function PlatformManagement({
setDeleteTarget(null)}
+ onClick={() => !submitting && setDeleteTarget(null)}
/>
@@ -568,28 +824,29 @@ export default function PlatformManagement({
setDeleteTarget(null)}
+ disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
취소
{
- const key = deleteTarget === "Android" ? "android" : "ios";
- setDeletedPlatforms((prev) => new Set(prev).add(key));
- setAddedPlatforms((prev) => {
- const next = new Set(prev);
- next.delete(key);
- return next;
- });
- toast.success(`${deleteTarget} 플랫폼이 삭제되었습니다.`);
- setDeleteTarget(null);
- }}
- className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
+ onClick={handleDelete}
+ disabled={submitting}
+ className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
-
- delete
-
- 삭제
+ {submitting ? (
+ <>
+ progress_activity
+ 삭제 중...
+ >
+ ) : (
+ <>
+
+ delete
+
+ 삭제
+ >
+ )}
@@ -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({
setEditTarget(null)}
+ onClick={() => !submitting && setEditTarget(null)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
close
@@ -669,7 +926,7 @@ export default function PlatformManagement({
color: statusColor === "red" ? "#dc2626" : statusColor === "amber" ? "#d97706" : "#16a34a",
}}
>
- {cred.credentialStatus === "error" ? "error" : cred.credentialStatus === "warn" ? "warning" : "check_circle"}
+ {cred?.credentialStatus === "error" ? "error" : cred?.credentialStatus === "warn" ? "warning" : "check_circle"}
setIosAuthType("p8")}
+ disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5"
@@ -720,6 +978,7 @@ export default function PlatformManagement({
setIosAuthType("p12")}
+ disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5"
@@ -790,6 +1049,7 @@ export default function PlatformManagement({
setSelectedFile(null)}
+ disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
close
@@ -798,6 +1058,23 @@ export default function PlatformManagement({
)}
+ {/* iOS Bundle ID */}
+ {!isAndroid && (
+
+
+ Bundle ID
+
+ 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"
/>
setEditTarget(null)}
+ disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
취소
{
- toast.success("인증서가 저장되었습니다.");
- setEditTarget(null);
- }}
- className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
+ onClick={handleEdit}
+ disabled={submitting}
+ className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
- save
- 저장
+ {submitting ? (
+ <>
+ progress_activity
+ 저장 중...
+ >
+ ) : (
+ <>
+ save
+ 저장
+ >
+ )}
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
+
+
+ 서비스 정보를 불러오지 못했습니다.
+
+
window.location.reload()}
+ className="text-sm text-[#2563EB] hover:underline font-medium"
+ >
+ 다시 시도
+
+
+
+ );
+ }
+
+ // 오늘 발송 = 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 키
setShowApiKey(false)}
+ onClick={handleCloseApiKey}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
@@ -75,10 +219,16 @@ export default function ServiceDetailPage() {
-
- {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
+
+
+ 서비스 정보를 불러올 수 없습니다.
+
+
navigate("/services")}
+ className="text-sm text-[#2563EB] hover:underline font-medium"
+ >
+ 목록으로 돌아가기
+
+
+
+ );
+ }
+
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() {
- 저장하기
+ {submitting ? "저장 중..." : "저장하기"}
@@ -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
+
+
+ 데이터를 불러오지 못했습니다.
+
+
loadData(currentPage, appliedSearch, appliedStatus)}
+ className="text-sm text-[#2563EB] hover:underline font-medium"
+ >
+ 다시 시도
+
+
+ ) : 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() {
{/* 플랫폼 */}
-
-
+ {svc.platforms ? (
+ <>
+
+
+ >
+ ) : (
+
-
+ )}
{/* 기기 수 */}
@@ -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() {
- 등록하기
+ {submitting ? "등록 중..." : "등록하기"}
@@ -199,6 +248,62 @@ export default function ServiceRegisterPage() {
)}
+
+ {/* API Key 결과 모달 */}
+ {showApiKeyModal && apiKeyResult && (
+
+
+
+
+
+
+
+ check_circle
+
+
+
+ 서비스 등록 완료
+
+
+
+
+ close
+
+
+
+
+
+
+ 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;