feat: 서비스 관리 API 연동 및 UI 개선 (#31) #32
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,
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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; // 오늘 발송 전일 대비 증감률 (%)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일별 발송 추이
|
// 일별 발송 추이
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" ||
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user