SPMS_WEB/react/src/features/service/components/PlatformManagement.tsx
SEAN bb4d531d8c feat: 서비스 관리 API 연동 및 UI 개선 (#31)
- 플랫폼 관리 API 연동 (FCM/APNs 인증서 등록/삭제)
- 서비스 상세 통계 카드 대시보드 KPI API 연결
- 통계 서브텍스트 전일 대비 변동 데이터 연결 (변동 없음 표시 포함)
- ServiceHeaderCard/ServiceEditPage optional chaining 버그 수정
- 날짜 표기 YYYY-MM-DD 형식 통일
- 페이지네이션 totalCount 필드명 수정 및 패딩 정렬
- 서비스 등록 완료 모달 UI 통일 및 문구 수정

Closes #31
2026-03-01 10:35:54 +09:00

1183 lines
49 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { toast } from "sonner";
import type { ServiceDetail } from "../types";
import {
registerFcm,
deleteFcm,
registerApns,
deleteApns,
} from "@/api/service.api";
// 파일을 텍스트로 읽기 (FCM JSON, APNs p8)
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("파일 읽기 실패"));
reader.readAsText(file);
});
}
// 파일을 Base64로 읽기 (APNs p12)
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// "data:application/...;base64," 접두사 제거
const base64 = dataUrl.split(",")[1] ?? "";
resolve(base64);
};
reader.onerror = () => reject(new Error("파일 읽기 실패"));
reader.readAsDataURL(file);
});
}
// Apple 로고 SVG
const AppleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
// 인증 상태 배지
function CredentialStatusBadge({
status,
reason,
}: {
status: "ok" | "warn" | "error" | null;
reason: string | null;
}) {
if (!status || status === "ok") {
return (
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
</span>
</div>
);
}
if (status === "warn") {
return (
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
</span>
{reason && (
<p className="text-xs text-amber-600 font-medium">{reason}</p>
)}
</div>
);
}
// error
return (
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
</span>
{reason && (
<p className="text-xs text-red-600 font-medium">{reason}</p>
)}
</div>
);
}
// 플랫폼 아이템 메뉴
function PlatformMenu({
onEdit,
onDelete,
}: {
onEdit: () => void;
onDelete: () => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, []);
return (
<div className="relative flex-shrink-0" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="text-gray-400 hover:text-[#2563EB] transition p-2 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-36 bg-white border border-gray-200 rounded-lg shadow-lg z-10 py-1">
<button
onClick={() => {
onEdit();
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-[#0f172a] hover:bg-gray-50 flex items-center gap-2 transition-colors"
>
<span className="material-symbols-outlined text-base text-gray-500">
edit
</span>
<span> </span>
</button>
<button
onClick={() => {
onDelete();
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
>
<span className="material-symbols-outlined text-base">delete</span>
<span></span>
</button>
</div>
)}
</div>
);
}
interface PlatformManagementProps {
service: ServiceDetail;
onRefresh: () => void;
}
export default function PlatformManagement({
service,
onRefresh,
}: PlatformManagementProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null);
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [addTab, setAddTab] = useState<"android" | "ios">("android");
const [addIosAuthType, setAddIosAuthType] = useState<"p8" | "p12">("p8");
const [addFile, setAddFile] = useState<File | null>(null);
const [addShowPassword, setAddShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
// 추가 모달 폼 상태
const [addBundleId, setAddBundleId] = useState("");
const [addKeyId, setAddKeyId] = useState("");
const [addTeamId, setAddTeamId] = useState("");
const [addCertPassword, setAddCertPassword] = useState("");
// 수정 모달 폼 상태
const [editBundleId, setEditBundleId] = useState("");
const [editKeyId, setEditKeyId] = useState("");
const [editTeamId, setEditTeamId] = useState("");
const [editCertPassword, setEditCertPassword] = useState("");
const androidVisible = !!service.platforms?.android?.registered;
const iosVisible = !!service.platforms?.ios?.registered;
const allRegistered = androidVisible && iosVisible;
const noneRegistered = !androidVisible && !iosVisible;
// 추가 모달 폼 초기화
const resetAddForm = () => {
setAddFile(null);
setAddShowPassword(false);
setAddBundleId("");
setAddKeyId("");
setAddTeamId("");
setAddCertPassword("");
};
// 수정 모달 폼 초기화
const resetEditForm = () => {
setSelectedFile(null);
setShowPassword(false);
setEditBundleId("");
setEditKeyId("");
setEditTeamId("");
setEditCertPassword("");
};
// 플랫폼 추가 핸들러
const handleAdd = async () => {
if (!addFile) {
toast.error("인증서 파일을 업로드해주세요.");
return;
}
const isAndroidTab = addTab === "android";
setSubmitting(true);
try {
if (isAndroidTab) {
// Android: FCM JSON
const text = await readFileAsText(addFile);
await registerFcm(service.serviceCode, { serviceAccountJson: text });
} else if (addIosAuthType === "p8") {
// iOS p8
if (!addBundleId.trim()) {
toast.error("Bundle ID를 입력해주세요.");
setSubmitting(false);
return;
}
if (!addKeyId.trim() || addKeyId.trim().length !== 10) {
toast.error("Key ID는 정확히 10자여야 합니다.");
setSubmitting(false);
return;
}
if (!addTeamId.trim() || addTeamId.trim().length !== 10) {
toast.error("Team ID는 정확히 10자여야 합니다.");
setSubmitting(false);
return;
}
const privateKey = await readFileAsText(addFile);
await registerApns(service.serviceCode, {
authType: "p8",
bundleId: addBundleId.trim(),
keyId: addKeyId.trim(),
teamId: addTeamId.trim(),
privateKey,
});
} else {
// iOS p12
if (!addBundleId.trim()) {
toast.error("Bundle ID를 입력해주세요.");
setSubmitting(false);
return;
}
const certificateBase64 = await readFileAsBase64(addFile);
await registerApns(service.serviceCode, {
authType: "p12",
bundleId: addBundleId.trim(),
certificateBase64,
certPassword: addCertPassword,
});
}
const name = isAndroidTab ? "Android" : "iOS";
toast.success(`${name} 플랫폼이 추가되었습니다.`);
setShowAddModal(false);
resetAddForm();
onRefresh();
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "플랫폼 추가에 실패했습니다.";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
// 인증서 수정(덮어쓰기) 핸들러
const handleEdit = async () => {
if (!editTarget || !selectedFile) {
toast.error("인증서 파일을 업로드해주세요.");
return;
}
const isAndroid = editTarget === "android";
setSubmitting(true);
try {
if (isAndroid) {
const text = await readFileAsText(selectedFile);
await registerFcm(service.serviceCode, { serviceAccountJson: text });
} else if (iosAuthType === "p8") {
if (!editBundleId.trim()) {
toast.error("Bundle ID를 입력해주세요.");
setSubmitting(false);
return;
}
if (!editKeyId.trim() || editKeyId.trim().length !== 10) {
toast.error("Key ID는 정확히 10자여야 합니다.");
setSubmitting(false);
return;
}
if (!editTeamId.trim() || editTeamId.trim().length !== 10) {
toast.error("Team ID는 정확히 10자여야 합니다.");
setSubmitting(false);
return;
}
const privateKey = await readFileAsText(selectedFile);
await registerApns(service.serviceCode, {
authType: "p8",
bundleId: editBundleId.trim(),
keyId: editKeyId.trim(),
teamId: editTeamId.trim(),
privateKey,
});
} else {
if (!editBundleId.trim()) {
toast.error("Bundle ID를 입력해주세요.");
setSubmitting(false);
return;
}
const certificateBase64 = await readFileAsBase64(selectedFile);
await registerApns(service.serviceCode, {
authType: "p12",
bundleId: editBundleId.trim(),
certificateBase64,
certPassword: editCertPassword,
});
}
toast.success("인증서가 저장되었습니다.");
setEditTarget(null);
resetEditForm();
onRefresh();
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "인증서 저장에 실패했습니다.";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
// 플랫폼 삭제 핸들러
const handleDelete = async () => {
if (!deleteTarget) return;
setSubmitting(true);
try {
if (deleteTarget === "Android") {
await deleteFcm(service.serviceCode);
} else {
await deleteApns(service.serviceCode);
}
toast.success(`${deleteTarget} 플랫폼이 삭제되었습니다.`);
setDeleteTarget(null);
onRefresh();
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "플랫폼 삭제에 실패했습니다.";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
return (
<>
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
{/* 헤더 */}
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
<button
disabled={allRegistered}
onClick={() => {
const androidAvail = !androidVisible;
setAddTab(androidAvail ? "android" : "ios");
resetAddForm();
setShowAddModal(true);
}}
className={`border px-4 py-2 rounded text-sm font-medium flex items-center gap-2 transition-colors ${
allRegistered
? "border-gray-300 text-gray-400 cursor-not-allowed"
: "border-[#2563EB] text-[#2563EB] hover:bg-[#2563EB]/5 cursor-pointer"
}`}
title={
allRegistered
? "모든 플랫폼이 등록되어 있습니다"
: ""
}
>
<span className="material-symbols-outlined text-base">add</span>
<span> </span>
</button>
</div>
{/* Android */}
{androidVisible && (
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-green-600 text-2xl">
android
</span>
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[#0f172a]">Android</h4>
<p className="text-xs text-gray-600 mt-1">
Google FCM을 Android
</p>
<CredentialStatusBadge
status={service.platforms?.android?.credentialStatus ?? null}
reason={service.platforms?.android?.statusReason ?? null}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
setEditTarget("android");
resetEditForm();
}}
onDelete={() => setDeleteTarget("Android")}
/>
</div>
)}
{/* iOS */}
{iosVisible && (
<div className="px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="size-12 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<AppleLogo className="w-6 h-6 text-gray-600" />
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-[#0f172a]">iOS</h4>
<p className="text-xs text-gray-600 mt-1">
Apple APNs를 iOS
</p>
<CredentialStatusBadge
status={service.platforms?.ios?.credentialStatus ?? null}
reason={service.platforms?.ios?.statusReason ?? null}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
setEditTarget("ios");
resetEditForm();
// 기존 인증 방식이 있으면 프리셋
if (service.apnsAuthType) setIosAuthType(service.apnsAuthType);
if (service.apnsBundleId) setEditBundleId(service.apnsBundleId);
if (service.apnsKeyId) setEditKeyId(service.apnsKeyId);
if (service.apnsTeamId) setEditTeamId(service.apnsTeamId);
}}
onDelete={() => setDeleteTarget("iOS")}
/>
</div>
)}
{/* 빈 상태 */}
{noneRegistered && (
<div className="px-6 py-12 text-center">
<span className="material-symbols-outlined text-gray-300 text-5xl mb-3 block">
devices
</span>
<p className="text-sm text-gray-400">
.
</p>
<p className="text-xs text-gray-400 mt-1">
.
</p>
</div>
)}
</div>
{/* 플랫폼 추가 모달 (탭 방식) */}
{showAddModal && (() => {
const androidAvail = !androidVisible;
const iosAvail = !iosVisible;
const isAndroidTab = addTab === "android";
const fileAccept = isAndroidTab
? ".json"
: addIosAuthType === "p8" ? ".p8" : ".p12";
const fileHint = isAndroidTab
? ".json 파일만 업로드 가능합니다"
: addIosAuthType === "p8"
? ".p8 파일만 업로드 가능합니다"
: ".p12 파일만 업로드 가능합니다";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
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="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-[#2563EB]/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-[#2563EB] text-xl">
add_circle
</span>
</div>
<div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
<p className="text-xs text-[#64748b]">
.
</p>
</div>
</div>
<button
onClick={() => !submitting && setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 탭 */}
<div className="flex border-b border-gray-200 mb-5">
<button
onClick={() => {
if (androidAvail) {
setAddTab("android");
setAddFile(null);
}
}}
disabled={!androidAvail || submitting}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!androidAvail
? "text-gray-300 border-transparent cursor-not-allowed"
: isAndroidTab
? "text-[#2563EB] border-[#2563EB]"
: "text-[#64748b] border-transparent hover:text-[#0f172a]"
}`}
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>android</span>
<span>Android</span>
{!androidAvail && (
<span className="text-[10px] bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full"></span>
)}
</button>
<button
onClick={() => {
if (iosAvail) {
setAddTab("ios");
setAddFile(null);
}
}}
disabled={!iosAvail || submitting}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
!iosAvail
? "text-gray-300 border-transparent cursor-not-allowed"
: !isAndroidTab
? "text-[#2563EB] border-[#2563EB]"
: "text-[#64748b] border-transparent hover:text-[#0f172a]"
}`}
>
<AppleLogo className="w-4 h-4" />
<span>iOS</span>
{!iosAvail && (
<span className="text-[10px] bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded-full"></span>
)}
</button>
</div>
{/* iOS 인증 방식 선택 */}
{!isAndroidTab && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { setAddIosAuthType("p8"); setAddFile(null); }}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${addIosAuthType === "p8" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
vpn_key
</span>
<span className={`text-sm font-medium ${addIosAuthType === "p8" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Token (.p8)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
APNs Auth Key , ()
</p>
</button>
<button
type="button"
onClick={() => { setAddIosAuthType("p12"); setAddFile(null); }}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
addIosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${addIosAuthType === "p12" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
badge
</span>
<span className={`text-sm font-medium ${addIosAuthType === "p12" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Certificate (.p12)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
, 1
</p>
</button>
</div>
</div>
)}
{/* 파일 업로드 */}
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
{!addFile ? (
<label className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5 transition">
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
upload_file
</span>
<p className="text-sm text-[#0f172a] font-medium">
</p>
<p className="text-xs text-[#64748b] mt-1">{fileHint}</p>
<input
type="file"
className="hidden"
accept={fileAccept}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setAddFile(file);
}}
/>
</label>
) : (
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
description
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f172a] truncate">
{addFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(addFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={() => setAddFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</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 */}
{!isAndroidTab && addIosAuthType === "p8" && (
<div className="mb-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Key ID
</label>
<input
type="text"
value={addKeyId}
onChange={(e) => setAddKeyId(e.target.value)}
placeholder="예: ABC123DEFG"
disabled={submitting}
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Team ID
</label>
<input
type="text"
value={addTeamId}
onChange={(e) => setAddTeamId(e.target.value)}
placeholder="예: 9ABCDEFGH1"
disabled={submitting}
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-[#64748b] text-xs mt-2">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>Apple Developer .</span>
</div>
</div>
)}
{/* iOS P12: 비밀번호 */}
{!isAndroidTab && addIosAuthType === "p12" && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
P12
</label>
<div className="relative">
<input
type={addShowPassword ? "text" : "password"}
value={addCertPassword}
onChange={(e) => setAddCertPassword(e.target.value)}
placeholder="P12 인증서 비밀번호를 입력하세요"
disabled={submitting}
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
<button
type="button"
onClick={() => setAddShowPassword(!addShowPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition"
>
<span className="material-symbols-outlined text-lg">
{addShowPassword ? "visibility" : "visibility_off"}
</span>
</button>
</div>
</div>
)}
{/* 하단 버튼 */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
<button
onClick={() => setShowAddModal(false)}
disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">check</span>
<span></span>
</>
)}
</button>
</div>
</div>
</div>
);
})()}
{/* 삭제 확인 모달 */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => !submitting && setDeleteTarget(null)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<strong>{deleteTarget}</strong> ?
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>
.
</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteTarget(null)}
disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleDelete}
disabled={submitting}
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">
delete
</span>
<span></span>
</>
)}
</button>
</div>
</div>
</div>
)}
{/* 인증서 수정 모달 */}
{editTarget && (() => {
const cred = editTarget === "android"
? service.platforms?.android
: service.platforms?.ios;
const isAndroid = editTarget === "android";
const statusLabel =
cred?.credentialStatus === "error" ? "미인증 상태"
: cred?.credentialStatus === "warn" ? "조치 필요"
: "인증됨";
const statusDesc =
cred?.credentialStatus === "error"
? "인증서가 등록되지 않았습니다. 인증서를 업로드해주세요."
: cred?.statusReason ?? "";
const statusColor =
cred?.credentialStatus === "error" ? "red"
: cred?.credentialStatus === "warn" ? "amber"
: "green";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => !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="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`size-10 rounded-full flex items-center justify-center flex-shrink-0 ${
isAndroid ? "bg-green-100" : "bg-gray-200"
}`}>
{isAndroid ? (
<span className="material-symbols-outlined text-green-600 text-xl">
android
</span>
) : (
<AppleLogo className="w-5 h-5 text-gray-600" />
)}
</div>
<div>
<h3 className="text-lg font-bold text-[#0f172a]">
/
</h3>
<p className="text-xs text-[#64748b]">
{isAndroid
? "Android — Service Account JSON"
: `iOS — ${iosAuthType === "p8" ? "APNs Auth Key (.p8)" : "Certificate (.p12)"}`}
</p>
</div>
</div>
<button
onClick={() => !submitting && setEditTarget(null)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 현재 상태 */}
<div className={`bg-${statusColor}-50 border border-${statusColor}-200 rounded-lg px-4 py-3 mb-5`}
style={{
backgroundColor: statusColor === "red" ? "#fef2f2" : statusColor === "amber" ? "#fffbeb" : "#f0fdf4",
borderColor: statusColor === "red" ? "#fecaca" : statusColor === "amber" ? "#fde68a" : "#bbf7d0",
}}>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined flex-shrink-0"
style={{
fontSize: "14px",
color: statusColor === "red" ? "#dc2626" : statusColor === "amber" ? "#d97706" : "#16a34a",
}}
>
{cred?.credentialStatus === "error" ? "error" : cred?.credentialStatus === "warn" ? "warning" : "check_circle"}
</span>
<span className="text-xs font-medium" style={{
color: statusColor === "red" ? "#b91c1c" : statusColor === "amber" ? "#b45309" : "#15803d",
}}>
{statusLabel}
</span>
</div>
{statusDesc && (
<p className="text-xs mt-1 ml-5" style={{
color: statusColor === "red" ? "#dc2626" : statusColor === "amber" ? "#d97706" : "#16a34a",
}}>
{statusDesc}
</p>
)}
</div>
{/* iOS 인증 방식 선택 */}
{!isAndroid && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIosAuthType("p8")}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p8"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
vpn_key
</span>
<span className={`text-sm font-medium ${iosAuthType === "p8" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Token (.p8)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
APNs Auth Key , ()
</p>
</button>
<button
type="button"
onClick={() => setIosAuthType("p12")}
disabled={submitting}
className={`p-3 rounded-lg border-2 text-left transition ${
iosAuthType === "p12"
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`material-symbols-outlined ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-gray-500"}`}
style={{ fontSize: "14px" }}
>
badge
</span>
<span className={`text-sm font-medium ${iosAuthType === "p12" ? "text-[#2563EB]" : "text-[#0f172a]"}`}>
Certificate (.p12)
</span>
</div>
<p className="text-[11px] text-[#64748b]">
, 1
</p>
</button>
</div>
</div>
)}
{/* 파일 업로드 */}
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
{!selectedFile ? (
<label className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5 transition">
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
upload_file
</span>
<p className="text-sm text-[#0f172a] font-medium">
</p>
<p className="text-xs text-[#64748b] mt-1">
{isAndroid
? ".json 파일만 업로드 가능합니다"
: iosAuthType === "p8"
? ".p8 파일만 업로드 가능합니다"
: ".p12 파일만 업로드 가능합니다"}
</p>
<input
type="file"
className="hidden"
accept={isAndroid ? ".json" : iosAuthType === "p8" ? ".p8" : ".p12"}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setSelectedFile(file);
}}
/>
</label>
) : (
<div className="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg p-4">
<span className="material-symbols-outlined text-[#2563EB] text-xl flex-shrink-0">
description
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f172a] truncate">
{selectedFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={() => setSelectedFile(null)}
disabled={submitting}
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</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 */}
{!isAndroid && iosAuthType === "p8" && (
<div className="mb-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Key ID
</label>
<input
type="text"
value={editKeyId}
onChange={(e) => setEditKeyId(e.target.value)}
placeholder="예: ABC123DEFG"
disabled={submitting}
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
Team ID
</label>
<input
type="text"
value={editTeamId}
onChange={(e) => setEditTeamId(e.target.value)}
placeholder="예: 9ABCDEFGH1"
disabled={submitting}
className="w-full px-3 py-2.5 border border-gray-300 rounded text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-[#64748b] text-xs mt-2">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span>Apple Developer .</span>
</div>
</div>
)}
{/* iOS P12: 비밀번호 */}
{!isAndroid && iosAuthType === "p12" && (
<div className="mb-5">
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
P12
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={editCertPassword}
onChange={(e) => setEditCertPassword(e.target.value)}
placeholder="P12 인증서 비밀번호를 입력하세요"
disabled={submitting}
className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition"
>
<span className="material-symbols-outlined text-lg">
{showPassword ? "visibility" : "visibility_off"}
</span>
</button>
</div>
</div>
)}
{/* 하단 버튼 */}
<div className="flex justify-end gap-3">
<button
onClick={() => setEditTarget(null)}
disabled={submitting}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={handleEdit}
disabled={submitting}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50"
>
{submitting ? (
<>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
<span> ...</span>
</>
) : (
<>
<span className="material-symbols-outlined text-base">save</span>
<span></span>
</>
)}
</button>
</div>
</div>
</div>
);
})()}
</>
);
}