feat: 서비스 관리 페이지 구현 (#14)

- 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세)
- 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달)
- 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크)
- 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달)
- 공통 훅 추출 (useShake, useBreadcrumbBack)
- 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit)
- 인증 페이지 useShake 공통 훅 리팩터링

Closes #14
This commit is contained in:
SEAN 2026-02-27 13:33:56 +09:00
parent 59c206e0c2
commit 9db9d87dea
22 changed files with 3022 additions and 51 deletions

View File

@ -25,3 +25,4 @@ export default function CategoryBadge({ variant, icon, label }: CategoryBadgePro
</span>
);
}

View File

@ -3,6 +3,7 @@ interface SearchInputProps {
onChange: (value: string) => void;
placeholder?: string;
label?: string;
disabled?: boolean;
}
/** 검색 아이콘 + 텍스트 입력 */
@ -11,6 +12,7 @@ export default function SearchInput({
onChange,
placeholder = "검색...",
label,
disabled,
}: SearchInputProps) {
return (
<div className="flex-1">
@ -28,7 +30,8 @@ export default function SearchInput({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
disabled={disabled}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
</div>
</div>

View File

@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import CategoryBadge from "@/components/common/CategoryBadge";
/** 경로 → breadcrumb 레이블 매핑 */
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
const pathLabels: Record<string, string> = {
"/dashboard": "대시보드",
"/services": "서비스 관리",
@ -18,11 +18,35 @@ const pathLabels: Record<string, string> = {
"/settings/notifications": "알림",
};
/** pathname → breadcrumb 배열 생성 (누적 경로 기반) */
/**
*
* pattern으로 pathname을 crumbs
*/
const dynamicPatterns: {
pattern: RegExp;
crumbs: (match: RegExpMatchArray) => { path: string; label: string }[];
}[] = [
{
// /services/:id 또는 /services/:id/edit (register 제외)
pattern: /^\/services\/(?!register$)([^/]+)(\/edit)?$/,
crumbs: (match) => {
const id = match[1];
const isEdit = !!match[2];
const result = [{ path: `/services/${id}`, label: "서비스 상세" }];
if (isEdit) {
result.push({ path: `/services/${id}/edit`, label: "서비스 수정" });
}
return result;
},
},
];
/** pathname → breadcrumb 배열 생성 */
function buildBreadcrumbs(pathname: string) {
const segments = pathname.split("/").filter(Boolean);
const crumbs: { path: string; label: string }[] = [];
// 1) 정적 경로 매칭 (누적 경로 기반)
let currentPath = "";
for (const segment of segments) {
currentPath += `/${segment}`;
@ -32,6 +56,14 @@ function buildBreadcrumbs(pathname: string) {
}
}
// 2) 동적 경로 패턴 매칭
for (const { pattern, crumbs: buildDynamic } of dynamicPatterns) {
const match = pathname.match(pattern);
if (match) {
crumbs.push(...buildDynamic(match));
}
}
return crumbs;
}

View File

@ -2,8 +2,10 @@ import { useState } from "react";
import { Outlet } from "react-router-dom";
import AppSidebar from "./AppSidebar";
import AppHeader from "./AppHeader";
import useBreadcrumbBack from "@/hooks/useBreadcrumbBack";
export default function AppLayout() {
useBreadcrumbBack();
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);

View File

@ -1,8 +1,8 @@
import { useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import useShake from "@/hooks/useShake";
const resetSchema = z.object({
email: z
@ -19,7 +19,7 @@ interface Props {
}
export default function ResetPasswordModal({ open, onClose }: Props) {
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
const { triggerShake, cls } = useShake();
const {
register,
@ -30,11 +30,6 @@ export default function ResetPasswordModal({ open, onClose }: Props) {
resolver: zodResolver(resetSchema),
});
const triggerShake = useCallback((fieldNames: string[]) => {
setShakeFields(new Set(fieldNames));
setTimeout(() => setShakeFields(new Set()), 400);
}, []);
/* 유효성 통과 → 발송 처리 */
const onSubmit = (_data: ResetForm) => {
// TODO: API 연동
@ -93,7 +88,7 @@ export default function ResetPasswordModal({ open, onClose }: Props) {
errors.email
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
} ${cls("email")}`}
{...register("email")}
/>
{errors.email && (

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@ -7,6 +7,7 @@ import { AxiosError } from "axios";
import { login } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import { useAuthStore } from "@/stores/authStore";
import useShake from "@/hooks/useShake";
import ResetPasswordModal from "../components/ResetPasswordModal";
/* ───── zod 스키마 ───── */
@ -26,7 +27,7 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
const { triggerShake, cls } = useShake();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginSuccess, setLoginSuccess] = useState(false);
@ -39,12 +40,6 @@ export default function LoginPage() {
resolver: zodResolver(loginSchema),
});
/* shake 트리거 */
const triggerShake = useCallback((fieldNames: string[]) => {
setShakeFields(new Set(fieldNames));
setTimeout(() => setShakeFields(new Set()), 400);
}, []);
/* 유효성 통과 → 로그인 처리 */
const onSubmit = async (data: LoginForm) => {
setLoginError(null);
@ -160,7 +155,7 @@ export default function LoginPage() {
errors.email
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
} ${cls("email")}`}
{...register("email")}
/>
{errors.email && (
@ -193,7 +188,7 @@ export default function LoginPage() {
errors.password
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${shakeFields.has("password") ? "animate-shake" : ""}`}
} ${cls("password")}`}
{...register("password")}
/>
<button

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@ -6,6 +6,7 @@ import { Link, useNavigate } from "react-router-dom";
import { AxiosError } from "axios";
import { signup } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import useShake from "@/hooks/useShake";
/* ───── 정규식 ───── */
const pwRegex =
@ -44,7 +45,7 @@ type SignupForm = z.infer<typeof signupSchema>;
export default function SignupPage() {
const navigate = useNavigate();
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
const { triggerShake, cls } = useShake();
const [emailSentModal, setEmailSentModal] = useState(false);
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);
@ -75,12 +76,6 @@ export default function SignupPage() {
? "match"
: "mismatch";
/* shake 트리거 */
const triggerShake = useCallback((fieldNames: string[]) => {
setShakeFields(new Set(fieldNames));
setTimeout(() => setShakeFields(new Set()), 400);
}, []);
/* 전화번호 자동 하이픈 */
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let v = e.target.value.replace(/[^0-9]/g, "");
@ -144,7 +139,7 @@ export default function SignupPage() {
errors[field]
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${shakeFields.has(field) ? "animate-shake" : ""}`;
} ${cls(field)}`;
return (
<>
@ -342,7 +337,7 @@ export default function SignupPage() {
type="checkbox"
className={`peer size-5 cursor-pointer appearance-none rounded border-2 transition-all checked:border-primary checked:bg-primary ${
errors.agree ? "border-red-500" : "border-gray-300"
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
} ${cls("agree")}`}
{...register("agree")}
/>
<span className="material-symbols-outlined pointer-events-none absolute text-sm text-white opacity-0 peer-checked:opacity-100">
@ -352,7 +347,7 @@ export default function SignupPage() {
<span
className={`text-sm transition-colors ${
errors.agree ? "text-red-500" : "text-gray-600"
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
} ${cls("agree")}`}
>
<button
type="button"

View File

@ -1,9 +1,10 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { AxiosError } from "axios";
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
import type { ApiError } from "@/types/api";
import { useAuthStore } from "@/stores/authStore";
import useShake from "@/hooks/useShake";
interface LocationState {
verifySessionId?: string;
@ -21,7 +22,7 @@ export default function VerifyEmailPage() {
const [code, setCode] = useState<string[]>(Array(6).fill(""));
const [codeError, setCodeError] = useState<string | null>(null);
const [shakeCode, setShakeCode] = useState(false);
const { triggerShake, cls } = useShake();
const [successModal, setSuccessModal] = useState(false);
const [resendModal, setResendModal] = useState(false);
const [homeOverlay, setHomeOverlay] = useState(false);
@ -46,12 +47,6 @@ export default function VerifyEmailPage() {
const isFilled = code.every((c) => c.length === 1);
/* shake 트리거 */
const triggerShake = useCallback(() => {
setShakeCode(true);
setTimeout(() => setShakeCode(false), 400);
}, []);
/* 입력 처리 */
const handleInput = (index: number, value: string) => {
const digit = value.replace(/[^0-9]/g, "");
@ -114,7 +109,7 @@ export default function VerifyEmailPage() {
const axiosErr = err as AxiosError<ApiError>;
const msg = axiosErr.response?.data?.msg;
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
triggerShake();
triggerShake(["code"]);
} finally {
setIsLoading(false);
}
@ -231,7 +226,7 @@ export default function VerifyEmailPage() {
codeError
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
: "border-gray-300"
} ${shakeCode ? "animate-shake" : ""}`}
} ${cls("code")}`}
/>
))}
</div>

View File

@ -0,0 +1,888 @@
import { useState, useRef, useEffect } from "react";
import { toast } from "sonner";
import type { ServiceDetail } from "../types";
// 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;
}
export default function PlatformManagement({
service,
}: 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 [deletedPlatforms, setDeletedPlatforms] = useState<Set<string>>(new Set());
const [addedPlatforms, setAddedPlatforms] = useState<Set<string>>(new Set());
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 androidVisible = (service.platforms.android.registered || addedPlatforms.has("android")) && !deletedPlatforms.has("android");
const iosVisible = (service.platforms.ios.registered || addedPlatforms.has("ios")) && !deletedPlatforms.has("ios");
const allRegistered = androidVisible && iosVisible;
const noneRegistered = !androidVisible && !iosVisible;
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");
setAddFile(null);
setAddShowPassword(false);
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}
reason={service.platforms.android.statusReason}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
setEditTarget("android");
setSelectedFile(null);
}}
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}
reason={service.platforms.ios.statusReason}
/>
</div>
</div>
<PlatformMenu
onEdit={() => {
setEditTarget("ios");
setSelectedFile(null);
setShowPassword(false);
}}
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={() => 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={() => 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}
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}
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); }}
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); }}
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)}
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 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"
placeholder="예: ABC123DEFG"
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"
placeholder="예: 9ABCDEFGH1"
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"}
placeholder="P12 인증서 비밀번호를 입력하세요"
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)}
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={() => {
setAddedPlatforms((prev) => new Set(prev).add(addTab));
setDeletedPlatforms((prev) => {
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"
>
<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={() => 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)}
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={() => {
const key = deleteTarget === "Android" ? "android" : "ios";
setDeletedPlatforms((prev) => new Set(prev).add(key));
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"
>
<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={() => 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={() => 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")}
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")}
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)}
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 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"
placeholder="예: ABC123DEFG"
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"
placeholder="예: 9ABCDEFGH1"
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"}
placeholder="P12 인증서 비밀번호를 입력하세요"
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)}
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={() => {
toast.success("인증서가 저장되었습니다.");
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"
>
<span className="material-symbols-outlined text-base">save</span>
<span></span>
</button>
</div>
</div>
</div>
);
})()}
</>
);
}

View File

@ -0,0 +1,356 @@
import { useState } from "react";
// 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>
);
interface PlatformSelectorProps {
androidChecked: boolean;
iosChecked: boolean;
onAndroidChange: (checked: boolean) => void;
onIosChange: (checked: boolean) => void;
iosAuthType: "p8" | "p12";
onIosAuthTypeChange: (type: "p8" | "p12") => void;
}
export default function PlatformSelector({
androidChecked,
iosChecked,
onAndroidChange,
onIosChange,
iosAuthType,
onIosAuthTypeChange,
}: PlatformSelectorProps) {
const [androidFile, setAndroidFile] = useState<File | null>(null);
const [iosFile, setIosFile] = useState<File | null>(null);
const [showPassword, setShowPassword] = useState(false);
return (
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-3">
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Android 카드 ── */}
<div
onClick={() => { if (!androidChecked) onAndroidChange(true); }}
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${androidChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
>
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
id="android-checkbox"
checked={androidChecked}
onChange={(e) => {
onAndroidChange(e.target.checked);
if (!e.target.checked) setAndroidFile(null);
}}
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
/>
<label
htmlFor="android-checkbox"
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
>
<span className="material-symbols-outlined text-[#22C55E]" style={{ fontSize: "20px" }}>
android
</span>
<span>Android</span>
</label>
</div>
{/* 파일 업로드 */}
<div>
<p className="text-xs font-medium text-[#64748b] mb-2">
</p>
{!androidFile ? (
androidChecked ? (
<label
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors cursor-pointer hover:border-[#2563EB] hover:bg-[#2563EB]/5"
>
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
upload_file
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
<p className="text-xs text-gray-400 mt-1">
.json
</p>
<input
type="file"
className="hidden"
accept=".json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setAndroidFile(file);
}}
/>
</label>
) : (
<div
className="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
>
<span className="material-symbols-outlined text-gray-400 text-3xl mb-2">
lock
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
</div>
)
) : (
<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">
{androidFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(androidFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => setAndroidFile(null)}
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>
</div>
{/* ── iOS 카드 ── */}
<div
onClick={() => { if (!iosChecked) onIosChange(true); }}
className={`p-4 rounded-lg border border-gray-200 bg-white transition-opacity ${iosChecked ? "opacity-100" : "opacity-50 cursor-pointer hover:opacity-70"}`}
>
<div className="flex items-center gap-2 mb-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
id="ios-checkbox"
checked={iosChecked}
onChange={(e) => {
onIosChange(e.target.checked);
if (!e.target.checked) {
setIosFile(null);
setShowPassword(false);
}
}}
className="w-4 h-4 rounded border-gray-300 text-[#2563EB] focus:ring-[#2563EB] cursor-pointer"
/>
<label
htmlFor="ios-checkbox"
className="flex items-center gap-2 text-sm font-medium text-[#0f172a] cursor-pointer flex-1"
>
<AppleLogo className="w-5 h-5" />
<span>iOS</span>
</label>
</div>
{/* iOS 미선택 시 — Android와 동일 */}
{!iosChecked && (
<div>
<p className="text-xs font-medium text-[#64748b] mb-2">
</p>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"
>
<span className="material-symbols-outlined text-gray-400 text-3xl flex justify-center mb-2">
lock
</span>
<p className="text-sm text-[#64748b] font-medium">
</p>
</div>
</div>
)}
{/* iOS 선택됨 — 상세 페이지 모달과 동일 레이아웃 */}
{iosChecked && (
<div className="space-y-5">
{/* 인증 방식 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => { onIosAuthTypeChange("p8"); setIosFile(null); }}
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={() => { onIosAuthTypeChange("p12"); setIosFile(null); }}
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>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
{!iosFile ? (
<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 text-center">
<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">
{iosAuthType === "p8"
? ".p8 파일만 업로드 가능합니다"
: ".p12 파일만 업로드 가능합니다"}
</p>
<input
type="file"
className="hidden"
accept={iosAuthType === "p8" ? ".p8" : ".p12"}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setIosFile(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">
{iosFile.name}
</p>
<p className="text-xs text-[#64748b]">
{(iosFile.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => setIosFile(null)}
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>
{/* P8: Key ID + Team ID */}
{iosAuthType === "p8" && (
<div>
<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"
placeholder="예: ABC123DEFG"
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"
placeholder="예: 9ABCDEFGH1"
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>
)}
{/* P12: 비밀번호 */}
{iosAuthType === "p12" && (
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
P12
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="P12 인증서 비밀번호를 입력하세요"
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>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
import type { PlatformCredentialSummary } from "../types";
// 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>
);
// 상태별 dot 색상
const DOT_STYLES = {
warn: "bg-amber-500",
error: "bg-red-500",
} as const;
// 상태별 툴팁 스타일
const TOOLTIP_STYLES = {
warn: "bg-[#fffbeb] text-[#b45309] border border-[#fde68a]",
error: "bg-[#fef2f2] text-[#dc2626] border border-[#fecaca]",
} as const;
const TOOLTIP_ARROW = {
warn: "border-b-[#fde68a]",
error: "border-b-[#fecaca]",
} as const;
const TOOLTIP_LABEL = {
warn: "주의",
error: "경고",
} as const;
// 상태별 배지 스타일 (error 상태일 때 배지 자체도 회색)
const BADGE_ACTIVE = {
android: "bg-green-50 text-green-700 border-green-200",
ios: "bg-slate-100 text-slate-700 border-slate-200",
} as const;
const BADGE_INACTIVE = "bg-gray-100 text-gray-400 border-gray-200";
interface PlatformStatusIndicatorProps {
platform: "android" | "ios";
credential: PlatformCredentialSummary;
}
export default function PlatformStatusIndicator({
platform,
credential,
}: PlatformStatusIndicatorProps) {
if (!credential.registered) return null;
const hasIssue =
credential.credentialStatus === "warn" ||
credential.credentialStatus === "error";
const badgeClass =
credential.credentialStatus === "error"
? BADGE_INACTIVE
: BADGE_ACTIVE[platform];
return (
<div className="relative inline-flex group">
<span
className={`inline-flex items-center justify-center w-8 h-6 rounded text-xs font-medium border ${badgeClass}`}
>
{platform === "android" ? (
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>android</span>
) : (
<AppleLogo className="w-3.5 h-3.5" />
)}
</span>
{hasIssue && credential.credentialStatus && (
<>
<span
className={`absolute -top-1 -right-1 size-2.5 rounded-full border-2 border-white ${DOT_STYLES[credential.credentialStatus]}`}
/>
{/* 호버 툴팁 */}
<span
className={`absolute top-full left-1/2 -translate-x-1/2 mt-1.5 text-[11px] font-medium px-2.5 py-0.5 rounded-md whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 pointer-events-none z-50 ${TOOLTIP_STYLES[credential.credentialStatus]}`}
>
{/* 위쪽 화살표 */}
<span
className={`absolute bottom-full left-1/2 -translate-x-1/2 border-4 border-transparent ${TOOLTIP_ARROW[credential.credentialStatus]}`}
/>
{TOOLTIP_LABEL[credential.credentialStatus]}
</span>
</>
)}
</div>
);
}

View File

@ -0,0 +1,122 @@
import { Link } from "react-router-dom";
import StatusBadge from "@/components/common/StatusBadge";
import CopyButton from "@/components/common/CopyButton";
import PlatformStatusIndicator from "./PlatformStatusIndicator";
import { formatNumber } from "@/utils/format";
import type { ServiceDetail } from "../types";
import { SERVICE_STATUS } from "../types";
interface ServiceHeaderCardProps {
service: ServiceDetail;
onShowApiKey: () => void;
}
export default function ServiceHeaderCard({
service,
onShowApiKey,
}: ServiceHeaderCardProps) {
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6 mb-6">
{/* 상단 행 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="size-14 rounded-xl bg-[#2563EB]/10 flex items-center justify-center">
<span className="material-symbols-outlined text-[#2563EB] text-3xl">
{service.serviceIcon}
</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-[#0f172a]">
{service.serviceName}
</h2>
<StatusBadge
variant={
service.status === SERVICE_STATUS.ACTIVE ? "success" : "error"
}
label={
service.status === SERVICE_STATUS.ACTIVE ? "활성" : "비활성"
}
/>
</div>
<div className="flex items-center gap-4 mt-1.5">
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-[#64748b] font-mono">
{service.serviceCode}
</span>
<CopyButton text={service.serviceCode} />
</div>
<span className="text-gray-300">|</span>
<button
onClick={onShowApiKey}
className="flex items-center gap-1 text-sm text-[#2563EB] hover:text-[#1d4ed8] transition-colors cursor-pointer"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
vpn_key
</span>
<span className="font-medium">API </span>
</button>
</div>
</div>
</div>
<Link
to={`/services/${service.serviceCode}/edit`}
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">edit</span>
<span></span>
</Link>
</div>
{/* 메타 정보 */}
<div className="mt-6 pt-5 border-t border-gray-100 grid grid-cols-2 sm:grid-cols-4 gap-6">
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<div className="flex items-center gap-2.5 mt-1.5">
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{formatNumber(service.deviceCount)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt ?? "-"}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
interface StatCard {
label: string;
value: string;
sub: { type: "trend" | "stable" | "live"; text: string; color?: string };
icon: string;
iconBg: string;
iconColor: string;
}
interface ServiceStatsCardsProps {
totalSent: number;
successRate: number;
deviceCount: number;
todaySent: number;
}
export default function ServiceStatsCards({
totalSent,
successRate,
deviceCount,
todaySent,
}: ServiceStatsCardsProps) {
const cards: StatCard[] = [
{
label: "총 발송 수",
value: totalSent.toLocaleString(),
sub: { type: "trend", text: "+12.5%", color: "text-indigo-600" },
icon: "equalizer",
iconBg: "bg-indigo-50",
iconColor: "text-indigo-600",
},
{
label: "성공률",
value: `${successRate}%`,
sub: { type: "stable", text: "Stable" },
icon: "check_circle",
iconBg: "bg-emerald-50",
iconColor: "text-emerald-600",
},
{
label: "등록 기기 수",
value: deviceCount.toLocaleString(),
sub: { type: "trend", text: "+82 today", color: "text-amber-600" },
icon: "devices",
iconBg: "bg-amber-50",
iconColor: "text-amber-600",
},
{
label: "오늘 발송",
value: todaySent.toLocaleString(),
sub: { type: "live", text: "Live" },
icon: "today",
iconBg: "bg-[#2563EB]/5",
iconColor: "text-[#2563EB]",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{cards.map((card) => (
<div
key={card.label}
className="bg-white border border-gray-200 rounded-lg shadow-sm p-6"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
{card.label}
</p>
<p className="text-2xl font-bold text-[#0f172a] mt-2">
{card.value}
</p>
<div className="mt-2">
{card.sub.type === "trend" && (
<p className={`text-xs ${card.sub.color ?? "text-green-600"} font-medium flex items-center gap-1`}>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
trending_up
</span>
<span>{card.sub.text}</span>
</p>
)}
{card.sub.type === "stable" && (
<p className="text-xs text-gray-600 font-medium flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-gray-400" />
<span>{card.sub.text}</span>
</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
className={`size-12 rounded-lg ${card.iconBg} flex items-center justify-center flex-shrink-0`}
>
<span
className={`material-symbols-outlined ${card.iconColor} text-2xl`}
>
{card.icon}
</span>
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -1,7 +1,88 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import CopyButton from "@/components/common/CopyButton";
import ServiceHeaderCard from "../components/ServiceHeaderCard";
import ServiceStatsCards from "../components/ServiceStatsCards";
import PlatformManagement from "../components/PlatformManagement";
import { MOCK_SERVICE_DETAIL, MOCK_SERVICES } from "../types";
export default function ServiceDetailPage() {
const { id } = useParams<{ id: string }>();
const [showApiKey, setShowApiKey] = useState(false);
// 목 데이터에서 서비스 조회 (목록에서 클릭한 ID로)
const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id);
const service = listItem
? { ...MOCK_SERVICE_DETAIL, ...listItem, serviceCode: listItem.serviceCode }
: MOCK_SERVICE_DETAIL;
return (
<div className="p-6">
<h1 className="text-2xl font-bold"> </h1>
<div>
<PageHeader title="서비스 상세 정보" />
<ServiceHeaderCard
service={service}
onShowApiKey={() => setShowApiKey(true)}
/>
<ServiceStatsCards
totalSent={154200}
successRate={99.2}
deviceCount={service.deviceCount}
todaySent={5300}
/>
<PlatformManagement service={service} />
{/* API 키 확인 모달 */}
{showApiKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowApiKey(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">
vpn_key
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]">API </h3>
</div>
<button
onClick={() => setShowApiKey(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="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">
<code className="flex-1 text-sm font-mono text-[#0f172a] break-all select-all">
{service.apiKey}
</code>
<CopyButton text={service.apiKey} />
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,439 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import useShake from "@/hooks/useShake";
import { formatNumber } from "@/utils/format";
import { MOCK_SERVICE_DETAIL, MOCK_SERVICES, SERVICE_STATUS } from "../types";
// 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>
);
export default function ServiceEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 목 데이터에서 서비스 조회
const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id);
const service = listItem
? { ...MOCK_SERVICE_DETAIL, ...listItem }
: MOCK_SERVICE_DETAIL;
// 폼 상태
const [serviceName, setServiceName] = useState(service.serviceName);
const [isActive, setIsActive] = useState(
service.status === SERVICE_STATUS.ACTIVE
);
const [description, setDescription] = useState(service.description ?? "");
// 에러 상태
const [nameError, setNameError] = useState(false);
const { triggerShake, cls } = useShake();
// 모달 상태
const [showSaveModal, setShowSaveModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
// 저장
const handleSave = () => {
if (!serviceName.trim()) {
setNameError(true);
triggerShake(["name"]);
return;
}
setShowSaveModal(true);
};
const handleSaveConfirm = () => {
setShowSaveModal(false);
toast.success("변경사항이 저장되었습니다.");
navigate(`/services/${id}`);
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold"> </h1>
<div>
<PageHeader
title="서비스 수정"
description="서비스 정보를 수정하고 저장하세요."
/>
{/* 폼 카드 */}
<div className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden mb-8">
<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>
<div className="flex items-center gap-3">
<input
type="text"
value={serviceName}
onChange={(e) => {
setServiceName(e.target.value);
if (e.target.value.trim()) setNameError(false);
}}
className={`flex-1 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")}`}
/>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`w-[100px] inline-flex items-center justify-center gap-1.5 rounded px-3 py-2 text-xs font-medium cursor-pointer transition-colors flex-shrink-0 border ${
isActive
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
: "bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200"
}`}
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{isActive ? "check_circle" : "cancel"}
</span>
<span>{isActive ? "활성" : "비활성"}</span>
</button>
</div>
{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> .</span>
</div>
)}
</div>
{/* 2. 서비스 ID (읽기 전용) */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
ID
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={service.serviceCode}
disabled
className="flex-1 px-3 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 cursor-not-allowed font-mono"
/>
<div className="w-[100px] flex items-center justify-center gap-1.5 bg-amber-50 border border-amber-200 rounded px-2 py-2 flex-shrink-0">
<span
className="material-symbols-outlined text-amber-600"
style={{ fontSize: "14px" }}
>
lock
</span>
<span className="text-xs text-amber-700 font-medium whitespace-nowrap">
</span>
</div>
</div>
</div>
{/* 3. 설명 */}
<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)}
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition placeholder-gray-400 resize-none"
/>
</div>
{/* 메타 정보 (읽기 전용) */}
<div className="pt-4 border-t border-gray-100">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<div className="flex items-center gap-1.5 mt-1.5">
<PlatformStatusIndicator
platform="android"
credential={service.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={service.platforms.ios}
/>
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
<span className="text-xs text-gray-400"></span>
)}
</div>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{formatNumber(service.deviceCount)}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.createdAt}
</p>
</div>
<div className="flex flex-col">
<p className="text-xs text-[#64748b] font-semibold uppercase tracking-wide">
</p>
<p className="text-sm font-semibold text-[#0f172a] mt-1.5">
{service.updatedAt ?? "-"}
</p>
</div>
</div>
</div>
</div>
{/* 하단 액션바 */}
<div className="px-8 py-5 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
<button
onClick={() => setShowCancelModal(true)}
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={handleSave}
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 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>
<span className="inline-flex items-center gap-1.5 text-xs text-[#64748b] bg-gray-50 border border-gray-200 px-3 py-1.5 rounded-full">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
info
</span>
</span>
</div>
{/* Android */}
{service.platforms.android.registered && (
<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>
<div className="flex items-center gap-2 mt-2">
{service.platforms.android.credentialStatus === "error" ? (
<>
<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>
<p className="text-xs text-red-600 font-medium">
{service.platforms.android.statusReason}
</p>
</>
) : service.platforms.android.credentialStatus === "warn" ? (
<>
<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>
<p className="text-xs text-amber-600 font-medium">
{service.platforms.android.statusReason}
</p>
</>
) : (
<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>
</div>
</div>
</div>
)}
{/* iOS */}
{service.platforms.ios.registered && (
<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>
<div className="flex items-center gap-2 mt-2">
{service.platforms.ios.credentialStatus === "error" ? (
<>
<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>
<p className="text-xs text-red-600 font-medium">
{service.platforms.ios.statusReason}
</p>
</>
) : service.platforms.ios.credentialStatus === "warn" ? (
<>
<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>
<p className="text-xs text-amber-600 font-medium">
{service.platforms.ios.statusReason}
</p>
</>
) : (
<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>
</div>
</div>
</div>
)}
{/* 빈 상태 */}
{!service.platforms.android.registered &&
!service.platforms.ios.registered && (
<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>
</div>
)}
</div>
{/* 저장 확인 모달 */}
{showSaveModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowSaveModal(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-blue-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-blue-600 text-xl">
save
</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> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowSaveModal(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={handleSaveConfirm}
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>
)}
{/* 취소 확인 모달 */}
{showCancelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowCancelModal(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-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<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" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowCancelModal(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={() => navigate(`/services/${id}`)}
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>
)}
</div>
);
}

View File

@ -1,7 +1,302 @@
import { useState, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import FilterResetButton from "@/components/common/FilterResetButton";
import StatusBadge from "@/components/common/StatusBadge";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton";
import PlatformStatusIndicator from "../components/PlatformStatusIndicator";
import { formatDate, formatNumber } from "@/utils/format";
import { MOCK_SERVICES, SERVICE_STATUS } from "../types";
import type { ServiceSummary } from "../types";
const STATUS_OPTIONS = ["전체 상태", "활성", "비활성"];
const PAGE_SIZE = 10;
// 상태값 매핑
function getStatusBadge(status: ServiceSummary["status"]) {
if (status === SERVICE_STATUS.ACTIVE) {
return <StatusBadge variant="success" label="활성" />;
}
return <StatusBadge variant="error" label="비활성" />;
}
export default function ServiceListPage() {
const navigate = useNavigate();
// 필터 상태
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("전체 상태");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedStatus, setAppliedStatus] = useState("전체 상태");
// 조회 버튼 (로딩 인디케이터 포함)
const handleQuery = () => {
setLoading(true);
setTimeout(() => {
setAppliedSearch(search);
setAppliedStatus(statusFilter);
setCurrentPage(1);
setLoading(false);
}, 400);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setStatusFilter("전체 상태");
setAppliedSearch("");
setAppliedStatus("전체 상태");
setCurrentPage(1);
};
// 필터링된 데이터
const filtered = useMemo(() => {
return MOCK_SERVICES.filter((svc) => {
// 검색
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 (
<div className="p-6">
<h1 className="text-2xl font-bold"> </h1>
<div>
<PageHeader
title="서비스 관리"
description="등록된 서비스의 현황을 조회하고 관리할 수 있습니다."
action={
<Link
to="/services/register"
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-lg">add</span>
</Link>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="검색어를 입력하세요"
label="서비스명 / ID"
disabled={loading}
/>
<FilterDropdown
label="상태"
value={statusFilter}
options={STATUS_OPTIONS}
onChange={setStatusFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 테이블 */}
{loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-gray-100 animate-pulse" />
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse" />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5">
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
<div className="w-8 h-6 rounded bg-gray-100 animate-pulse" />
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-12 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-5 w-14 rounded-full bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : paged.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{paged.map((svc, idx) => (
<tr
key={svc.serviceCode}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => navigate(`/services/${svc.serviceCode}`)}
>
{/* 서비스명 */}
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<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">
{svc.serviceIcon}
</span>
</div>
<span className="text-sm font-medium text-[#0f172a]">
{svc.serviceName}
</span>
</div>
</td>
{/* 서비스 ID */}
<td className="px-6 py-4 text-center">
<div
className="inline-flex items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<code className="text-sm text-gray-700 font-medium">
{svc.serviceCode}
</code>
<CopyButton text={svc.serviceCode} />
</div>
</td>
{/* 플랫폼 */}
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5">
<PlatformStatusIndicator
platform="android"
credential={svc.platforms.android}
/>
<PlatformStatusIndicator
platform="ios"
credential={svc.platforms.ios}
/>
</div>
</td>
{/* 기기 수 */}
<td className="px-6 py-4 text-center text-sm text-gray-700 font-medium">
{formatNumber(svc.deviceCount)}
</td>
{/* 상태 */}
<td className="px-6 py-4 text-center">
{getStatusBadge(svc.status)}
</td>
{/* 등록일 */}
<td className="px-6 py-4 text-center text-sm text-gray-500">
{formatDate(svc.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
</div>
);
}

View File

@ -1,7 +1,204 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import PlatformSelector from "../components/PlatformSelector";
import useShake from "@/hooks/useShake";
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 { triggerShake, cls } = useShake();
// 모달
const [showConfirm, setShowConfirm] = useState(false);
// 등록 버튼 클릭
const handleRegister = () => {
if (!serviceName.trim()) {
setNameError(true);
triggerShake(["name"]);
return;
}
setShowConfirm(true);
};
// 등록 확인
const handleConfirm = () => {
setShowConfirm(false);
toast.success("서비스가 등록되었습니다.");
navigate("/services");
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold"> </h1>
<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> .</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}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
>
</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>
)}
</div>
);
}

View File

@ -1 +1,318 @@
// Service feature 타입 정의
// 서비스 상태
export const SERVICE_STATUS = {
ACTIVE: "Active",
SUSPENDED: "Suspended",
} as const;
export type ServiceStatus = (typeof SERVICE_STATUS)[keyof typeof SERVICE_STATUS];
// 인증서 상태
export const CREDENTIAL_STATUS = {
OK: "ok",
WARN: "warn",
ERROR: "error",
} as const;
export type CredentialStatus =
(typeof CREDENTIAL_STATUS)[keyof typeof CREDENTIAL_STATUS];
// 플랫폼 인증 요약
export interface PlatformCredentialSummary {
registered: boolean;
credentialStatus: CredentialStatus | null;
statusReason: string | null;
expiresAt: string | null;
}
// 서비스 목록 항목
export interface ServiceSummary {
serviceCode: string;
serviceName: string;
serviceIcon: string;
description: string | null;
status: ServiceStatus;
createdAt: string;
deviceCount: number;
platforms: {
android: PlatformCredentialSummary;
ios: PlatformCredentialSummary;
};
}
// 서비스 상세
export interface ServiceDetail extends ServiceSummary {
apiKey: string;
apiKeyCreatedAt: string;
apnsAuthType: "p8" | "p12" | null;
hasApnsKey: boolean;
hasFcmCredentials: boolean;
createdByName: string | null;
updatedAt: string | null;
}
// 목 데이터 - 서비스 목록
export const MOCK_SERVICES: ServiceSummary[] = [
{
serviceCode: "svc-factory-01",
serviceName: "스마트 팩토리 모니터링",
serviceIcon: "smart_toy",
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,
},
},
},
];
// 목 데이터 - 서비스 상세
export const MOCK_SERVICE_DETAIL: ServiceDetail = {
serviceCode: "svc-factory-monitoring-01",
serviceName: "(주) 미래테크",
serviceIcon: "corporate_fare",
description:
"공장 모니터링 시스템을 위한 푸시 알림 서비스입니다. 설비 이상 감지, 작업 지시, 안전 알림 등을 실시간으로 전달합니다.",
status: SERVICE_STATUS.ACTIVE,
createdAt: "2023-10-15",
deviceCount: 1240,
platforms: {
android: {
registered: true,
credentialStatus: "error",
statusReason: "인증서를 등록해주세요",
expiresAt: null,
},
ios: {
registered: true,
credentialStatus: "warn",
statusReason: ".p12 인증서 만료 30일 전",
expiresAt: "2024-06-15",
},
},
apiKey: "a3f8k2m1x9c4b7e0d5g6h8j2l4n7p0q3r5s8t1u6v9w2y4z0A3B7C1D5E9F2G6H0",
apiKeyCreatedAt: "2023-10-15",
apnsAuthType: "p12",
hasApnsKey: true,
hasFcmCredentials: false,
createdByName: "Admin User",
updatedAt: "2024-05-12",
};

View File

@ -0,0 +1,39 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
/**
*
* : /services/svc-01/edit back /services/svc-01 back /services
*/
export default function useBreadcrumbBack() {
const { pathname } = useLocation();
const navigate = useNavigate();
const currentPathRef = useRef(pathname);
const isRedirectingRef = useRef(false);
// 경로 변경 시 현재 경로 저장 (리다이렉트는 무시)
useEffect(() => {
if (isRedirectingRef.current) {
isRedirectingRef.current = false;
} else {
currentPathRef.current = pathname;
}
}, [pathname]);
useEffect(() => {
const handler = () => {
const prevPath = currentPathRef.current;
const segments = prevPath.split("/").filter(Boolean);
// 최상위 경로(depth 1 이하)면 기본 뒤로가기
if (segments.length <= 1) return;
const parentPath = "/" + segments.slice(0, -1).join("/");
isRedirectingRef.current = true;
navigate(parentPath, { replace: true });
};
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, [navigate]);
}

View File

@ -0,0 +1,24 @@
import { useState, useCallback } from "react";
/**
* shake .
* - `shaking` : Set
* - `triggerShake` : 0.4 shake
* - `cls` : "animate-shake" "" (className에 )
*/
export default function useShake() {
const [shaking, setShaking] = useState<Set<string>>(new Set());
const triggerShake = useCallback((fields: string[]) => {
setShaking(new Set(fields));
setTimeout(() => setShaking(new Set()), 400);
}, []);
/** className 헬퍼 */
const cls = useCallback(
(field: string) => (shaking.has(field) ? "animate-shake" : ""),
[shaking],
);
return { shaking, triggerShake, cls } as const;
}