feat: 서비스 관리 페이지 구현 (#14)
- 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세) - 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달) - 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크) - 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달) - 공통 훅 추출 (useShake, useBreadcrumbBack) - 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit) - 인증 페이지 useShake 공통 훅 리팩터링 Closes #14
This commit is contained in:
parent
59c206e0c2
commit
9db9d87dea
|
|
@ -25,3 +25,4 @@ export default function CategoryBadge({ variant, icon, label }: CategoryBadgePro
|
|||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
888
react/src/features/service/components/PlatformManagement.tsx
Normal file
888
react/src/features/service/components/PlatformManagement.tsx
Normal 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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
356
react/src/features/service/components/PlatformSelector.tsx
Normal file
356
react/src/features/service/components/PlatformSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
122
react/src/features/service/components/ServiceHeaderCard.tsx
Normal file
122
react/src/features/service/components/ServiceHeaderCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
react/src/features/service/components/ServiceStatsCards.tsx
Normal file
113
react/src/features/service/components/ServiceStatsCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
39
react/src/hooks/useBreadcrumbBack.ts
Normal file
39
react/src/hooks/useBreadcrumbBack.ts
Normal 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]);
|
||||
}
|
||||
24
react/src/hooks/useShake.ts
Normal file
24
react/src/hooks/useShake.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user