@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 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 | v4 |
63
- | React Router | ^7 |
64
- | TanStack Query | ^5 |
65
- | @farmzone/fz-react-ui | latest |
66
- | react-hook-form + zod | 최신 |
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 { LayoutDashboard, Users, ScrollText } from "lucide-react";
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: Users, label: "사용자 관리", path: "/user" },
83
- { icon: ScrollText, label: "로그", path: "/log" },
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: "user", element: <UserListPage /> },
97
- { path: "user/:id", element: <UserDetailPage /> },
98
- { path: "log", element: <LogPage /> },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmzone/fz-template-react",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Farmzone React 프로젝트 보일러플레이트 생성 CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "__PROJECT_NAME__",
3
- "private": true,
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.6",
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",
@@ -9,8 +9,8 @@ importers:
9
9
  .:
10
10
  dependencies:
11
11
  '@farmzone/fz-react-ui':
12
- specifier: ^0.0.6
13
- version: 0.0.6(@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)
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.6':
395
- resolution: {integrity: sha512-4BwWCXQBtjZf9Aj1CmnbulJQHMtUVX5EmjhRpNCYbnovD4o+hz/TUTuv3y+JmNqZFnq5TYNgB6zzytBhBpIZ5g==}
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.6(@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)':
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) => apiInstance.post<Sample>("/samples", data).then((r) => r.data),
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
- apiInstance.put<Sample>(`/samples/${id}`, data).then((r) => r.data),
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-2 gap-2 w-full">
309
- {postFiles
310
- .map((postFile) => postFile.filePath)
311
- .map((img, idx) => (
312
- <FilePreviewCard
313
- key={idx}
314
- src={img}
315
- fileName={getFileLabel(img, idx)}
316
- onClick={() => filePreview.open(idx)}
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 { Button, Modal, ModalBody, ModalFooter, ModalIconHeader, SubmitForm } from "@farmzone/fz-react-ui";
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="overflow-hidden rounded-lg border border-gray-200">
53
- <SubmitForm
54
- key={formKey}
55
- formId={formId}
56
- schema={sampleFormSchema}
57
- defaultValues={defaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
58
- onSubmit={onSubmit}
59
- >
60
- <SubmitForm.Row formKey="name" label="이름" required maxLength={100} />
61
- <SubmitForm.Row formKey="description" formType="textarea" label="설명" required maxLength={500} />
62
- <SubmitForm.Row formKey="category" formType="radio" label="카테고리" options={CATEGORY_OPTIONS} />
63
- <SubmitForm.Row formKey="active" formType="switch" label="사용 여부" />
64
- </SubmitForm>
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 { Badge, Button, confirmModal, toast } from "@farmzone/fz-react-ui";
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
- const openEditModal = () => setIsEditOpen(true);
52
- const closeEditModal = () => setIsEditOpen(false);
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
- await putSample({
56
- id: sampleId,
57
- data: {
58
- name: data.name,
59
- description: data.description,
60
- category: data.category as "BASIC" | "ADVANCED",
61
- active: data.active,
62
- },
63
- });
64
- closeEditModal();
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
- ? { name: sample.name, description: sample.description, category: sample.category, active: sample.active }
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
- <p className="whitespace-pre-wrap text-sm text-gray-600">
302
- {reply.content}
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 { useDeleteSamples, useGetSamples, usePostSample, usePutSample } from "@/app/api/queries";
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
- if (modalMode === "create") {
78
- await postSample({
79
- name: data.name,
80
- description: data.description,
81
- category: data.category as "BASIC" | "ADVANCED",
82
- active: data.active,
83
- });
84
- } else if (modalMode === "edit" && selectedSample) {
85
- await putSample({
86
- id: selectedSample.id,
87
- data: {
88
- name: data.name,
89
- description: data.description,
90
- category: data.category as "BASIC" | "ADVANCED",
91
- active: data.active,
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 ? "O" : "X"}
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, Button, type Column } from "@farmzone/fz-react-ui";
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 }: { value: string }) => (
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 }: { value: boolean }) => (
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 }: { value: string }) => (
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-40 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
+ "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-40 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
+ "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
  }