feat: 서비스 관리 페이지 구현 (#14)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/16
This commit is contained in:
commit
1b4fc79b2d
|
|
@ -25,3 +25,4 @@ export default function CategoryBadge({ variant, icon, label }: CategoryBadgePro
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ interface SearchInputProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 검색 아이콘 + 텍스트 입력 */
|
/** 검색 아이콘 + 텍스트 입력 */
|
||||||
|
|
@ -11,6 +12,7 @@ export default function SearchInput({
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "검색...",
|
placeholder = "검색...",
|
||||||
label,
|
label,
|
||||||
|
disabled,
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -28,7 +30,8 @@ export default function SearchInput({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import CategoryBadge from "@/components/common/CategoryBadge";
|
import CategoryBadge from "@/components/common/CategoryBadge";
|
||||||
|
|
||||||
/** 경로 → breadcrumb 레이블 매핑 */
|
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
|
||||||
const pathLabels: Record<string, string> = {
|
const pathLabels: Record<string, string> = {
|
||||||
"/dashboard": "대시보드",
|
"/dashboard": "대시보드",
|
||||||
"/services": "서비스 관리",
|
"/services": "서비스 관리",
|
||||||
|
|
@ -18,11 +18,35 @@ const pathLabels: Record<string, string> = {
|
||||||
"/settings/notifications": "알림",
|
"/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) {
|
function buildBreadcrumbs(pathname: string) {
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const crumbs: { path: string; label: string }[] = [];
|
const crumbs: { path: string; label: string }[] = [];
|
||||||
|
|
||||||
|
// 1) 정적 경로 매칭 (누적 경로 기반)
|
||||||
let currentPath = "";
|
let currentPath = "";
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
currentPath += `/${segment}`;
|
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;
|
return crumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { useState } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import AppSidebar from "./AppSidebar";
|
import AppSidebar from "./AppSidebar";
|
||||||
import AppHeader from "./AppHeader";
|
import AppHeader from "./AppHeader";
|
||||||
|
import useBreadcrumbBack from "@/hooks/useBreadcrumbBack";
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
|
useBreadcrumbBack();
|
||||||
const [termsModal, setTermsModal] = useState(false);
|
const [termsModal, setTermsModal] = useState(false);
|
||||||
const [privacyModal, setPrivacyModal] = useState(false);
|
const [privacyModal, setPrivacyModal] = useState(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
const resetSchema = z.object({
|
const resetSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
|
|
@ -19,7 +19,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResetPasswordModal({ open, onClose }: Props) {
|
export default function ResetPasswordModal({ open, onClose }: Props) {
|
||||||
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
|
const { triggerShake, cls } = useShake();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -30,11 +30,6 @@ export default function ResetPasswordModal({ open, onClose }: Props) {
|
||||||
resolver: zodResolver(resetSchema),
|
resolver: zodResolver(resetSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerShake = useCallback((fieldNames: string[]) => {
|
|
||||||
setShakeFields(new Set(fieldNames));
|
|
||||||
setTimeout(() => setShakeFields(new Set()), 400);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* 유효성 통과 → 발송 처리 */
|
/* 유효성 통과 → 발송 처리 */
|
||||||
const onSubmit = (_data: ResetForm) => {
|
const onSubmit = (_data: ResetForm) => {
|
||||||
// TODO: API 연동
|
// TODO: API 연동
|
||||||
|
|
@ -93,7 +88,7 @@ export default function ResetPasswordModal({ open, onClose }: Props) {
|
||||||
errors.email
|
errors.email
|
||||||
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
: "border-gray-300"
|
: "border-gray-300"
|
||||||
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
|
} ${cls("email")}`}
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -7,6 +7,7 @@ import { AxiosError } from "axios";
|
||||||
import { login } from "@/api/auth.api";
|
import { login } from "@/api/auth.api";
|
||||||
import type { ApiError } from "@/types/api";
|
import type { ApiError } from "@/types/api";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
import ResetPasswordModal from "../components/ResetPasswordModal";
|
import ResetPasswordModal from "../components/ResetPasswordModal";
|
||||||
|
|
||||||
/* ───── zod 스키마 ───── */
|
/* ───── zod 스키마 ───── */
|
||||||
|
|
@ -26,7 +27,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [resetOpen, setResetOpen] = 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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||||
|
|
@ -39,12 +40,6 @@ export default function LoginPage() {
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
/* shake 트리거 */
|
|
||||||
const triggerShake = useCallback((fieldNames: string[]) => {
|
|
||||||
setShakeFields(new Set(fieldNames));
|
|
||||||
setTimeout(() => setShakeFields(new Set()), 400);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* 유효성 통과 → 로그인 처리 */
|
/* 유효성 통과 → 로그인 처리 */
|
||||||
const onSubmit = async (data: LoginForm) => {
|
const onSubmit = async (data: LoginForm) => {
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
|
|
@ -160,7 +155,7 @@ export default function LoginPage() {
|
||||||
errors.email
|
errors.email
|
||||||
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
: "border-gray-300"
|
: "border-gray-300"
|
||||||
} ${shakeFields.has("email") ? "animate-shake" : ""}`}
|
} ${cls("email")}`}
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
|
|
@ -193,7 +188,7 @@ export default function LoginPage() {
|
||||||
errors.password
|
errors.password
|
||||||
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
: "border-gray-300"
|
: "border-gray-300"
|
||||||
} ${shakeFields.has("password") ? "animate-shake" : ""}`}
|
} ${cls("password")}`}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -6,6 +6,7 @@ import { Link, useNavigate } from "react-router-dom";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { signup } from "@/api/auth.api";
|
import { signup } from "@/api/auth.api";
|
||||||
import type { ApiError } from "@/types/api";
|
import type { ApiError } from "@/types/api";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
/* ───── 정규식 ───── */
|
/* ───── 정규식 ───── */
|
||||||
const pwRegex =
|
const pwRegex =
|
||||||
|
|
@ -44,7 +45,7 @@ type SignupForm = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [shakeFields, setShakeFields] = useState<Set<string>>(new Set());
|
const { triggerShake, cls } = useShake();
|
||||||
const [emailSentModal, setEmailSentModal] = useState(false);
|
const [emailSentModal, setEmailSentModal] = useState(false);
|
||||||
const [termsModal, setTermsModal] = useState(false);
|
const [termsModal, setTermsModal] = useState(false);
|
||||||
const [privacyModal, setPrivacyModal] = useState(false);
|
const [privacyModal, setPrivacyModal] = useState(false);
|
||||||
|
|
@ -75,12 +76,6 @@ export default function SignupPage() {
|
||||||
? "match"
|
? "match"
|
||||||
: "mismatch";
|
: "mismatch";
|
||||||
|
|
||||||
/* shake 트리거 */
|
|
||||||
const triggerShake = useCallback((fieldNames: string[]) => {
|
|
||||||
setShakeFields(new Set(fieldNames));
|
|
||||||
setTimeout(() => setShakeFields(new Set()), 400);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* 전화번호 자동 하이픈 */
|
/* 전화번호 자동 하이픈 */
|
||||||
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
let v = e.target.value.replace(/[^0-9]/g, "");
|
let v = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
|
@ -144,7 +139,7 @@ export default function SignupPage() {
|
||||||
errors[field]
|
errors[field]
|
||||||
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
: "border-gray-300"
|
: "border-gray-300"
|
||||||
} ${shakeFields.has(field) ? "animate-shake" : ""}`;
|
} ${cls(field)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -342,7 +337,7 @@ export default function SignupPage() {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className={`peer size-5 cursor-pointer appearance-none rounded border-2 transition-all checked:border-primary checked:bg-primary ${
|
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"
|
errors.agree ? "border-red-500" : "border-gray-300"
|
||||||
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
|
} ${cls("agree")}`}
|
||||||
{...register("agree")}
|
{...register("agree")}
|
||||||
/>
|
/>
|
||||||
<span className="material-symbols-outlined pointer-events-none absolute text-sm text-white opacity-0 peer-checked:opacity-100">
|
<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
|
<span
|
||||||
className={`text-sm transition-colors ${
|
className={`text-sm transition-colors ${
|
||||||
errors.agree ? "text-red-500" : "text-gray-600"
|
errors.agree ? "text-red-500" : "text-gray-600"
|
||||||
} ${shakeFields.has("agree") ? "animate-shake" : ""}`}
|
} ${cls("agree")}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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 { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
|
import { verifyEmail, resendVerifyEmail } from "@/api/auth.api";
|
||||||
import type { ApiError } from "@/types/api";
|
import type { ApiError } from "@/types/api";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import useShake from "@/hooks/useShake";
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
verifySessionId?: string;
|
verifySessionId?: string;
|
||||||
|
|
@ -21,7 +22,7 @@ export default function VerifyEmailPage() {
|
||||||
|
|
||||||
const [code, setCode] = useState<string[]>(Array(6).fill(""));
|
const [code, setCode] = useState<string[]>(Array(6).fill(""));
|
||||||
const [codeError, setCodeError] = useState<string | null>(null);
|
const [codeError, setCodeError] = useState<string | null>(null);
|
||||||
const [shakeCode, setShakeCode] = useState(false);
|
const { triggerShake, cls } = useShake();
|
||||||
const [successModal, setSuccessModal] = useState(false);
|
const [successModal, setSuccessModal] = useState(false);
|
||||||
const [resendModal, setResendModal] = useState(false);
|
const [resendModal, setResendModal] = useState(false);
|
||||||
const [homeOverlay, setHomeOverlay] = useState(false);
|
const [homeOverlay, setHomeOverlay] = useState(false);
|
||||||
|
|
@ -46,12 +47,6 @@ export default function VerifyEmailPage() {
|
||||||
|
|
||||||
const isFilled = code.every((c) => c.length === 1);
|
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 handleInput = (index: number, value: string) => {
|
||||||
const digit = value.replace(/[^0-9]/g, "");
|
const digit = value.replace(/[^0-9]/g, "");
|
||||||
|
|
@ -114,7 +109,7 @@ export default function VerifyEmailPage() {
|
||||||
const axiosErr = err as AxiosError<ApiError>;
|
const axiosErr = err as AxiosError<ApiError>;
|
||||||
const msg = axiosErr.response?.data?.msg;
|
const msg = axiosErr.response?.data?.msg;
|
||||||
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
|
setCodeError(msg ?? "인증 코드가 올바르지 않습니다.");
|
||||||
triggerShake();
|
triggerShake(["code"]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +226,7 @@ export default function VerifyEmailPage() {
|
||||||
codeError
|
codeError
|
||||||
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]"
|
||||||
: "border-gray-300"
|
: "border-gray-300"
|
||||||
} ${shakeCode ? "animate-shake" : ""}`}
|
} ${cls("code")}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">서비스 상세</h1>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">서비스 수정</h1>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">서비스 목록</h1>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">서비스 등록</h1>
|
<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>
|
</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