first commit

This commit is contained in:
2025-10-13 17:37:10 +09:00
commit 9cfc08f3d1
23 changed files with 6140 additions and 0 deletions

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

37
src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { BrowserRouter } from 'react-router-dom'
import { queryClient } from '@/lib/query-client'
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="min-h-screen bg-background">
<div className="container mx-auto p-8">
<h1 className="text-4xl font-bold text-foreground mb-4">
SAM React Frontend
</h1>
<p className="text-muted-foreground">
React .
</p>
<div className="mt-8 p-6 bg-card rounded-lg border">
<h2 className="text-2xl font-semibold mb-2"> </h2>
<ul className="list-disc list-inside space-y-1 text-card-foreground">
<li>Vite + React + TypeScript</li>
<li>Tailwind CSS with shadcn/ui theming</li>
<li>React Query for server state</li>
<li>Zustand for global state</li>
<li>Axios with interceptors</li>
<li>React Router for routing</li>
</ul>
</div>
</div>
</div>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
export default App

52
src/index.css Normal file
View File

@@ -0,0 +1,52 @@
@import "tailwindcss";
@theme {
/* Colors */
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-card: #ffffff;
--color-card-foreground: #0a0a0a;
--color-popover: #ffffff;
--color-popover-foreground: #0a0a0a;
--color-primary: #18181b;
--color-primary-foreground: #fafafa;
--color-secondary: #f4f4f5;
--color-secondary-foreground: #18181b;
--color-muted: #f4f4f5;
--color-muted-foreground: #71717a;
--color-accent: #f4f4f5;
--color-accent-foreground: #18181b;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
--color-border: #e4e4e7;
--color-input: #e4e4e7;
--color-ring: #18181b;
/* Border radius */
--radius: 0.5rem;
}
/* Dark mode colors (optional) */
@media (prefers-color-scheme: dark) {
@theme {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-card: #0a0a0a;
--color-card-foreground: #fafafa;
--color-popover: #0a0a0a;
--color-popover-foreground: #fafafa;
--color-primary: #fafafa;
--color-primary-foreground: #18181b;
--color-secondary: #27272a;
--color-secondary-foreground: #fafafa;
--color-muted: #27272a;
--color-muted-foreground: #a1a1aa;
--color-accent: #27272a;
--color-accent-foreground: #fafafa;
--color-destructive: #7f1d1d;
--color-destructive-foreground: #fafafa;
--color-border: #27272a;
--color-input: #27272a;
--color-ring: #d4d4d8;
}
}

73
src/lib/axios.ts Normal file
View File

@@ -0,0 +1,73 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { API_BASE_URL, API_KEY } from './utils'
/**
* Axios instance with default configuration
*/
export const axiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
})
/**
* Request interceptor - Add API Key and Bearer token
*/
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Add API Key header
if (API_KEY) {
config.headers['X-API-Key'] = API_KEY
}
// Add Bearer token if available
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
/**
* Response interceptor - Handle common errors
*/
axiosInstance.interceptors.response.use(
(response) => {
return response
},
(error: AxiosError) => {
// Handle 401 Unauthorized - redirect to login
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
window.location.href = '/login'
}
// Handle 403 Forbidden
if (error.response?.status === 403) {
console.error('Access forbidden:', error.response.data)
}
// Handle 404 Not Found
if (error.response?.status === 404) {
console.error('Resource not found:', error.config?.url)
}
// Handle 500 Internal Server Error
if (error.response?.status === 500) {
console.error('Server error:', error.response.data)
}
return Promise.reject(error)
}
)
export default axiosInstance

18
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,18 @@
import { QueryClient } from '@tanstack/react-query'
/**
* React Query client configuration
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
},
mutations: {
retry: 0,
},
},
})

43
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,43 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Merge Tailwind CSS classes with conflict resolution
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Format date using date-fns
*/
export function formatDate(date: Date | string, _format: string = 'yyyy-MM-dd'): string {
// TODO: Implement using date-fns when needed
return new Date(date).toLocaleDateString()
}
/**
* Delay execution (useful for testing loading states)
*/
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* Get environment variable with type safety
*/
export function getEnv(key: string, defaultValue?: string): string {
const value = import.meta.env[key]
if (value === undefined && defaultValue === undefined) {
throw new Error(`Environment variable ${key} is not defined`)
}
return value ?? defaultValue ?? ''
}
/**
* API base URL
*/
export const API_BASE_URL = getEnv('VITE_API_BASE_URL', 'http://api.sam.kr')
/**
* API Key
*/
export const API_KEY = getEnv('VITE_API_KEY', '')

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

85
src/services/api.ts Normal file
View File

