tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/react/src/components/ui/tooltip.tsx b/react/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..d80a144
--- /dev/null
+++ b/react/src/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/react/src/features/auth/components/.gitkeep b/react/src/features/auth/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/auth/hooks/.gitkeep b/react/src/features/auth/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/auth/pages/LoginPage.tsx b/react/src/features/auth/pages/LoginPage.tsx
new file mode 100644
index 0000000..c62b127
--- /dev/null
+++ b/react/src/features/auth/pages/LoginPage.tsx
@@ -0,0 +1,7 @@
+export default function LoginPage() {
+ return (
+
+ 로그인
+
+ );
+}
diff --git a/react/src/features/auth/pages/SignupPage.tsx b/react/src/features/auth/pages/SignupPage.tsx
new file mode 100644
index 0000000..2253310
--- /dev/null
+++ b/react/src/features/auth/pages/SignupPage.tsx
@@ -0,0 +1,7 @@
+export default function SignupPage() {
+ return (
+
+ 회원가입
+
+ );
+}
diff --git a/react/src/features/auth/pages/VerifyEmailPage.tsx b/react/src/features/auth/pages/VerifyEmailPage.tsx
new file mode 100644
index 0000000..f85e294
--- /dev/null
+++ b/react/src/features/auth/pages/VerifyEmailPage.tsx
@@ -0,0 +1,7 @@
+export default function VerifyEmailPage() {
+ return (
+
+ 이메일 인증
+
+ );
+}
diff --git a/react/src/features/auth/types.ts b/react/src/features/auth/types.ts
new file mode 100644
index 0000000..66fcab4
--- /dev/null
+++ b/react/src/features/auth/types.ts
@@ -0,0 +1,24 @@
+/** 로그인 요청 */
+export interface LoginRequest {
+ email: string;
+ password: string;
+}
+
+/** 로그인 응답 */
+export interface LoginResponse {
+ accessToken: string;
+ user: {
+ id: number;
+ email: string;
+ name: string;
+ role: string;
+ };
+}
+
+/** 회원가입 요청 */
+export interface SignupRequest {
+ email: string;
+ password: string;
+ passwordConfirm: string;
+ name: string;
+}
diff --git a/react/src/features/dashboard/components/.gitkeep b/react/src/features/dashboard/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/dashboard/hooks/.gitkeep b/react/src/features/dashboard/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/dashboard/pages/DashboardPage.tsx b/react/src/features/dashboard/pages/DashboardPage.tsx
new file mode 100644
index 0000000..639e7a5
--- /dev/null
+++ b/react/src/features/dashboard/pages/DashboardPage.tsx
@@ -0,0 +1,7 @@
+export default function DashboardPage() {
+ return (
+
+ 대시보드
+
+ );
+}
diff --git a/react/src/features/dashboard/pages/HomePage.tsx b/react/src/features/dashboard/pages/HomePage.tsx
new file mode 100644
index 0000000..e3af4f5
--- /dev/null
+++ b/react/src/features/dashboard/pages/HomePage.tsx
@@ -0,0 +1,7 @@
+export default function HomePage() {
+ return (
+
+ 홈
+
+ );
+}
diff --git a/react/src/features/dashboard/types.ts b/react/src/features/dashboard/types.ts
new file mode 100644
index 0000000..c200449
--- /dev/null
+++ b/react/src/features/dashboard/types.ts
@@ -0,0 +1 @@
+// Dashboard feature 타입 정의
diff --git a/react/src/features/device/components/.gitkeep b/react/src/features/device/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/device/hooks/.gitkeep b/react/src/features/device/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/device/pages/DeviceListPage.tsx b/react/src/features/device/pages/DeviceListPage.tsx
new file mode 100644
index 0000000..bb60a18
--- /dev/null
+++ b/react/src/features/device/pages/DeviceListPage.tsx
@@ -0,0 +1,7 @@
+export default function DeviceListPage() {
+ return (
+
+ 디바이스 목록
+
+ );
+}
diff --git a/react/src/features/device/types.ts b/react/src/features/device/types.ts
new file mode 100644
index 0000000..311610f
--- /dev/null
+++ b/react/src/features/device/types.ts
@@ -0,0 +1 @@
+// Device feature 타입 정의
diff --git a/react/src/features/message/components/.gitkeep b/react/src/features/message/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/message/hooks/.gitkeep b/react/src/features/message/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/message/pages/MessageListPage.tsx b/react/src/features/message/pages/MessageListPage.tsx
new file mode 100644
index 0000000..daf3915
--- /dev/null
+++ b/react/src/features/message/pages/MessageListPage.tsx
@@ -0,0 +1,7 @@
+export default function MessageListPage() {
+ return (
+
+ 메시지 목록
+
+ );
+}
diff --git a/react/src/features/message/pages/MessageRegisterPage.tsx b/react/src/features/message/pages/MessageRegisterPage.tsx
new file mode 100644
index 0000000..ec064da
--- /dev/null
+++ b/react/src/features/message/pages/MessageRegisterPage.tsx
@@ -0,0 +1,7 @@
+export default function MessageRegisterPage() {
+ return (
+
+ 메시지 등록
+
+ );
+}
diff --git a/react/src/features/message/types.ts b/react/src/features/message/types.ts
new file mode 100644
index 0000000..7e2e6c7
--- /dev/null
+++ b/react/src/features/message/types.ts
@@ -0,0 +1 @@
+// Message feature 타입 정의
diff --git a/react/src/features/service/components/.gitkeep b/react/src/features/service/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/service/hooks/.gitkeep b/react/src/features/service/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/service/pages/ServiceDetailPage.tsx b/react/src/features/service/pages/ServiceDetailPage.tsx
new file mode 100644
index 0000000..49915b2
--- /dev/null
+++ b/react/src/features/service/pages/ServiceDetailPage.tsx
@@ -0,0 +1,7 @@
+export default function ServiceDetailPage() {
+ return (
+
+ 서비스 상세
+
+ );
+}
diff --git a/react/src/features/service/pages/ServiceEditPage.tsx b/react/src/features/service/pages/ServiceEditPage.tsx
new file mode 100644
index 0000000..373169e
--- /dev/null
+++ b/react/src/features/service/pages/ServiceEditPage.tsx
@@ -0,0 +1,7 @@
+export default function ServiceEditPage() {
+ return (
+
+ 서비스 수정
+
+ );
+}
diff --git a/react/src/features/service/pages/ServiceListPage.tsx b/react/src/features/service/pages/ServiceListPage.tsx
new file mode 100644
index 0000000..1ce95e6
--- /dev/null
+++ b/react/src/features/service/pages/ServiceListPage.tsx
@@ -0,0 +1,7 @@
+export default function ServiceListPage() {
+ return (
+
+ 서비스 목록
+
+ );
+}
diff --git a/react/src/features/service/pages/ServiceRegisterPage.tsx b/react/src/features/service/pages/ServiceRegisterPage.tsx
new file mode 100644
index 0000000..a42b7cd
--- /dev/null
+++ b/react/src/features/service/pages/ServiceRegisterPage.tsx
@@ -0,0 +1,7 @@
+export default function ServiceRegisterPage() {
+ return (
+
+ 서비스 등록
+
+ );
+}
diff --git a/react/src/features/service/types.ts b/react/src/features/service/types.ts
new file mode 100644
index 0000000..817f707
--- /dev/null
+++ b/react/src/features/service/types.ts
@@ -0,0 +1 @@
+// Service feature 타입 정의
diff --git a/react/src/features/settings/components/.gitkeep b/react/src/features/settings/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/settings/hooks/.gitkeep b/react/src/features/settings/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/settings/pages/MyPage.tsx b/react/src/features/settings/pages/MyPage.tsx
new file mode 100644
index 0000000..70ae8f0
--- /dev/null
+++ b/react/src/features/settings/pages/MyPage.tsx
@@ -0,0 +1,7 @@
+export default function MyPage() {
+ return (
+
+ 마이페이지
+
+ );
+}
diff --git a/react/src/features/settings/pages/NotificationsPage.tsx b/react/src/features/settings/pages/NotificationsPage.tsx
new file mode 100644
index 0000000..a259f08
--- /dev/null
+++ b/react/src/features/settings/pages/NotificationsPage.tsx
@@ -0,0 +1,7 @@
+export default function NotificationsPage() {
+ return (
+
+ 알림 설정
+
+ );
+}
diff --git a/react/src/features/settings/pages/ProfileEditPage.tsx b/react/src/features/settings/pages/ProfileEditPage.tsx
new file mode 100644
index 0000000..b3e8154
--- /dev/null
+++ b/react/src/features/settings/pages/ProfileEditPage.tsx
@@ -0,0 +1,7 @@
+export default function ProfileEditPage() {
+ return (
+
+ 프로필 수정
+
+ );
+}
diff --git a/react/src/features/settings/types.ts b/react/src/features/settings/types.ts
new file mode 100644
index 0000000..d8bd4d1
--- /dev/null
+++ b/react/src/features/settings/types.ts
@@ -0,0 +1 @@
+// Settings feature 타입 정의
diff --git a/react/src/features/statistics/components/.gitkeep b/react/src/features/statistics/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/statistics/hooks/.gitkeep b/react/src/features/statistics/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/statistics/pages/StatisticsHistoryPage.tsx b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx
new file mode 100644
index 0000000..27cf737
--- /dev/null
+++ b/react/src/features/statistics/pages/StatisticsHistoryPage.tsx
@@ -0,0 +1,7 @@
+export default function StatisticsHistoryPage() {
+ return (
+
+ 통계 이력
+
+ );
+}
diff --git a/react/src/features/statistics/pages/StatisticsPage.tsx b/react/src/features/statistics/pages/StatisticsPage.tsx
new file mode 100644
index 0000000..1ea67c0
--- /dev/null
+++ b/react/src/features/statistics/pages/StatisticsPage.tsx
@@ -0,0 +1,7 @@
+export default function StatisticsPage() {
+ return (
+
+ 통계
+
+ );
+}
diff --git a/react/src/features/statistics/types.ts b/react/src/features/statistics/types.ts
new file mode 100644
index 0000000..b047e83
--- /dev/null
+++ b/react/src/features/statistics/types.ts
@@ -0,0 +1 @@
+// Statistics feature 타입 정의
diff --git a/react/src/features/tag/components/.gitkeep b/react/src/features/tag/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/tag/hooks/.gitkeep b/react/src/features/tag/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/react/src/features/tag/pages/TagManagePage.tsx b/react/src/features/tag/pages/TagManagePage.tsx
new file mode 100644
index 0000000..7286466
--- /dev/null
+++ b/react/src/features/tag/pages/TagManagePage.tsx
@@ -0,0 +1,7 @@
+export default function TagManagePage() {
+ return (
+
+ 태그 관리
+
+ );
+}
diff --git a/react/src/features/tag/types.ts b/react/src/features/tag/types.ts
new file mode 100644
index 0000000..86ec9ca
--- /dev/null
+++ b/react/src/features/tag/types.ts
@@ -0,0 +1 @@
+// Tag feature 타입 정의
diff --git a/react/src/hooks/use-mobile.ts b/react/src/hooks/use-mobile.ts
new file mode 100644
index 0000000..2b0fe1d
--- /dev/null
+++ b/react/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/react/src/index.css b/react/src/index.css
index 08a3ac9..3fcc283 100644
--- a/react/src/index.css
+++ b/react/src/index.css
@@ -1,68 +1,131 @@
+@import "tailwindcss";
+@import "shadcn/tailwind.css";
+
:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
+ --radius: 0.625rem;
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
+ /* 기본 색상 - guideline.html 기반 HEX */
+ --background: #ffffff;
+ --foreground: #0f172a;
+ --card: #ffffff;
+ --card-foreground: #0f172a;
+ --popover: #ffffff;
+ --popover-foreground: #0f172a;
+ --primary: #2563eb;
+ --primary-foreground: #ffffff;
+ --secondary: #f1f5f9;
+ --secondary-foreground: #0f172a;
+ --muted: #f1f5f9;
+ --muted-foreground: #64748b;
+ --accent: #f1f5f9;
+ --accent-foreground: #0f172a;
+ --destructive: #ef4444;
+ --border: #e5e7eb;
+ --input: #e5e7eb;
+ --ring: #2563eb;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ /* 차트 색상 */
+ --chart-1: #2563eb;
+ --chart-2: #22c55e;
+ --chart-3: #9333ea;
+ --chart-4: #f59e0b;
+ --chart-5: #ef4444;
+
+ /* 사이드바 - 다크 사이드바 */
+ --sidebar: #111827;
+ --sidebar-foreground: #ffffff;
+ --sidebar-primary: #2563eb;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: rgba(255, 255, 255, 0.1);
+ --sidebar-accent-foreground: #ffffff;
+ --sidebar-border: rgba(255, 255, 255, 0.1);
+ --sidebar-ring: #2563eb;
+
+ /* SPMS 전용 색상 */
+ --spms-success: #22c55e;
+ --spms-success-foreground: #ffffff;
+ --spms-warning: #f59e0b;
+ --spms-warning-foreground: #ffffff;
+ --spms-surface: #f8f9fc;
}
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ /* SPMS 전용 유틸리티 색상 */
+ --color-spms-success: var(--spms-success);
+ --color-spms-success-foreground: var(--spms-success-foreground);
+ --color-spms-warning: var(--spms-warning);
+ --color-spms-warning-foreground: var(--spms-warning-foreground);
+ --color-spms-surface: var(--spms-surface);
+
+ /* Noto Sans KR 폰트 매핑 */
+ --font-sans: "Noto Sans KR", system-ui, -apple-system, sans-serif;
}
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
-}
-
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
}
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
+ body {
+ @apply bg-background text-foreground font-sans;
}
}
+
+/* shake 애니메이션 (1회) */
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20%,
+ 60% {
+ transform: translateX(-6px);
+ }
+ 40%,
+ 80% {
+ transform: translateX(6px);
+ }
+}
+
+.animate-shake {
+ animation: shake 0.4s ease;
+}
diff --git a/react/src/lib/utils.ts b/react/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/react/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/react/src/main.tsx b/react/src/main.tsx
index bef5202..eff7ccc 100644
--- a/react/src/main.tsx
+++ b/react/src/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
-createRoot(document.getElementById('root')!).render(
+createRoot(document.getElementById("root")!).render(
,
-)
+);
diff --git a/react/src/routes/ProtectedRoute.tsx b/react/src/routes/ProtectedRoute.tsx
new file mode 100644
index 0000000..944d7c5
--- /dev/null
+++ b/react/src/routes/ProtectedRoute.tsx
@@ -0,0 +1,13 @@
+import { Navigate, Outlet } from "react-router-dom";
+import { useAuthStore } from "@/stores/authStore";
+
+/** 미인증 시 /login으로 리다이렉트 */
+export default function ProtectedRoute() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/react/src/routes/PublicRoute.tsx b/react/src/routes/PublicRoute.tsx
new file mode 100644
index 0000000..4912507
--- /dev/null
+++ b/react/src/routes/PublicRoute.tsx
@@ -0,0 +1,13 @@
+import { Navigate, Outlet } from "react-router-dom";
+import { useAuthStore } from "@/stores/authStore";
+
+/** 인증 상태에서 / 로 리다이렉트 */
+export default function PublicRoute() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/react/src/routes/index.tsx b/react/src/routes/index.tsx
new file mode 100644
index 0000000..39769df
--- /dev/null
+++ b/react/src/routes/index.tsx
@@ -0,0 +1,93 @@
+import { createBrowserRouter } from "react-router-dom";
+import { lazy, Suspense, type ComponentType } from "react";
+import AppLayout from "@/components/layout/AppLayout";
+import AuthLayout from "@/components/layout/AuthLayout";
+import ProtectedRoute from "./ProtectedRoute";
+import PublicRoute from "./PublicRoute";
+
+/** lazy import 래퍼 */
+function lazyPage(importFn: () => Promise<{ default: ComponentType }>) {
+ const LazyComponent = lazy(importFn);
+ return (
+ 로딩 중...}>
+
+
+ );
+}
+
+/* Auth 페이지 */
+const LoginPage = () => lazyPage(() => import("@/features/auth/pages/LoginPage"));
+const SignupPage = () => lazyPage(() => import("@/features/auth/pages/SignupPage"));
+const VerifyEmailPage = () => lazyPage(() => import("@/features/auth/pages/VerifyEmailPage"));
+
+/* Dashboard 페이지 */
+const HomePage = () => lazyPage(() => import("@/features/dashboard/pages/HomePage"));
+const DashboardPage = () => lazyPage(() => import("@/features/dashboard/pages/DashboardPage"));
+
+/* Service 페이지 */
+const ServiceListPage = () => lazyPage(() => import("@/features/service/pages/ServiceListPage"));
+const ServiceRegisterPage = () => lazyPage(() => import("@/features/service/pages/ServiceRegisterPage"));
+const ServiceDetailPage = () => lazyPage(() => import("@/features/service/pages/ServiceDetailPage"));
+const ServiceEditPage = () => lazyPage(() => import("@/features/service/pages/ServiceEditPage"));
+
+/* Message 페이지 */
+const MessageListPage = () => lazyPage(() => import("@/features/message/pages/MessageListPage"));
+const MessageRegisterPage = () => lazyPage(() => import("@/features/message/pages/MessageRegisterPage"));
+
+/* Statistics 페이지 */
+const StatisticsPage = () => lazyPage(() => import("@/features/statistics/pages/StatisticsPage"));
+const StatisticsHistoryPage = () => lazyPage(() => import("@/features/statistics/pages/StatisticsHistoryPage"));
+
+/* Device 페이지 */
+const DeviceListPage = () => lazyPage(() => import("@/features/device/pages/DeviceListPage"));
+
+/* Tag 페이지 */
+const TagManagePage = () => lazyPage(() => import("@/features/tag/pages/TagManagePage"));
+
+/* Settings 페이지 */
+const MyPage = () => lazyPage(() => import("@/features/settings/pages/MyPage"));
+const ProfileEditPage = () => lazyPage(() => import("@/features/settings/pages/ProfileEditPage"));
+const NotificationsPage = () => lazyPage(() => import("@/features/settings/pages/NotificationsPage"));
+
+export const router = createBrowserRouter([
+ {
+ /* 공개 라우트 (인증 불필요) */
+ element: ,
+ children: [
+ {
+ element: ,
+ children: [
+ { path: "/login", element: },
+ { path: "/signup", element: },
+ { path: "/verify-email", element: },
+ ],
+ },
+ ],
+ },
+ {
+ /* 보호된 라우트 (인증 필요) */
+ element: ,
+ children: [
+ {
+ element: ,
+ children: [
+ { path: "/", element: },
+ { path: "/dashboard", element: },
+ { path: "/services", element: },
+ { path: "/services/register", element: },
+ { path: "/services/:id", element: },
+ { path: "/services/:id/edit", element: },
+ { path: "/messages", element: },
+ { path: "/messages/register", element: },
+ { path: "/statistics", element: },
+ { path: "/statistics/history", element: },
+ { path: "/devices", element: },
+ { path: "/tags", element: },
+ { path: "/settings", element: },
+ { path: "/settings/profile", element: },
+ { path: "/settings/notifications", element: },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/react/src/stores/authStore.ts b/react/src/stores/authStore.ts
new file mode 100644
index 0000000..6f73ae6
--- /dev/null
+++ b/react/src/stores/authStore.ts
@@ -0,0 +1,16 @@
+import { create } from "zustand";
+import type { User } from "@/types/user";
+
+interface AuthState {
+ user: User | null;
+ isAuthenticated: boolean;
+ setUser: (user: User) => void;
+ logout: () => void;
+}
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ isAuthenticated: false,
+ setUser: (user) => set({ user, isAuthenticated: true }),
+ logout: () => set({ user: null, isAuthenticated: false }),
+}));
diff --git a/react/src/stores/uiStore.ts b/react/src/stores/uiStore.ts
new file mode 100644
index 0000000..86c0eca
--- /dev/null
+++ b/react/src/stores/uiStore.ts
@@ -0,0 +1,11 @@
+import { create } from "zustand";
+
+interface UiState {
+ isSidebarOpen: boolean;
+ toggleSidebar: () => void;
+}
+
+export const useUiStore = create((set) => ({
+ isSidebarOpen: true,
+ toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
+}));
diff --git a/react/src/types/api.ts b/react/src/types/api.ts
new file mode 100644
index 0000000..cc227c3
--- /dev/null
+++ b/react/src/types/api.ts
@@ -0,0 +1,24 @@
+/** API 응답 공통 래퍼 */
+export interface ApiResponse {
+ success: boolean;
+ data: T;
+ message?: string;
+}
+
+/** 페이지네이션 응답 */
+export interface PaginatedResponse {
+ success: boolean;
+ data: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
+
+/** API 에러 */
+export interface ApiError {
+ success: false;
+ message: string;
+ code?: string;
+ errors?: Record;
+}
diff --git a/react/src/types/common.ts b/react/src/types/common.ts
new file mode 100644
index 0000000..cc0512a
--- /dev/null
+++ b/react/src/types/common.ts
@@ -0,0 +1,17 @@
+/** 정렬 방향 */
+export type SortDirection = "asc" | "desc";
+
+/** 목록 조회 파라미터 */
+export interface ListParams {
+ page?: number;
+ pageSize?: number;
+ search?: string;
+ sortBy?: string;
+ sortDirection?: SortDirection;
+}
+
+/** 셀렉트 옵션 */
+export interface SelectOption {
+ label: string;
+ value: string;
+}
diff --git a/react/src/types/user.ts b/react/src/types/user.ts
new file mode 100644
index 0000000..3d0dd00
--- /dev/null
+++ b/react/src/types/user.ts
@@ -0,0 +1,19 @@
+/** 사용자 역할 */
+export const UserRole = {
+ ADMIN: "ADMIN",
+ MANAGER: "MANAGER",
+ USER: "USER",
+} as const;
+
+export type UserRole = (typeof UserRole)[keyof typeof UserRole];
+
+/** 사용자 */
+export interface User {
+ id: number;
+ email: string;
+ name: string;
+ role: UserRole;
+ profileImage?: string;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/react/src/utils/format.ts b/react/src/utils/format.ts
new file mode 100644
index 0000000..09bf683
--- /dev/null
+++ b/react/src/utils/format.ts
@@ -0,0 +1,22 @@
+import { format, formatDistanceToNow } from "date-fns";
+import { ko } from "date-fns/locale";
+
+/** 날짜 포맷 (YYYY-MM-DD) */
+export function formatDate(date: string | Date): string {
+ return format(new Date(date), "yyyy-MM-dd");
+}
+
+/** 날짜+시간 포맷 (YYYY-MM-DD HH:mm) */
+export function formatDateTime(date: string | Date): string {
+ return format(new Date(date), "yyyy-MM-dd HH:mm");
+}
+
+/** 상대 시간 (예: "3분 전") */
+export function formatRelativeTime(date: string | Date): string {
+ return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko });
+}
+
+/** 숫자 포맷 (천 단위 콤마) */
+export function formatNumber(value: number): string {
+ return new Intl.NumberFormat("ko-KR").format(value);
+}
diff --git a/react/src/utils/storage.ts b/react/src/utils/storage.ts
new file mode 100644
index 0000000..a12aee5
--- /dev/null
+++ b/react/src/utils/storage.ts
@@ -0,0 +1,25 @@
+/** localStorage 타입 안전 헬퍼 */
+
+/** 값 가져오기 */
+export function getStorageItem(key: string, fallback: T): T {
+ try {
+ const item = localStorage.getItem(key);
+ return item ? (JSON.parse(item) as T) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+/** 값 저장하기 */
+export function setStorageItem(key: string, value: T): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ console.warn(`localStorage에 "${key}" 저장 실패`);
+ }
+}
+
+/** 값 제거하기 */
+export function removeStorageItem(key: string): void {
+ localStorage.removeItem(key);
+}
diff --git a/react/src/vite-env.d.ts b/react/src/vite-env.d.ts
new file mode 100644
index 0000000..3ee984d
--- /dev/null
+++ b/react/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string;
+ readonly VITE_APP_TITLE: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/react/tsconfig.app.json b/react/tsconfig.app.json
index a9b5a59..5fab448 100644
--- a/react/tsconfig.app.json
+++ b/react/tsconfig.app.json
@@ -22,7 +22,12 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
},
"include": ["src"]
}
diff --git a/react/tsconfig.json b/react/tsconfig.json
index 1ffef60..aa3c04f 100644
--- a/react/tsconfig.json
+++ b/react/tsconfig.json
@@ -1,4 +1,10 @@
{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
diff --git a/react/vite.config.ts b/react/vite.config.ts
index 8b0f57b..f412045 100644
--- a/react/vite.config.ts
+++ b/react/vite.config.ts
@@ -1,7 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import path from 'path'
-// https://vite.dev/config/
export default defineConfig({
- plugins: [react()],
-})
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+})
\ No newline at end of file
|