+
+
+ {/* 폼 카드 */}
+
+
+ {/* 1. 서비스 명 */}
+
+
+
{
+ 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 && (
+
+
+ error
+
+ 서비스 명을 입력해주세요.
+
+ )}
+
+
+ {/* 2. 플랫폼 선택 */}
+
+
+ {/* 4. 설명 */}
+
+
+
+
+ {/* 5. 관련 링크 */}
+
+
+
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"
+ />
+
+
+ info
+
+ 서비스와 관련된 웹사이트나 문서 링크를 입력하세요.
+
+
+
+
+ {/* 하단 액션바 */}
+
+
+
+
+
+
+ {/* 등록 확인 모달 */}
+ {showConfirm && (
+
+
setShowConfirm(false)}
+ />
+
+
+
+
+ warning
+
+
+
+ 서비스 등록 확인
+
+
+
+ 입력한 내용으로 서비스를 등록하시겠습니까?
+
+
+
+
+ info
+
+ 서비스 ID는 등록 후 자동으로 생성됩니다.
+
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/react/src/features/service/types.ts b/react/src/features/service/types.ts
index 817f707..5fabce2 100644
--- a/react/src/features/service/types.ts
+++ b/react/src/features/service/types.ts
@@ -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",
+};
diff --git a/react/src/hooks/useBreadcrumbBack.ts b/react/src/hooks/useBreadcrumbBack.ts
new file mode 100644
index 0000000..99ca57e
--- /dev/null
+++ b/react/src/hooks/useBreadcrumbBack.ts
@@ -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]);
+}
diff --git a/react/src/hooks/useShake.ts b/react/src/hooks/useShake.ts
new file mode 100644
index 0000000..7d7cbe7
--- /dev/null
+++ b/react/src/hooks/useShake.ts
@@ -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
>(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;
+}