@farmzone/fz-template-react 1.0.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmzone/fz-template-react",
3
- "version": "1.0.5",
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.5",
3
+ "version": "1.0.6",
5
4
  "type": "module",
6
5
  "scripts": {
7
6
  "dev": "vite --host 0.0.0.0 --port 5000",
@@ -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);
@@ -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)}
@@ -1,9 +1,19 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useRef } from "react";
2
2
  import { useFormContext } from "react-hook-form";
3
- import { Button, Modal, ModalBody, ModalFooter, ModalIconHeader, SubmitForm } from "@farmzone/fz-react-ui";
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";
4
13
  import { z } from "zod";
5
14
 
6
15
  import { checkSampleNameAvailable } from "@/app/api/queries";
16
+ import type { FileResponse } from "@/types";
7
17
 
8
18
  export const sampleFormSchema = z.object({
9
19
  name: z.string().min(1, "이름을 입력해 주세요."),
@@ -34,25 +44,34 @@ const PRIORITY_OPTIONS = Array.from({ length: 10 }, (_, i) => ({
34
44
  }));
35
45
 
36
46
  function SampleNameChecker({ originalName }: { originalName?: string }) {
37
- const { watch, setError, clearErrors } = useFormContext();
47
+ const { watch, setError, clearErrors, formState } = useFormContext();
38
48
  const name = watch("name") as string;
49
+ const isDuplicateRef = useRef(false);
39
50
 
40
51
  useEffect(() => {
41
52
  if (!name || name === originalName) {
53
+ isDuplicateRef.current = false;
42
54
  clearErrors("name");
43
55
  return;
44
56
  }
45
57
  const timer = setTimeout(async () => {
46
- const available = await checkSampleNameAvailable(name);
47
- if (available) {
48
- clearErrors("name");
49
- } else {
58
+ const isDuplicate = await checkSampleNameAvailable(name);
59
+ isDuplicateRef.current = isDuplicate;
60
+ if (isDuplicate) {
50
61
  setError("name", { type: "manual", message: "이미 사용 중인 샘플명입니다." });
62
+ } else {
63
+ clearErrors("name");
51
64
  }
52
65
  }, 500);
53
66
  return () => clearTimeout(timer);
54
67
  }, [name, originalName, setError, clearErrors]);
55
68
 
69
+ useEffect(() => {
70
+ if (isDuplicateRef.current && !formState.errors.name) {
71
+ setError("name", { type: "manual", message: "이미 사용 중인 샘플명입니다." });
72
+ }
73
+ }, [formState.errors.name, setError]);
74
+
56
75
  return null;
57
76
  }
58
77
 
@@ -65,6 +84,10 @@ interface SampleFormModalProps {
65
84
  isPending: boolean;
66
85
  formKey?: string | number;
67
86
  originalName?: string;
87
+ pendingFiles: Array<File>;
88
+ onPendingFilesChange: (files: Array<File>) => void;
89
+ existingFiles?: Array<FileResponse>;
90
+ onDeleteExistingFile?: (id: number) => void;
68
91
  }
69
92
 
70
93
  export function SampleFormModal({
@@ -76,6 +99,10 @@ export function SampleFormModal({
76
99
  isPending,
77
100
  formKey,
78
101
  originalName,
102
+ pendingFiles,
103
+ onPendingFilesChange,
104
+ existingFiles,
105
+ onDeleteExistingFile,
79
106
  }: SampleFormModalProps) {
80
107
  const formId = mode === "create" ? "sample-create-form" : "sample-edit-form";
81
108
  const title = mode === "create" ? "샘플 등록" : "샘플 수정";
@@ -85,21 +112,67 @@ export function SampleFormModal({
85
112
  <Modal isOpen={isOpen} onClose={onClose} contentClassName="max-w-3xl rounded-xl bg-white">
86
113
  <ModalIconHeader type={mode} title={title} onClose={onClose} />
87
114
  <ModalBody className="px-6 py-5">
88
- <div className="overflow-hidden rounded-lg border border-gray-200">
89
- <SubmitForm
90
- key={formKey}
91
- formId={formId}
92
- schema={sampleFormSchema}
93
- defaultValues={defaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
94
- onSubmit={onSubmit}
95
- >
96
- <SampleNameChecker originalName={originalName} />
97
- <SubmitForm.Row formKey="name" label="이름" required maxLength={100} />
98
- <SubmitForm.Row formKey="description" formType="textarea" label="설명" required maxLength={500} />
99
- <SubmitForm.Row formKey="category" formType="radio" label="카테고리" options={CATEGORY_OPTIONS} />
100
- <SubmitForm.Row formKey="priority" formType="select" label="우선순위" options={PRIORITY_OPTIONS} />
101
- <SubmitForm.Row formKey="active" formType="switch" label="사용 여부" />
102
- </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>
103
176
  </div>
104
177
  </ModalBody>
105
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,24 +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
- priority: data.priority,
62
- active: data.active,
63
- },
64
- });
65
- 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
+ }
66
152
  };
67
153
 
68
154
  const handleDelete = () => {
@@ -79,7 +165,7 @@ export default function SampleDetailPage() {
79
165
  };
80
166
 
81
167
  const isCommentOwner = (commentUserId: number | null) =>
82
- commentUserId !== null && String(commentUserId) === currentUser?.id;
168
+ commentUserId !== null && String(commentUserId) === String(currentUser?.id);
83
169
 
84
170
  const handleCommentEditStart = (comment: Comment) => {
85
171
  if (!isCommentOwner(comment.userId)) {
@@ -141,7 +227,13 @@ export default function SampleDetailPage() {
141
227
  };
142
228
 
143
229
  const editDefaultValues: SampleFormData | undefined = sample
144
- ? { name: sample.name, description: sample.description, category: sample.category, priority: sample.priority, 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
+ }
145
237
  : undefined;
146
238
 
147
239
  return (
@@ -205,6 +297,27 @@ export default function SampleDetailPage() {
205
297
  <div className="min-h-44 px-6 py-6">
206
298
  <p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{sample.description}</p>
207
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
+ )}
208
321
  </>
209
322
  ) : (
210
323
  <div className="p-8 text-center text-sm text-gray-400">샘플을 찾을 수 없습니다.</div>
@@ -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)}
@@ -322,7 +435,7 @@ export default function SampleDetailPage() {
322
435
  <div className="flex-1 space-y-0.5">
323
436
  <div className="flex items-center gap-2">
324
437
  <span className="text-sm font-semibold text-gray-800">
325
- {deepReply.userName}
438
+ {deepReply.userName || "알수없음"}
326
439
  </span>
327
440
  <span className="text-xs text-gray-400">
328
441
  {formatDateTime(deepReply.createdAt)}
@@ -351,7 +464,6 @@ export default function SampleDetailPage() {
351
464
  })}
352
465
  </ul>
353
466
  )}
354
-
355
467
  </li>
356
468
  );
357
469
  })}
