- 플랫폼 관리 API 연동 (FCM/APNs 인증서 등록/삭제) - 서비스 상세 통계 카드 대시보드 KPI API 연결 - 통계 서브텍스트 전일 대비 변동 데이터 연결 (변동 없음 표시 포함) - ServiceHeaderCard/ServiceEditPage optional chaining 버그 수정 - 날짜 표기 YYYY-MM-DD 형식 통일 - 페이지네이션 totalCount 필드명 수정 및 패딩 정렬 - 서비스 등록 완료 모달 UI 통일 및 문구 수정 Closes #31
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import { AxiosError } from "axios";
|
|
import PageHeader from "@/components/common/PageHeader";
|
|
import CopyButton from "@/components/common/CopyButton";
|
|
import PlatformSelector from "../components/PlatformSelector";
|
|
import useShake from "@/hooks/useShake";
|
|
import { createService } from "@/api/service.api";
|
|
import type { ApiError } from "@/types/api";
|
|
import type { CreateServiceResponse } from "../types";
|
|
|
|
export default function ServiceRegisterPage() {
|
|
const navigate = useNavigate();
|
|
|
|
// 폼 상태
|
|
const [serviceName, setServiceName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [relatedLink, setRelatedLink] = useState("");
|
|
const [androidChecked, setAndroidChecked] = useState(false);
|
|
const [iosChecked, setIosChecked] = useState(false);
|
|
const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8");
|
|
|
|
// 에러 상태
|
|
const [nameError, setNameError] = useState(false);
|
|
const [nameErrorMsg, setNameErrorMsg] = useState("서비스 명을 입력해주세요.");
|
|
const { triggerShake, cls } = useShake();
|
|
|
|
// 제출 상태
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// 모달
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
|
|
// API Key 모달
|
|
const [apiKeyResult, setApiKeyResult] = useState<CreateServiceResponse | null>(null);
|
|
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
|
|
|
|
// 등록 버튼 클릭
|
|
const handleRegister = () => {
|
|
const trimmed = serviceName.trim();
|
|
if (!trimmed || trimmed.length < 2) {
|
|
setNameError(true);
|
|
setNameErrorMsg(
|
|
!trimmed ? "서비스 명을 입력해주세요." : "서비스 명은 2자 이상이어야 합니다.",
|
|
);
|
|
triggerShake(["name"]);
|
|
return;
|
|
}
|
|
setShowConfirm(true);
|
|
};
|
|
|
|
// 등록 확인
|
|
const handleConfirm = async () => {
|
|
setShowConfirm(false);
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
const res = await createService({
|
|
serviceName: serviceName.trim(),
|
|
description: description.trim() || null,
|
|
});
|
|
setApiKeyResult(res.data.data);
|
|
setShowApiKeyModal(true);
|
|
} catch (err) {
|
|
const axiosErr = err as AxiosError<ApiError>;
|
|
const status = axiosErr.response?.status;
|
|
const msg = axiosErr.response?.data?.msg;
|
|
|
|
if (status === 409) {
|
|
toast.error(msg ?? "이미 사용 중인 서비스 명입니다.");
|
|
setNameError(true);
|
|
setNameErrorMsg(msg ?? "이미 사용 중인 서비스 명입니다.");
|
|
triggerShake(["name"]);
|
|
} else {
|
|
toast.error(msg ?? "서비스 등록에 실패했습니다. 다시 시도해주세요.");
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// API Key 모달 닫기 → 상세 페이지 이동
|
|
const handleApiKeyModalClose = () => {
|
|
setShowApiKeyModal(false);
|
|
if (apiKeyResult) {
|
|
navigate(`/services/${apiKeyResult.serviceCode}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="서비스 등록"
|
|
description="새로운 플랫폼 서비스를 등록하고 API 권한을 설정하세요."
|
|
/>
|
|
|
|
{/* 폼 카드 */}
|
|
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
|
|
<div className="p-8 space-y-6">
|
|
{/* 1. 서비스 명 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
|
서비스 명 <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={serviceName}
|
|
onChange={(e) => {
|
|
setServiceName(e.target.value);
|
|
if (e.target.value.trim()) setNameError(false);
|
|
}}
|
|
placeholder="서비스 명을 입력하세요"
|
|
className={`w-full px-3 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 ${
|
|
nameError
|
|
? "border-red-500 ring-2 ring-red-500/15"
|
|
: "border-gray-300"
|
|
} ${cls("name")}`}
|
|
/>
|
|
{nameError && (
|
|
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
|
|
<span
|
|
className="material-symbols-outlined flex-shrink-0"
|
|
style={{ fontSize: "14px" }}
|
|
>
|
|
error
|
|
</span>
|
|
<span>{nameErrorMsg}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 2. 플랫폼 선택 */}
|
|
<PlatformSelector
|
|
androidChecked={androidChecked}
|
|
iosChecked={iosChecked}
|
|
onAndroidChange={setAndroidChecked}
|
|
onIosChange={setIosChecked}
|
|
iosAuthType={iosAuthType}
|
|
onIosAuthTypeChange={setIosAuthType}
|
|
/>
|
|
|
|
{/* 4. 설명 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
|
설명
|
|
</label>
|
|
<textarea
|
|
rows={4}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="서비스에 대한 간략한 설명을 입력하세요"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 5. 관련 링크 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
|
관련 링크
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={relatedLink}
|
|
onChange={(e) => setRelatedLink(e.target.value)}
|
|
placeholder="https://example.com"
|
|
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-shadow placeholder-gray-400"
|
|
/>
|
|
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1.5">
|
|
<span
|
|
className="material-symbols-outlined flex-shrink-0"
|
|
style={{ fontSize: "14px" }}
|
|
>
|
|
info
|
|
</span>
|
|
<span>서비스와 관련된 웹사이트나 문서 링크를 입력하세요.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 하단 액션바 */}
|
|
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => navigate("/services")}
|
|
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={handleRegister}
|
|
disabled={submitting}
|
|
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{submitting ? "등록 중..." : "등록하기"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 등록 확인 모달 */}
|
|
{showConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/40"
|
|
onClick={() => setShowConfirm(false)}
|
|
/>
|
|
<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-amber-100 flex items-center justify-center flex-shrink-0">
|
|
<span className="material-symbols-outlined text-amber-600 text-xl">
|
|
warning
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg font-bold text-[#0f172a]">
|
|
서비스 등록 확인
|
|
</h3>
|
|
</div>
|
|
<p className="text-sm text-[#0f172a] mb-2">
|
|
입력한 내용으로 서비스를 등록하시겠습니까?
|
|
</p>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 mb-5">
|
|
<div className="flex items-center gap-1.5 text-blue-700 text-xs font-medium">
|
|
<span
|
|
className="material-symbols-outlined flex-shrink-0"
|
|
style={{ fontSize: "14px" }}
|
|
>
|
|
info
|
|
</span>
|
|
<span>서비스 ID는 등록 후 자동으로 생성됩니다.</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowConfirm(false)}
|
|
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={handleConfirm}
|
|
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"
|
|
>
|
|
<span className="material-symbols-outlined text-base">
|
|
check
|
|
</span>
|
|
<span>등록</span>
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|