feat: 서비스 관리 API 연동 및 UI 개선 (#31)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
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:
commit
8f753a668b
81
react/src/api/service.api.ts
Normal file
81
react/src/api/service.api.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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; // 오늘 발송 전일 대비 증감률 (%)
|
||||
}
|
||||
|
||||
// 일별 발송 추이
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export interface PaginatedResponse<T> {
|
|||
msg: string | null;
|
||||
data: {
|
||||
items: T[];
|
||||
total: number;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user