SPMS_WEB/react/src/features/service/pages/ServiceRegisterPage.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

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>
);
}