@farmzone/fz-template-react 1.0.4 → 1.0.6
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/README.md +46 -50
- package/package.json +1 -1
- package/template/package.json +2 -3
- package/template/pnpm-lock.yaml +5 -5
- package/template/src/app/api/queries.ts +17 -3
- package/template/src/pages/post/detail/index.tsx +12 -15
- package/template/src/pages/post/index.tsx +0 -1
- package/template/src/pages/sample/SampleFormModal.tsx +125 -14
- package/template/src/pages/sample/detail/index.tsx +142 -49
- package/template/src/pages/sample/index.tsx +51 -22
- package/template/src/pages/sample/modal/index.tsx +56 -1
- package/template/src/pages/user/config/columns.tsx +2 -9
- package/template/src/pages/user/index.tsx +18 -9
- package/template/src/shared/components/FilePreviewCard.tsx +5 -4
- package/template/src/types/sample.ts +5 -0
package/README.md
CHANGED
|
@@ -24,68 +24,58 @@ npx @farmzone/fz-template-react
|
|
|
24
24
|
# 프로젝트 이름을 입력하세요: my-app
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
## 생성되는 프로젝트 구조
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
my-app/
|
|
31
|
-
├── src/
|
|
32
|
-
│ ├── app/
|
|
33
|
-
│ │ ├── layout/
|
|
34
|
-
│ │ │ ├── Layout.tsx # 사이드바 + Outlet 레이아웃
|
|
35
|
-
│ │ │ ├── Sidebar.tsx # 사이드바 컴포넌트
|
|
36
|
-
│ │ │ └── menu.ts # 메뉴 설정
|
|
37
|
-
│ │ ├── router/
|
|
38
|
-
│ │ │ └── Router.tsx
|
|
39
|
-
│ │ └── App.tsx # QueryClientProvider 루트
|
|
40
|
-
│ ├── pages/
|
|
41
|
-
│ │ ├── dashboard/
|
|
42
|
-
│ │ │ └── index.tsx
|
|
43
|
-
│ │ └── error/
|
|
44
|
-
│ │ ├── Error.tsx
|
|
45
|
-
│ │ └── NotFound.tsx
|
|
46
|
-
│ └── index.tsx
|
|
47
|
-
├── .gitignore
|
|
48
|
-
├── index.html
|
|
49
|
-
├── index.css
|
|
50
|
-
├── vite.config.ts
|
|
51
|
-
├── tsconfig.json
|
|
52
|
-
└── package.json
|
|
53
|
-
```
|
|
54
|
-
|
|
55
27
|
## 포함된 기술 스택
|
|
56
28
|
|
|
57
|
-
| 항목 | 버전
|
|
58
|
-
| --------------------- |
|
|
59
|
-
| React | ^19
|
|
60
|
-
| Vite | ^7
|
|
61
|
-
| TypeScript | ~5.9
|
|
62
|
-
| Tailwind CSS |
|
|
63
|
-
| React Router | ^7
|
|
64
|
-
| TanStack Query | ^5
|
|
65
|
-
| @farmzone/fz-react-ui |
|
|
66
|
-
| react-hook-form
|
|
29
|
+
| 항목 | 버전 |
|
|
30
|
+
| --------------------- | ------- |
|
|
31
|
+
| React | ^19 |
|
|
32
|
+
| Vite | ^7 |
|
|
33
|
+
| TypeScript | ~5.9 |
|
|
34
|
+
| Tailwind CSS | ^4 |
|
|
35
|
+
| React Router | ^7 |
|
|
36
|
+
| TanStack Query | ^5 |
|
|
37
|
+
| @farmzone/fz-react-ui | ^0.0.6 |
|
|
38
|
+
| react-hook-form | ^7 |
|
|
39
|
+
| zod | ^3 |
|
|
40
|
+
| Zustand | ^5 |
|
|
41
|
+
| axios | ^1 |
|
|
42
|
+
| dayjs | ^1 |
|
|
43
|
+
| MSW | ^2 |
|
|
44
|
+
| lucide-react | ^1 |
|
|
67
45
|
|
|
68
46
|
## 메뉴 추가
|
|
69
47
|
|
|
70
|
-
`src/app/layout/menu.ts`
|
|
48
|
+
`src/app/layout/menu.ts`의 `MENU_SECTIONS` 배열에 추가:
|
|
71
49
|
|
|
72
50
|
```ts
|
|
73
|
-
import {
|
|
51
|
+
import type { MenuSection } from "@farmzone/fz-react-ui";
|
|
52
|
+
import { LayoutDashboard, Users, FileText, ScrollText } from "lucide-react";
|
|
74
53
|
|
|
75
|
-
export const MENU_SECTIONS: MenuSection
|
|
76
|
-
{
|
|
77
|
-
items: [{ icon: LayoutDashboard, label: "대시보드", path: "/" }],
|
|
78
|
-
},
|
|
54
|
+
export const MENU_SECTIONS: Array<MenuSection> = [
|
|
79
55
|
{
|
|
80
|
-
title: "관리",
|
|
81
56
|
items: [
|
|
82
|
-
{ icon:
|
|
83
|
-
{
|
|
57
|
+
{ icon: LayoutDashboard, label: "대시보드", path: "/" },
|
|
58
|
+
{
|
|
59
|
+
icon: FileText,
|
|
60
|
+
label: "게시글 관리",
|
|
61
|
+
children: [{ label: "게시글 관리", path: "/post" }],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
icon: Users,
|
|
65
|
+
label: "사용자 관리",
|
|
66
|
+
children: [{ label: "사용자 관리", path: "/user" }],
|
|
67
|
+
},
|
|
84
68
|
],
|
|
85
69
|
},
|
|
70
|
+
{
|
|
71
|
+
title: "시스템",
|
|
72
|
+
items: [{ icon: ScrollText, label: "로그 관리", path: "/system/log" }],
|
|
73
|
+
},
|
|
86
74
|
];
|
|
87
75
|
```
|
|
88
76
|
|
|
77
|
+
`children` 배열을 사용하면 아코디언 서브메뉴로 렌더링됩니다.
|
|
78
|
+
|
|
89
79
|
## 라우트 추가
|
|
90
80
|
|
|
91
81
|
`src/app/router/Router.tsx`의 `children` 배열에 추가:
|
|
@@ -93,12 +83,18 @@ export const MENU_SECTIONS: MenuSection[] = [
|
|
|
93
83
|
```tsx
|
|
94
84
|
children: [
|
|
95
85
|
{ index: true, element: <DashboardPage /> },
|
|
96
|
-
{ path: "
|
|
97
|
-
{ path: "
|
|
98
|
-
{ path: "
|
|
86
|
+
{ path: "sample", element: <SamplePage /> },
|
|
87
|
+
{ path: "sample/modal", element: <SampleModalPage /> },
|
|
88
|
+
{ path: "sample/:id", element: <SampleDetailPage /> },
|
|
89
|
+
{ path: "post", element: <PostPage /> },
|
|
90
|
+
{ path: "post/:id", element: <PostDetailPage /> },
|
|
91
|
+
{ path: "user", element: <UserPage /> },
|
|
92
|
+
{ path: "system/log", element: <LogPage /> },
|
|
99
93
|
],
|
|
100
94
|
```
|
|
101
95
|
|
|
96
|
+
`/login` 경로와 `authLoader`(토큰 미존재 시 `/login` 리다이렉트)는 Router.tsx에 이미 포함되어 있습니다.
|
|
97
|
+
|
|
102
98
|
## npm 배포
|
|
103
99
|
|
|
104
100
|
```bash
|
package/package.json
CHANGED
package/template/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__PROJECT_NAME__",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.4",
|
|
3
|
+
"version": "1.0.6",
|
|
5
4
|
"type": "module",
|
|
6
5
|
"scripts": {
|
|
7
6
|
"dev": "vite --host 0.0.0.0 --port 5000",
|
|
@@ -10,7 +9,7 @@
|
|
|
10
9
|
"preview": "vite preview"
|
|
11
10
|
},
|
|
12
11
|
"dependencies": {
|
|
13
|
-
"@farmzone/fz-react-ui": "^0.0.
|
|
12
|
+
"@farmzone/fz-react-ui": "^0.0.8",
|
|
14
13
|
"@hookform/resolvers": "^5.2.2",
|
|
15
14
|
"@tanstack/react-query": "^5.90.0",
|
|
16
15
|
"axios": "^1.13.0",
|
package/template/pnpm-lock.yaml
CHANGED
|
@@ -9,8 +9,8 @@ importers:
|
|
|
9
9
|
.:
|
|
10
10
|
dependencies:
|
|
11
11
|
'@farmzone/fz-react-ui':
|
|
12
|
-
specifier: ^0.0.
|
|
13
|
-
version: 0.0.
|
|
12
|
+
specifier: ^0.0.8
|
|
13
|
+
version: 0.0.8(@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-daum-postcode@4.0.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-hook-form@7.78.0(react@19.2.7))(react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(zod@3.25.76)
|
|
14
14
|
'@hookform/resolvers':
|
|
15
15
|
specifier: ^5.2.2
|
|
16
16
|
version: 5.4.0(react-hook-form@7.78.0(react@19.2.7))
|
|
@@ -391,8 +391,8 @@ packages:
|
|
|
391
391
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
|
392
392
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
|
393
393
|
|
|
394
|
-
'@farmzone/fz-react-ui@0.0.
|
|
395
|
-
resolution: {integrity: sha512-
|
|
394
|
+
'@farmzone/fz-react-ui@0.0.8':
|
|
395
|
+
resolution: {integrity: sha512-atqHgWVSy0sy15P30i1jN3BMtZJQbfyoMwUk1K5Xpgrk4xXtN8lC8AK+LMV8ZHfq0hV0EX+SHJGVpee0PLeHNQ==}
|
|
396
396
|
peerDependencies:
|
|
397
397
|
'@hookform/resolvers': ^5.2.2
|
|
398
398
|
react: '>=18'
|
|
@@ -2501,7 +2501,7 @@ snapshots:
|
|
|
2501
2501
|
'@eslint/core': 0.17.0
|
|
2502
2502
|
levn: 0.4.1
|
|
2503
2503
|
|
|
2504
|
-
'@farmzone/fz-react-ui@0.0.
|
|
2504
|
+
'@farmzone/fz-react-ui@0.0.8(@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.7)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-daum-postcode@4.0.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react-hook-form@7.78.0(react@19.2.7))(react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(zod@3.25.76)':
|
|
2505
2505
|
dependencies:
|
|
2506
2506
|
'@radix-ui/react-popover': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
2507
2507
|
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
@@ -65,7 +65,12 @@ export const useGetSample = (id: number | null) => {
|
|
|
65
65
|
export const usePostSample = () => {
|
|
66
66
|
const queryClient = useQueryClient();
|
|
67
67
|
return useMutation({
|
|
68
|
-
mutationFn: (data: SampleForm
|
|
68
|
+
mutationFn: async ({ data, files }: { data: SampleForm; files?: Array<File> }) => {
|
|
69
|
+
const formData = new FormData();
|
|
70
|
+
formData.append("request", JSON.stringify(data));
|
|
71
|
+
files?.forEach((f) => formData.append("files", f));
|
|
72
|
+
return (await apiFormDataInstance.post<Sample>("/samples", formData)).data;
|
|
73
|
+
},
|
|
69
74
|
onSuccess: () => {
|
|
70
75
|
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
71
76
|
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
@@ -76,8 +81,12 @@ export const usePostSample = () => {
|
|
|
76
81
|
export const usePutSample = () => {
|
|
77
82
|
const queryClient = useQueryClient();
|
|
78
83
|
return useMutation({
|
|
79
|
-
mutationFn: ({ id, data }: { id: number; data: SampleForm }) =>
|
|
80
|
-
|
|
84
|
+
mutationFn: async ({ id, data, files }: { id: number; data: SampleForm; files?: Array<File> }) => {
|
|
85
|
+
const formData = new FormData();
|
|
86
|
+
formData.append("request", JSON.stringify(data));
|
|
87
|
+
files?.forEach((f) => formData.append("files", f));
|
|
88
|
+
return (await apiFormDataInstance.put<Sample>(`/samples/${id}`, formData)).data;
|
|
89
|
+
},
|
|
81
90
|
onSuccess: () => {
|
|
82
91
|
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
83
92
|
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
@@ -221,6 +230,11 @@ export const useDeleteComment = () => {
|
|
|
221
230
|
});
|
|
222
231
|
};
|
|
223
232
|
|
|
233
|
+
// --- Sample check ---
|
|
234
|
+
|
|
235
|
+
export const checkSampleNameAvailable = (name: string): Promise<boolean> =>
|
|
236
|
+
apiInstance.get<boolean>("/samples/check/name", { params: { name } }).then((r) => r.data);
|
|
237
|
+
|
|
224
238
|
// --- User ---
|
|
225
239
|
|
|
226
240
|
export const checkUserIdAvailable = (userId: string): Promise<boolean> =>
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
Button,
|
|
9
9
|
confirmModal,
|
|
10
10
|
FilePreviewViewer,
|
|
11
|
-
getFileLabel,
|
|
12
11
|
toast,
|
|
13
12
|
useFilePreviewViewer,
|
|
14
13
|
} from "@farmzone/fz-react-ui";
|
|
@@ -301,21 +300,19 @@ export default function PostDetailPage() {
|
|
|
301
300
|
<p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{post.content}</p>
|
|
302
301
|
</div>
|
|
303
302
|
|
|
304
|
-
{/* TODO fix issues */}
|
|
305
303
|
{/* 첨부파일 미리보기 뷰어 */}
|
|
306
304
|
{postFiles.length > 0 && (
|
|
307
305
|
<>
|
|
308
|
-
<div className="grid grid-cols-
|
|
309
|
-
{postFiles
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
))}
|
|
306
|
+
<div className="m-4 grid grid-cols-8 gap-2 w-full">
|
|
307
|
+
{postFiles.map((postFile, idx) => (
|
|
308
|
+
<FilePreviewCard
|
|
309
|
+
key={idx}
|
|
310
|
+
src={blobUrls[idx] ?? ""}
|
|
311
|
+
fileName={previewFileNames[idx] ?? postFile.fileName}
|
|
312
|
+
fileType={previewFileTypes[idx]}
|
|
313
|
+
onClick={() => filePreview.open(idx)}
|
|
314
|
+
/>
|
|
315
|
+
))}
|
|
319
316
|
</div>
|
|
320
317
|
<FilePreviewViewer
|
|
321
318
|
{...filePreview.viewerProps}
|
|
@@ -399,7 +396,7 @@ export default function PostDetailPage() {
|
|
|
399
396
|
<div className="flex-1 space-y-0.5">
|
|
400
397
|
<div className="flex items-center gap-2">
|
|
401
398
|
<span className="text-sm font-semibold text-gray-800">
|
|
402
|
-
{reply.userName}
|
|
399
|
+
{reply.userName || "알수없음"}
|
|
403
400
|
</span>
|
|
404
401
|
<span className="text-xs text-gray-400">
|
|
405
402
|
{formatDateTime(reply.createdAt)}
|
|
@@ -454,7 +451,7 @@ export default function PostDetailPage() {
|
|
|
454
451
|
<div className="flex-1 space-y-0.5">
|
|
455
452
|
<div className="flex items-center gap-2">
|
|
456
453
|
<span className="text-sm font-semibold text-gray-800">
|
|
457
|
-
{deepReply.userName}
|
|
454
|
+
{deepReply.userName || "알수없음"}
|
|
458
455
|
</span>
|
|
459
456
|
<span className="text-xs text-gray-400">
|
|
460
457
|
{formatDateTime(deepReply.createdAt)}
|
|
@@ -209,7 +209,6 @@ export default function PostPage() {
|
|
|
209
209
|
data={content}
|
|
210
210
|
isLoading={isLoading}
|
|
211
211
|
rowKey="id"
|
|
212
|
-
sortVisibleType={"hovered"}
|
|
213
212
|
sortOption={sortOption}
|
|
214
213
|
onSortChange={(sortKey: string, sortOrder: "asc" | "desc") => {
|
|
215
214
|
setSortOption({ sortKey, sortOrder });
|
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useFormContext } from "react-hook-form";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
FileUploader,
|
|
6
|
+
Modal,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalFooter,
|
|
9
|
+
ModalIconHeader,
|
|
10
|
+
SubmitForm,
|
|
11
|
+
} from "@farmzone/fz-react-ui";
|
|
12
|
+
import { Paperclip, X } from "lucide-react";
|
|
2
13
|
import { z } from "zod";
|
|
3
14
|
|
|
15
|
+
import { checkSampleNameAvailable } from "@/app/api/queries";
|
|
16
|
+
import type { FileResponse } from "@/types";
|
|
17
|
+
|
|
4
18
|
export const sampleFormSchema = z.object({
|
|
5
19
|
name: z.string().min(1, "이름을 입력해 주세요."),
|
|
6
20
|
description: z.string().min(1, "설명을 입력해 주세요."),
|
|
7
21
|
category: z.string(),
|
|
22
|
+
priority: z.coerce.number().min(1).max(10),
|
|
8
23
|
active: z.boolean(),
|
|
9
24
|
});
|
|
10
25
|
|
|
@@ -14,6 +29,7 @@ export const SAMPLE_FORM_DEFAULT_VALUES: SampleFormData = {
|
|
|
14
29
|
name: "",
|
|
15
30
|
description: "",
|
|
16
31
|
category: "BASIC",
|
|
32
|
+
priority: 1,
|
|
17
33
|
active: true,
|
|
18
34
|
};
|
|
19
35
|
|
|
@@ -22,6 +38,43 @@ const CATEGORY_OPTIONS = [
|
|
|
22
38
|
{ label: "고급", value: "ADVANCED" },
|
|
23
39
|
];
|
|
24
40
|
|
|
41
|
+
const PRIORITY_OPTIONS = Array.from({ length: 10 }, (_, i) => ({
|
|
42
|
+
label: String(i + 1),
|
|
43
|
+
value: String(i + 1),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
function SampleNameChecker({ originalName }: { originalName?: string }) {
|
|
47
|
+
const { watch, setError, clearErrors, formState } = useFormContext();
|
|
48
|
+
const name = watch("name") as string;
|
|
49
|
+
const isDuplicateRef = useRef(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!name || name === originalName) {
|
|
53
|
+
isDuplicateRef.current = false;
|
|
54
|
+
clearErrors("name");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const timer = setTimeout(async () => {
|
|
58
|
+
const isDuplicate = await checkSampleNameAvailable(name);
|
|
59
|
+
isDuplicateRef.current = isDuplicate;
|
|
60
|
+
if (isDuplicate) {
|
|
61
|
+
setError("name", { type: "manual", message: "이미 사용 중인 샘플명입니다." });
|
|
62
|
+
} else {
|
|
63
|
+
clearErrors("name");
|
|
64
|
+
}
|
|
65
|
+
}, 500);
|
|
66
|
+
return () => clearTimeout(timer);
|
|
67
|
+
}, [name, originalName, setError, clearErrors]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isDuplicateRef.current && !formState.errors.name) {
|
|
71
|
+
setError("name", { type: "manual", message: "이미 사용 중인 샘플명입니다." });
|
|
72
|
+
}
|
|
73
|
+
}, [formState.errors.name, setError]);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
interface SampleFormModalProps {
|
|
26
79
|
mode: "create" | "edit";
|
|
27
80
|
isOpen: boolean;
|
|
@@ -30,6 +83,11 @@ interface SampleFormModalProps {
|
|
|
30
83
|
onSubmit: (data: SampleFormData) => Promise<void>;
|
|
31
84
|
isPending: boolean;
|
|
32
85
|
formKey?: string | number;
|
|
86
|
+
originalName?: string;
|
|
87
|
+
pendingFiles: Array<File>;
|
|
88
|
+
onPendingFilesChange: (files: Array<File>) => void;
|
|
89
|
+
existingFiles?: Array<FileResponse>;
|
|
90
|
+
onDeleteExistingFile?: (id: number) => void;
|
|
33
91
|
}
|
|
34
92
|
|
|
35
93
|
export function SampleFormModal({
|
|
@@ -40,6 +98,11 @@ export function SampleFormModal({
|
|
|
40
98
|
onSubmit,
|
|
41
99
|
isPending,
|
|
42
100
|
formKey,
|
|
101
|
+
originalName,
|
|
102
|
+
pendingFiles,
|
|
103
|
+
onPendingFilesChange,
|
|
104
|
+
existingFiles,
|
|
105
|
+
onDeleteExistingFile,
|
|
43
106
|
}: SampleFormModalProps) {
|
|
44
107
|
const formId = mode === "create" ? "sample-create-form" : "sample-edit-form";
|
|
45
108
|
const title = mode === "create" ? "샘플 등록" : "샘플 수정";
|
|
@@ -49,19 +112,67 @@ export function SampleFormModal({
|
|
|
49
112
|
<Modal isOpen={isOpen} onClose={onClose} contentClassName="max-w-3xl rounded-xl bg-white">
|
|
50
113
|
<ModalIconHeader type={mode} title={title} onClose={onClose} />
|
|
51
114
|
<ModalBody className="px-6 py-5">
|
|
52
|
-
<div className="
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
<div className="space-y-4">
|
|
116
|
+
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
117
|
+
<SubmitForm
|
|
118
|
+
key={formKey}
|
|
119
|
+
formId={formId}
|
|
120
|
+
schema={sampleFormSchema}
|
|
121
|
+
defaultValues={defaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
|
|
122
|
+
onSubmit={onSubmit}
|
|
123
|
+
>
|
|
124
|
+
<SampleNameChecker originalName={originalName} />
|
|
125
|
+
<SubmitForm.Row formKey="name" label="이름" required maxLength={100} />
|
|
126
|
+
<SubmitForm.Row
|
|
127
|
+
formKey="description"
|
|
128
|
+
formType="textarea"
|
|
129
|
+
label="설명"
|
|
130
|
+
required
|
|
131
|
+
maxLength={500}
|
|
132
|
+
/>
|
|
133
|
+
<SubmitForm.Row
|
|
134
|
+
formKey="category"
|
|
135
|
+
formType="radio"
|
|
136
|
+
label="카테고리"
|
|
137
|
+
options={CATEGORY_OPTIONS}
|
|
138
|
+
/>
|
|
139
|
+
<SubmitForm.Row
|
|
140
|
+
formKey="priority"
|
|
141
|
+
formType="select"
|
|
142
|
+
label="우선순위"
|
|
143
|
+
options={PRIORITY_OPTIONS}
|
|
144
|
+
/>
|
|
145
|
+
<SubmitForm.Row formKey="active" formType="switch" label="사용 여부" />
|
|
146
|
+
</SubmitForm>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div>
|
|
150
|
+
<p className="mb-1.5 text-sm font-medium text-gray-700">첨부파일</p>
|
|
151
|
+
{existingFiles && existingFiles.length > 0 && (
|
|
152
|
+
<ul className="mb-3 space-y-1.5">
|
|
153
|
+
{existingFiles.map((file) => (
|
|
154
|
+
<li
|
|
155
|
+
key={file.id}
|
|
156
|
+
className="flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-3 py-2"
|
|
157
|
+
>
|
|
158
|
+
<span className="flex items-center gap-1.5 truncate text-sm text-gray-700">
|
|
159
|
+
<Paperclip size={13} className="shrink-0 text-gray-400" />
|
|
160
|
+
{file.fileName}
|
|
161
|
+
</span>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={() => onDeleteExistingFile?.(file.id)}
|
|
165
|
+
className="ml-2 shrink-0 rounded p-0.5 text-gray-400 hover:bg-red-50 hover:text-red-500"
|
|
166
|
+
aria-label="파일 삭제"
|
|
167
|
+
>
|
|
168
|
+
<X size={14} />
|
|
169
|
+
</button>
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
<FileUploader files={pendingFiles} onChange={onPendingFilesChange} multiple />
|
|
175
|
+
</div>
|
|
65
176
|
</div>
|
|
66
177
|
</ModalBody>
|
|
67
178
|
<ModalFooter className="flex justify-end gap-2 border-t border-gray-200 bg-neutral-50 px-5 py-3">
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import { useNavigate, useParams } from "react-router";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
confirmModal,
|
|
7
|
+
FilePreviewViewer,
|
|
8
|
+
toast,
|
|
9
|
+
useFilePreviewViewer,
|
|
10
|
+
} from "@farmzone/fz-react-ui";
|
|
4
11
|
import { CornerDownRight, Pencil, Trash2 } from "lucide-react";
|
|
5
12
|
|
|
6
13
|
import {
|
|
@@ -12,14 +19,22 @@ import {
|
|
|
12
19
|
usePutSample,
|
|
13
20
|
} from "@/app/api/queries";
|
|
14
21
|
import { useUserStore } from "@/app/store";
|
|
15
|
-
import type { Comment } from "@/types";
|
|
22
|
+
import type { Comment, FileResponse } from "@/types";
|
|
23
|
+
import { apiFileInstance } from "@/app/api/api";
|
|
16
24
|
import ListHeader from "@/app/layout/ListHeader";
|
|
17
25
|
import CommentInput from "@/shared/components/CommentInput";
|
|
18
26
|
import { formatDateTime } from "@/shared/utils/format";
|
|
19
27
|
import ListContents from "@/app/layout/ListContents";
|
|
28
|
+
import FilePreviewCard from "@/shared/components/FilePreviewCard";
|
|
20
29
|
import { SampleFormModal } from "../SampleFormModal";
|
|
21
30
|
import type { SampleFormData } from "../SampleFormModal";
|
|
22
31
|
|
|
32
|
+
function toPreviewFileType(mimeType: string): "image" | "pdf" | "unsupported" {
|
|
33
|
+
if (mimeType?.startsWith("image/")) return "image";
|
|
34
|
+
if (mimeType === "application/pdf") return "pdf";
|
|
35
|
+
return "unsupported";
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
function countComments(list: Array<Comment>): number {
|
|
24
39
|
return list.reduce((sum, c) => {
|
|
25
40
|
const depth2 = c.replies ?? [];
|
|
@@ -37,6 +52,13 @@ export default function SampleDetailPage() {
|
|
|
37
52
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
38
53
|
const [replyingToId, setReplyingToId] = useState<number | null>(null);
|
|
39
54
|
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
|
55
|
+
const [pendingFiles, setPendingFiles] = useState<Array<File>>([]);
|
|
56
|
+
const [existingFiles, setExistingFiles] = useState<Array<FileResponse>>([]);
|
|
57
|
+
const [deleteFileIds, setDeleteFileIds] = useState<Array<number>>([]);
|
|
58
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
59
|
+
const [blobUrls, setBlobUrls] = useState<Array<string>>([]);
|
|
60
|
+
const [previewFileNames, setPreviewFileNames] = useState<Array<string>>([]);
|
|
61
|
+
const [previewFileTypes, setPreviewFileTypes] = useState<Array<"image" | "pdf" | "unsupported">>([]);
|
|
40
62
|
|
|
41
63
|
const { data: sample, isLoading } = useGetSample(sampleId);
|
|
42
64
|
const { data: comments = [] } = useGetComments("SAMPLE", sampleId);
|
|
@@ -45,23 +67,88 @@ export default function SampleDetailPage() {
|
|
|
45
67
|
const { mutateAsync: deleteComment } = useDeleteComment();
|
|
46
68
|
const { mutateAsync: putComment, isPending: isCommentUpdating } = usePutComment();
|
|
47
69
|
|
|
70
|
+
const sampleFiles = sample?.files ?? [];
|
|
48
71
|
const topComments = comments.filter((c) => c.parentCommentId === null);
|
|
49
72
|
const totalCommentCount = countComments(topComments);
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (sampleFiles.length === 0) {
|
|
76
|
+
setBlobUrls([]);
|
|
77
|
+
setPreviewFileNames([]);
|
|
78
|
+
setPreviewFileTypes([]);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
const createdUrls: Array<string> = [];
|
|
84
|
+
|
|
85
|
+
(async () => {
|
|
86
|
+
const results = await Promise.all(
|
|
87
|
+
sampleFiles.map(async (file) => {
|
|
88
|
+
try {
|
|
89
|
+
const { data: blob } = await apiFileInstance.get(file.filePath, { responseType: "blob" });
|
|
90
|
+
const url = URL.createObjectURL(blob);
|
|
91
|
+
createdUrls.push(url);
|
|
92
|
+
return { url, name: file.fileName, type: toPreviewFileType(file.fileType) };
|
|
93
|
+
} catch {
|
|
94
|
+
return { url: "", name: file.fileName, type: "unsupported" as const };
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
if (!cancelled) {
|
|
99
|
+
setBlobUrls(results.map((r) => r.url));
|
|
100
|
+
setPreviewFileNames(results.map((r) => r.name));
|
|
101
|
+
setPreviewFileTypes(results.map((r) => r.type));
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
createdUrls.forEach((url) => url && URL.revokeObjectURL(url));
|
|
108
|
+
};
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, [sampleFiles.length, sampleFiles[0]?.id]);
|
|
111
|
+
|
|
112
|
+
const filePreview = useFilePreviewViewer(blobUrls);
|
|
113
|
+
|
|
114
|
+
const openEditModal = () => {
|
|
115
|
+
setPendingFiles([]);
|
|
116
|
+
setExistingFiles(sample?.files ?? []);
|
|
117
|
+
setDeleteFileIds([]);
|
|
118
|
+
setIsEditOpen(true);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const closeEditModal = () => {
|
|
122
|
+
setIsEditOpen(false);
|
|
123
|
+
setPendingFiles([]);
|
|
124
|
+
setExistingFiles([]);
|
|
125
|
+
setDeleteFileIds([]);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleDeleteExistingFile = (fileId: number) => {
|
|
129
|
+
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
130
|
+
setDeleteFileIds((prev) => [...prev, fileId]);
|
|
131
|
+
};
|
|
53
132
|
|
|
54
133
|
const handleEdit = async (data: SampleFormData) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
134
|
+
setIsUploading(true);
|
|
135
|
+
try {
|
|
136
|
+
await putSample({
|
|
137
|
+
id: sampleId,
|
|
138
|
+
data: {
|
|
139
|
+
name: data.name,
|
|
140
|
+
description: data.description,
|
|
141
|
+
category: data.category as "BASIC" | "ADVANCED",
|
|
142
|
+
priority: data.priority,
|
|
143
|
+
active: data.active,
|
|
144
|
+
deleteFileIds: deleteFileIds.length > 0 ? deleteFileIds : undefined,
|
|
145
|
+
},
|
|
146
|
+
files: pendingFiles.length > 0 ? pendingFiles : undefined,
|
|
147
|
+
});
|
|
148
|
+
closeEditModal();
|
|
149
|
+
} finally {
|
|
150
|
+
setIsUploading(false);
|
|
151
|
+
}
|
|
65
152
|
};
|
|
66
153
|
|
|
67
154
|
const handleDelete = () => {
|
|
@@ -78,7 +165,7 @@ export default function SampleDetailPage() {
|
|
|
78
165
|
};
|
|
79
166
|
|
|
80
167
|
const isCommentOwner = (commentUserId: number | null) =>
|
|
81
|
-
commentUserId !== null && String(commentUserId) === currentUser?.id;
|
|
168
|
+
commentUserId !== null && String(commentUserId) === String(currentUser?.id);
|
|
82
169
|
|
|
83
170
|
const handleCommentEditStart = (comment: Comment) => {
|
|
84
171
|
if (!isCommentOwner(comment.userId)) {
|
|
@@ -140,7 +227,13 @@ export default function SampleDetailPage() {
|
|
|
140
227
|
};
|
|
141
228
|
|
|
142
229
|
const editDefaultValues: SampleFormData | undefined = sample
|
|
143
|
-
? {
|
|
230
|
+
? {
|
|
231
|
+
name: sample.name,
|
|
232
|
+
description: sample.description,
|
|
233
|
+
category: sample.category,
|
|
234
|
+
priority: sample.priority,
|
|
235
|
+
active: sample.active,
|
|
236
|
+
}
|
|
144
237
|
: undefined;
|
|
145
238
|
|
|
146
239
|
return (
|
|
@@ -204,6 +297,27 @@ export default function SampleDetailPage() {
|
|
|
204
297
|
<div className="min-h-44 px-6 py-6">
|
|
205
298
|
<p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{sample.description}</p>
|
|
206
299
|
</div>
|
|
300
|
+
|
|
301
|
+
{sampleFiles.length > 0 && (
|
|
302
|
+
<>
|
|
303
|
+
<div className="m-4 grid grid-cols-8 gap-2 w-full">
|
|
304
|
+
{sampleFiles.map((_, idx) => (
|
|
305
|
+
<FilePreviewCard
|
|
306
|
+
key={idx}
|
|
307
|
+
src={blobUrls[idx] ?? ""}
|
|
308
|
+
fileName={previewFileNames[idx] ?? ""}
|
|
309
|
+
fileType={previewFileTypes[idx]}
|
|
310
|
+
onClick={() => filePreview.open(idx)}
|
|
311
|
+
/>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
<FilePreviewViewer
|
|
315
|
+
{...filePreview.viewerProps}
|
|
316
|
+
fileNames={previewFileNames}
|
|
317
|
+
fileTypes={previewFileTypes}
|
|
318
|
+
/>
|
|
319
|
+
</>
|
|
320
|
+
)}
|
|
207
321
|
</>
|
|
208
322
|
) : (
|
|
209
323
|
<div className="p-8 text-center text-sm text-gray-400">샘플을 찾을 수 없습니다.</div>
|
|
@@ -269,7 +383,6 @@ export default function SampleDetailPage() {
|
|
|
269
383
|
<ul className="border-t border-gray-50 bg-gray-50/60">
|
|
270
384
|
{depth2Replies.map((reply) => {
|
|
271
385
|
const depth3Replies = reply.replies ?? [];
|
|
272
|
-
const isReplyingToReply = replyingToId === reply.id;
|
|
273
386
|
const isEditingReply = editingCommentId === reply.id;
|
|
274
387
|
|
|
275
388
|
return (
|
|
@@ -279,7 +392,7 @@ export default function SampleDetailPage() {
|
|
|
279
392
|
<div className="flex-1 space-y-0.5">
|
|
280
393
|
<div className="flex items-center gap-2">
|
|
281
394
|
<span className="text-sm font-semibold text-gray-800">
|
|
282
|
-
{reply.userName}
|
|
395
|
+
{reply.userName || "알수없음"}
|
|
283
396
|
</span>
|
|
284
397
|
<span className="text-xs text-gray-400">
|
|
285
398
|
{formatDateTime(reply.createdAt)}
|
|
@@ -297,21 +410,9 @@ export default function SampleDetailPage() {
|
|
|
297
410
|
onCancel={() => setEditingCommentId(null)}
|
|
298
411
|
/>
|
|
299
412
|
) : (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
</p>
|
|
304
|
-
{reply.content !== "삭제된 댓글입니다." && (
|
|
305
|
-
<button
|
|
306
|
-
type="button"
|
|
307
|
-
onClick={() => setReplyingToId(isReplyingToReply ? null : reply.id)}
|
|
308
|
-
className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-blue-500"
|
|
309
|
-
>
|
|
310
|
-
<CornerDownRight size={11} />
|
|
311
|
-
{isReplyingToReply ? "취소" : "답글"}
|
|
312
|
-
</button>
|
|
313
|
-
)}
|
|
314
|
-
</>
|
|
413
|
+
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
414
|
+
{reply.content}
|
|
415
|
+
</p>
|
|
315
416
|
)}
|
|
316
417
|
</div>
|
|
317
418
|
{!isEditingReply && renderCommentActions(reply)}
|
|
@@ -334,7 +435,7 @@ export default function SampleDetailPage() {
|
|
|
334
435
|
<div className="flex-1 space-y-0.5">
|
|
335
436
|
<div className="flex items-center gap-2">
|
|
336
437
|
<span className="text-sm font-semibold text-gray-800">
|
|
337
|
-
{deepReply.userName}
|
|
438
|
+
{deepReply.userName || "알수없음"}
|
|
338
439
|
</span>
|
|
339
440
|
<span className="text-xs text-gray-400">
|
|
340
441
|
{formatDateTime(deepReply.createdAt)}
|
|
@@ -363,19 +464,6 @@ export default function SampleDetailPage() {
|
|
|
363
464
|
})}
|
|
364
465
|
</ul>
|
|
365
466
|
)}
|
|
366
|
-
|
|
367
|
-
{/* depth 2 답글 입력창 */}
|
|
368
|
-
{isReplyingToReply && !isEditingReply && (
|
|
369
|
-
<div className="border-t border-blue-50 bg-blue-50/30 py-3 pl-10 pr-5">
|
|
370
|
-
<CommentInput
|
|
371
|
-
targetType="SAMPLE"
|
|
372
|
-
targetId={sampleId}
|
|
373
|
-
parentCommentId={reply.id}
|
|
374
|
-
placeholder="답글을 입력하세요. @를 입력하면 사용자를 멘션할 수 있습니다."
|
|
375
|
-
onSuccess={() => setReplyingToId(null)}
|
|
376
|
-
/>
|
|
377
|
-
</div>
|
|
378
|
-
)}
|
|
379
467
|
</li>
|
|
380
468
|
);
|
|
381
469
|
})}
|
|
@@ -416,8 +504,13 @@ export default function SampleDetailPage() {
|
|
|
416
504
|
onClose={closeEditModal}
|
|
417
505
|
defaultValues={editDefaultValues}
|
|
418
506
|
onSubmit={handleEdit}
|
|
419
|
-
isPending={isUpdating}
|
|
507
|
+
isPending={isUpdating || isUploading}
|
|
420
508
|
formKey={sampleId}
|
|
509
|
+
originalName={sample?.name}
|
|
510
|
+
pendingFiles={pendingFiles}
|
|
511
|
+
onPendingFilesChange={setPendingFiles}
|
|
512
|
+
existingFiles={existingFiles}
|
|
513
|
+
onDeleteExistingFile={handleDeleteExistingFile}
|
|
421
514
|
/>
|
|
422
515
|
</div>
|
|
423
516
|
);
|
|
@@ -2,7 +2,13 @@ import { useState } from "react";
|
|
|
2
2
|
import { useNavigate } from "react-router";
|
|
3
3
|
import { Badge, Button, confirmModal, PageFilter, Select, Table } from "@farmzone/fz-react-ui";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
checkSampleNameAvailable,
|
|
7
|
+
useDeleteSamples,
|
|
8
|
+
useGetSamples,
|
|
9
|
+
usePostSample,
|
|
10
|
+
usePutSample,
|
|
11
|
+
} from "@/app/api/queries";
|
|
6
12
|
import type { Sample } from "@/types";
|
|
7
13
|
import ListContents from "@/app/layout/ListContents";
|
|
8
14
|
import ListHeader from "@/app/layout/ListHeader";
|
|
@@ -40,6 +46,8 @@ export default function SamplePage() {
|
|
|
40
46
|
const [selectedSample, setSelectedSample] = useState<Sample | null>(null);
|
|
41
47
|
const [editDefaultValues, setEditDefaultValues] = useState<SampleFormData | undefined>(undefined);
|
|
42
48
|
const [selectedKeys, setSelectedKeys] = useState<Array<string | number>>([]);
|
|
49
|
+
const [pendingFiles, setPendingFiles] = useState<Array<File>>([]);
|
|
50
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
43
51
|
const [sortOption, setSortOption] = useState<{ sortKey: string; sortOrder: "asc" | "desc" } | undefined>(
|
|
44
52
|
undefined,
|
|
45
53
|
);
|
|
@@ -71,28 +79,46 @@ export default function SamplePage() {
|
|
|
71
79
|
setModalMode(null);
|
|
72
80
|
setSelectedSample(null);
|
|
73
81
|
setEditDefaultValues(undefined);
|
|
82
|
+
setPendingFiles([]);
|
|
74
83
|
};
|
|
75
84
|
|
|
76
85
|
const handleSubmit = async (data: SampleFormData) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
setIsUploading(true);
|
|
87
|
+
try {
|
|
88
|
+
if (modalMode === "create") {
|
|
89
|
+
const isDuplicate = await checkSampleNameAvailable(data.name);
|
|
90
|
+
if (isDuplicate) return;
|
|
91
|
+
await postSample({
|
|
92
|
+
data: {
|
|
93
|
+
name: data.name,
|
|
94
|
+
description: data.description,
|
|
95
|
+
category: data.category as "BASIC" | "ADVANCED",
|
|
96
|
+
priority: data.priority,
|
|
97
|
+
active: data.active,
|
|
98
|
+
},
|
|
99
|
+
files: pendingFiles.length > 0 ? pendingFiles : undefined,
|
|
100
|
+
});
|
|
101
|
+
} else if (modalMode === "edit" && selectedSample) {
|
|
102
|
+
if (data.name !== selectedSample.name) {
|
|
103
|
+
const isDuplicate = await checkSampleNameAvailable(data.name);
|
|
104
|
+
if (isDuplicate) return;
|
|
105
|
+
}
|
|
106
|
+
await putSample({
|
|
107
|
+
id: selectedSample.id,
|
|
108
|
+
data: {
|
|
109
|
+
name: data.name,
|
|
110
|
+
description: data.description,
|
|
111
|
+
category: data.category as "BASIC" | "ADVANCED",
|
|
112
|
+
priority: data.priority,
|
|
113
|
+
active: data.active,
|
|
114
|
+
},
|
|
115
|
+
files: pendingFiles.length > 0 ? pendingFiles : undefined,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
closeModal();
|
|
119
|
+
} finally {
|
|
120
|
+
setIsUploading(false);
|
|
94
121
|
}
|
|
95
|
-
closeModal();
|
|
96
122
|
};
|
|
97
123
|
|
|
98
124
|
const columns = [
|
|
@@ -133,12 +159,12 @@ export default function SamplePage() {
|
|
|
133
159
|
},
|
|
134
160
|
{
|
|
135
161
|
key: "active",
|
|
136
|
-
title: "
|
|
162
|
+
title: "상태",
|
|
137
163
|
align: "center" as const,
|
|
138
164
|
width: "30px",
|
|
139
165
|
render: (_: unknown, record: Sample) => (
|
|
140
166
|
<Badge
|
|
141
|
-
text={record.active ? "
|
|
167
|
+
text={record.active ? "활성" : "비활성"}
|
|
142
168
|
className={`scale-90 ${record.active ? "bg-green-100 text-green-700 border-green-100" : "bg-red-100 text-red-500 border-red-100"}`}
|
|
143
169
|
/>
|
|
144
170
|
),
|
|
@@ -261,8 +287,11 @@ export default function SamplePage() {
|
|
|
261
287
|
onClose={closeModal}
|
|
262
288
|
defaultValues={editDefaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
|
|
263
289
|
onSubmit={handleSubmit}
|
|
264
|
-
isPending={isCreating || isUpdating}
|
|
290
|
+
isPending={isCreating || isUpdating || isUploading}
|
|
265
291
|
formKey={selectedSample?.id}
|
|
292
|
+
originalName={selectedSample?.name}
|
|
293
|
+
pendingFiles={pendingFiles}
|
|
294
|
+
onPendingFilesChange={setPendingFiles}
|
|
266
295
|
/>
|
|
267
296
|
</div>
|
|
268
297
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Button, Modal, Tab } from "@farmzone/fz-react-ui";
|
|
3
|
-
import { ArrowRight, Clock, Maximize2, Minimize2, X } from "lucide-react";
|
|
3
|
+
import { ArrowRight, Clock, Download, Maximize2, Minimize2, X } from "lucide-react";
|
|
4
|
+
import { apiInstance } from "@/app/api/api";
|
|
4
5
|
import ListHeader from "@/app/layout/ListHeader";
|
|
5
6
|
|
|
6
7
|
const CATTLE = {
|
|
@@ -235,6 +236,35 @@ function CattleDetailModal({ isOpen, onClose }: { isOpen: boolean; onClose: () =
|
|
|
235
236
|
|
|
236
237
|
export default function SampleModalPage() {
|
|
237
238
|
const [isOpen, setIsOpen] = useState(false);
|
|
239
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
240
|
+
|
|
241
|
+
const handleExcelTemplateDownload = async (menuCode: string) => {
|
|
242
|
+
setIsDownloading(true);
|
|
243
|
+
try {
|
|
244
|
+
const res = await apiInstance.get<Blob>(`/excel/template?menuCode=${menuCode}`, {
|
|
245
|
+
responseType: "blob",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const blob = new Blob([res.data], {
|
|
249
|
+
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
|
|
253
|
+
const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
|
|
254
|
+
const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "샘플 일괄등록 업로딩양식.xlsx";
|
|
255
|
+
|
|
256
|
+
const url = URL.createObjectURL(blob);
|
|
257
|
+
const anchor = document.createElement("a");
|
|
258
|
+
anchor.href = url;
|
|
259
|
+
anchor.download = fileName;
|
|
260
|
+
document.body.appendChild(anchor);
|
|
261
|
+
anchor.click();
|
|
262
|
+
document.body.removeChild(anchor);
|
|
263
|
+
URL.revokeObjectURL(url);
|
|
264
|
+
} finally {
|
|
265
|
+
setIsDownloading(false);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
238
268
|
|
|
239
269
|
return (
|
|
240
270
|
<div className="p-6">
|
|
@@ -247,6 +277,31 @@ export default function SampleModalPage() {
|
|
|
247
277
|
</Button>
|
|
248
278
|
</div>
|
|
249
279
|
</div>
|
|
280
|
+
<div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
|
|
281
|
+
<div className="text-center">
|
|
282
|
+
<p className="mb-4 text-sm text-gray-500">
|
|
283
|
+
아래 버튼을 클릭하여 템플릿 다운로드 기능을 확인하세요.
|
|
284
|
+
</p>
|
|
285
|
+
<div className="flex flex-col gap-4">
|
|
286
|
+
<Button
|
|
287
|
+
variant="outline"
|
|
288
|
+
onClick={() => handleExcelTemplateDownload("SAMPLE")}
|
|
289
|
+
disabled={isDownloading}
|
|
290
|
+
>
|
|
291
|
+
<Download className="mr-1.5 h-4 w-4" />
|
|
292
|
+
엑셀 업로드 양식 다운로드 (SAMPLE)
|
|
293
|
+
</Button>
|
|
294
|
+
{/* <Button
|
|
295
|
+
variant="outline"
|
|
296
|
+
onClick={() => handleExcelTemplateDownload("POST")}
|
|
297
|
+
disabled={isDownloading}
|
|
298
|
+
>
|
|
299
|
+
<Download className="mr-1.5 h-4 w-4" />
|
|
300
|
+
엑셀 업로드 양식 다운로드 (POST)
|
|
301
|
+
</Button> */}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
250
305
|
<CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
|
251
306
|
</div>
|
|
252
307
|
);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Badge,
|
|
2
|
-
import { Pencil, Trash2 } from "lucide-react";
|
|
1
|
+
import { Badge, type Column } from "@farmzone/fz-react-ui";
|
|
3
2
|
|
|
4
3
|
import type { User } from "@/types";
|
|
5
4
|
import { formatDateTime } from "@/shared/utils/format";
|
|
@@ -17,13 +16,7 @@ interface ActionHandlers {
|
|
|
17
16
|
pageSize: number;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
export const getUserColumns = ({
|
|
21
|
-
onEdit,
|
|
22
|
-
onDelete,
|
|
23
|
-
totalElements,
|
|
24
|
-
page,
|
|
25
|
-
pageSize,
|
|
26
|
-
}: ActionHandlers): Array<Column<User>> => [
|
|
19
|
+
export const getUserColumns = ({ totalElements, page, pageSize }: ActionHandlers): Array<Column<User>> => [
|
|
27
20
|
{
|
|
28
21
|
key: "index",
|
|
29
22
|
title: "No",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useFormContext } from "react-hook-form";
|
|
3
3
|
import { X } from "lucide-react";
|
|
4
4
|
import {
|
|
@@ -140,25 +140,25 @@ const USER_READ_FIELDS: Array<DetailField<UserViewData>> = [
|
|
|
140
140
|
{
|
|
141
141
|
key: "role",
|
|
142
142
|
label: "구분",
|
|
143
|
-
render: ({ value }
|
|
144
|
-
<span className="text-sm text-gray-900">{value === "ADMIN" ? "관리자" : "일반"}</span>
|
|
143
|
+
render: ({ value }) => (
|
|
144
|
+
<span className="text-sm text-gray-900">{(value as string) === "ADMIN" ? "관리자" : "일반"}</span>
|
|
145
145
|
),
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
key: "active",
|
|
149
149
|
label: "사용여부",
|
|
150
|
-
render: ({ value }
|
|
150
|
+
render: ({ value }) => (
|
|
151
151
|
<Badge
|
|
152
|
-
text={value ? "활성" : "비활성"}
|
|
153
|
-
className={`scale-90 ${value ? "bg-green-100 text-green-700 border-green-100" : "bg-gray-100 text-gray-500 border-gray-200"}`}
|
|
152
|
+
text={(value as boolean) ? "활성" : "비활성"}
|
|
153
|
+
className={`scale-90 ${(value as boolean) ? "bg-green-100 text-green-700 border-green-100" : "bg-gray-100 text-gray-500 border-gray-200"}`}
|
|
154
154
|
/>
|
|
155
155
|
),
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
key: "gender",
|
|
159
159
|
label: "성별",
|
|
160
|
-
render: ({ value }
|
|
161
|
-
<span className="text-sm text-gray-900">{value === "M" ? "남" : value === "F" ? "여" : "-"}</span>
|
|
160
|
+
render: ({ value }) => (
|
|
161
|
+
<span className="text-sm text-gray-900">{(value as string) === "M" ? "남" : (value as string) === "F" ? "여" : "-"}</span>
|
|
162
162
|
),
|
|
163
163
|
},
|
|
164
164
|
{ key: "birthday", label: "생년월일" },
|
|
@@ -199,16 +199,19 @@ const USER_EDIT_FIELDS: Array<FormFieldConfig<UserEditFormData>> = [
|
|
|
199
199
|
type ModalMode = "detail" | "create";
|
|
200
200
|
|
|
201
201
|
function UserIdChecker() {
|
|
202
|
-
const { watch, setError, clearErrors } = useFormContext();
|
|
202
|
+
const { watch, setError, clearErrors, formState } = useFormContext();
|
|
203
203
|
const userId = watch("userId") as string;
|
|
204
|
+
const isDuplicateRef = useRef(false);
|
|
204
205
|
|
|
205
206
|
useEffect(() => {
|
|
206
207
|
if (!userId) {
|
|
208
|
+
isDuplicateRef.current = false;
|
|
207
209
|
clearErrors("userId");
|
|
208
210
|
return;
|
|
209
211
|
}
|
|
210
212
|
const timer = setTimeout(async () => {
|
|
211
213
|
const available = await checkUserIdAvailable(userId);
|
|
214
|
+
isDuplicateRef.current = !available;
|
|
212
215
|
if (available) {
|
|
213
216
|
clearErrors("userId");
|
|
214
217
|
} else {
|
|
@@ -218,6 +221,12 @@ function UserIdChecker() {
|
|
|
218
221
|
return () => clearTimeout(timer);
|
|
219
222
|
}, [userId, setError, clearErrors]);
|
|
220
223
|
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (isDuplicateRef.current && !formState.errors.userId) {
|
|
226
|
+
setError("userId", { type: "manual", message: "이미 사용 중인 아이디입니다." });
|
|
227
|
+
}
|
|
228
|
+
}, [formState.errors.userId, setError]);
|
|
229
|
+
|
|
221
230
|
return null;
|
|
222
231
|
}
|
|
223
232
|
|
|
@@ -8,13 +8,14 @@ interface FilePreviewCardProps {
|
|
|
8
8
|
fileSize?: string;
|
|
9
9
|
onClick?: () => void;
|
|
10
10
|
className?: string;
|
|
11
|
+
fileType?: "image" | "pdf" | "unsupported";
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/** 예시용 파일 카드 UI — FilePreviewViewer와 분리, 사용처에서 자유롭게 커스터마이징 */
|
|
14
15
|
export default function FilePreviewCard(props: FilePreviewCardProps) {
|
|
15
|
-
const { src, fileName, fileSize, onClick, className } = props;
|
|
16
|
+
const { src, fileName, fileSize, onClick, className, fileType: fileTypeProp } = props;
|
|
16
17
|
|
|
17
|
-
const fileType = getPreviewFileType(src);
|
|
18
|
+
const fileType = fileTypeProp ?? getPreviewFileType(src);
|
|
18
19
|
const isImage = fileType === "image";
|
|
19
20
|
const imageSrc = useStableImageSrc(isImage ? src : undefined);
|
|
20
21
|
|
|
@@ -26,7 +27,7 @@ export default function FilePreviewCard(props: FilePreviewCardProps) {
|
|
|
26
27
|
type="button"
|
|
27
28
|
onClick={onClick}
|
|
28
29
|
className={cn(
|
|
29
|
-
"group flex flex-col w-full h-
|
|
30
|
+
"group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
|
|
30
31
|
className,
|
|
31
32
|
)}
|
|
32
33
|
>
|
|
@@ -50,7 +51,7 @@ export default function FilePreviewCard(props: FilePreviewCardProps) {
|
|
|
50
51
|
type="button"
|
|
51
52
|
onClick={onClick}
|
|
52
53
|
className={cn(
|
|
53
|
-
"group flex flex-col w-full h-
|
|
54
|
+
"group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
|
|
54
55
|
className,
|
|
55
56
|
)}
|
|
56
57
|
>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FileResponse } from "./common";
|
|
2
|
+
|
|
1
3
|
export type SampleCategory = "BASIC" | "ADVANCED";
|
|
2
4
|
|
|
3
5
|
export interface Sample {
|
|
@@ -9,6 +11,7 @@ export interface Sample {
|
|
|
9
11
|
active: boolean;
|
|
10
12
|
createdAt: string;
|
|
11
13
|
updatedAt: string;
|
|
14
|
+
files?: Array<FileResponse>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export interface GetSamplesParams {
|
|
@@ -24,5 +27,7 @@ export interface SampleForm {
|
|
|
24
27
|
name: string;
|
|
25
28
|
description: string;
|
|
26
29
|
category: SampleCategory;
|
|
30
|
+
priority: number;
|
|
27
31
|
active: boolean;
|
|
32
|
+
deleteFileIds?: Array<number>;
|
|
28
33
|
}
|