- 플랫폼 관리 API 연동 (FCM/APNs 인증서 등록/삭제) - 서비스 상세 통계 카드 대시보드 KPI API 연결 - 통계 서브텍스트 전일 대비 변동 데이터 연결 (변동 없음 표시 포함) - ServiceHeaderCard/ServiceEditPage optional chaining 버그 수정 - 날짜 표기 YYYY-MM-DD 형식 통일 - 페이지네이션 totalCount 필드명 수정 및 패딩 정렬 - 서비스 등록 완료 모달 UI 통일 및 문구 수정 Closes #31
1183 lines
49 KiB
TypeScript
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>
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|