feat: 서비스 관리 API 연동 및 UI 개선 (#31)
All checks were successful
SPMS_BO/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/32
This commit is contained in:
김선규 2026-03-01 01:38:39 +00:00
commit 8f753a668b
13 changed files with 1115 additions and 487 deletions

View File

@ -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<PaginatedResponse<ServiceSummary>>(
"/v1/in/service/list",
data,
);
}
/** 서비스 상세 조회 */
export function fetchServiceDetail(serviceCode: string) {
return apiClient.post<ApiResponse<ServiceDetail>>(
`/v1/in/service/${serviceCode}`,
);
}
/** API Key 전체 조회 (마스킹 해제) */
export function fetchApiKey(serviceCode: string) {
return apiClient.post<ApiResponse<ApiKeyResponse>>(
`/v1/in/service/${serviceCode}/apikey/view`,
);
}
/** 서비스 생성 */
export function createService(data: CreateServiceRequest) {
return apiClient.post<ApiResponse<CreateServiceResponse>>(
"/v1/in/service/create",
data,
);
}
/** 서비스 수정 */
export function updateService(data: UpdateServiceRequest) {
return apiClient.post<ApiResponse<null>>(
"/v1/in/service/update",
data,
);
}
/** FCM 인증서 등록 */
export function registerFcm(serviceCode: string, data: RegisterFcmRequest) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/fcm`,
data,
);
}
/** FCM 인증서 삭제 */
export function deleteFcm(serviceCode: string) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/fcm/delete`,
);
}
/** APNs 인증서 등록 */
export function registerApns(serviceCode: string, data: RegisterApnsRequest) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/apns`,
data,
);
}
/** APNs 인증서 삭제 */
export function deleteApns(serviceCode: string) {
return apiClient.post<ApiResponse<null>>(
`/v1/in/service/${serviceCode}/apns/delete`,
);
}

View File

@ -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 (
<div className="flex items-center justify-between px-2 py-3">
<div className="flex items-center justify-between px-6 py-3">
<span className="text-sm text-gray-500">
{totalItems.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
{safeTotal.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
</span>
<div className="flex items-center gap-1">

View File

@ -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; // 오늘 발송 전일 대비 증감률 (%)
}
// 일별 발송 추이

View File

@ -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<string> {
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<string> {
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<string | null>(null);
const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null);
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [deletedPlatforms, setDeletedPlatforms] = useState<Set<string>>(new Set());
const [addedPlatforms, setAddedPlatforms] = useState<Set<string>>(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<File | null>(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 (
<>
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
@ -144,11 +368,9 @@ export default function PlatformManagement({
<button
disabled={allRegistered}
onClick={() => {
// 삭제된(미등록) 플랫폼 중 첫 번째를 기본 탭으로 선택
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
</p>
<CredentialStatusBadge
status={service.platforms.android.credentialStatus}
reason={service.platforms.android.statusReason}
status={service.platforms?.android?.credentialStatus ?? null}
reason={service.platforms?.android?.statusReason ?? null}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
setEditTarget("android");
setSelectedFile(null);
resetEditForm();
}}
onDelete={() => setDeleteTarget("Android")}
/>
@ -210,16 +432,20 @@ export default function PlatformManagement({
Apple APNs를 iOS
</p>
<CredentialStatusBadge
status={service.platforms.ios.credentialStatus}
reason={service.platforms.ios.statusReason}
status={service.platforms?.ios?.credentialStatus ?? null}
reason={service.platforms?.ios?.statusReason ?? null}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
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({
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowAddModal(false)}
onClick={() => !submitting && setShowAddModal(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
{/* 헤더 */}
@ -279,7 +505,7 @@ export default function PlatformManagement({
</div>
</div>
<button
onClick={() => setShowAddModal(false)}
onClick={() => !submitting && setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
@ -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({
<button
type="button"
onClick={() => { 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({
<button
type="button"
onClick={() => { 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({
</div>
<button
onClick={() => setAddFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
@ -440,6 +669,23 @@ export default function PlatformManagement({
)}
</div>
{/* iOS Bundle ID */}
{!isAndroidTab && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Bundle ID
</label>
<input
type="text"
value={addBundleId}
onChange={(e) => 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"
/>
</div>
)}
{/* iOS P8: Key ID + Team ID */}
{!isAndroidTab && addIosAuthType === "p8" && (
<div className="mb-5">
@ -450,7 +696,10 @@ export default function PlatformManagement({
</label>
<input
type="text"
value={addKeyId}
onChange={(e) => 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"
/>
</div>
@ -460,7 +709,10 @@ export default function PlatformManagement({
</label>
<input
type="text"
value={addTeamId}
onChange={(e) => 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"
/>
</div>
@ -486,7 +738,10 @@ export default function PlatformManagement({
<div className="relative">
<input
type={addShowPassword ? "text" : "password"}
value={addCertPassword}
onChange={(e) => 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"
/>
<button
@ -506,26 +761,27 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
<button
onClick={() => 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"
>
</button>
<button
onClick={() => {
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"
>
<span className="material-symbols-outlined text-base">check</span>
<span></span>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">check</span>
<span></span>
</>
)}
</button>
</div>
</div>
@ -538,7 +794,7 @@ export default function PlatformManagement({
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setDeleteTarget(null)}
onClick={() => !submitting && setDeleteTarget(null)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
@ -568,28 +824,29 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3">
<button
onClick={() => 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"
>
</button>
<button
onClick={() => {
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"
>
<span className="material-symbols-outlined text-base">
delete
</span>
<span></span>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">
delete
</span>
<span></span>
</>
)}
</button>
</div>
</div>
@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setEditTarget(null)}
onClick={() => !submitting && setEditTarget(null)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
{/* 헤더 */}
@ -648,7 +905,7 @@ export default function PlatformManagement({
</div>
</div>
<button
onClick={() => setEditTarget(null)}
onClick={() => !submitting && setEditTarget(null)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
@ -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"}
</span>
<span className="text-xs font-medium" style={{
color: statusColor === "red" ? "#b91c1c" : statusColor === "amber" ? "#b45309" : "#15803d",
@ -696,6 +953,7 @@ export default function PlatformManagement({
<button
type="button"
onClick={() => 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({
<button
type="button"
onClick={() => 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({
</div>
<button
onClick={() => setSelectedFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
@ -798,6 +1058,23 @@ export default function PlatformManagement({
)}
</div>
{/* iOS Bundle ID */}
{!isAndroid && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Bundle ID
</label>
<input
type="text"
value={editBundleId}
onChange={(e) => 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"
/>
</div>
)}
{/* iOS P8: Key ID + Team ID */}
{!isAndroid && iosAuthType === "p8" && (
<div className="mb-5">
@ -808,7 +1085,10 @@ export default function PlatformManagement({
</label>
<input
type="text"
value={editKeyId}
onChange={(e) => 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"
/>
</div>
@ -818,7 +1098,10 @@ export default function PlatformManagement({
</label>
<input
type="text"
value={editTeamId}
onChange={(e) => 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"
/>
</div>
@ -844,7 +1127,10 @@ export default function PlatformManagement({
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={editCertPassword}
onChange={(e) => 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"
/>
<button
@ -864,19 +1150,27 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3">
<button
onClick={() => 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"
>
</button>
<button
onClick={() => {
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"
>
<span className="material-symbols-outlined text-base">save</span>
<span></span>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">save</span>
<span></span>
</>
)}
</button>
</div>
</div>

View File

@ -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" ||

View File

@ -22,7 +22,7 @@ export default function ServiceHeaderCard({
<div className="flex items-center gap-4">
<div className="size-14 rounded-xl bg-[#2563EB]/10 flex items-center justify-center">
<span className="material-symbols-outlined text-[#2563EB] text-3xl">
{service.serviceIcon}
{service.serviceIcon || "hub"}
</span>
</div>
<div className="flex flex-col">
@ -78,18 +78,24 @@ export default function ServiceHeaderCard({
</p>
<div className="flex items-center gap-2.5 mt-1.5">
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
<span className="text-xs text-gray-400"></span>
)}
{service.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<span className="text-xs text-gray-400"></span>
)}
</>
) : (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
@ -105,7 +111,7 @@ export default function ServiceHeaderCard({
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt}
{service.createdAt?.slice(0, 10)}
</p>
</div>
<div className="flex flex-col">
@ -113,7 +119,7 @@ export default function ServiceHeaderCard({
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt ?? "-"}
{service.updatedAt?.slice(0, 10) ?? "-"}
</p>
</div>
</div>

View File

@ -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"}
</span>
<span>{card.sub.text}</span>
</p>
@ -88,12 +114,6 @@ export default function ServiceStatsCards({
<span>{card.sub.text}</span>
</p>
)}
{card.sub.type === "live" && (
<p className="text-xs text-[#2563EB] font-medium flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-[#2563EB] animate-pulse" />
<span>{card.sub.text}</span>
</p>
)}
</div>
</div>
<div

View File

@ -1,21 +1,161 @@
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton";
import ServiceHeaderCard from "../components/ServiceHeaderCard";
import ServiceStatsCards from "../components/ServiceStatsCards";
import PlatformManagement from "../components/PlatformManagement";
import { MOCK_SERVICE_DETAIL, MOCK_SERVICES } from "../types";
import { fetchServiceDetail, fetchApiKey } from "@/api/service.api";
import { fetchDashboard } from "@/api/dashboard.api";
import type { ServiceDetail } from "../types";
import type { DashboardKpi } from "@/features/dashboard/types";
export default function ServiceDetailPage() {
const { id } = useParams<{ id: string }>();
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<ServiceDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// 통계 상태
const [stats, setStats] = useState<DashboardKpi | null>(null);
// API 키 모달 상태
const [showApiKey, setShowApiKey] = useState(false);
const [fullApiKey, setFullApiKey] = useState<string | null>(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 (
<div>
<PageHeader title="서비스 상세 정보" />
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
<div className="flex items-center gap-4">
<div className="size-14 rounded-xl bg-gray-100 animate-pulse" />
<div className="flex flex-col gap-2">
<div className="h-6 w-48 rounded bg-gray-100 animate-pulse" />
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
</div>
</div>
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
</div>
</div>
);
}
// 에러 상태
if (error || !service) {
return (
<div>
<PageHeader title="서비스 상세 정보" />
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-4xl text-gray-300">
cloud_off
</span>
<p className="text-sm text-gray-500">
.
</p>
<button
onClick={() => window.location.reload()}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
</div>
);
}
// 오늘 발송 = daily_trend의 오늘 데이터 또는 kpi.total_sent (서비스별 조회 시 오늘 기간)
const todaySent = stats?.total_sent ?? 0;
return (
<div>
@ -23,24 +163,28 @@ export default function ServiceDetailPage() {
<ServiceHeaderCard
service={service}
onShowApiKey={() => setShowApiKey(true)}
onShowApiKey={handleShowApiKey}
/>
<ServiceStatsCards
totalSent={154200}
successRate={99.2}
totalSent={stats?.total_sent ?? 0}
successRate={stats?.success_rate ?? 0}
deviceCount={service.deviceCount}
todaySent={5300}
todaySent={todaySent}
sentChangeRate={stats?.sent_change_rate}
successRateChange={stats?.success_rate_change}
deviceCountChange={stats?.device_count_change}
todaySentChangeRate={stats?.today_sent_change_rate}
/>
<PlatformManagement service={service} />
<PlatformManagement service={service} onRefresh={handleRefresh} />
{/* API 키 확인 모달 */}
{showApiKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowApiKey(false)}
onClick={handleCloseApiKey}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center justify-between mb-4">
@ -53,7 +197,7 @@ export default function ServiceDetailPage() {
<h3 className="text-lg font-bold text-[#0f172a]">API </h3>
</div>
<button
onClick={() => setShowApiKey(false)}
onClick={handleCloseApiKey}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">
@ -75,10 +219,16 @@ export default function ServiceDetailPage() {
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3">
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{service.apiKey}
</code>
<CopyButton text={service.apiKey} />
{apiKeyLoading ? (
<div className="flex-1 h-5 rounded bg-gray-200 animate-pulse" />
) : (
<>
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{fullApiKey ?? service.apiKey}
</code>
<CopyButton text={fullApiKey ?? service.apiKey} />
</>
)}
</div>
</div>
</div>

View File

@ -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<ServiceDetail | null>(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<ApiError>;
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 (
<div>
<PageHeader title="서비스 수정" />
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-8">
<div className="space-y-6">
<div>
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-10 bg-gray-100 rounded animate-pulse" />
</div>
<div>
<div className="h-4 w-16 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-10 bg-gray-100 rounded animate-pulse" />
</div>
<div>
<div className="h-4 w-12 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-24 bg-gray-100 rounded animate-pulse" />
</div>
</div>
</div>
</div>
);
}
// 에러 상태
if (error || !service) {
return (
<div>
<PageHeader title="서비스 수정" />
<div className="border border-gray-200 rounded-lg bg-white shadow-sm p-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
error_outline
</span>
<p className="text-sm text-gray-500 mb-4">
.
</p>
<button
onClick={() => navigate("/services")}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
</div>
);
}
return (
<div>
<PageHeader
@ -108,7 +231,7 @@ export default function ServiceEditPage() {
>
error
</span>
<span> .</span>
<span>{nameErrorMsg}</span>
</div>
)}
</div>
@ -160,18 +283,24 @@ export default function ServiceEditPage() {
</p>
<div className="flex items-center gap-1.5 mt-1.5">
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
<span className="text-xs text-gray-400"></span>
)}
{service.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<span className="text-xs text-gray-400"></span>
)}
</>
) : (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
@ -187,7 +316,7 @@ export default function ServiceEditPage() {
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt}
{service.createdAt?.slice(0, 10)}
</p>
</div>
<div className="flex flex-col">
@ -195,7 +324,7 @@ export default function ServiceEditPage() {
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt ?? "-"}
{service.updatedAt?.slice(0, 10) ?? "-"}
</p>
</div>
</div>
@ -212,9 +341,10 @@ export default function ServiceEditPage() {
</button>
<button
onClick={handleSave}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "저장 중..." : "저장하기"}
</button>
</div>
</div>
@ -235,7 +365,7 @@ export default function ServiceEditPage() {
</div>
{/* Android */}
{service.platforms.android.registered && (
{service.platforms?.android?.registered && (
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
@ -279,7 +409,7 @@ export default function ServiceEditPage() {
)}
{/* iOS */}
{service.platforms.ios.registered && (
{service.platforms?.ios?.registered && (
<div className="px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
@ -321,8 +451,8 @@ export default function ServiceEditPage() {
)}
{/* 빈 상태 */}
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
{!service.platforms?.android?.registered &&
!service.platforms?.ios?.registered && (
<div className="px-6 py-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
devices
@ -375,7 +505,8 @@ export default function ServiceEditPage() {
</button>
<button
onClick={handleSaveConfirm}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>

View File

@ -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 <StatusBadge variant="success" label="활성" />;
@ -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<ServiceSummary[]>([]);
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 (
<div>
@ -132,8 +153,23 @@ export default function ServiceListPage() {
</div>
</div>
{/* 테이블 */}
{loading ? (
{/* 에러 상태 */}
{error ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-12 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-4xl text-gray-300">
cloud_off
</span>
<p className="text-sm text-gray-500">
.
</p>
<button
onClick={() => loadData(currentPage, appliedSearch, appliedStatus)}
className="text-sm text-[#2563EB] hover:underline font-medium"
>
</button>
</div>
) : loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
@ -194,7 +230,7 @@ export default function ServiceListPage() {
</tbody>
</table>
</div>
) : paged.length > 0 ? (
) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
@ -220,10 +256,10 @@ export default function ServiceListPage() {
</tr>
</thead>
<tbody>
{paged.map((svc, idx) => (
{items.map((svc, idx) => (
<tr
key={svc.serviceCode}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => navigate(`/services/${svc.serviceCode}`)}
>
{/* 서비스명 */}
@ -231,7 +267,7 @@ export default function ServiceListPage() {
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-[#2563EB] text-base">
{svc.serviceIcon}
{svc.serviceIcon || "hub"}
</span>
</div>
<span className="text-sm font-medium text-[#0f172a]">
@ -254,14 +290,20 @@ export default function ServiceListPage() {
{/* 플랫폼 */}
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5">
<PlatformStatusIndicator
platform="android"
credential={svc.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={svc.platforms.ios}
/>
{svc.platforms ? (
<>
<PlatformStatusIndicator
platform="android"
credential={svc.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={svc.platforms.ios}
/>
</>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</div>
</td>
{/* 기기 수 */}
@ -287,7 +329,7 @@ export default function ServiceListPage() {
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
onPageChange={handlePageChange}
/>
</div>
) : (

View File

@ -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<CreateServiceResponse | null>(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<ApiError>;
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
</span>
<span> .</span>
<span>{nameErrorMsg}</span>
</div>
)}
</div>
@ -140,9 +188,10 @@ export default function ServiceRegisterPage() {
</button>
<button
onClick={handleRegister}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "등록 중..." : "등록하기"}
</button>
</div>
</div>
@ -199,6 +248,62 @@ export default function ServiceRegisterPage() {
</div>
</div>
)}
{/* API Key 결과 모달 */}
{showApiKeyModal && apiKeyResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-green-600 text-xl">
check_circle
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">
</h3>
</div>
<button
onClick={handleApiKeyModalClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">
close
</span>
</button>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
warning
</span>
<span>
API .
</span>
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3 mb-4">
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{apiKeyResult.apiKey}
</code>
<CopyButton text={apiKeyResult.apiKey} />
</div>
<div className="flex justify-end">
<button
onClick={handleApiKeyModalClose}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

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

View File

@ -13,7 +13,7 @@ export interface PaginatedResponse<T> {
msg: string | null;
data: {
items: T[];
total: number;
totalCount: number;
page: number;
pageSize: number;
totalPages: number;