@@ -0,0 +1,85 @@
import axiosInstance from '@/lib/axios'
import type { ApiResponse, LoginResponse } from '@/types/api'
/**
* Auth API Service
*/
export const authApi = {
/**
* Login with email and password
*/
login: async (email: string, password: string): Promise<ApiResponse<LoginResponse>> => {
const response = await axiosInstance.post<ApiResponse<LoginResponse>>('/v1/login', {
email,
password,
})
return response.data
},
/**
* Logout current user
*/
logout: async (): Promise<ApiResponse<null>> => {
const response = await axiosInstance.post<ApiResponse<null>>('/v1/logout')
return response.data
},
/**
* Get current user profile
*/
me: async (): Promise<ApiResponse<any>> => {
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me')
return response.data
},
}
/**
* Tenant API Service
*/
export const tenantApi = {
/**
* Switch to different tenant
*/
switch: async (tenantId: number): Promise<ApiResponse<any>> => {
const response = await axiosInstance.post<ApiResponse<any>>(`/v1/tenants/${tenantId}/switch`)
return response.data
},
/**
* Get list of tenants for current user
*/
list: async (): Promise<ApiResponse<any>> => {
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me/tenants')
return response.data
},
}
/**
* Generic API helper for CRUD operations
*/
export const createCrudApi = <T>(basePath: string) => ({
list: async (params?: Record<string, any>): Promise<ApiResponse<T[]>> => {
const response = await axiosInstance.get<ApiResponse<T[]>>(basePath, { params })
return response.data
},
get: async (id: number | string): Promise<ApiResponse<T>> => {
const response = await axiosInstance.get<ApiResponse<T>>(`${basePath}/${id}`)
return response.data
},
create: async (data: Partial<T>): Promise<ApiResponse<T>> => {
const response = await axiosInstance.post<ApiResponse<T>>(basePath, data)
return response.data
},
update: async (id: number | string, data: Partial<T>): Promise<ApiResponse<T>> => {
const response = await axiosInstance.put<ApiResponse<T>>(`${basePath}/${id}`, data)
return response.data
},
delete: async (id: number | string): Promise<ApiResponse<null>> => {
const response = await axiosInstance.delete<ApiResponse<null>>(`${basePath}/${id}`)
return response.data
},
})

83
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,83 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { AuthUser, Tenant } from '@/types/api'
interface AuthState {
user: AuthUser | null
token: string | null
isAuthenticated: boolean
currentTenant: Tenant | null
}
interface AuthActions {
setAuth: (user: AuthUser, token: string) => void
setCurrentTenant: (tenant: Tenant) => void
logout: () => void
clearAuth: () => void
}
/**
* Authentication store using Zustand with persistence
*/
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set) => ({
// Initial state
user: null,
token: null,
isAuthenticated: false,
currentTenant: null,
// Actions
setAuth: (user, token) => {
localStorage.setItem('auth_token', token)
set({
user,
token,
isAuthenticated: true,
currentTenant: user.current_tenant,
})
},
setCurrentTenant: (tenant) => {
set((state) => ({
currentTenant: tenant,
user: state.user
? { ...state.user, current_tenant: tenant }
: null,
}))
},
logout: () => {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
set({
user: null,
token: null,
isAuthenticated: false,
currentTenant: null,
})
},
clearAuth: () => {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
set({
user: null,
token: null,
isAuthenticated: false,
currentTenant: null,
})
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
currentTenant: state.currentTenant,
}),
}
)
)

85
src/types/api.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Standard API response structure (from SAM API)
*/
export interface ApiResponse<T = any> {
success: boolean
message: string
data: T
}
/**
* API error response
*/
export interface ApiErrorResponse {
success: false
message: string
errors?: Record<string, string[]>
}
/**
* Pagination metadata
*/
export interface PaginationMeta {
current_page: number
last_page: number
per_page: number
total: number
from: number
to: number
}
/**
* Paginated API response
*/
export interface PaginatedResponse<T> {
data: T[]
meta: PaginationMeta
links: {
first: string
last: string
prev: string | null
next: string | null
}
}
/**
* User type
*/
export interface User {
id: number
name: string
email: string
is_active: boolean
created_at: string
updated_at: string
}
/**
* Tenant type
*/
export interface Tenant {
id: number
name: string
code: string
is_active: boolean
created_at: string
updated_at: string
}
/**
* Auth login response
*/
export interface LoginResponse {
token: string
user: User
tenants: Tenant[]
current_tenant: Tenant | null
}
/**
* Auth user with tenant info
*/
export interface AuthUser extends User {
current_tenant: Tenant | null
tenants: Tenant[]
}