feat: 서비스 관리 API 연동 및 UI 개선 (#31) #32

Merged
seonkyu.kim merged 1 commits from feature/SPMS-31-service-api-integration into develop 2026-03-01 01:38:41 +00:00
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, pageSize,
onPageChange, onPageChange,
}: PaginationProps) { }: PaginationProps) {
const start = (currentPage - 1) * pageSize + 1; const safeTotal = totalItems ?? 0;
const end = Math.min(currentPage * pageSize, totalItems); const start = safeTotal > 0 ? (currentPage - 1) * pageSize + 1 : 0;
const end = Math.min(currentPage * pageSize, safeTotal);
/** 표시할 페이지 번호 목록 (최대 5개) */ /** 표시할 페이지 번호 목록 (최대 5개) */
const getPageNumbers = () => { const getPageNumbers = () => {
@ -33,9 +34,9 @@ export default function Pagination({
if (totalPages <= 0) return null; if (totalPages <= 0) return null;
return ( 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"> <span className="text-sm text-gray-500">
{totalItems.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()} {safeTotal.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -19,6 +19,9 @@ export interface DashboardKpi {
device_count: number; device_count: number;
service_count: number; service_count: number;
sent_change_rate: 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 { useState, useRef, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceDetail } from "../types"; 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 // Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => ( const AppleLogo = ({ className }: { className?: string }) => (
@ -112,29 +143,222 @@ function PlatformMenu({
interface PlatformManagementProps { interface PlatformManagementProps {
service: ServiceDetail; service: ServiceDetail;
onRefresh: () => void;
} }
export default function PlatformManagement({ export default function PlatformManagement({
service, service,
onRefresh,
}: PlatformManagementProps) { }: PlatformManagementProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null); const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null);
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8"); const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showPassword, setShowPassword] = useState(false); 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 [showAddModal, setShowAddModal] = useState(false);
const [addTab, setAddTab] = useState<"android" | "ios">("android"); const [addTab, setAddTab] = useState<"android" | "ios">("android");
const [addIosAuthType, setAddIosAuthType] = useState<"p8" | "p12">("p8"); const [addIosAuthType, setAddIosAuthType] = useState<"p8" | "p12">("p8");
const [addFile, setAddFile] = useState<File | null>(null); const [addFile, setAddFile] = useState<File | null>(null);
const [addShowPassword, setAddShowPassword] = useState(false); 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 allRegistered = androidVisible && iosVisible;
const noneRegistered = !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 ( return (
<> <>
<div className="bg-white border border-gray-200 rounded-lg shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg shadow-sm">
@ -144,11 +368,9 @@ export default function PlatformManagement({
<button <button
disabled={allRegistered} disabled={allRegistered}
onClick={() => { onClick={() => {
// 삭제된(미등록) 플랫폼 중 첫 번째를 기본 탭으로 선택
const androidAvail = !androidVisible; const androidAvail = !androidVisible;
setAddTab(androidAvail ? "android" : "ios"); setAddTab(androidAvail ? "android" : "ios");
setAddFile(null); resetAddForm();
setAddShowPassword(false);
setShowAddModal(true); setShowAddModal(true);
}} }}
className={`border px-4 py-2 rounded text-sm font-medium flex items-center gap-2 transition-colors ${ 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 Google FCM을 Android
</p> </p>
<CredentialStatusBadge <CredentialStatusBadge
status={service.platforms.android.credentialStatus} status={service.platforms?.android?.credentialStatus ?? null}
reason={service.platforms.android.statusReason} reason={service.platforms?.android?.statusReason ?? null}
/> />
</div> </div>
</div> </div>
<PlatformMenu <PlatformMenu
onEdit={() => { onEdit={() => {
setEditTarget("android"); setEditTarget("android");
setSelectedFile(null); resetEditForm();
}} }}
onDelete={() => setDeleteTarget("Android")} onDelete={() => setDeleteTarget("Android")}
/> />
@ -210,16 +432,20 @@ export default function PlatformManagement({
Apple APNs를 iOS Apple APNs를 iOS
</p> </p>
<CredentialStatusBadge <CredentialStatusBadge
status={service.platforms.ios.credentialStatus} status={service.platforms?.ios?.credentialStatus ?? null}
reason={service.platforms.ios.statusReason} reason={service.platforms?.ios?.statusReason ?? null}
/> />
</div> </div>
</div> </div>
<PlatformMenu <PlatformMenu
onEdit={() => { onEdit={() => {
setEditTarget("ios"); setEditTarget("ios");
setSelectedFile(null); resetEditForm();
setShowPassword(false); // 기존 인증 방식이 있으면 프리셋
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")} 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="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/40" 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"> <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>
</div> </div>
<button <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" 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> <span className="material-symbols-outlined text-xl">close</span>
@ -295,7 +521,7 @@ export default function PlatformManagement({
setAddFile(null); 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 ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!androidAvail !androidAvail
? "text-gray-300 border-transparent cursor-not-allowed" ? "text-gray-300 border-transparent cursor-not-allowed"
@ -317,7 +543,7 @@ export default function PlatformManagement({
setAddFile(null); 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 ${ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!iosAvail !iosAvail
? "text-gray-300 border-transparent cursor-not-allowed" ? "text-gray-300 border-transparent cursor-not-allowed"
@ -344,6 +570,7 @@ export default function PlatformManagement({
<button <button
type="button" type="button"
onClick={() => { setAddIosAuthType("p8"); setAddFile(null); }} onClick={() => { setAddIosAuthType("p8"); setAddFile(null); }}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${ className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p8" addIosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5" ? "border-[#2563EB] bg-[#2563EB]/5"
@ -368,6 +595,7 @@ export default function PlatformManagement({
<button <button
type="button" type="button"
onClick={() => { setAddIosAuthType("p12"); setAddFile(null); }} onClick={() => { setAddIosAuthType("p12"); setAddFile(null); }}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${ className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p12" addIosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5" ? "border-[#2563EB] bg-[#2563EB]/5"
@ -432,6 +660,7 @@ export default function PlatformManagement({
</div> </div>
<button <button
onClick={() => setAddFile(null)} onClick={() => setAddFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0" className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
> >
<span className="material-symbols-outlined text-lg">close</span> <span className="material-symbols-outlined text-lg">close</span>
@ -440,6 +669,23 @@ export default function PlatformManagement({
)} )}
</div> </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 */} {/* iOS P8: Key ID + Team ID */}
{!isAndroidTab && addIosAuthType === "p8" && ( {!isAndroidTab && addIosAuthType === "p8" && (
<div className="mb-5"> <div className="mb-5">
@ -450,7 +696,10 @@ export default function PlatformManagement({
</label> </label>
<input <input
type="text" type="text"
value={addKeyId}
onChange={(e) => setAddKeyId(e.target.value)}
placeholder="예: ABC123DEFG" 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" 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> </div>
@ -460,7 +709,10 @@ export default function PlatformManagement({
</label> </label>
<input <input
type="text" type="text"
value={addTeamId}
onChange={(e) => setAddTeamId(e.target.value)}
placeholder="예: 9ABCDEFGH1" 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" 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> </div>
@ -486,7 +738,10 @@ export default function PlatformManagement({
<div className="relative"> <div className="relative">
<input <input
type={addShowPassword ? "text" : "password"} type={addShowPassword ? "text" : "password"}
value={addCertPassword}
onChange={(e) => setAddCertPassword(e.target.value)}
placeholder="P12 인증서 비밀번호를 입력하세요" 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" 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 <button
@ -506,26 +761,27 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100"> <div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
<button <button
onClick={() => setShowAddModal(false)} 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" 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>
<button <button
onClick={() => { onClick={handleAdd}
setAddedPlatforms((prev) => new Set(prev).add(addTab)); disabled={submitting}
setDeletedPlatforms((prev) => { 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"
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"
> >
{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 className="material-symbols-outlined text-base">check</span>
<span></span> <span></span>
</>
)}
</button> </button>
</div> </div>
</div> </div>
@ -538,7 +794,7 @@ export default function PlatformManagement({
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/40" 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="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"> <div className="flex items-center gap-3 mb-4">
@ -568,28 +824,29 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
onClick={() => setDeleteTarget(null)} 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" 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>
<button <button
onClick={() => { onClick={handleDelete}
const key = deleteTarget === "Android" ? "android" : "ios"; disabled={submitting}
setDeletedPlatforms((prev) => new Set(prev).add(key)); 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"
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"
> >
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base"> <span className="material-symbols-outlined text-base">
delete delete
</span> </span>
<span></span> <span></span>
</>
)}
</button> </button>
</div> </div>
</div> </div>
@ -599,27 +856,27 @@ export default function PlatformManagement({
{/* 인증서 수정 모달 */} {/* 인증서 수정 모달 */}
{editTarget && (() => { {editTarget && (() => {
const cred = editTarget === "android" const cred = editTarget === "android"
? service.platforms.android ? service.platforms?.android
: service.platforms.ios; : service.platforms?.ios;
const isAndroid = editTarget === "android"; const isAndroid = editTarget === "android";
const statusLabel = const statusLabel =
cred.credentialStatus === "error" ? "미인증 상태" cred?.credentialStatus === "error" ? "미인증 상태"
: cred.credentialStatus === "warn" ? "조치 필요" : cred?.credentialStatus === "warn" ? "조치 필요"
: "인증됨"; : "인증됨";
const statusDesc = const statusDesc =
cred.credentialStatus === "error" cred?.credentialStatus === "error"
? "인증서가 등록되지 않았습니다. 인증서를 업로드해주세요." ? "인증서가 등록되지 않았습니다. 인증서를 업로드해주세요."
: cred.statusReason ?? ""; : cred?.statusReason ?? "";
const statusColor = const statusColor =
cred.credentialStatus === "error" ? "red" cred?.credentialStatus === "error" ? "red"
: cred.credentialStatus === "warn" ? "amber" : cred?.credentialStatus === "warn" ? "amber"
: "green"; : "green";
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/40" 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"> <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>
</div> </div>
<button <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" 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> <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", 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>
<span className="text-xs font-medium" style={{ <span className="text-xs font-medium" style={{
color: statusColor === "red" ? "#b91c1c" : statusColor === "amber" ? "#b45309" : "#15803d", color: statusColor === "red" ? "#b91c1c" : statusColor === "amber" ? "#b45309" : "#15803d",
@ -696,6 +953,7 @@ export default function PlatformManagement({
<button <button
type="button" type="button"
onClick={() => setIosAuthType("p8")} onClick={() => setIosAuthType("p8")}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${ className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p8" iosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5" ? "border-[#2563EB] bg-[#2563EB]/5"
@ -720,6 +978,7 @@ export default function PlatformManagement({
<button <button
type="button" type="button"
onClick={() => setIosAuthType("p12")} onClick={() => setIosAuthType("p12")}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${ className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p12" iosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5" ? "border-[#2563EB] bg-[#2563EB]/5"
@ -790,6 +1049,7 @@ export default function PlatformManagement({
</div> </div>
<button <button
onClick={() => setSelectedFile(null)} onClick={() => setSelectedFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0" className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
> >
<span className="material-symbols-outlined text-lg">close</span> <span className="material-symbols-outlined text-lg">close</span>
@ -798,6 +1058,23 @@ export default function PlatformManagement({
)} )}
</div> </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 */} {/* iOS P8: Key ID + Team ID */}
{!isAndroid && iosAuthType === "p8" && ( {!isAndroid && iosAuthType === "p8" && (
<div className="mb-5"> <div className="mb-5">
@ -808,7 +1085,10 @@ export default function PlatformManagement({
</label> </label>
<input <input
type="text" type="text"
value={editKeyId}
onChange={(e) => setEditKeyId(e.target.value)}
placeholder="예: ABC123DEFG" 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" 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> </div>
@ -818,7 +1098,10 @@ export default function PlatformManagement({
</label> </label>
<input <input
type="text" type="text"
value={editTeamId}
onChange={(e) => setEditTeamId(e.target.value)}
placeholder="예: 9ABCDEFGH1" 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" 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> </div>
@ -844,7 +1127,10 @@ export default function PlatformManagement({
<div className="relative"> <div className="relative">
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
value={editCertPassword}
onChange={(e) => setEditCertPassword(e.target.value)}
placeholder="P12 인증서 비밀번호를 입력하세요" 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" 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 <button
@ -864,19 +1150,27 @@ export default function PlatformManagement({
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
onClick={() => setEditTarget(null)} 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" 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>
<button <button
onClick={() => { onClick={handleEdit}
toast.success("인증서가 저장되었습니다."); disabled={submitting}
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 disabled:opacity-50"
}}
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"
> >
{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 className="material-symbols-outlined text-base">save</span>
<span></span> <span></span>
</>
)}
</button> </button>
</div> </div>
</div> </div>

View File

@ -46,7 +46,7 @@ export default function PlatformStatusIndicator({
platform, platform,
credential, credential,
}: PlatformStatusIndicatorProps) { }: PlatformStatusIndicatorProps) {
if (!credential.registered) return null; if (!credential?.registered) return null;
const hasIssue = const hasIssue =
credential.credentialStatus === "warn" || credential.credentialStatus === "warn" ||

View File

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

View File

@ -1,7 +1,7 @@
interface StatCard { interface StatCard {
label: string; label: string;
value: string; value: string;
sub: { type: "trend" | "stable" | "live"; text: string; color?: string }; sub: { type: "trend" | "stable"; text: string; color?: string };
icon: string; icon: string;
iconBg: string; iconBg: string;
iconColor: string; iconColor: string;
@ -12,6 +12,10 @@ interface ServiceStatsCardsProps {
successRate: number; successRate: number;
deviceCount: number; deviceCount: number;
todaySent: number; todaySent: number;
sentChangeRate?: number;
successRateChange?: number;
deviceCountChange?: number;
todaySentChangeRate?: number;
} }
export default function ServiceStatsCards({ export default function ServiceStatsCards({
@ -19,12 +23,22 @@ export default function ServiceStatsCards({
successRate, successRate,
deviceCount, deviceCount,
todaySent, todaySent,
sentChangeRate = 0,
successRateChange = 0,
deviceCountChange = 0,
todaySentChangeRate = 0,
}: ServiceStatsCardsProps) { }: ServiceStatsCardsProps) {
const noChange: StatCard["sub"] = { type: "stable", text: "변동 없음" };
const cards: StatCard[] = [ const cards: StatCard[] = [
{ {
label: "총 발송 수", label: "총 발송 수",
value: totalSent.toLocaleString(), 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", icon: "equalizer",
iconBg: "bg-indigo-50", iconBg: "bg-indigo-50",
iconColor: "text-indigo-600", iconColor: "text-indigo-600",
@ -32,7 +46,11 @@ export default function ServiceStatsCards({
{ {
label: "성공률", label: "성공률",
value: `${successRate}%`, 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", icon: "check_circle",
iconBg: "bg-emerald-50", iconBg: "bg-emerald-50",
iconColor: "text-emerald-600", iconColor: "text-emerald-600",
@ -40,7 +58,11 @@ export default function ServiceStatsCards({
{ {
label: "등록 기기 수", label: "등록 기기 수",
value: deviceCount.toLocaleString(), 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", icon: "devices",
iconBg: "bg-amber-50", iconBg: "bg-amber-50",
iconColor: "text-amber-600", iconColor: "text-amber-600",
@ -48,7 +70,11 @@ export default function ServiceStatsCards({
{ {
label: "오늘 발송", label: "오늘 발송",
value: todaySent.toLocaleString(), 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", icon: "today",
iconBg: "bg-[#2563EB]/5", iconBg: "bg-[#2563EB]/5",
iconColor: "text-[#2563EB]", iconColor: "text-[#2563EB]",
@ -77,7 +103,7 @@ export default function ServiceStatsCards({
className="material-symbols-outlined" className="material-symbols-outlined"
style={{ fontSize: "14px" }} style={{ fontSize: "14px" }}
> >
trending_up {card.sub.text.startsWith("-") ? "trending_down" : "trending_up"}
</span> </span>
<span>{card.sub.text}</span> <span>{card.sub.text}</span>
</p> </p>
@ -88,12 +114,6 @@ export default function ServiceStatsCards({
<span>{card.sub.text}</span> <span>{card.sub.text}</span>
</p> </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> </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 { useParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import ServiceHeaderCard from "../components/ServiceHeaderCard"; import ServiceHeaderCard from "../components/ServiceHeaderCard";
import ServiceStatsCards from "../components/ServiceStatsCards"; import ServiceStatsCards from "../components/ServiceStatsCards";
import PlatformManagement from "../components/PlatformManagement"; 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() { export default function ServiceDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [showApiKey, setShowApiKey] = useState(false);
// 목 데이터에서 서비스 조회 (목록에서 클릭한 ID로) // 데이터 상태
const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id); const [service, setService] = useState<ServiceDetail | null>(null);
const service = listItem const [loading, setLoading] = useState(true);
? { ...MOCK_SERVICE_DETAIL, ...listItem, serviceCode: listItem.serviceCode } const [error, setError] = useState(false);
: MOCK_SERVICE_DETAIL;
// 통계 상태
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 ( return (
<div> <div>
@ -23,24 +163,28 @@ export default function ServiceDetailPage() {
<ServiceHeaderCard <ServiceHeaderCard
service={service} service={service}
onShowApiKey={() => setShowApiKey(true)} onShowApiKey={handleShowApiKey}
/> />
<ServiceStatsCards <ServiceStatsCards
totalSent={154200} totalSent={stats?.total_sent ?? 0}
successRate={99.2} successRate={stats?.success_rate ?? 0}
deviceCount={service.deviceCount} 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 키 확인 모달 */} {/* API 키 확인 모달 */}
{showApiKey && ( {showApiKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div <div
className="absolute inset-0 bg-black/40" 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="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 justify-between mb-4">
@ -53,7 +197,7 @@ export default function ServiceDetailPage() {
<h3 className="text-lg font-bold text-[#0f172a]">API </h3> <h3 className="text-lg font-bold text-[#0f172a]">API </h3>
</div> </div>
<button <button
onClick={() => setShowApiKey(false)} onClick={handleCloseApiKey}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100" className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
> >
<span className="material-symbols-outlined text-xl"> <span className="material-symbols-outlined text-xl">
@ -75,10 +219,16 @@ export default function ServiceDetailPage() {
</div> </div>
</div> </div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4 flex items-center gap-3">
{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"> <code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{service.apiKey} {fullApiKey ?? service.apiKey}
</code> </code>
<CopyButton text={service.apiKey} /> <CopyButton text={fullApiKey ?? service.apiKey} />
</>
)}
</div> </div>
</div> </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 { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { AxiosError } from "axios";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator"; import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import useShake from "@/hooks/useShake"; import useShake from "@/hooks/useShake";
import { formatNumber } from "@/utils/format"; 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 // Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => ( const AppleLogo = ({ className }: { className?: string }) => (
@ -18,43 +22,162 @@ export default function ServiceEditPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
// 목 데이터에서 서비스 조회 // 데이터 상태
const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id); const [service, setService] = useState<ServiceDetail | null>(null);
const service = listItem const [loading, setLoading] = useState(true);
? { ...MOCK_SERVICE_DETAIL, ...listItem } const [error, setError] = useState(false);
: MOCK_SERVICE_DETAIL;
// 폼 상태 // 폼 상태 (service 로드 후 초기화)
const [serviceName, setServiceName] = useState(service.serviceName); const [serviceName, setServiceName] = useState("");
const [isActive, setIsActive] = useState( const [isActive, setIsActive] = useState(true);
service.status === SERVICE_STATUS.ACTIVE const [description, setDescription] = useState("");
);
const [description, setDescription] = useState(service.description ?? "");
// 에러 상태 // 에러 상태
const [nameError, setNameError] = useState(false); const [nameError, setNameError] = useState(false);
const [nameErrorMsg, setNameErrorMsg] = useState("필수 입력 항목입니다.");
const { triggerShake, cls } = useShake(); const { triggerShake, cls } = useShake();
// 제출 상태
const [submitting, setSubmitting] = useState(false);
// 모달 상태 // 모달 상태
const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false);
const [showCancelModal, setShowCancelModal] = 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 = () => { const handleSave = () => {
if (!serviceName.trim()) { const trimmed = serviceName.trim();
if (!trimmed || trimmed.length < 2) {
setNameError(true); setNameError(true);
setNameErrorMsg(
!trimmed ? "필수 입력 항목입니다." : "서비스 명은 2자 이상이어야 합니다.",
);
triggerShake(["name"]); triggerShake(["name"]);
return; return;
} }
setShowSaveModal(true); setShowSaveModal(true);
}; };
const handleSaveConfirm = () => { const handleSaveConfirm = async () => {
setShowSaveModal(false); setShowSaveModal(false);
setSubmitting(true);
try {
await updateService({
serviceCode: id!,
serviceName: serviceName.trim(),
description: description.trim() || null,
status: isActive ? 0 : 1,
});
toast.success("변경사항이 저장되었습니다."); toast.success("변경사항이 저장되었습니다.");
navigate(`/services/${id}`); 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 ( return (
<div> <div>
<PageHeader <PageHeader
@ -108,7 +231,7 @@ export default function ServiceEditPage() {
> >
error error
</span> </span>
<span> .</span> <span>{nameErrorMsg}</span>
</div> </div>
)} )}
</div> </div>
@ -160,6 +283,8 @@ export default function ServiceEditPage() {
</p> </p>
<div className="flex items-center gap-1.5 mt-1.5"> <div className="flex items-center gap-1.5 mt-1.5">
{service.platforms ? (
<>
<PlatformStatusIndicator <PlatformStatusIndicator
platform="android" platform="android"
credential={service.platforms.android} credential={service.platforms.android}
@ -168,8 +293,12 @@ export default function ServiceEditPage() {
platform="ios" platform="ios"
credential={service.platforms.ios} credential={service.platforms.ios}
/> />
{!service.platforms.android.registered && {!service.platforms?.android?.registered &&
!service.platforms.ios.registered && ( !service.platforms?.ios?.registered && (
<span className="text-xs text-gray-400"></span>
)}
</>
) : (
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400"></span>
)} )}
</div> </div>
@ -187,7 +316,7 @@ export default function ServiceEditPage() {
</p> </p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5"> <p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt} {service.createdAt?.slice(0, 10)}
</p> </p>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
@ -195,7 +324,7 @@ export default function ServiceEditPage() {
</p> </p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5"> <p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt ?? "-"} {service.updatedAt?.slice(0, 10) ?? "-"}
</p> </p>
</div> </div>
</div> </div>
@ -212,9 +341,10 @@ export default function ServiceEditPage() {
</button> </button>
<button <button
onClick={handleSave} 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> </button>
</div> </div>
</div> </div>
@ -235,7 +365,7 @@ export default function ServiceEditPage() {
</div> </div>
{/* Android */} {/* 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="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="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0"> <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 */} {/* iOS */}
{service.platforms.ios.registered && ( {service.platforms?.ios?.registered && (
<div className="px-6 py-5 flex items-center justify-between"> <div className="px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1"> <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"> <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?.android?.registered &&
!service.platforms.ios.registered && ( !service.platforms?.ios?.registered && (
<div className="px-6 py-12 text-center"> <div className="px-6 py-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block"> <span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
devices devices
@ -375,7 +505,8 @@ export default function ServiceEditPage() {
</button> </button>
<button <button
onClick={handleSaveConfirm} 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> </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 { Link, useNavigate } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
@ -10,13 +10,21 @@ import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator"; import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import { formatDate, formatNumber } from "@/utils/format"; 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"; import type { ServiceSummary } from "../types";
const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"]; const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"];
const PAGE_SIZE = 10; 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"]) { function getStatusBadge(status: ServiceSummary["status"]) {
if (status === SERVICE_STATUS.ACTIVE) { if (status === SERVICE_STATUS.ACTIVE) {
return <StatusBadge variant="success" label="활성" />; return <StatusBadge variant="success" label="활성" />;
@ -31,21 +39,57 @@ export default function ServiceListPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("전체 상태"); const [statusFilter, setStatusFilter] = useState("전체 상태");
const [currentPage, setCurrentPage] = useState(1); 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 [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
// 실제 적용될 필터 (조회 버튼 클릭 시 반영) // 실제 적용될 필터 (조회 버튼 클릭 시 반영)
const [appliedSearch, setAppliedSearch] = useState(""); const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("전체 상태"); const [appliedStatus, setAppliedStatus] = useState("전체 상태");
// 조회 버튼 (로딩 인디케이터 포함) // API 호출
const handleQuery = () => { const loadData = useCallback(
async (page: number, searchKeyword: string, statusFilterValue: string) => {
setLoading(true); setLoading(true);
setTimeout(() => { 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 = () => {
setAppliedSearch(search); setAppliedSearch(search);
setAppliedStatus(statusFilter); setAppliedStatus(statusFilter);
setCurrentPage(1); setCurrentPage(1);
setLoading(false); loadData(1, search, statusFilter);
}, 400);
}; };
// 필터 초기화 // 필터 초기화
@ -55,37 +99,14 @@ export default function ServiceListPage() {
setAppliedSearch(""); setAppliedSearch("");
setAppliedStatus("전체 상태"); setAppliedStatus("전체 상태");
setCurrentPage(1); setCurrentPage(1);
loadData(1, "", "전체 상태");
}; };
// 필터링된 데이터 // 페이지 변경
const filtered = useMemo(() => { const handlePageChange = (page: number) => {
return MOCK_SERVICES.filter((svc) => { setCurrentPage(page);
// 검색 loadData(page, appliedSearch, appliedStatus);
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
);
return ( return (
<div> <div>
@ -132,8 +153,23 @@ export default function ServiceListPage() {
</div> </div>
</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"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm"> <table className="w-full text-sm">
@ -194,7 +230,7 @@ export default function ServiceListPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
) : paged.length > 0 ? ( ) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
@ -220,10 +256,10 @@ export default function ServiceListPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paged.map((svc, idx) => ( {items.map((svc, idx) => (
<tr <tr
key={svc.serviceCode} 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}`)} onClick={() => navigate(`/services/${svc.serviceCode}`)}
> >
{/* 서비스명 */} {/* 서비스명 */}
@ -231,7 +267,7 @@ export default function ServiceListPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0"> <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"> <span className="material-symbols-outlined text-[#2563EB] text-base">
{svc.serviceIcon} {svc.serviceIcon || "hub"}
</span> </span>
</div> </div>
<span className="text-sm font-medium text-[#0f172a]"> <span className="text-sm font-medium text-[#0f172a]">
@ -254,6 +290,8 @@ export default function ServiceListPage() {
{/* 플랫폼 */} {/* 플랫폼 */}
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-1.5">
{svc.platforms ? (
<>
<PlatformStatusIndicator <PlatformStatusIndicator
platform="android" platform="android"
credential={svc.platforms.android} credential={svc.platforms.android}
@ -262,6 +300,10 @@ export default function ServiceListPage() {
platform="ios" platform="ios"
credential={svc.platforms.ios} credential={svc.platforms.ios}
/> />
</>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</div> </div>
</td> </td>
{/* 기기 수 */} {/* 기기 수 */}
@ -287,7 +329,7 @@ export default function ServiceListPage() {
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems} totalItems={totalItems}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
onPageChange={setCurrentPage} onPageChange={handlePageChange}
/> />
</div> </div>
) : ( ) : (

View File

@ -1,9 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { AxiosError } from "axios";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton";
import PlatformSelector from "../components/PlatformSelector"; import PlatformSelector from "../components/PlatformSelector";
import useShake from "@/hooks/useShake"; 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() { export default function ServiceRegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -18,15 +23,27 @@ export default function ServiceRegisterPage() {
// 에러 상태 // 에러 상태
const [nameError, setNameError] = useState(false); const [nameError, setNameError] = useState(false);
const [nameErrorMsg, setNameErrorMsg] = useState("서비스 명을 입력해주세요.");
const { triggerShake, cls } = useShake(); const { triggerShake, cls } = useShake();
// 제출 상태
const [submitting, setSubmitting] = useState(false);
// 모달 // 모달
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
// API Key 모달
const [apiKeyResult, setApiKeyResult] = useState<CreateServiceResponse | null>(null);
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
// 등록 버튼 클릭 // 등록 버튼 클릭
const handleRegister = () => { const handleRegister = () => {
if (!serviceName.trim()) { const trimmed = serviceName.trim();
if (!trimmed || trimmed.length < 2) {
setNameError(true); setNameError(true);
setNameErrorMsg(
!trimmed ? "서비스 명을 입력해주세요." : "서비스 명은 2자 이상이어야 합니다.",
);
triggerShake(["name"]); triggerShake(["name"]);
return; return;
} }
@ -34,10 +51,41 @@ export default function ServiceRegisterPage() {
}; };
// 등록 확인 // 등록 확인
const handleConfirm = () => { const handleConfirm = async () => {
setShowConfirm(false); setShowConfirm(false);
toast.success("서비스가 등록되었습니다."); setSubmitting(true);
navigate("/services");
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 ( return (
@ -77,7 +125,7 @@ export default function ServiceRegisterPage() {
> >
error error
</span> </span>
<span> .</span> <span>{nameErrorMsg}</span>
</div> </div>
)} )}
</div> </div>
@ -140,9 +188,10 @@ export default function ServiceRegisterPage() {
</button> </button>
<button <button
onClick={handleRegister} 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> </button>
</div> </div>
</div> </div>
@ -199,6 +248,62 @@ export default function ServiceRegisterPage() {
</div> </div>
</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> </div>
); );
} }

View File

@ -28,7 +28,7 @@ export interface PlatformCredentialSummary {
export interface ServiceSummary { export interface ServiceSummary {
serviceCode: string; serviceCode: string;
serviceName: string; serviceName: string;
serviceIcon: string; serviceIcon?: string;
description: string | null; description: string | null;
status: ServiceStatus; status: ServiceStatus;
createdAt: string; createdAt: string;
@ -36,7 +36,7 @@ export interface ServiceSummary {
platforms: { platforms: {
android: PlatformCredentialSummary; android: PlatformCredentialSummary;
ios: PlatformCredentialSummary; ios: PlatformCredentialSummary;
}; } | null;
} }
// 서비스 상세 // 서비스 상세
@ -48,271 +48,66 @@ export interface ServiceDetail extends ServiceSummary {
hasFcmCredentials: boolean; hasFcmCredentials: boolean;
createdByName: string | null; createdByName: string | null;
updatedAt: 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[] = [ export interface ServiceListRequest {
{ page: number;
serviceCode: "svc-factory-01", pageSize: number;
serviceName: "스마트 팩토리 모니터링", searchKeyword?: string;
serviceIcon: "smart_toy", status?: number; // 0=Active, 1=Suspended, undefined=전체
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,
},
},
},
];
// 목 데이터 - 서비스 상세 // API 키 응답 (view/refresh 공용)
export const MOCK_SERVICE_DETAIL: ServiceDetail = { export interface ApiKeyResponse {
serviceCode: "svc-factory-monitoring-01", serviceCode: string;
serviceName: "(주) 미래테크", apiKey: string;
serviceIcon: "corporate_fare", apiKeyCreatedAt: string;
description: }
"공장 모니터링 시스템을 위한 푸시 알림 서비스입니다. 설비 이상 감지, 작업 지시, 안전 알림 등을 실시간으로 전달합니다.",
status: SERVICE_STATUS.ACTIVE, // 서비스 생성 요청
createdAt: "2023-10-15", export interface CreateServiceRequest {
deviceCount: 1240, serviceName: string;
platforms: { description?: string | null;
android: { }
registered: true,
credentialStatus: "error", // 서비스 생성 응답 (API Key 1회 표시)
statusReason: "인증서를 등록해주세요", export interface CreateServiceResponse {
expiresAt: null, serviceCode: string;
}, apiKey: string;
ios: { apiKeyCreatedAt: string;
registered: true, }
credentialStatus: "warn",
statusReason: ".p12 인증서 만료 30일 전", // 서비스 수정 요청
expiresAt: "2024-06-15", export interface UpdateServiceRequest {
}, serviceCode: string;
}, serviceName?: string | null;
apiKey: "a3f8k2m1x9c4b7e0d5g6h8j2l4n7p0q3r5s8t1u6v9w2y4z0A3B7C1D5E9F2G6H0", description?: string | null;
apiKeyCreatedAt: "2023-10-15", status?: number | null; // 0=Active, 1=Suspended
apnsAuthType: "p12", }
hasApnsKey: true,
hasFcmCredentials: false, // FCM 인증서 등록 요청
createdByName: "Admin User", export interface RegisterFcmRequest {
updatedAt: "2024-05-12", 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; msg: string | null;
data: { data: {
items: T[]; items: T[];
total: number; totalCount: number;
page: number; page: number;
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;