first commit
This commit is contained in:
28
src/common/api/endpoints.ts
Normal file
28
src/common/api/endpoints.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* API 경로 상수 정의
|
||||
*
|
||||
* 목표:
|
||||
* - 문자열 API 경로를 코드 전역에 흩뿌리지 않음
|
||||
* - route 변경/버전 변경 시 영향 범위 최소화
|
||||
*
|
||||
* 규칙:
|
||||
* - API 경로는 endpoints.ts로만 관리
|
||||
* - feature 코드에서 문자열 URL 직접 사용 금지
|
||||
*/
|
||||
export const API = {
|
||||
AUTH: {
|
||||
CSRF: "/auth/csrf",
|
||||
LOGIN: "/auth/login",
|
||||
LOGOUT: "/auth/logout",
|
||||
ME: "/auth/me",
|
||||
},
|
||||
USER: {
|
||||
LIST: "/users",
|
||||
DETAIL: (id: string | number) => `/users/${encodeURIComponent(id)}`,
|
||||
ME: "/users/me",
|
||||
},
|
||||
DOCUMENT: {
|
||||
LIST: "/documents",
|
||||
DETAIL: (id: string | number) => `/documents/${encodeURIComponent(id)}`,
|
||||
},
|
||||
} as const;
|
||||
22
src/common/api/http.ts
Normal file
22
src/common/api/http.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Axios 인스턴스 설정
|
||||
*
|
||||
* - 세션 기반 인증 (withCredentials: true)
|
||||
* - 환경 변수 기반 baseURL 설정
|
||||
* - 타임아웃 설정
|
||||
*/
|
||||
const baseURL = `${import.meta.env.VITE_API_BASE_URL ?? ""}${import.meta.env.VITE_API_PREFIX ?? "/api"}`
|
||||
.replace(/\/+$/g, "");
|
||||
const timeout = Number(import.meta.env.VITE_HTTP_TIMEOUT_MS ?? 20000);
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
107
src/common/api/interceptors.ts
Normal file
107
src/common/api/interceptors.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { http } from "./http";
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
/**
|
||||
* Axios Interceptor 설정
|
||||
*
|
||||
* 역할:
|
||||
* - 모든 요청은 전역 로딩과 연결
|
||||
* - 모든 응답은 로딩 종료 보장
|
||||
* - 401 응답은 전역 인증 만료로 처리
|
||||
* - 401 재시도 루프 방지
|
||||
* - 민감정보는 절대 로그에 남기지 않음
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||
* - 401인데 아무 처리 없이 콘솔만 출력 금지
|
||||
*
|
||||
* 주의:
|
||||
* - loading.start/end는 동적으로 주입받아야 함 (순환 참조 방지)
|
||||
* - window 객체를 통해 접근
|
||||
*/
|
||||
|
||||
// 401 재시도 루프 방지 플래그
|
||||
let isHandling401 = false;
|
||||
|
||||
// 로딩 제어 함수 (동적 주입)
|
||||
let loadingStart: (() => void) | null = null;
|
||||
let loadingEnd: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 로딩 제어 함수 등록 (LoadingProvider에서 호출)
|
||||
*/
|
||||
export function setLoadingControls(start: () => void, end: () => void) {
|
||||
loadingStart = start;
|
||||
loadingEnd = end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 인터셉터
|
||||
*/
|
||||
http.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 로딩 시작 (X-Skip-Loading 헤더가 없을 때만)
|
||||
if (!config.headers?.["X-Skip-Loading"] && loadingStart) {
|
||||
loadingStart();
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 요청 에러 시에도 로딩 종료
|
||||
if (loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 응답 인터셉터
|
||||
*/
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
// 로딩 종료 (X-Skip-Loading 헤더가 없을 때만)
|
||||
if (!response.config.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// 로딩 종료 (에러 시에도)
|
||||
if (!error.config?.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
|
||||
// 401 처리 (인증 만료)
|
||||
if (status === 401 && !isHandling401) {
|
||||
isHandling401 = true;
|
||||
|
||||
// 전역 인증 만료 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("app:unauthorized"));
|
||||
|
||||
// 플래그 리셋 (다음 요청을 위해)
|
||||
setTimeout(() => {
|
||||
isHandling401 = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 403 처리 (권한 없음)
|
||||
if (status === 403) {
|
||||
// 필요 시 권한 없음 이벤트 발생
|
||||
// window.dispatchEvent(new CustomEvent("app:forbidden"));
|
||||
}
|
||||
|
||||
// 로깅 (민감정보 제외)
|
||||
// 운영 환경에서는 console.log 금지
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_HTTP_LOGGING === "true") {
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
console.warn(`[API Error] ${method} ${url} - ${status}`);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
24
src/common/auth/AuthContext.tsx
Normal file
24
src/common/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext } from "react";
|
||||
import type { AuthContextValue } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 Context 정의
|
||||
*
|
||||
* 규칙:
|
||||
* - 인증 상태 및 사용자 정보는 AuthContext에서만 관리
|
||||
* - 페이지별 /me 호출로 중복 상태 생성 금지
|
||||
* - 앱 초기 로딩에서 1회 /me 호출 + refresh 로직만 허용
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
loading: true,
|
||||
user: null,
|
||||
login: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
logout: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
refreshMe: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
});
|
||||
97
src/common/auth/AuthProvider.tsx
Normal file
97
src/common/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
import { authService } from "./authService";
|
||||
import type { User, AuthContextValue } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 Provider
|
||||
*
|
||||
* 책임:
|
||||
* - 앱 최초 로딩 시 사용자 상태 확정
|
||||
* - /api/auth/me 호출
|
||||
* - 인증 성공 시 user 상태 설정
|
||||
* - 인증 실패(401) 시 user = null 처리
|
||||
* - 전역 unauthorized 이벤트 대응
|
||||
*
|
||||
* 규칙:
|
||||
* - AuthProvider가 초기 /me 호출 → user set
|
||||
* - 페이지는 useAuth로 읽기만
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
/**
|
||||
* 사용자 정보 갱신
|
||||
*/
|
||||
const refreshMe = useCallback(async () => {
|
||||
try {
|
||||
const me = await authService.me();
|
||||
setUser(me);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await authService.login(username, password);
|
||||
// 로그인 성공 후 사용자 정보 갱신
|
||||
await refreshMe();
|
||||
}, [refreshMe]);
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 로그아웃 실패해도 상태는 초기화
|
||||
} finally {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 초기 사용자 정보 조회
|
||||
*/
|
||||
useEffect(() => {
|
||||
refreshMe();
|
||||
}, [refreshMe]);
|
||||
|
||||
/**
|
||||
* 전역 401 이벤트 리스너
|
||||
* interceptor에서 발생시킨 이벤트 처리
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
window.addEventListener("app:unauthorized", handleUnauthorized);
|
||||
return () => {
|
||||
window.removeEventListener("app:unauthorized", handleUnauthorized);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
loading,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
refreshMe,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
29
src/common/auth/ProtectedRoute.tsx
Normal file
29
src/common/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
/**
|
||||
* 보호된 라우트
|
||||
*
|
||||
* 역할:
|
||||
* - 인증이 필요한 페이지 접근 제어
|
||||
* - 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||
*
|
||||
* 규칙:
|
||||
* - ProtectedRoute는 라우팅 레벨에서 적용
|
||||
* - 버튼 숨김/메뉴 숨김은 보안이 아님 (라우트 차단 필수)
|
||||
*/
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { loading, user } = useAuth();
|
||||
|
||||
// 로딩 중에는 아무것도 렌더링하지 않음
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우 로그인 페이지로 리다이렉트
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
51
src/common/auth/authService.ts
Normal file
51
src/common/auth/authService.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { http } from "../api/http";
|
||||
import { API } from "../api/endpoints";
|
||||
import type { User } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 서비스
|
||||
*
|
||||
* 역할:
|
||||
* - login / logout / me API 호출
|
||||
* - 세션 기반 인증 (withCredentials 자동 처리)
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||
* - 토큰을 프론트엔드에서 직접 관리하지 않음
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
await http.post(API.AUTH.LOGIN, { username, password });
|
||||
},
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
await http.post(API.AUTH.LOGOUT);
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
async me(): Promise<User | null> {
|
||||
try {
|
||||
const response = await http.get(API.AUTH.ME);
|
||||
// ApiResponse 구조에 맞게 data.data 접근
|
||||
return response.data?.data ?? null;
|
||||
} catch (error) {
|
||||
// 401 등 에러 시 null 반환
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* CSRF 토큰 초기화
|
||||
*/
|
||||
async csrf(): Promise<void> {
|
||||
await http.get(API.AUTH.CSRF);
|
||||
},
|
||||
};
|
||||
32
src/common/auth/types.ts
Normal file
32
src/common/auth/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 인증 관련 타입 정의
|
||||
*
|
||||
* 백엔드 MeResponse와 일치하도록 정의
|
||||
* - userId: 사용자 ID (number)
|
||||
* - email: 이메일 (string, 필수)
|
||||
* - displayName: 표시 이름 (string, 필수)
|
||||
* - role: 역할 (string, 필수)
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - password, token 등 민감정보 포함 금지
|
||||
* - 최소 정보만 포함
|
||||
*/
|
||||
export interface User {
|
||||
userId: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
loading: boolean;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export interface AuthActions {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthContextValue extends AuthState, AuthActions {}
|
||||
14
src/common/auth/useAuth.ts
Normal file
14
src/common/auth/useAuth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
/**
|
||||
* AuthContext 접근 훅
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const { loading, user, login, logout } = useAuth();
|
||||
* ```
|
||||
*/
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
68
src/common/components/action/Button.tsx
Normal file
68
src/common/components/action/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Button 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 범용성 위주, 과도한 추상화 지양
|
||||
* - 동작은 props 옵션으로만 제어
|
||||
*
|
||||
* 지원 옵션:
|
||||
* - disabled: 비활성화
|
||||
* - loading: 로딩 상태 (중복 클릭 방지)
|
||||
* - size: 버튼 크기
|
||||
* - variant: 버튼 스타일 변형
|
||||
*/
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "outline" | "ghost";
|
||||
|
||||
export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
onClick,
|
||||
className = "",
|
||||
id,
|
||||
name,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || loading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
const sizeClass = `btn-${size}`;
|
||||
const variantClass = `btn-${variant}`;
|
||||
const loadingClass = loading ? "btn-loading" : "";
|
||||
const disabledClass = disabled || loading ? "btn-disabled" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
name={name}
|
||||
className={`btn ${sizeClass} ${variantClass} ${loadingClass} ${disabledClass} ${className}`.trim()}
|
||||
disabled={disabled || loading}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{loading && <span className="btn-spinner" aria-hidden="true" />}
|
||||
<span className={loading ? "btn-content-loading" : ""}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
4
src/common/components/action/index.ts
Normal file
4
src/common/components/action/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Action 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./Button";
|
||||
189
src/common/components/display/table/Table.tsx
Normal file
189
src/common/components/display/table/Table.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from "react";
|
||||
import type { SortValue, TableColumn, RowSelectionState } from "./types";
|
||||
import { TableEmpty } from "./TableEmpty";
|
||||
import { TableSkeleton } from "./TableSkeleton";
|
||||
|
||||
/**
|
||||
* 공통 Table 컴포넌트
|
||||
*
|
||||
* 목표:
|
||||
* - 페이지마다 테이블이 달라도 "동작"은 동일하게 유지
|
||||
* - 로딩, Empty, RowClick, Selection, Server Sort UI를 표준화
|
||||
* - 데이터 fetch는 Table이 하지 않음
|
||||
* - 페이지 상태(page/size/sort/filter)는 URL state가 담당
|
||||
*
|
||||
* Table이 책임지는 것:
|
||||
* - 로딩 표시(영역 Skeleton)
|
||||
* - Empty 표시
|
||||
* - Row 클릭 핸들링(옵션)
|
||||
* - Selection(checkbox) 핸들링(옵션)
|
||||
* - 서버 정렬 UI 표시 및 정렬 변경 이벤트 발생
|
||||
*
|
||||
* Table이 책임지지 않는 것:
|
||||
* - 데이터 fetch
|
||||
* - page/size/filter 상태 관리
|
||||
* - 비즈니스 로직 처리(권한에 따른 컬럼 숨김, API 조건 조합 등)
|
||||
*/
|
||||
export interface TableProps<T> {
|
||||
columns: Array<TableColumn<T>>;
|
||||
rows: T[];
|
||||
getRowId: (row: T) => string;
|
||||
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
|
||||
onRowClick?: (row: T) => void;
|
||||
|
||||
selection?: RowSelectionState;
|
||||
|
||||
sort?: SortValue;
|
||||
onSortChange?: (next: SortValue) => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
getRowId,
|
||||
loading,
|
||||
emptyText,
|
||||
onRowClick,
|
||||
selection,
|
||||
sort,
|
||||
onSortChange,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const allIds = rows.map(getRowId);
|
||||
const allSelected =
|
||||
selection
|
||||
? allIds.length > 0 && allIds.every((id) => selection.selectedIds.has(id))
|
||||
: false;
|
||||
|
||||
function toggleSort(col: TableColumn<T>) {
|
||||
if (!col.sortable || !onSortChange) return;
|
||||
const key = col.sortKey ?? col.key;
|
||||
|
||||
if (!sort || sort.key !== key) {
|
||||
return onSortChange({ key, dir: "asc" });
|
||||
}
|
||||
if (sort.dir === "asc") {
|
||||
return onSortChange({ key, dir: "desc" });
|
||||
}
|
||||
return onSortChange(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||
<tr>
|
||||
{selection ? (
|
||||
<th style={{ padding: 10, width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => selection.toggleAll(allIds)}
|
||||
aria-label="select all"
|
||||
/>
|
||||
</th>
|
||||
) : null}
|
||||
|
||||
{columns.map((c) => {
|
||||
const isSorted = sort && (c.sortKey ?? c.key) === sort.key;
|
||||
const sortMark = isSorted
|
||||
? sort!.dir === "asc"
|
||||
? " ▲"
|
||||
: " ▼"
|
||||
: "";
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => toggleSort(c)}
|
||||
style={{
|
||||
padding: 10,
|
||||
textAlign: c.align ?? "left",
|
||||
width: c.width,
|
||||
cursor: c.sortable ? "pointer" : "default",
|
||||
userSelect: "none",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
{c.header}
|
||||
{c.sortable ? sortMark : null}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={(selection ? 1 : 0) + columns.length}
|
||||
style={{ padding: 12 }}
|
||||
>
|
||||
<TableSkeleton rows={8} />
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={(selection ? 1 : 0) + columns.length}
|
||||
style={{ padding: 12 }}
|
||||
>
|
||||
<TableEmpty text={emptyText ?? "데이터가 없습니다."} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const id = getRowId(row);
|
||||
const clickable = Boolean(onRowClick);
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
style={{
|
||||
cursor: clickable ? "pointer" : "default",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{selection ? (
|
||||
<td
|
||||
style={{ padding: 10, width: 44 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selectedIds.has(id)}
|
||||
onChange={() => selection.toggle(id)}
|
||||
aria-label={`select ${id}`}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
style={{ padding: 10, textAlign: c.align ?? "left" }}
|
||||
>
|
||||
{c.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/common/components/display/table/TableEmpty.tsx
Normal file
16
src/common/components/display/table/TableEmpty.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Table Empty 컴포넌트
|
||||
*
|
||||
* 데이터가 없을 때 표시되는 컴포넌트
|
||||
*/
|
||||
export interface TableEmptyProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function TableEmpty({ text }: TableEmptyProps) {
|
||||
return (
|
||||
<div style={{ padding: 12, textAlign: "center", color: "#888" }}>
|
||||
{text ?? "데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/common/components/display/table/TablePagination.tsx
Normal file
60
src/common/components/display/table/TablePagination.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Table Pagination 컴포넌트
|
||||
*
|
||||
* 주의:
|
||||
* - Pagination은 Table 내부 상태로 두지 않음
|
||||
* - Pagination은 URL state 업데이트만 호출
|
||||
*/
|
||||
export interface TablePaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onChangePage: (page: number) => void;
|
||||
}
|
||||
|
||||
export function TablePagination({ page, totalPages, onChangePage }: TablePaginationProps) {
|
||||
const tp = Math.max(1, totalPages ?? 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChangePage(1)}
|
||||
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{"<<"}
|
||||
</button>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChangePage(page - 1)}
|
||||
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ padding: "0 8px" }}>
|
||||
Page {page} / {tp}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= tp}
|
||||
onClick={() => onChangePage(page + 1)}
|
||||
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= tp}
|
||||
onClick={() => onChangePage(tp)}
|
||||
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{">>"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Table Skeleton 컴포넌트
|
||||
*
|
||||
* 로딩 중 표시되는 스켈레톤 UI
|
||||
*/
|
||||
export interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 8 }: TableSkeletonProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 14,
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
borderRadius: 6,
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/common/components/display/table/index.ts
Normal file
6
src/common/components/display/table/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Table } from "./Table";
|
||||
export type { TableProps } from "./Table";
|
||||
export { TableEmpty } from "./TableEmpty";
|
||||
export { TableSkeleton } from "./TableSkeleton";
|
||||
export { TablePagination } from "./TablePagination";
|
||||
export type { TableColumn, SortValue, RowSelectionState, ColumnAlign } from "./types";
|
||||
33
src/common/components/display/table/types.ts
Normal file
33
src/common/components/display/table/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Table 컴포넌트 타입 정의
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - Column 정의는 "표준 타입"으로만 작성
|
||||
* - Column 정의는 페이지마다 임의로 만들지 않음
|
||||
*/
|
||||
|
||||
export type ColumnAlign = "left" | "center" | "right";
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string;
|
||||
header: React.ReactNode;
|
||||
width?: number | string;
|
||||
align?: ColumnAlign;
|
||||
cell: (row: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
sortKey?: string;
|
||||
};
|
||||
|
||||
export type SortValue = {
|
||||
key: string;
|
||||
dir: "asc" | "desc";
|
||||
} | null;
|
||||
|
||||
export type RowSelectionState = {
|
||||
selectedIds: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
toggleAll: (ids: string[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
45
src/common/components/form/Checkbox.tsx
Normal file
45
src/common/components/form/Checkbox.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Checkbox 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 UI 통일 목적
|
||||
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||
*/
|
||||
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`checkbox-wrapper ${className}`.trim()}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="checkbox-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
183
src/common/components/form/Input.tsx
Normal file
183
src/common/components/form/Input.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
/**
|
||||
* Input 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 범용성 위주, 과도한 추상화 지양
|
||||
* - 동작은 props 옵션으로만 제어
|
||||
*
|
||||
* 지원 타입:
|
||||
* - text: 기본 텍스트 입력
|
||||
* - password: 비밀번호 입력
|
||||
* - number: 숫자 입력 (숫자 이외 문자 자동 제거)
|
||||
*
|
||||
* text 타입 옵션:
|
||||
* - formatEmail: 이메일 형식 자동 포맷
|
||||
* - formatPhone: 전화번호 형식 자동 포맷
|
||||
* - validationRegex: 정규식 검증 (옵션)
|
||||
*
|
||||
* 검색 옵션:
|
||||
* - debounce, commitOnEnter, clearOnEscape 등
|
||||
*/
|
||||
export interface InputSearchOptions {
|
||||
enabled?: boolean;
|
||||
debounceMs?: number;
|
||||
autoCommit?: boolean;
|
||||
commitOnEnter?: boolean;
|
||||
clearOnEscape?: boolean;
|
||||
onCommit?: (value: string) => void;
|
||||
}
|
||||
|
||||
export interface InputFormatOptions {
|
||||
formatEmail?: boolean;
|
||||
formatPhone?: boolean;
|
||||
validationRegex?: RegExp;
|
||||
}
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
type?: "text" | "password" | "number";
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
search?: InputSearchOptions;
|
||||
format?: InputFormatOptions;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
format,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
// value prop 변경 시 localValue 동기화
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
// 포맷 적용 함수
|
||||
const applyFormat = (v: string): string => {
|
||||
if (type !== "text" || !format) return v;
|
||||
|
||||
// 이메일 포맷 (자동 포맷은 적용하지 않음, 검증만)
|
||||
if (format.formatEmail) {
|
||||
// 이메일 형식 검증은 onChange에서 처리하지 않고, 외부에서 처리
|
||||
return v;
|
||||
}
|
||||
|
||||
// 전화번호 포맷 (010-1234-5678)
|
||||
if (format.formatPhone) {
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
// 정규식 검증
|
||||
const validateRegex = (v: string): boolean => {
|
||||
if (!format?.validationRegex) return true;
|
||||
return format.validationRegex.test(v);
|
||||
};
|
||||
|
||||
// 검색 커밋
|
||||
const commit = (v: string) => {
|
||||
search?.onCommit?.(v);
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
let v = e.target.value;
|
||||
|
||||
// number 타입: 숫자 이외 문자 제거
|
||||
if (type === "number") {
|
||||
v = v.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
// 포맷 적용 (text 타입만)
|
||||
if (type === "text" && format) {
|
||||
v = applyFormat(v);
|
||||
}
|
||||
|
||||
// 정규식 검증 (실패 시 이전 값 유지)
|
||||
if (format?.validationRegex && !validateRegex(v)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalValue(v);
|
||||
onChange?.(v);
|
||||
|
||||
if (!search?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// autoCommit이면 즉시 커밋
|
||||
if (search.autoCommit) {
|
||||
commit(v);
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce 처리
|
||||
if (search.debounceMs) {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
timer.current = window.setTimeout(() => {
|
||||
commit(v);
|
||||
}, search.debounceMs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!search?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter 키 커밋
|
||||
if (e.key === "Enter" && search.commitOnEnter) {
|
||||
e.preventDefault();
|
||||
commit(localValue);
|
||||
}
|
||||
|
||||
// Escape 키 초기화
|
||||
if (e.key === "Escape" && search.clearOnEscape) {
|
||||
setLocalValue("");
|
||||
onChange?.("");
|
||||
commit("");
|
||||
}
|
||||
};
|
||||
|
||||
// cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
className={`input ${className}`.trim()}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/common/components/form/Radio.tsx
Normal file
45
src/common/components/form/Radio.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Radio 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 UI 통일 목적
|
||||
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||
*/
|
||||
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Radio({
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: RadioProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`radio-wrapper ${className}`.trim()}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="radio"
|
||||
className="radio"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="radio-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
64
src/common/components/form/Select.tsx
Normal file
64
src/common/components/form/Select.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Select 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 select 기능을 공통 컴포넌트화
|
||||
* - 스타일 통일 목적 위주
|
||||
*/
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
|
||||
options: SelectOption[];
|
||||
value?: string | number;
|
||||
onChange?: (value: string | number) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedValue = e.target.value;
|
||||
// 숫자 옵션이면 숫자로 변환 시도
|
||||
const numValue = Number(selectedValue);
|
||||
const finalValue = isNaN(numValue) ? selectedValue : numValue;
|
||||
onChange?.(finalValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
className={`select ${className}`.trim()}
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
46
src/common/components/form/Textarea.tsx
Normal file
46
src/common/components/form/Textarea.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Textarea 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
*
|
||||
* 기본 속성:
|
||||
* - resize: none
|
||||
* - width: 100%
|
||||
*/
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
className={`textarea ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
resize: "none",
|
||||
width: "100%",
|
||||
...props.style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
src/common/components/form/index.ts
Normal file
8
src/common/components/form/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Form 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./Input";
|
||||
export * from "./Select";
|
||||
export * from "./Textarea";
|
||||
export * from "./Checkbox";
|
||||
export * from "./Radio";
|
||||
23
src/common/components/index.ts
Normal file
23
src/common/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 공통 컴포넌트 통합 export
|
||||
*
|
||||
* 모든 공통 컴포넌트는 여기서 export하여 사용 편의성 제공
|
||||
*
|
||||
* 디렉터리 구조:
|
||||
* - form/: 폼 입력 컴포넌트 (Input, Select, Textarea, Checkbox, Radio)
|
||||
* - action/: 액션 컴포넌트 (Button)
|
||||
* - display/: 표시 컴포넌트 (Table)
|
||||
* - system/: 시스템 컴포넌트 (LoadingOverlay, LoadingProvider, Toast, CommonModal)
|
||||
*/
|
||||
|
||||
// Form 컴포넌트
|
||||
export * from "./form";
|
||||
|
||||
// Action 컴포넌트
|
||||
export * from "./action";
|
||||
|
||||
// Display 컴포넌트
|
||||
export * from "./display/table";
|
||||
|
||||
// System 컴포넌트
|
||||
export * from "./system";
|
||||
237
src/common/components/system/CommonModal.tsx
Normal file
237
src/common/components/system/CommonModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* CommonModal 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
*
|
||||
* 구조:
|
||||
* - 헤더: 타이틀, X 닫기 버튼
|
||||
* - 컨텐츠: children
|
||||
* - 푸터: 확인/닫기 버튼 (옵션)
|
||||
*
|
||||
* 사이즈:
|
||||
* - 기본: width 50vw, height 50vh
|
||||
* - 커스텀 사이즈 옵션 제공
|
||||
*/
|
||||
export interface CommonModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
|
||||
// 푸터 옵션
|
||||
showFooter?: boolean;
|
||||
onConfirm?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
|
||||
// 사이즈 옵션
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
|
||||
// 공통 속성
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CommonModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
showFooter = true,
|
||||
onConfirm,
|
||||
confirmText = "확인",
|
||||
cancelText = "닫기",
|
||||
width = "50vw",
|
||||
height = "50vh",
|
||||
id,
|
||||
className = "",
|
||||
}: CommonModalProps) {
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
// 모달 열릴 때 body 스크롤 방지
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
height: typeof height === "number" ? `${height}px` : height,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-backdrop ${className}`.trim()}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={id}
|
||||
className={`modal ${className}`.trim()}
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "90vh",
|
||||
...modalStyle,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid #e0e0e0",
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="modal-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: "24px",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "20px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
{showFooter && (
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: onConfirm ? "flex-end" : "center",
|
||||
gap: "8px",
|
||||
padding: "16px 20px",
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
}}
|
||||
>
|
||||
{onConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-cancel"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "1px solid #ccc",
|
||||
background: "white",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
background: "#007bff",
|
||||
color: "white",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/common/components/system/LoadingOverlay.tsx
Normal file
46
src/common/components/system/LoadingOverlay.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 전역 로딩 Overlay 컴포넌트
|
||||
*
|
||||
* 설계 목적:
|
||||
* - "표현 전용 컴포넌트"
|
||||
* - 로딩 상태를 내부에서 판단하지 않음
|
||||
* - visible prop만 보고 렌더링 여부 결정
|
||||
* - 디자인 시스템에 맞게 교체 가능
|
||||
*
|
||||
* 규칙:
|
||||
* - 로딩 상태를 내부에서 판단하지 않음
|
||||
* - visible prop만 보고 렌더링 여부 결정
|
||||
*/
|
||||
export interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "20px 40px",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/common/components/system/LoadingProvider.tsx
Normal file
72
src/common/components/system/LoadingProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { setLoadingControls } from "../api/interceptors";
|
||||
|
||||
/**
|
||||
* 전역 로딩 Provider
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 네트워크 통신이 발생하면 사용자는 항상 "지금 처리 중"임을 인지
|
||||
* - 페이지별 로딩 처리로 인한 UX 불일치와 코드 중복 제거
|
||||
* - Vibe Coding / AI 코드 생성 시 "각 페이지에서 로딩 처리" 패턴 원천 차단
|
||||
*
|
||||
* 동작 원칙:
|
||||
* - 요청 시작 시 count +1
|
||||
* - 요청 종료 시 count -1
|
||||
* - count > 0 이면 로딩 표시
|
||||
* - 중첩 요청에서도 안정적으로 동작
|
||||
*
|
||||
* 책임:
|
||||
* - 로딩 상태 단일 관리
|
||||
* - Axios Interceptor와 직접 연결
|
||||
* - UI 표현은 담당하지 않음
|
||||
*/
|
||||
interface LoadingContextValue {
|
||||
start: () => void;
|
||||
end: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const LoadingContext = createContext<LoadingContextValue>({
|
||||
start: () => {},
|
||||
end: () => {},
|
||||
visible: false,
|
||||
});
|
||||
|
||||
export function LoadingProvider({ children }: { children: React.ReactNode }) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const end = useCallback(() => {
|
||||
setCount((c) => Math.max(0, c - 1));
|
||||
}, []);
|
||||
|
||||
// interceptor에 로딩 제어 함수 등록
|
||||
useEffect(() => {
|
||||
setLoadingControls(start, end);
|
||||
return () => {
|
||||
setLoadingControls(() => {}, () => {});
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
start,
|
||||
end,
|
||||
visible: count > 0,
|
||||
}),
|
||||
[start, end, count]
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingContext.Provider value={value}>
|
||||
{children}
|
||||
</LoadingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLoading() {
|
||||
return useContext(LoadingContext);
|
||||
}
|
||||
103
src/common/components/system/Toast.tsx
Normal file
103
src/common/components/system/Toast.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Toast 메시지 시스템
|
||||
*
|
||||
* 목적:
|
||||
* - 사용자에게 "즉시 피드백" 제공
|
||||
* - 성공/실패/경고 메시지를 화면 어디서든 통일되게 출력
|
||||
* - 페이지마다 alert/console.log로 처리하는 것을 금지
|
||||
*
|
||||
* 규칙:
|
||||
* - 성공: 저장/삭제/등록 완료 정도만 짧게 표시
|
||||
* - 실패: 네트워크/권한/유효성 등 최소 정보로 표시
|
||||
* - 경고: 사용자 선택이 필요할 때만
|
||||
*/
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: "success" | "error" | "warning" | "info";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
push: (message: string, type?: ToastItem["type"]) => void;
|
||||
remove: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue>({
|
||||
push: () => {},
|
||||
remove: () => {},
|
||||
});
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(message: string, type: ToastItem["type"] = "info") => {
|
||||
const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
setItems((prev) => [...prev, { id, message, type }]);
|
||||
// 3.5초 후 자동 제거
|
||||
window.setTimeout(() => remove(id), 3500);
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ push, remove }), [push, remove]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 14,
|
||||
bottom: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
zIndex: 9998,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
border: "1px solid rgba(255,255,255,0.16)",
|
||||
background:
|
||||
t.type === "error"
|
||||
? "rgba(220, 38, 38, 0.9)"
|
||||
: t.type === "success"
|
||||
? "rgba(34, 197, 94, 0.9)"
|
||||
: t.type === "warning"
|
||||
? "rgba(234, 179, 8, 0.9)"
|
||||
: "rgba(0,0,0,0.65)",
|
||||
color: "white",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
maxWidth: 360,
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
onClick={() => remove(t.id)}
|
||||
role="alert"
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/common/components/system/index.ts
Normal file
8
src/common/components/system/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* System 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./CommonModal";
|
||||
export * from "./LoadingOverlay";
|
||||
export * from "./LoadingProvider";
|
||||
export * from "./Toast";
|
||||
export * from "./useToast";
|
||||
17
src/common/components/system/useToast.ts
Normal file
17
src/common/components/system/useToast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import { ToastContext } from "./Toast";
|
||||
|
||||
/**
|
||||
* Toast 훅
|
||||
*
|
||||
* ToastProvider의 Context를 사용하여 Toast 기능을 제공합니다.
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const toast = useToast();
|
||||
* toast.push("메시지", "success");
|
||||
* ```
|
||||
*/
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
60
src/common/hooks/useAppError.ts
Normal file
60
src/common/hooks/useAppError.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from "react";
|
||||
import { mapAxiosError } from "../utils/errorMapper";
|
||||
import { useToast } from "../components";
|
||||
import type { AppError } from "../types/error";
|
||||
|
||||
/**
|
||||
* 공통 에러 처리 훅
|
||||
*
|
||||
* 역할:
|
||||
* - AxiosError를 AppError로 변환
|
||||
* - Toast 메시지 자동 표시 (옵션)
|
||||
* - 에러 정보 반환
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const { handle } = useAppError();
|
||||
*
|
||||
* try {
|
||||
* await apiCall();
|
||||
* } catch (error) {
|
||||
* const appError = handle(error);
|
||||
* // 추가 처리
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface UseAppErrorOptions {
|
||||
toast?: boolean;
|
||||
fallbackMessage?: string;
|
||||
}
|
||||
|
||||
export function useAppError() {
|
||||
const toast = useToast();
|
||||
|
||||
const handle = useCallback(
|
||||
(error: unknown, opts?: UseAppErrorOptions): AppError => {
|
||||
const appErr = mapAxiosError(error);
|
||||
|
||||
// Toast 표시 (기본값: true)
|
||||
if (opts?.toast !== false) {
|
||||
const message = opts?.fallbackMessage ?? appErr.message;
|
||||
const type =
|
||||
appErr.kind === "unauthorized" ||
|
||||
appErr.kind === "forbidden" ||
|
||||
appErr.kind === "server" ||
|
||||
appErr.kind === "network" ||
|
||||
appErr.kind === "timeout"
|
||||
? "error"
|
||||
: appErr.kind === "validation"
|
||||
? "warning"
|
||||
: "error";
|
||||
toast.push(message, type);
|
||||
}
|
||||
|
||||
return appErr;
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return { handle };
|
||||
}
|
||||
120
src/common/hooks/useFilterQuery.ts
Normal file
120
src/common/hooks/useFilterQuery.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* URL 기반 필터 쿼리 훅
|
||||
*
|
||||
* 역할:
|
||||
* - URL → 상태 파싱
|
||||
* - 상태 → URL 반영
|
||||
* - 타입 정규화
|
||||
* - page 리셋 규칙 강제 (q, f_*, size 변경 시 page=1)
|
||||
* - 조합형 검색 통합
|
||||
*
|
||||
* 핵심 원칙:
|
||||
* - 리스트 상태의 단일 진실은 URL SearchParams
|
||||
* - 컴포넌트 내부 state는 보조 표현일 뿐
|
||||
* - 뒤로가기/앞으로가기/새로고침 시 100% 동일 상태 복원
|
||||
*
|
||||
* page 리셋 규칙 (중요):
|
||||
* - q 변경 시 page=1
|
||||
* - f_* 변경 시 page=1
|
||||
* - size 변경 시 page=1
|
||||
*/
|
||||
export interface FilterQueryState {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FilterQueryUpdate {
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
filters?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface UseFilterQueryOptions {
|
||||
defaults?: {
|
||||
size?: number;
|
||||
sort?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useFilterQuery(options: UseFilterQueryOptions = {}) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const defaults = options.defaults ?? {};
|
||||
|
||||
// URL → 상태 파싱
|
||||
const state: FilterQueryState = {
|
||||
q: params.get("q") ?? "",
|
||||
page: Number(params.get("page") ?? 1) || 1,
|
||||
size: Number(params.get("size") ?? defaults.size ?? 20) || 20,
|
||||
sort: params.get("sort") ?? defaults.sort,
|
||||
filters: Object.fromEntries(
|
||||
[...params.entries()].filter(([k]) => k.startsWith("f_"))
|
||||
),
|
||||
};
|
||||
|
||||
// 상태 → URL 반영
|
||||
const update = (next: FilterQueryUpdate) => {
|
||||
const p = new URLSearchParams(params);
|
||||
|
||||
// q 변경 시 page=1 리셋
|
||||
if (next.q !== undefined) {
|
||||
if (next.q === "") {
|
||||
p.delete("q");
|
||||
} else {
|
||||
p.set("q", next.q);
|
||||
}
|
||||
p.set("page", "1");
|
||||
}
|
||||
|
||||
// page 변경
|
||||
if (next.page !== undefined) {
|
||||
p.set("page", String(next.page));
|
||||
}
|
||||
|
||||
// size 변경 시 page=1 리셋
|
||||
if (next.size !== undefined) {
|
||||
p.set("size", String(next.size));
|
||||
p.set("page", "1");
|
||||
}
|
||||
|
||||
// sort 변경
|
||||
if (next.sort !== undefined) {
|
||||
if (next.sort === "" || next.sort == null) {
|
||||
p.delete("sort");
|
||||
} else {
|
||||
p.set("sort", next.sort);
|
||||
}
|
||||
}
|
||||
|
||||
// filters 변경 시 page=1 리셋
|
||||
if (next.filters !== undefined) {
|
||||
// 기존 f_* 파라미터 제거
|
||||
const keysToDelete = [...p.keys()].filter((k) => k.startsWith("f_"));
|
||||
keysToDelete.forEach((k) => p.delete(k));
|
||||
|
||||
// 새로운 filters 설정
|
||||
Object.entries(next.filters).forEach(([k, v]) => {
|
||||
if (v == null || v === "") {
|
||||
p.delete(k);
|
||||
} else {
|
||||
p.set(k, String(v));
|
||||
}
|
||||
});
|
||||
|
||||
// filters 변경 시 page=1 리셋
|
||||
if (Object.keys(next.filters).length > 0) {
|
||||
p.set("page", "1");
|
||||
}
|
||||
}
|
||||
|
||||
setParams(p);
|
||||
};
|
||||
|
||||
return { state, update };
|
||||
}
|
||||
23
src/common/query/QueryProvider.tsx
Normal file
23
src/common/query/QueryProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
/**
|
||||
* TanStack Query Provider 래퍼
|
||||
*
|
||||
* 옵션으로 사용 가능하도록 래핑
|
||||
* 환경 변수 VITE_USE_REACT_QUERY로 제어
|
||||
*/
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||
|
||||
if (!useReactQuery) {
|
||||
// TanStack Query 비활성화 시 그대로 children 반환
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
48
src/common/query/hooks/useDocuments.ts
Normal file
48
src/common/query/hooks/useDocuments.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../keys";
|
||||
import { http } from "../../api/http";
|
||||
import { API } from "../../api/endpoints";
|
||||
|
||||
/**
|
||||
* 문서 목록 Query Hook
|
||||
*
|
||||
* 주의:
|
||||
* - TanStack Query가 비활성화된 경우 사용 불가
|
||||
* - 환경 변수 VITE_USE_REACT_QUERY=true 필요
|
||||
*/
|
||||
export interface DocumentRow {
|
||||
id: string | number;
|
||||
title: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
items: DocumentRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function useDocuments(params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) {
|
||||
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||
|
||||
if (!useReactQuery) {
|
||||
throw new Error(
|
||||
"TanStack Query is not enabled. Set VITE_USE_REACT_QUERY=true in .env"
|
||||
);
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.documents(params),
|
||||
queryFn: async () => {
|
||||
const res = await http.get(API.DOCUMENT.LIST, { params });
|
||||
// ApiResponse 구조에 맞게 data.data 접근
|
||||
return (res.data?.data ?? { items: [], total: 0 }) as DocumentListResponse;
|
||||
},
|
||||
keepPreviousData: true,
|
||||
});
|
||||
}
|
||||
33
src/common/query/keys.ts
Normal file
33
src/common/query/keys.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* QueryKey 팩토리
|
||||
*
|
||||
* 원칙:
|
||||
* - queryKey의 shape는 항상 동일해야 함
|
||||
* - params는 URL state를 그대로 사용하되 "정규화된 값"만 포함
|
||||
* - filters는 useFilterQuery에서 만든 "빈값 제거/타입 통일"된 값을 사용
|
||||
*
|
||||
* 정리:
|
||||
* - 리스트: ["documents", {q,page,size,sort,filters}]
|
||||
* - 상세: ["document", id]
|
||||
*/
|
||||
export const qk = {
|
||||
documents: (params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) => ["documents", params] as const,
|
||||
|
||||
document: (id: string | number) => ["document", id] as const,
|
||||
|
||||
users: (params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) => ["users", params] as const,
|
||||
|
||||
user: (id: string | number) => ["user", id] as const,
|
||||
};
|
||||
28
src/common/query/queryClient.ts
Normal file
28
src/common/query/queryClient.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* TanStack Query Client 설정
|
||||
*
|
||||
* 정책:
|
||||
* - staleTime 30초: 뒤로가기 UX에 유리 (짧은 시간은 캐시를 신선하게 취급)
|
||||
* - retry 1: 네트워크 튐 보완
|
||||
* - refetchOnWindowFocus false: 업무 시스템에서 포커스만으로 리패치 반복 방지
|
||||
*
|
||||
* 전제:
|
||||
* - 검색/필터/페이지/정렬 상태는 URL이 단일 진실
|
||||
* - TanStack Query는 URL state를 입력으로 받아 fetch
|
||||
* - 전역 로딩(Overlay)은 axios interceptor가 처리
|
||||
* - Query의 loading은 TableSkeleton 같은 "영역 UX"에만 사용
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
13
src/common/types/api.ts
Normal file
13
src/common/types/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* API 응답 타입 정의
|
||||
*
|
||||
* 백엔드 ApiResponse<T> 구조와 일치
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
29
src/common/types/error.ts
Normal file
29
src/common/types/error.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 공통 에러 타입
|
||||
*
|
||||
* 설계 목적:
|
||||
* - AxiosError를 공통 구조(AppError)로 매핑
|
||||
* - 사용자에게는 안전한 메시지만 표시
|
||||
* - 필요 시 토스트로 즉시 피드백
|
||||
* - 로깅은 민감정보를 완전히 제거한 형태로만
|
||||
*
|
||||
* 규정:
|
||||
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||
* - 서버 message는 내부 정보(SQL, stack trace, 경로 등)를 포함할 수 있음
|
||||
* - 프론트는 상태코드/코드 기반으로 사용자 메시지를 통제
|
||||
*/
|
||||
export type AppError = {
|
||||
kind:
|
||||
| "network"
|
||||
| "timeout"
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "notfound"
|
||||
| "validation"
|
||||
| "server"
|
||||
| "unknown";
|
||||
status?: number;
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
115
src/common/utils/errorMapper.ts
Normal file
115
src/common/utils/errorMapper.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AxiosError } from "axios";
|
||||
import type { AppError } from "../types/error";
|
||||
|
||||
/**
|
||||
* AxiosError → AppError 매핑
|
||||
*
|
||||
* 규정:
|
||||
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||
* - 상태코드/코드 기반으로 사용자 메시지 통제
|
||||
* - 에러 종류를 최소 공통 분류로 통일
|
||||
*/
|
||||
function isTimeout(err: AxiosError): boolean {
|
||||
return (
|
||||
err.code === "ECONNABORTED" ||
|
||||
(typeof err.message === "string" &&
|
||||
err.message.toLowerCase().includes("timeout"))
|
||||
);
|
||||
}
|
||||
|
||||
export function mapAxiosError(error: unknown): AppError {
|
||||
const ax = error as AxiosError<any>;
|
||||
|
||||
// 응답이 없는 경우 (네트워크 오류, 타임아웃)
|
||||
if (!ax.response) {
|
||||
if (isTimeout(ax)) {
|
||||
return {
|
||||
kind: "timeout",
|
||||
message: "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "network",
|
||||
message: "네트워크 오류가 발생했습니다. 연결 상태를 확인해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
const status = ax.response.status;
|
||||
const data = ax.response.data;
|
||||
|
||||
// 서버 메시지 추출 (안전하게)
|
||||
const serverMessage: string | undefined =
|
||||
(typeof data?.error?.message === "string" && data.error.message) ||
|
||||
(typeof data?.message === "string" && data.message) ||
|
||||
(typeof data?.error === "string" && data.error) ||
|
||||
undefined;
|
||||
|
||||
// 상태코드별 에러 분류
|
||||
if (status === 401) {
|
||||
return {
|
||||
kind: "unauthorized",
|
||||
status,
|
||||
message: serverMessage ?? "로그인이 필요합니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
kind: "forbidden",
|
||||
status,
|
||||
message: serverMessage ?? "권한이 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
kind: "notfound",
|
||||
status,
|
||||
message: serverMessage ?? "요청한 리소스를 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
return {
|
||||
kind: "validation",
|
||||
status,
|
||||
message: serverMessage ?? "충돌이 발생했습니다. 다시 시도해주세요.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
kind: "server",
|
||||
status,
|
||||
message: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4xx 클라이언트 오류
|
||||
if (status >= 400 && status < 500) {
|
||||
return {
|
||||
kind: "validation",
|
||||
status,
|
||||
message: serverMessage ?? "요청 값이 올바르지 않습니다.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
// 5xx 서버 오류
|
||||
if (status >= 500) {
|
||||
return {
|
||||
kind: "server",
|
||||
status,
|
||||
message: serverMessage ?? "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 알 수 없는 오류
|
||||
return {
|
||||
kind: "unknown",
|
||||
status,
|
||||
message: serverMessage ?? "알 수 없는 오류가 발생했습니다.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user