@farmzone/fz-template-react 1.0.4 → 1.0.5
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 -2
- package/template/pnpm-lock.yaml +5 -5
- package/template/src/app/api/queries.ts +5 -0
- package/template/src/pages/post/index.tsx +0 -1
- package/template/src/pages/sample/SampleFormModal.tsx +38 -0
- package/template/src/pages/sample/detail/index.tsx +5 -29
- package/template/src/pages/sample/index.tsx +10 -1
- package/template/src/pages/sample/modal/index.tsx +48 -1
- package/template/src/pages/user/config/columns.tsx +2 -9
- package/template/src/pages/user/index.tsx +7 -7
- package/template/src/types/sample.ts +1 -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,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__PROJECT_NAME__",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite --host 0.0.0.0 --port 5000",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"preview": "vite preview"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@farmzone/fz-react-ui": "^0.0.
|
|
13
|
+
"@farmzone/fz-react-ui": "^0.0.8",
|
|
14
14
|
"@hookform/resolvers": "^5.2.2",
|
|
15
15
|
"@tanstack/react-query": "^5.90.0",
|
|
16
16
|
"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)
|
|
@@ -221,6 +221,11 @@ export const useDeleteComment = () => {
|
|
|
221
221
|
});
|
|
222
222
|
};
|
|
223
223
|
|
|
224
|
+
// --- Sample check ---
|
|
225
|
+
|
|
226
|
+
export const checkSampleNameAvailable = (name: string): Promise<boolean> =>
|
|
227
|
+
apiInstance.get<boolean>("/samples/check/name", { params: { name } }).then((r) => r.data);
|
|
228
|
+
|
|
224
229
|
// --- User ---
|
|
225
230
|
|
|
226
231
|
export const checkUserIdAvailable = (userId: string): Promise<boolean> =>
|
|
@@ -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,15 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useFormContext } from "react-hook-form";
|
|
1
3
|
import { Button, Modal, ModalBody, ModalFooter, ModalIconHeader, SubmitForm } from "@farmzone/fz-react-ui";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
|
|
6
|
+
import { checkSampleNameAvailable } from "@/app/api/queries";
|
|
7
|
+
|
|
4
8
|
export const sampleFormSchema = z.object({
|
|
5
9
|
name: z.string().min(1, "이름을 입력해 주세요."),
|
|
6
10
|
description: z.string().min(1, "설명을 입력해 주세요."),
|
|
7
11
|
category: z.string(),
|
|
12
|
+
priority: z.coerce.number().min(1).max(10),
|
|
8
13
|
active: z.boolean(),
|
|
9
14
|
});
|
|
10
15
|
|
|
@@ -14,6 +19,7 @@ export const SAMPLE_FORM_DEFAULT_VALUES: SampleFormData = {
|
|
|
14
19
|
name: "",
|
|
15
20
|
description: "",
|
|
16
21
|
category: "BASIC",
|
|
22
|
+
priority: 1,
|
|
17
23
|
active: true,
|
|
18
24
|
};
|
|
19
25
|
|
|
@@ -22,6 +28,34 @@ const CATEGORY_OPTIONS = [
|
|
|
22
28
|
{ label: "고급", value: "ADVANCED" },
|
|
23
29
|
];
|
|
24
30
|
|
|
31
|
+
const PRIORITY_OPTIONS = Array.from({ length: 10 }, (_, i) => ({
|
|
32
|
+
label: String(i + 1),
|
|
33
|
+
value: String(i + 1),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
function SampleNameChecker({ originalName }: { originalName?: string }) {
|
|
37
|
+
const { watch, setError, clearErrors } = useFormContext();
|
|
38
|
+
const name = watch("name") as string;
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!name || name === originalName) {
|
|
42
|
+
clearErrors("name");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const timer = setTimeout(async () => {
|
|
46
|
+
const available = await checkSampleNameAvailable(name);
|
|
47
|
+
if (available) {
|
|
48
|
+
clearErrors("name");
|
|
49
|
+
} else {
|
|
50
|
+
setError("name", { type: "manual", message: "이미 사용 중인 샘플명입니다." });
|
|
51
|
+
}
|
|
52
|
+
}, 500);
|
|
53
|
+
return () => clearTimeout(timer);
|
|
54
|
+
}, [name, originalName, setError, clearErrors]);
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
interface SampleFormModalProps {
|
|
26
60
|
mode: "create" | "edit";
|
|
27
61
|
isOpen: boolean;
|
|
@@ -30,6 +64,7 @@ interface SampleFormModalProps {
|
|
|
30
64
|
onSubmit: (data: SampleFormData) => Promise<void>;
|
|
31
65
|
isPending: boolean;
|
|
32
66
|
formKey?: string | number;
|
|
67
|
+
originalName?: string;
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
export function SampleFormModal({
|
|
@@ -40,6 +75,7 @@ export function SampleFormModal({
|
|
|
40
75
|
onSubmit,
|
|
41
76
|
isPending,
|
|
42
77
|
formKey,
|
|
78
|
+
originalName,
|
|
43
79
|
}: SampleFormModalProps) {
|
|
44
80
|
const formId = mode === "create" ? "sample-create-form" : "sample-edit-form";
|
|
45
81
|
const title = mode === "create" ? "샘플 등록" : "샘플 수정";
|
|
@@ -57,9 +93,11 @@ export function SampleFormModal({
|
|
|
57
93
|
defaultValues={defaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
|
|
58
94
|
onSubmit={onSubmit}
|
|
59
95
|
>
|
|
96
|
+
<SampleNameChecker originalName={originalName} />
|
|
60
97
|
<SubmitForm.Row formKey="name" label="이름" required maxLength={100} />
|
|
61
98
|
<SubmitForm.Row formKey="description" formType="textarea" label="설명" required maxLength={500} />
|
|
62
99
|
<SubmitForm.Row formKey="category" formType="radio" label="카테고리" options={CATEGORY_OPTIONS} />
|
|
100
|
+
<SubmitForm.Row formKey="priority" formType="select" label="우선순위" options={PRIORITY_OPTIONS} />
|
|
63
101
|
<SubmitForm.Row formKey="active" formType="switch" label="사용 여부" />
|
|
64
102
|
</SubmitForm>
|
|
65
103
|
</div>
|
|
@@ -58,6 +58,7 @@ export default function SampleDetailPage() {
|
|
|
58
58
|
name: data.name,
|
|
59
59
|
description: data.description,
|
|
60
60
|
category: data.category as "BASIC" | "ADVANCED",
|
|
61
|
+
priority: data.priority,
|
|
61
62
|
active: data.active,
|
|
62
63
|
},
|
|
63
64
|
});
|
|
@@ -140,7 +141,7 @@ export default function SampleDetailPage() {
|
|
|
140
141
|
};
|
|
141
142
|
|
|
142
143
|
const editDefaultValues: SampleFormData | undefined = sample
|
|
143
|
-
? { name: sample.name, description: sample.description, category: sample.category, active: sample.active }
|
|
144
|
+
? { name: sample.name, description: sample.description, category: sample.category, priority: sample.priority, active: sample.active }
|
|
144
145
|
: undefined;
|
|
145
146
|
|
|
146
147
|
return (
|
|
@@ -269,7 +270,6 @@ export default function SampleDetailPage() {
|
|
|
269
270
|
<ul className="border-t border-gray-50 bg-gray-50/60">
|
|
270
271
|
{depth2Replies.map((reply) => {
|
|
271
272
|
const depth3Replies = reply.replies ?? [];
|
|
272
|
-
const isReplyingToReply = replyingToId === reply.id;
|
|
273
273
|
const isEditingReply = editingCommentId === reply.id;
|
|
274
274
|
|
|
275
275
|
return (
|
|
@@ -297,21 +297,9 @@ export default function SampleDetailPage() {
|
|
|
297
297
|
onCancel={() => setEditingCommentId(null)}
|
|
298
298
|
/>
|
|
299
299
|
) : (
|
|
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
|
-
</>
|
|
300
|
+
<p className="whitespace-pre-wrap text-sm text-gray-600">
|
|
301
|
+
{reply.content}
|
|
302
|
+
</p>
|
|
315
303
|
)}
|
|
316
304
|
</div>
|
|
317
305
|
{!isEditingReply && renderCommentActions(reply)}
|
|
@@ -364,18 +352,6 @@ export default function SampleDetailPage() {
|
|
|
364
352
|
</ul>
|
|
365
353
|
)}
|
|
366
354
|
|
|
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
355
|
</li>
|
|
380
356
|
);
|
|
381
357
|
})}
|
|
@@ -2,7 +2,7 @@ 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 { useDeleteSamples, useGetSamples, usePostSample, usePutSample } from "@/app/api/queries";
|
|
5
|
+
import { checkSampleNameAvailable, useDeleteSamples, useGetSamples, usePostSample, usePutSample } from "@/app/api/queries";
|
|
6
6
|
import type { Sample } from "@/types";
|
|
7
7
|
import ListContents from "@/app/layout/ListContents";
|
|
8
8
|
import ListHeader from "@/app/layout/ListHeader";
|
|
@@ -75,19 +75,27 @@ export default function SamplePage() {
|
|
|
75
75
|
|
|
76
76
|
const handleSubmit = async (data: SampleFormData) => {
|
|
77
77
|
if (modalMode === "create") {
|
|
78
|
+
const available = await checkSampleNameAvailable(data.name);
|
|
79
|
+
if (!available) return;
|
|
78
80
|
await postSample({
|
|
79
81
|
name: data.name,
|
|
80
82
|
description: data.description,
|
|
81
83
|
category: data.category as "BASIC" | "ADVANCED",
|
|
84
|
+
priority: data.priority,
|
|
82
85
|
active: data.active,
|
|
83
86
|
});
|
|
84
87
|
} else if (modalMode === "edit" && selectedSample) {
|
|
88
|
+
if (data.name !== selectedSample.name) {
|
|
89
|
+
const available = await checkSampleNameAvailable(data.name);
|
|
90
|
+
if (!available) return;
|
|
91
|
+
}
|
|
85
92
|
await putSample({
|
|
86
93
|
id: selectedSample.id,
|
|
87
94
|
data: {
|
|
88
95
|
name: data.name,
|
|
89
96
|
description: data.description,
|
|
90
97
|
category: data.category as "BASIC" | "ADVANCED",
|
|
98
|
+
priority: data.priority,
|
|
91
99
|
active: data.active,
|
|
92
100
|
},
|
|
93
101
|
});
|
|
@@ -263,6 +271,7 @@ export default function SamplePage() {
|
|
|
263
271
|
onSubmit={handleSubmit}
|
|
264
272
|
isPending={isCreating || isUpdating}
|
|
265
273
|
formKey={selectedSample?.id}
|
|
274
|
+
originalName={selectedSample?.name}
|
|
266
275
|
/>
|
|
267
276
|
</div>
|
|
268
277
|
);
|
|
@@ -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,27 @@ 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
|
+
const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
|
|
248
|
+
const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
|
|
249
|
+
const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "엑셀_업로드_양식.xlsx";
|
|
250
|
+
const url = URL.createObjectURL(res.data);
|
|
251
|
+
const anchor = document.createElement("a");
|
|
252
|
+
anchor.href = url;
|
|
253
|
+
anchor.download = fileName;
|
|
254
|
+
anchor.click();
|
|
255
|
+
URL.revokeObjectURL(url);
|
|
256
|
+
} finally {
|
|
257
|
+
setIsDownloading(false);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
238
260
|
|
|
239
261
|
return (
|
|
240
262
|
<div className="p-6">
|
|
@@ -247,6 +269,31 @@ export default function SampleModalPage() {
|
|
|
247
269
|
</Button>
|
|
248
270
|
</div>
|
|
249
271
|
</div>
|
|
272
|
+
<div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
|
|
273
|
+
<div className="text-center">
|
|
274
|
+
<p className="mb-4 text-sm text-gray-500">
|
|
275
|
+
아래 버튼을 클릭하여 템플릿 다운로드 기능을 확인하세요.
|
|
276
|
+
</p>
|
|
277
|
+
<div className="flex flex-col gap-4">
|
|
278
|
+
<Button
|
|
279
|
+
variant="outline"
|
|
280
|
+
onClick={() => handleExcelTemplateDownload("SAMPLE")}
|
|
281
|
+
disabled={isDownloading}
|
|
282
|
+
>
|
|
283
|
+
<Download className="mr-1.5 h-4 w-4" />
|
|
284
|
+
엑셀 업로드 양식 다운로드 (SAMPLE)
|
|
285
|
+
</Button>
|
|
286
|
+
{/* <Button
|
|
287
|
+
variant="outline"
|
|
288
|
+
onClick={() => handleExcelTemplateDownload("POST")}
|
|
289
|
+
disabled={isDownloading}
|
|
290
|
+
>
|
|
291
|
+
<Download className="mr-1.5 h-4 w-4" />
|
|
292
|
+
엑셀 업로드 양식 다운로드 (POST)
|
|
293
|
+
</Button> */}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
250
297
|
<CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
|
251
298
|
</div>
|
|
252
299
|
);
|
|
@@ -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",
|
|
@@ -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: "생년월일" },
|