From 9db9d87dea23ca488aa89b5147c7b1d7e0ec788d Mon Sep 17 00:00:00 2001 From: SEAN Date: Fri, 27 Feb 2026 13:33:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서비스 목록 페이지 (검색/필터/페이지네이션, 행 클릭 → 상세) - 서비스 상세 페이지 (헤더카드/통계/플랫폼 관리 모달) - 서비스 등록 페이지 (서비스명/플랫폼 선택/설명/관련링크) - 서비스 수정 페이지 (상태 토글/메타정보/저장 확인 모달) - 공통 훅 추출 (useShake, useBreadcrumbBack) - 브레드크럼 동적 경로 지원 (/services/:id, /services/:id/edit) - 인증 페이지 useShake 공통 훅 리팩터링 Closes #14 --- react/src/components/common/CategoryBadge.tsx | 1 + react/src/components/common/SearchInput.tsx | 5 +- react/src/components/layout/AppHeader.tsx | 36 +- react/src/components/layout/AppLayout.tsx | 2 + .../auth/components/ResetPasswordModal.tsx | 11 +- react/src/features/auth/pages/LoginPage.tsx | 15 +- react/src/features/auth/pages/SignupPage.tsx | 17 +- .../features/auth/pages/VerifyEmailPage.tsx | 15 +- .../src/features/service/components/.gitkeep | 0 .../service/components/PlatformManagement.tsx | 888 ++++++++++++++++++ .../service/components/PlatformSelector.tsx | 356 +++++++ .../components/PlatformStatusIndicator.tsx | 89 ++ .../service/components/ServiceHeaderCard.tsx | 122 +++ .../service/components/ServiceStatsCards.tsx | 113 +++ react/src/features/service/hooks/.gitkeep | 0 .../service/pages/ServiceDetailPage.tsx | 85 +- .../service/pages/ServiceEditPage.tsx | 436 ++++++++- .../service/pages/ServiceListPage.tsx | 299 +++++- .../service/pages/ServiceRegisterPage.tsx | 201 +++- react/src/features/service/types.ts | 319 ++++++- react/src/hooks/useBreadcrumbBack.ts | 39 + react/src/hooks/useShake.ts | 24 + 22 files changed, 3022 insertions(+), 51 deletions(-) delete mode 100644 react/src/features/service/components/.gitkeep create mode 100644 react/src/features/service/components/PlatformManagement.tsx create mode 100644 react/src/features/service/components/PlatformSelector.tsx create mode 100644 react/src/features/service/components/PlatformStatusIndicator.tsx create mode 100644 react/src/features/service/components/ServiceHeaderCard.tsx create mode 100644 react/src/features/service/components/ServiceStatsCards.tsx delete mode 100644 react/src/features/service/hooks/.gitkeep create mode 100644 react/src/hooks/useBreadcrumbBack.ts create mode 100644 react/src/hooks/useShake.ts diff --git a/react/src/components/common/CategoryBadge.tsx b/react/src/components/common/CategoryBadge.tsx index b438257..de0ae38 100644 --- a/react/src/components/common/CategoryBadge.tsx +++ b/react/src/components/common/CategoryBadge.tsx @@ -25,3 +25,4 @@ export default function CategoryBadge({ variant, icon, label }: CategoryBadgePro ); } + diff --git a/react/src/components/common/SearchInput.tsx b/react/src/components/common/SearchInput.tsx index 267154c..9238b87 100644 --- a/react/src/components/common/SearchInput.tsx +++ b/react/src/components/common/SearchInput.tsx @@ -3,6 +3,7 @@ interface SearchInputProps { onChange: (value: string) => void; placeholder?: string; label?: string; + disabled?: boolean; } /** 검색 아이콘 + 텍스트 입력 */ @@ -11,6 +12,7 @@ export default function SearchInput({ onChange, placeholder = "검색...", label, + disabled, }: SearchInputProps) { return (
@@ -28,7 +30,8 @@ export default function SearchInput({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors" + disabled={disabled} + className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50" />
diff --git a/react/src/components/layout/AppHeader.tsx b/react/src/components/layout/AppHeader.tsx index 2749154..5977789 100644 --- a/react/src/components/layout/AppHeader.tsx +++ b/react/src/components/layout/AppHeader.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; import CategoryBadge from "@/components/common/CategoryBadge"; -/** 경로 → breadcrumb 레이블 매핑 */ +/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */ const pathLabels: Record = { "/dashboard": "대시보드", "/services": "서비스 관리", @@ -18,11 +18,35 @@ const pathLabels: Record = { "/settings/notifications": "알림", }; -/** pathname → breadcrumb 배열 생성 (누적 경로 기반) */ +/** + * 동적 경로 패턴 매칭 규칙 + * pattern으로 pathname을 매칭 → crumbs 함수가 추가할 브레드크럼 배열 반환 + */ +const dynamicPatterns: { + pattern: RegExp; + crumbs: (match: RegExpMatchArray) => { path: string; label: string }[]; +}[] = [ + { + // /services/:id 또는 /services/:id/edit (register 제외) + pattern: /^\/services\/(?!register$)([^/]+)(\/edit)?$/, + crumbs: (match) => { + const id = match[1]; + const isEdit = !!match[2]; + const result = [{ path: `/services/${id}`, label: "서비스 상세" }]; + if (isEdit) { + result.push({ path: `/services/${id}/edit`, label: "서비스 수정" }); + } + return result; + }, + }, +]; + +/** pathname → breadcrumb 배열 생성 */ function buildBreadcrumbs(pathname: string) { const segments = pathname.split("/").filter(Boolean); const crumbs: { path: string; label: string }[] = []; + // 1) 정적 경로 매칭 (누적 경로 기반) let currentPath = ""; for (const segment of segments) { currentPath += `/${segment}`; @@ -32,6 +56,14 @@ function buildBreadcrumbs(pathname: string) { } } + // 2) 동적 경로 패턴 매칭 + for (const { pattern, crumbs: buildDynamic } of dynamicPatterns) { + const match = pathname.match(pattern); + if (match) { + crumbs.push(...buildDynamic(match)); + } + } + return crumbs; } diff --git a/react/src/components/layout/AppLayout.tsx b/react/src/components/layout/AppLayout.tsx index 7c3f0be..5c0e38e 100644 --- a/react/src/components/layout/AppLayout.tsx +++ b/react/src/components/layout/AppLayout.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import { Outlet } from "react-router-dom"; import AppSidebar from "./AppSidebar"; import AppHeader from "./AppHeader"; +import useBreadcrumbBack from "@/hooks/useBreadcrumbBack"; export default function AppLayout() { + useBreadcrumbBack(); const [termsModal, setTermsModal] = useState(false); const [privacyModal, setPrivacyModal] = useState(false); diff --git a/react/src/features/auth/components/ResetPasswordModal.tsx b/react/src/features/auth/components/ResetPasswordModal.tsx index cd2cd3f..7bc5e12 100644 --- a/react/src/features/auth/components/ResetPasswordModal.tsx +++ b/react/src/features/auth/components/ResetPasswordModal.tsx @@ -1,8 +1,8 @@ -import { useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { toast } from "sonner"; +import useShake from "@/hooks/useShake"; const resetSchema = z.object({ email: z @@ -19,7 +19,7 @@ interface Props { } export default function ResetPasswordModal({ open, onClose }: Props) { - const [shakeFields, setShakeFields] = useState>(new Set()); + const { triggerShake, cls } = useShake(); const { register, @@ -30,11 +30,6 @@ export default function ResetPasswordModal({ open, onClose }: Props) { resolver: zodResolver(resetSchema), }); - const triggerShake = useCallback((fieldNames: string[]) => { - setShakeFields(new Set(fieldNames)); - setTimeout(() => setShakeFields(new Set()), 400); - }, []); - /* 유효성 통과 → 발송 처리 */ const onSubmit = (_data: ResetForm) => { // TODO: API 연동 @@ -93,7 +88,7 @@ export default function ResetPasswordModal({ open, onClose }: Props) { errors.email ? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]" : "border-gray-300" - } ${shakeFields.has("email") ? "animate-shake" : ""}`} + } ${cls("email")}`} {...register("email")} /> {errors.email && ( diff --git a/react/src/features/auth/pages/LoginPage.tsx b/react/src/features/auth/pages/LoginPage.tsx index 50dd46a..227d7d7 100644 --- a/react/src/features/auth/pages/LoginPage.tsx +++ b/react/src/features/auth/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -7,6 +7,7 @@ import { AxiosError } from "axios"; import { login } from "@/api/auth.api"; import type { ApiError } from "@/types/api"; import { useAuthStore } from "@/stores/authStore"; +import useShake from "@/hooks/useShake"; import ResetPasswordModal from "../components/ResetPasswordModal"; /* ───── zod 스키마 ───── */ @@ -26,7 +27,7 @@ export default function LoginPage() { const [showPassword, setShowPassword] = useState(false); const [resetOpen, setResetOpen] = useState(false); - const [shakeFields, setShakeFields] = useState>(new Set()); + const { triggerShake, cls } = useShake(); const [isLoading, setIsLoading] = useState(false); const [loginError, setLoginError] = useState(null); const [loginSuccess, setLoginSuccess] = useState(false); @@ -39,12 +40,6 @@ export default function LoginPage() { resolver: zodResolver(loginSchema), }); - /* shake 트리거 */ - const triggerShake = useCallback((fieldNames: string[]) => { - setShakeFields(new Set(fieldNames)); - setTimeout(() => setShakeFields(new Set()), 400); - }, []); - /* 유효성 통과 → 로그인 처리 */ const onSubmit = async (data: LoginForm) => { setLoginError(null); @@ -160,7 +155,7 @@ export default function LoginPage() { errors.email ? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]" : "border-gray-300" - } ${shakeFields.has("email") ? "animate-shake" : ""}`} + } ${cls("email")}`} {...register("email")} /> {errors.email && ( @@ -193,7 +188,7 @@ export default function LoginPage() { errors.password ? "border-red-500 shadow-[0_0_0_2px_rgba(239,68,68,0.15)]" : "border-gray-300" - } ${shakeFields.has("password") ? "animate-shake" : ""}`} + } ${cls("password")}`} {...register("password")} /> + {open && ( +
+ + +
+ )} + + ); +} + +interface PlatformManagementProps { + service: ServiceDetail; +} + +export default function PlatformManagement({ + service, +}: PlatformManagementProps) { + const [deleteTarget, setDeleteTarget] = useState(null); + const [editTarget, setEditTarget] = useState<"android" | "ios" | null>(null); + const [iosAuthType, setIosAuthType] = useState<"p8" | "p12">("p8"); + const [selectedFile, setSelectedFile] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [deletedPlatforms, setDeletedPlatforms] = useState>(new Set()); + const [addedPlatforms, setAddedPlatforms] = useState>(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(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 ( + <> +
+ {/* 헤더 */} +
+

플랫폼 관리

+ +
+ + {/* Android */} + {androidVisible && ( +
+
+
+ + android + +
+
+

Android

+

+ Google FCM을 통한 Android 기기 관리 +

+ +
+
+ { + setEditTarget("android"); + setSelectedFile(null); + }} + onDelete={() => setDeleteTarget("Android")} + /> +
+ )} + + {/* iOS */} + {iosVisible && ( +
+
+
+ +
+
+

iOS

+

+ Apple APNs를 통한 iOS 기기 관리 +

+ +
+
+ { + setEditTarget("ios"); + setSelectedFile(null); + setShowPassword(false); + }} + onDelete={() => setDeleteTarget("iOS")} + /> +
+ )} + + {/* 빈 상태 */} + {noneRegistered && ( +
+ + devices + +

+ 등록된 플랫폼이 없습니다. +

+

+ 플랫폼 추가 버튼을 눌러 플랫폼을 등록하세요. +

+
+ )} +
+ + {/* 플랫폼 추가 모달 (탭 방식) */} + {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 ( +
+
setShowAddModal(false)} + /> +
+ {/* 헤더 */} +
+
+
+ + add_circle + +
+
+

플랫폼 추가

+

+ 추가할 플랫폼을 선택하고 인증서를 업로드하세요. +

+
+
+ +
+ + {/* 탭 */} +
+ + +
+ + {/* iOS 인증 방식 선택 */} + {!isAndroidTab && ( +
+ +
+ + +
+
+ )} + + {/* 파일 업로드 */} +
+ + {!addFile ? ( + + ) : ( +
+ + description + +
+

+ {addFile.name} +

+

+ {(addFile.size / 1024).toFixed(1)} KB +

+
+ +
+ )} +
+ + {/* iOS P8: Key ID + Team ID */} + {!isAndroidTab && addIosAuthType === "p8" && ( +
+
+
+ + +
+
+ + +
+
+
+ + info + + Apple Developer 포털에서 확인할 수 있습니다. +
+
+ )} + + {/* iOS P12: 비밀번호 */} + {!isAndroidTab && addIosAuthType === "p12" && ( +
+ +
+ + +
+
+ )} + + {/* 하단 버튼 */} +
+ + +
+
+
+ ); + })()} + + {/* 삭제 확인 모달 */} + {deleteTarget && ( +
+
setDeleteTarget(null)} + /> +
+
+
+ + warning + +
+

플랫폼 삭제

+
+

+ {deleteTarget} 플랫폼을 삭제하시겠습니까? +

+
+
+ + info + + + 삭제 후 해당 플랫폼의 인증서 및 설정이 모두 제거됩니다. + +
+
+
+ + +
+
+
+ )} + + {/* 인증서 수정 모달 */} + {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 ( +
+
setEditTarget(null)} + /> +
+ {/* 헤더 */} +
+
+
+ {isAndroid ? ( + + android + + ) : ( + + )} +
+
+

+ 인증서 등록/수정 +

+

+ {isAndroid + ? "Android — Service Account JSON" + : `iOS — ${iosAuthType === "p8" ? "APNs Auth Key (.p8)" : "Certificate (.p12)"}`} +

+
+
+ +
+ + {/* 현재 상태 */} +
+
+ + {cred.credentialStatus === "error" ? "error" : cred.credentialStatus === "warn" ? "warning" : "check_circle"} + + + {statusLabel} + +
+ {statusDesc && ( +

+ {statusDesc} +

+ )} +
+ + {/* iOS 인증 방식 선택 */} + {!isAndroid && ( +
+ +
+ + +
+
+ )} + + {/* 파일 업로드 */} +
+ + {!selectedFile ? ( + + ) : ( +
+ + description + +
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(1)} KB +

+
+ +
+ )} +
+ + {/* iOS P8: Key ID + Team ID */} + {!isAndroid && iosAuthType === "p8" && ( +
+
+
+ + +
+
+ + +
+
+
+ + info + + Apple Developer 포털에서 확인할 수 있습니다. +
+
+ )} + + {/* iOS P12: 비밀번호 */} + {!isAndroid && iosAuthType === "p12" && ( +
+ +
+ + +
+
+ )} + + {/* 하단 버튼 */} +
+ + +
+
+
+ ); + })()} + + ); +} diff --git a/react/src/features/service/components/PlatformSelector.tsx b/react/src/features/service/components/PlatformSelector.tsx new file mode 100644 index 0000000..2802e23 --- /dev/null +++ b/react/src/features/service/components/PlatformSelector.tsx @@ -0,0 +1,356 @@ +import { useState } from "react"; + +// Apple 로고 SVG +const AppleLogo = ({ className }: { className?: string }) => ( + + + +); + +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(null); + const [iosFile, setIosFile] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + return ( +
+ +
+ {/* ── Android 카드 ── */} +
{ 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"}`} + > +
e.stopPropagation()}> + { + 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" + /> + +
+ + {/* 파일 업로드 */} +
+

+ 인증서 파일 +

+ {!androidFile ? ( + androidChecked ? ( + + ) : ( +
+ + lock + +

+ 플랫폼을 선택하세요 +

+
+ ) + ) : ( +
+ + description + +
+

+ {androidFile.name} +

+

+ {(androidFile.size / 1024).toFixed(1)} KB +

+
+ +
+ )} +
+
+ + {/* ── iOS 카드 ── */} +
{ 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"}`} + > +
e.stopPropagation()}> + { + 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" + /> + +
+ + {/* iOS 미선택 시 — Android와 동일 */} + {!iosChecked && ( +
+

+ 인증서 파일 +

+
+ + lock + +

+ 플랫폼을 선택하세요 +

+
+
+ )} + + {/* iOS 선택됨 — 상세 페이지 모달과 동일 레이아웃 */} + {iosChecked && ( +
+ {/* 인증 방식 선택 */} +
+ +
+ + +
+
+ + {/* 인증서 파일 */} +
+ + {!iosFile ? ( + + ) : ( +
+ + description + +
+

+ {iosFile.name} +

+

+ {(iosFile.size / 1024).toFixed(1)} KB +

+
+ +
+ )} +
+ + {/* P8: Key ID + Team ID */} + {iosAuthType === "p8" && ( +
+
+
+ + +
+
+ + +
+
+
+ + info + + Apple Developer 포털에서 확인할 수 있습니다. +
+
+ )} + + {/* P12: 비밀번호 */} + {iosAuthType === "p12" && ( +
+ +
+ + +
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/react/src/features/service/components/PlatformStatusIndicator.tsx b/react/src/features/service/components/PlatformStatusIndicator.tsx new file mode 100644 index 0000000..501de55 --- /dev/null +++ b/react/src/features/service/components/PlatformStatusIndicator.tsx @@ -0,0 +1,89 @@ +import type { PlatformCredentialSummary } from "../types"; + +// Apple 로고 SVG +const AppleLogo = ({ className }: { className?: string }) => ( + + + +); + +// 상태별 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 ( +
+ + {platform === "android" ? ( + android + ) : ( + + )} + + {hasIssue && credential.credentialStatus && ( + <> + + {/* 호버 툴팁 */} + + {/* 위쪽 화살표 */} + + {TOOLTIP_LABEL[credential.credentialStatus]} + + + )} +
+ ); +} diff --git a/react/src/features/service/components/ServiceHeaderCard.tsx b/react/src/features/service/components/ServiceHeaderCard.tsx new file mode 100644 index 0000000..6786798 --- /dev/null +++ b/react/src/features/service/components/ServiceHeaderCard.tsx @@ -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 ( +
+ {/* 상단 행 */} +
+
+
+ + {service.serviceIcon} + +
+
+
+

+ {service.serviceName} +

+ +
+
+
+ + {service.serviceCode} + + +
+ | + +
+
+
+ + edit + 수정하기 + +
+ + {/* 메타 정보 */} +
+
+

+ 플랫폼 +

+
+ + + {!service.platforms.android.registered && + !service.platforms.ios.registered && ( + 없음 + )} +
+
+
+

+ 등록 기기 +

+

+ {formatNumber(service.deviceCount)}대 +

+
+
+

+ 생성일 +

+

+ {service.createdAt} +

+
+
+

+ 마지막 업데이트 +

+

+ {service.updatedAt ?? "-"} +

+
+
+
+ ); +} diff --git a/react/src/features/service/components/ServiceStatsCards.tsx b/react/src/features/service/components/ServiceStatsCards.tsx new file mode 100644 index 0000000..b59a5a2 --- /dev/null +++ b/react/src/features/service/components/ServiceStatsCards.tsx @@ -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 ( +
+ {cards.map((card) => ( +
+
+
+

+ {card.label} +

+

+ {card.value} +

+
+ {card.sub.type === "trend" && ( +

+ + trending_up + + {card.sub.text} +

+ )} + {card.sub.type === "stable" && ( +

+ + {card.sub.text} +

+ )} + {card.sub.type === "live" && ( +

+ + {card.sub.text} +

+ )} +
+
+
+ + {card.icon} + +
+
+
+ ))} +
+ ); +} diff --git a/react/src/features/service/hooks/.gitkeep b/react/src/features/service/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/react/src/features/service/pages/ServiceDetailPage.tsx b/react/src/features/service/pages/ServiceDetailPage.tsx index 49915b2..474074c 100644 --- a/react/src/features/service/pages/ServiceDetailPage.tsx +++ b/react/src/features/service/pages/ServiceDetailPage.tsx @@ -1,7 +1,88 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import PageHeader from "@/components/common/PageHeader"; +import CopyButton from "@/components/common/CopyButton"; +import ServiceHeaderCard from "../components/ServiceHeaderCard"; +import ServiceStatsCards from "../components/ServiceStatsCards"; +import PlatformManagement from "../components/PlatformManagement"; +import { MOCK_SERVICE_DETAIL, MOCK_SERVICES } from "../types"; + export default function ServiceDetailPage() { + const { id } = useParams<{ id: string }>(); + const [showApiKey, setShowApiKey] = useState(false); + + // 목 데이터에서 서비스 조회 (목록에서 클릭한 ID로) + const listItem = MOCK_SERVICES.find((s) => s.serviceCode === id); + const service = listItem + ? { ...MOCK_SERVICE_DETAIL, ...listItem, serviceCode: listItem.serviceCode } + : MOCK_SERVICE_DETAIL; + return ( -
-

서비스 상세

+
+ + + setShowApiKey(true)} + /> + + + + + + {/* API 키 확인 모달 */} + {showApiKey && ( +
+
setShowApiKey(false)} + /> +
+
+
+
+ + vpn_key + +
+

API 키

+
+ +
+
+
+ + warning + + + API 키는 외부에 노출되지 않도록 주의해 주세요. + +
+
+
+ + {service.apiKey} + + +
+
+
+ )}
); } diff --git a/react/src/features/service/pages/ServiceEditPage.tsx b/react/src/features/service/pages/ServiceEditPage.tsx index 373169e..8aa41cf 100644 --- a/react/src/features/service/pages/ServiceEditPage.tsx +++ b/react/src/features/service/pages/ServiceEditPage.tsx @@ -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 }) => ( + + + +); + 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 ( -
-

서비스 수정

+
+ + + {/* 폼 카드 */} +
+
+ {/* 1. 서비스 명 + 상태 토글 */} +
+ +
+ { + 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")}`} + /> + +
+ {nameError && ( +
+ + error + + 필수 입력 항목입니다. +
+ )} +
+ + {/* 2. 서비스 ID (읽기 전용) */} +
+ +
+ +
+ + lock + + + 변경 불가 + +
+
+
+ + {/* 3. 설명 */} +
+ +