@farmzone/fz-template-react 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.js +10 -4
- package/package.json +1 -1
- package/template/.env.example +5 -0
- package/template/eslint.config.js +4 -1
- package/template/index.css +15 -2
- package/template/index.html +1 -1
- package/template/package.json +55 -41
- package/template/public/favicon.ico +0 -0
- package/template/public/mockServiceWorker.js +349 -0
- package/template/src/app/App.tsx +2 -0
- package/template/src/app/api/api.ts +178 -0
- package/template/src/app/api/queries.ts +321 -0
- package/template/src/app/api/queryKey.ts +7 -0
- package/template/src/app/api/token.ts +7 -0
- package/template/src/app/layout/Layout.tsx +33 -16
- package/template/src/app/layout/ListContents.tsx +9 -0
- package/template/src/app/layout/ListHeader.tsx +41 -0
- package/template/src/app/layout/MultiTabNav.tsx +101 -0
- package/template/src/app/layout/Sidebar.tsx +33 -53
- package/template/src/app/layout/UserInfo.tsx +94 -0
- package/template/src/app/layout/menu.ts +46 -21
- package/template/src/app/layout/tabSwitchStore.ts +11 -0
- package/template/src/app/router/Router.tsx +54 -28
- package/template/src/app/store/index.ts +26 -0
- package/template/src/index.tsx +21 -12
- package/template/src/mocks/browser.ts +17 -0
- package/template/src/mocks/handlers.ts +43 -0
- package/template/src/mocks/scenarios.ts +57 -0
- package/template/src/pages/dashboard/index.tsx +541 -8
- package/template/src/pages/error/Error.tsx +29 -17
- package/template/src/pages/error/NotFound.tsx +27 -17
- package/template/src/pages/login/index.tsx +317 -0
- package/template/src/pages/post/PostFormModal.tsx +128 -0
- package/template/src/pages/post/detail/index.tsx +548 -0
- package/template/src/pages/post/index.tsx +267 -0
- package/template/src/pages/sample/SampleFormModal.tsx +77 -0
- package/template/src/pages/sample/detail/index.tsx +424 -0
- package/template/src/pages/sample/index.tsx +269 -0
- package/template/src/pages/system/log/index.tsx +173 -0
- package/template/src/pages/user/config/columns.tsx +109 -0
- package/template/src/pages/user/config/schema.ts +54 -0
- package/template/src/pages/user/index.tsx +641 -0
- package/template/src/shared/components/CommentInput.tsx +243 -0
- package/template/src/shared/components/FilePreviewCard.tsx +70 -0
- package/template/src/shared/config/text.ts +27 -0
- package/template/src/shared/config/type.ts +40 -0
- package/template/src/shared/utils/format.ts +11 -0
- package/template/src/types/auth.ts +10 -0
- package/template/src/types/comment.ts +33 -0
- package/template/src/types/common.ts +19 -0
- package/template/src/types/dashboard.ts +53 -0
- package/template/src/types/index.ts +16 -0
- package/template/src/types/log.ts +21 -0
- package/template/src/types/post.ts +32 -0
- package/template/src/types/sample.ts +28 -0
- package/template/src/types/user.ts +51 -0
- package/template/src/vite-env.d.ts +10 -0
- package/template/gitignore +0 -32
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import utc from "dayjs/plugin/utc";
|
|
4
|
+
import Cookies from "js-cookie";
|
|
5
|
+
import { v4 as uuidv4 } from "uuid";
|
|
6
|
+
|
|
7
|
+
import { clearUserToken } from "@/app/api/token";
|
|
8
|
+
import { COMMON_API_MSG_TO_KR, SERVER_ERROR_MESSAGES } from "@/shared/config/text";
|
|
9
|
+
import { toast } from "@farmzone/fz-react-ui";
|
|
10
|
+
|
|
11
|
+
dayjs.extend(utc);
|
|
12
|
+
|
|
13
|
+
declare module "axios" {
|
|
14
|
+
interface InternalAxiosRequestConfig {
|
|
15
|
+
_retried?: boolean;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RETRYABLE_STATUSES = new Set([408, 503]);
|
|
20
|
+
const RETRY_DELAY_MS = 1_000;
|
|
21
|
+
|
|
22
|
+
export const isRetryable = (error: AxiosError): boolean => {
|
|
23
|
+
if (!error.response) return true;
|
|
24
|
+
return RETRYABLE_STATUSES.has(error.response.status);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface RefreshTokenResponse {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
refreshToken: string;
|
|
30
|
+
accessTokenExpiresAt: string;
|
|
31
|
+
refreshTokenExpiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const basePath = `${import.meta.env.VITE_APP_API_HOST}/api/${import.meta.env.VITE_APP_API_VERSION}`;
|
|
35
|
+
|
|
36
|
+
export const apiInstance = axios.create({
|
|
37
|
+
baseURL: `${import.meta.env.VITE_APP_API_HOST}/api/${import.meta.env.VITE_APP_API_VERSION}`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const refreshTokenInstance = axios.create({
|
|
41
|
+
baseURL: `${import.meta.env.VITE_APP_API_HOST}/api/${import.meta.env.VITE_APP_API_VERSION}`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const apiFormDataInstance = axios.create({
|
|
45
|
+
headers: {
|
|
46
|
+
Accept: "application/json",
|
|
47
|
+
"Content-Type": "multipart/form-data",
|
|
48
|
+
},
|
|
49
|
+
transformRequest: (formData) => formData,
|
|
50
|
+
baseURL: `${import.meta.env.VITE_APP_API_HOST}/api/${import.meta.env.VITE_APP_API_VERSION}`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const apiFileInstance = axios.create({
|
|
54
|
+
baseURL: `${import.meta.env.VITE_APP_API_HOST}`,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- Request interceptors ---
|
|
58
|
+
|
|
59
|
+
const attachAccessToken = (config: InternalAxiosRequestConfig) => {
|
|
60
|
+
const token = Cookies.get("AccessToken");
|
|
61
|
+
if (token) {
|
|
62
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const attachTraceId = (config: InternalAxiosRequestConfig) => {
|
|
68
|
+
config.headers["X-Trace-Id"] = uuidv4();
|
|
69
|
+
return config;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
apiInstance.interceptors.request.use(attachAccessToken);
|
|
73
|
+
apiInstance.interceptors.request.use(attachTraceId);
|
|
74
|
+
apiFormDataInstance.interceptors.request.use(attachAccessToken);
|
|
75
|
+
apiFormDataInstance.interceptors.request.use(attachTraceId);
|
|
76
|
+
apiFileInstance.interceptors.request.use(attachAccessToken);
|
|
77
|
+
apiFileInstance.interceptors.request.use(attachTraceId);
|
|
78
|
+
refreshTokenInstance.interceptors.request.use(attachTraceId);
|
|
79
|
+
|
|
80
|
+
refreshTokenInstance.interceptors.request.use((config) => {
|
|
81
|
+
const token = Cookies.get("RefreshToken");
|
|
82
|
+
|
|
83
|
+
if (!token) {
|
|
84
|
+
clearUserToken();
|
|
85
|
+
window.location.href = "/login";
|
|
86
|
+
return Promise.reject(new Error("No refresh token"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
90
|
+
return config;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// --- 401 token refresh ---
|
|
94
|
+
|
|
95
|
+
let refreshPromise: Promise<RefreshTokenResponse> | null = null;
|
|
96
|
+
|
|
97
|
+
const handleTokenRefresh = async (instance: AxiosInstance, error: AxiosError) => {
|
|
98
|
+
if (refreshPromise) {
|
|
99
|
+
await refreshPromise;
|
|
100
|
+
return instance(error.config!);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
refreshPromise = refreshTokenInstance
|
|
104
|
+
.post<RefreshTokenResponse>("/auth/refresh")
|
|
105
|
+
.then((res) => {
|
|
106
|
+
const accessExpiry = dayjs.utc(res.data.accessTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
|
|
107
|
+
const refreshExpiry = dayjs.utc(res.data.refreshTokenExpiresAt, "YYYY-MM-DD HH:mm:ss");
|
|
108
|
+
|
|
109
|
+
Cookies.set("AccessToken", res.data.accessToken, { expires: accessExpiry.toDate() });
|
|
110
|
+
Cookies.set("RefreshToken", res.data.refreshToken, { expires: refreshExpiry.toDate() });
|
|
111
|
+
|
|
112
|
+
return res.data;
|
|
113
|
+
})
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
console.error("ERROR:: refresh token 갱신 실패", err);
|
|
116
|
+
clearUserToken();
|
|
117
|
+
window.location.href = "/login";
|
|
118
|
+
throw err;
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
refreshPromise = null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await refreshPromise;
|
|
125
|
+
return instance(error.config!);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// --- Response error handler ---
|
|
129
|
+
|
|
130
|
+
const createErrorHandler = (instance: AxiosInstance) => async (err: AxiosError) => {
|
|
131
|
+
const code = (err.response?.data as { code?: string } | undefined)?.code;
|
|
132
|
+
const status = err.response?.status;
|
|
133
|
+
const rawMessage = (err.response?.data as { message?: string } | undefined)?.message ?? err?.message;
|
|
134
|
+
const message = (rawMessage && COMMON_API_MSG_TO_KR[rawMessage]) ?? rawMessage;
|
|
135
|
+
|
|
136
|
+
const isAuthEndpoint = err.config?.url?.includes("/auth/login");
|
|
137
|
+
if (status === 401 && code !== "INVALID_LOGIN_CREDENTIAL" && !isAuthEndpoint) {
|
|
138
|
+
return handleTokenRefresh(instance, err);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isRetryable(err) && !err.config?._retried) {
|
|
142
|
+
err.config!._retried = true;
|
|
143
|
+
await new Promise<void>((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
144
|
+
return instance(err.config!);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isRetryable(err)) {
|
|
148
|
+
toast.error(
|
|
149
|
+
status === 408
|
|
150
|
+
? (message ?? SERVER_ERROR_MESSAGES.SERVER_ERROR_NETWORK.replace("\n", " "))
|
|
151
|
+
: SERVER_ERROR_MESSAGES.SERVER_ERROR_NETWORK.replace("\n", " "),
|
|
152
|
+
{ id: "server-error" },
|
|
153
|
+
);
|
|
154
|
+
} else if (status === 500) {
|
|
155
|
+
toast.error(SERVER_ERROR_MESSAGES.SERVER_ERROR_TEMP.replace("\n", " "), {
|
|
156
|
+
id: "server-error",
|
|
157
|
+
});
|
|
158
|
+
} else if (status === 400) {
|
|
159
|
+
toast.error(message ?? SERVER_ERROR_MESSAGES.SERVER_ERROR_INVALID, { id: "server-error" });
|
|
160
|
+
} else if (
|
|
161
|
+
(status === 401 && code === "INVALID_LOGIN_CREDENTIAL") ||
|
|
162
|
+
(status === 403 && code === "ACCOUNT_DISABLED")
|
|
163
|
+
) {
|
|
164
|
+
toast.error(message ?? SERVER_ERROR_MESSAGES.SERVER_ERROR_INVALID, { id: "server-error" });
|
|
165
|
+
} else if (status === 403 || status === 404) {
|
|
166
|
+
// window.location.href = "/error";
|
|
167
|
+
} else if (status === 409 || status === 422) {
|
|
168
|
+
toast.error(message ?? SERVER_ERROR_MESSAGES.SERVER_ERROR_INVALID, { id: "server-error" });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Promise.reject(err);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// --- Response interceptors ---
|
|
175
|
+
|
|
176
|
+
apiInstance.interceptors.response.use((res) => res, createErrorHandler(apiInstance));
|
|
177
|
+
apiFormDataInstance.interceptors.response.use((res) => res, createErrorHandler(apiFormDataInstance));
|
|
178
|
+
apiFileInstance.interceptors.response.use((res) => res, createErrorHandler(apiFileInstance));
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { toast } from "@farmzone/fz-react-ui";
|
|
3
|
+
|
|
4
|
+
import { apiInstance, apiFormDataInstance } from "@/app/api/api";
|
|
5
|
+
import {
|
|
6
|
+
COMMENT_QUERY_KEY,
|
|
7
|
+
DASHBOARD_QUERY_KEY,
|
|
8
|
+
LOG_QUERY_KEY,
|
|
9
|
+
POST_QUERY_KEY,
|
|
10
|
+
SAMPLE_QUERY_KEY,
|
|
11
|
+
USER_QUERY_KEY,
|
|
12
|
+
} from "@/app/api/queryKey";
|
|
13
|
+
import { COMMON_MESSAGES } from "@/shared/config/text";
|
|
14
|
+
import type {
|
|
15
|
+
LoginResponse,
|
|
16
|
+
PageResponse,
|
|
17
|
+
Sample,
|
|
18
|
+
GetSamplesParams,
|
|
19
|
+
SampleForm,
|
|
20
|
+
Post,
|
|
21
|
+
GetPostsParams,
|
|
22
|
+
PostForm,
|
|
23
|
+
Comment,
|
|
24
|
+
CommentForm,
|
|
25
|
+
CommentTargetType,
|
|
26
|
+
CommentEditForm,
|
|
27
|
+
User,
|
|
28
|
+
GetUsersParams,
|
|
29
|
+
UserForm,
|
|
30
|
+
UserEditForm,
|
|
31
|
+
ActionLog,
|
|
32
|
+
GetLogsParams,
|
|
33
|
+
UserDashboardResponse,
|
|
34
|
+
} from "@/types";
|
|
35
|
+
|
|
36
|
+
// --- Auth ---
|
|
37
|
+
|
|
38
|
+
export const usePostLogin = () => {
|
|
39
|
+
return useMutation({
|
|
40
|
+
mutationFn: (params: { userId: string; password: string }) =>
|
|
41
|
+
apiInstance.post<LoginResponse>("/auth/login", params).then((res) => res.data),
|
|
42
|
+
onSuccess: () => {
|
|
43
|
+
toast.success("로그인 성공", { duration: 1500 });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// --- Sample ---
|
|
49
|
+
|
|
50
|
+
export const useGetSamples = (params: GetSamplesParams) => {
|
|
51
|
+
return useQuery({
|
|
52
|
+
queryKey: [SAMPLE_QUERY_KEY, "list", params],
|
|
53
|
+
queryFn: () => apiInstance.get<PageResponse<Sample>>("/samples", { params }).then((r) => r.data),
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const useGetSample = (id: number | null) => {
|
|
58
|
+
return useQuery({
|
|
59
|
+
queryKey: [SAMPLE_QUERY_KEY, "detail", id],
|
|
60
|
+
queryFn: () => apiInstance.get<Sample>(`/samples/${id}`).then((r) => r.data),
|
|
61
|
+
enabled: id !== null,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const usePostSample = () => {
|
|
66
|
+
const queryClient = useQueryClient();
|
|
67
|
+
return useMutation({
|
|
68
|
+
mutationFn: (data: SampleForm) => apiInstance.post<Sample>("/samples", data).then((r) => r.data),
|
|
69
|
+
onSuccess: () => {
|
|
70
|
+
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
71
|
+
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const usePutSample = () => {
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
return useMutation({
|
|
79
|
+
mutationFn: ({ id, data }: { id: number; data: SampleForm }) =>
|
|
80
|
+
apiInstance.put<Sample>(`/samples/${id}`, data).then((r) => r.data),
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
83
|
+
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const useDeleteSample = () => {
|
|
89
|
+
const queryClient = useQueryClient();
|
|
90
|
+
return useMutation({
|
|
91
|
+
mutationFn: (id: number) => apiInstance.delete(`/samples/${id}`),
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
94
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const useDeleteSamples = () => {
|
|
100
|
+
const queryClient = useQueryClient();
|
|
101
|
+
return useMutation({
|
|
102
|
+
mutationFn: (ids: Array<number>) =>
|
|
103
|
+
apiInstance.delete("/samples", {
|
|
104
|
+
params: { ids },
|
|
105
|
+
paramsSerializer: { indexes: null },
|
|
106
|
+
}),
|
|
107
|
+
onSuccess: () => {
|
|
108
|
+
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
109
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// --- Post ---
|
|
115
|
+
|
|
116
|
+
export const useGetPosts = (params: GetPostsParams) => {
|
|
117
|
+
return useQuery({
|
|
118
|
+
queryKey: [POST_QUERY_KEY, "list", params],
|
|
119
|
+
queryFn: () => apiInstance.get<PageResponse<Post>>("/posts", { params }).then((r) => r.data),
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const useGetPost = (id: number | null) => {
|
|
124
|
+
return useQuery({
|
|
125
|
+
queryKey: [POST_QUERY_KEY, "detail", id],
|
|
126
|
+
queryFn: () => apiInstance.get<Post>(`/posts/${id}`).then((r) => r.data),
|
|
127
|
+
enabled: id !== null,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const usePostPost = () => {
|
|
132
|
+
const queryClient = useQueryClient();
|
|
133
|
+
return useMutation({
|
|
134
|
+
mutationFn: (data: PostForm) => apiInstance.post<Post>("/posts", data).then((r) => r.data),
|
|
135
|
+
onSuccess: () => {
|
|
136
|
+
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
137
|
+
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const usePutPost = () => {
|
|
143
|
+
const queryClient = useQueryClient();
|
|
144
|
+
return useMutation({
|
|
145
|
+
mutationFn: ({ id, data }: { id: number; data: PostForm }) =>
|
|
146
|
+
apiInstance.put<Post>(`/posts/${id}`, data).then((r) => r.data),
|
|
147
|
+
onSuccess: () => {
|
|
148
|
+
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
149
|
+
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const useDeletePost = () => {
|
|
155
|
+
const queryClient = useQueryClient();
|
|
156
|
+
return useMutation({
|
|
157
|
+
mutationFn: (id: number) => apiInstance.delete(`/posts/${id}`),
|
|
158
|
+
onSuccess: () => {
|
|
159
|
+
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
160
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const useDeletePosts = () => {
|
|
166
|
+
const queryClient = useQueryClient();
|
|
167
|
+
return useMutation({
|
|
168
|
+
mutationFn: (ids: Array<number>) =>
|
|
169
|
+
apiInstance.delete("/posts", {
|
|
170
|
+
params: { ids },
|
|
171
|
+
paramsSerializer: { indexes: null },
|
|
172
|
+
}),
|
|
173
|
+
onSuccess: () => {
|
|
174
|
+
void queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY] });
|
|
175
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// --- Comment ---
|
|
181
|
+
|
|
182
|
+
export const useGetComments = (targetType: CommentTargetType, targetId: number) => {
|
|
183
|
+
return useQuery({
|
|
184
|
+
queryKey: [COMMENT_QUERY_KEY, targetType, targetId],
|
|
185
|
+
queryFn: () =>
|
|
186
|
+
apiInstance.get<Array<Comment>>("/comments", { params: { targetType, targetId } }).then((r) => r.data),
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const usePostComment = () => {
|
|
191
|
+
const queryClient = useQueryClient();
|
|
192
|
+
return useMutation({
|
|
193
|
+
mutationFn: (data: CommentForm) => apiInstance.post<Comment>("/comments", data).then((r) => r.data),
|
|
194
|
+
onSuccess: () => {
|
|
195
|
+
void queryClient.invalidateQueries({ queryKey: [COMMENT_QUERY_KEY] });
|
|
196
|
+
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const usePutComment = () => {
|
|
202
|
+
const queryClient = useQueryClient();
|
|
203
|
+
return useMutation({
|
|
204
|
+
mutationFn: ({ commentId, data }: { commentId: number; data: CommentEditForm }) =>
|
|
205
|
+
apiInstance.put<Comment>(`/comments/${commentId}`, data).then((r) => r.data),
|
|
206
|
+
onSuccess: () => {
|
|
207
|
+
void queryClient.invalidateQueries({ queryKey: [COMMENT_QUERY_KEY] });
|
|
208
|
+
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const useDeleteComment = () => {
|
|
214
|
+
const queryClient = useQueryClient();
|
|
215
|
+
return useMutation({
|
|
216
|
+
mutationFn: (commentId: number) => apiInstance.delete(`/comments/${commentId}`),
|
|
217
|
+
onSuccess: () => {
|
|
218
|
+
void queryClient.invalidateQueries({ queryKey: [COMMENT_QUERY_KEY] });
|
|
219
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// --- User ---
|
|
225
|
+
|
|
226
|
+
export const checkUserIdAvailable = (userId: string): Promise<boolean> =>
|
|
227
|
+
apiInstance.get<boolean>("/users/check/id", { params: { userId } }).then((r) => r.data);
|
|
228
|
+
|
|
229
|
+
export const useGetUsers = (params: GetUsersParams) => {
|
|
230
|
+
return useQuery({
|
|
231
|
+
queryKey: [USER_QUERY_KEY, "list", params],
|
|
232
|
+
queryFn: () => apiInstance.get<PageResponse<User>>("/users", { params }).then((r) => r.data),
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const useGetUser = (id: number | null) => {
|
|
237
|
+
return useQuery({
|
|
238
|
+
queryKey: [USER_QUERY_KEY, "detail", id],
|
|
239
|
+
queryFn: () => apiInstance.get<User>(`/users/${id}`).then((r) => r.data),
|
|
240
|
+
enabled: id !== null,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const usePostUser = () => {
|
|
245
|
+
const queryClient = useQueryClient();
|
|
246
|
+
return useMutation({
|
|
247
|
+
mutationFn: async ({ data, file }: { data: UserForm; file?: File }) => {
|
|
248
|
+
const formData = new FormData();
|
|
249
|
+
formData.append("request", JSON.stringify(data));
|
|
250
|
+
if (file) formData.append("files", file);
|
|
251
|
+
return (await apiFormDataInstance.post<User>("/users", formData)).data;
|
|
252
|
+
},
|
|
253
|
+
onSuccess: () => {
|
|
254
|
+
void queryClient.invalidateQueries({ queryKey: [USER_QUERY_KEY] });
|
|
255
|
+
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const usePutUser = () => {
|
|
261
|
+
const queryClient = useQueryClient();
|
|
262
|
+
return useMutation({
|
|
263
|
+
mutationFn: async ({ id, data, file }: { id: number; data: UserEditForm; file?: File }) => {
|
|
264
|
+
const formData = new FormData();
|
|
265
|
+
formData.append("request", JSON.stringify(data));
|
|
266
|
+
if (file) formData.append("files", file);
|
|
267
|
+
return (await apiFormDataInstance.put<User>(`/users/${id}`, formData)).data;
|
|
268
|
+
},
|
|
269
|
+
onSuccess: () => {
|
|
270
|
+
void queryClient.invalidateQueries({ queryKey: [USER_QUERY_KEY] });
|
|
271
|
+
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const useDeleteUser = () => {
|
|
277
|
+
const queryClient = useQueryClient();
|
|
278
|
+
return useMutation({
|
|
279
|
+
mutationFn: (id: number) => apiInstance.delete(`/users/${id}`),
|
|
280
|
+
onSuccess: () => {
|
|
281
|
+
void queryClient.invalidateQueries({ queryKey: [USER_QUERY_KEY] });
|
|
282
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export const useDeleteUsers = () => {
|
|
288
|
+
const queryClient = useQueryClient();
|
|
289
|
+
return useMutation({
|
|
290
|
+
mutationFn: (ids: Array<number>) =>
|
|
291
|
+
apiInstance.delete("/users", {
|
|
292
|
+
params: { ids },
|
|
293
|
+
paramsSerializer: { indexes: null },
|
|
294
|
+
}),
|
|
295
|
+
onSuccess: () => {
|
|
296
|
+
void queryClient.invalidateQueries({ queryKey: [USER_QUERY_KEY] });
|
|
297
|
+
toast.success(COMMON_MESSAGES.DELETE_SUCCESS);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// --- Action Log ---
|
|
303
|
+
|
|
304
|
+
export const useGetLogs = (params: GetLogsParams) => {
|
|
305
|
+
return useQuery({
|
|
306
|
+
queryKey: [LOG_QUERY_KEY, "list", params],
|
|
307
|
+
queryFn: () => apiInstance.get<PageResponse<ActionLog>>("/action-logs", { params }).then((r) => r.data),
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// --- Dashboard ---
|
|
312
|
+
|
|
313
|
+
export const useGetUserDashboard = (period = "30d") => {
|
|
314
|
+
return useQuery({
|
|
315
|
+
queryKey: [DASHBOARD_QUERY_KEY, period],
|
|
316
|
+
queryFn: () =>
|
|
317
|
+
apiInstance
|
|
318
|
+
.get<UserDashboardResponse>("/users/dashboard", { params: { period } })
|
|
319
|
+
.then((r) => r.data),
|
|
320
|
+
});
|
|
321
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const AUTH_QUERY_KEY = "auth";
|
|
2
|
+
export const SAMPLE_QUERY_KEY = "sample";
|
|
3
|
+
export const POST_QUERY_KEY = "post";
|
|
4
|
+
export const COMMENT_QUERY_KEY = "comment";
|
|
5
|
+
export const USER_QUERY_KEY = "user";
|
|
6
|
+
export const LOG_QUERY_KEY = "log";
|
|
7
|
+
export const DASHBOARD_QUERY_KEY = "dashboard";
|
|
@@ -1,16 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
)
|
|
16
|
-
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Outlet, useLocation } from "react-router";
|
|
3
|
+
import { toast } from "@farmzone/fz-react-ui";
|
|
4
|
+
|
|
5
|
+
import { useTabSwitchStore } from "./tabSwitchStore";
|
|
6
|
+
import MultiTabNav from "./MultiTabNav";
|
|
7
|
+
import Sidebar from "./Sidebar";
|
|
8
|
+
|
|
9
|
+
export default function Layout() {
|
|
10
|
+
const { pathname } = useLocation();
|
|
11
|
+
const prevPathnameRef = useRef(pathname);
|
|
12
|
+
const { tabSwitchKey } = useTabSwitchStore();
|
|
13
|
+
|
|
14
|
+
// 페이지 전환 시 success toast 외 모두 제거
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const prev = prevPathnameRef.current;
|
|
17
|
+
prevPathnameRef.current = pathname;
|
|
18
|
+
if (prev === pathname) return;
|
|
19
|
+
toast.dismissAllExcept("success");
|
|
20
|
+
}, [pathname]);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex h-screen bg-gray-50">
|
|
24
|
+
<Sidebar />
|
|
25
|
+
<div className="flex flex-1 flex-col overflow-hidden">
|
|
26
|
+
<MultiTabNav />
|
|
27
|
+
<main className="flex-1 overflow-y-auto">
|
|
28
|
+
<Outlet key={tabSwitchKey} />
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useLocation } from "react-router";
|
|
3
|
+
|
|
4
|
+
import { MENU_SECTIONS } from "./menu";
|
|
5
|
+
|
|
6
|
+
function findPathText(pathname: string): string {
|
|
7
|
+
for (const section of MENU_SECTIONS) {
|
|
8
|
+
for (const item of section.items) {
|
|
9
|
+
if (item.children?.length) {
|
|
10
|
+
for (const child of item.children) {
|
|
11
|
+
if (child.path && (pathname === child.path || pathname.startsWith(`${child.path}/`))) {
|
|
12
|
+
return `${item.label} > ${child.label}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
} else if (item.path && (pathname === item.path || pathname.startsWith(`${item.path}/`))) {
|
|
16
|
+
return section.title ? `${section.title} > ${item.label}` : item.label;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ListHeaderProps {
|
|
24
|
+
title: string;
|
|
25
|
+
rightArea?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function ListHeader({ title, rightArea }: ListHeaderProps) {
|
|
29
|
+
const { pathname } = useLocation();
|
|
30
|
+
const pathText = findPathText(pathname);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex min-h-8 items-end justify-between">
|
|
34
|
+
<div className="flex flex-col gap-0.5">
|
|
35
|
+
{pathText && <span className="text-xs text-gray-400">{pathText}</span>}
|
|
36
|
+
<h2 className="text-2xl font-bold text-gray-900">{title}</h2>
|
|
37
|
+
</div>
|
|
38
|
+
{rightArea && <div className="flex items-center">{rightArea}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|