@@ -392,8 +504,13 @@ export default function SampleDetailPage() {
392
504
  onClose={closeEditModal}
393
505
  defaultValues={editDefaultValues}
394
506
  onSubmit={handleEdit}
395
- isPending={isUpdating}
507
+ isPending={isUpdating || isUploading}
396
508
  formKey={sampleId}
509
+ originalName={sample?.name}
510
+ pendingFiles={pendingFiles}
511
+ onPendingFilesChange={setPendingFiles}
512
+ existingFiles={existingFiles}
513
+ onDeleteExistingFile={handleDeleteExistingFile}
397
514
  />
398
515
  </div>
399
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 { checkSampleNameAvailable, 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,36 +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
- const available = await checkSampleNameAvailable(data.name);
79
- if (!available) return;
80
- await postSample({
81
- name: data.name,
82
- description: data.description,
83
- category: data.category as "BASIC" | "ADVANCED",
84
- priority: data.priority,
85
- active: data.active,
86
- });
87
- } else if (modalMode === "edit" && selectedSample) {
88
- if (data.name !== selectedSample.name) {
89
- const available = await checkSampleNameAvailable(data.name);
90
- if (!available) return;
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
+ });
91
117
  }
92
- await putSample({
93
- id: selectedSample.id,
94
- data: {
95
- name: data.name,
96
- description: data.description,
97
- category: data.category as "BASIC" | "ADVANCED",
98
- priority: data.priority,
99
- active: data.active,
100
- },
101
- });
118
+ closeModal();
119
+ } finally {
120
+ setIsUploading(false);
102
121
  }
103
- closeModal();
104
122
  };
105
123
 
106
124
  const columns = [
@@ -141,12 +159,12 @@ export default function SamplePage() {
141
159
  },
142
160
  {
143
161
  key: "active",
144
- title: "사용여부",
162
+ title: "상태",
145
163
  align: "center" as const,
146
164
  width: "30px",
147
165
  render: (_: unknown, record: Sample) => (
148
166
  <Badge
149
- text={record.active ? "O" : "X"}
167
+ text={record.active ? "활성" : "비활성"}
150
168
  className={`scale-90 ${record.active ? "bg-green-100 text-green-700 border-green-100" : "bg-red-100 text-red-500 border-red-100"}`}
151
169
  />
152
170
  ),
@@ -269,9 +287,11 @@ export default function SamplePage() {
269
287
  onClose={closeModal}
270
288
  defaultValues={editDefaultValues ?? SAMPLE_FORM_DEFAULT_VALUES}
271
289
  onSubmit={handleSubmit}
272
- isPending={isCreating || isUpdating}
290
+ isPending={isCreating || isUpdating || isUploading}
273
291
  formKey={selectedSample?.id}
274
292
  originalName={selectedSample?.name}
293
+ pendingFiles={pendingFiles}
294
+ onPendingFilesChange={setPendingFiles}
275
295
  />
276
296
  </div>
277
297
  );
@@ -244,14 +244,22 @@ export default function SampleModalPage() {
244
244
  const res = await apiInstance.get<Blob>(`/excel/template?menuCode=${menuCode}`, {
245
245
  responseType: "blob",
246
246
  });
247
+
248
+ const blob = new Blob([res.data], {
249
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
250
+ });
251
+
247
252
  const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
248
253
  const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
249
- const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "엑셀_업로드_양식.xlsx";
250
- const url = URL.createObjectURL(res.data);
254
+ const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "샘플 일괄등록 업로딩양식.xlsx";
255
+
256
+ const url = URL.createObjectURL(blob);
251
257
  const anchor = document.createElement("a");
252
258
  anchor.href = url;
253
259
  anchor.download = fileName;
260
+ document.body.appendChild(anchor);
254
261
  anchor.click();
262
+ document.body.removeChild(anchor);
255
263
  URL.revokeObjectURL(url);
256
264
  } finally {
257
265
  setIsDownloading(false);
@@ -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 {
@@ -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 {
@@ -26,4 +29,5 @@ export interface SampleForm {
26
29
  category: SampleCategory;
27
30
  priority: number;
28
31
  active: boolean;
32
+ deleteFileIds?: Array<number>;
29
33
  }