@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 +1 -1
- package/template/package.json +1 -2
- package/template/src/app/api/queries.ts +12 -3
- package/template/src/pages/post/detail/index.tsx +12 -15
- package/template/src/pages/sample/SampleFormModal.tsx +95 -22
- package/template/src/pages/sample/detail/index.tsx +139 -22
- package/template/src/pages/sample/index.tsx +49 -29
- package/template/src/pages/sample/modal/index.tsx +10 -2
- package/template/src/pages/user/index.tsx +11 -2
- package/template/src/shared/components/FilePreviewCard.tsx +5 -4
- package/template/src/types/sample.ts +4 -0
package/package.json
CHANGED
package/template/package.json
CHANGED
|
@@ -65,7 +65,12 @@ export const useGetSample = (id: number | null) => {
|
|
|
65
65
|
export const usePostSample = () => {
|
|
66
66
|
const queryClient = useQueryClient();
|
|
67
67
|
return useMutation({
|
|
68
|
-
mutationFn: (data: SampleForm
|
|
68
|
+
mutationFn: async ({ data, files }: { data: SampleForm; files?: Array<File> }) => {
|
|
69
|
+
const formData = new FormData();
|
|
70
|
+
formData.append("request", JSON.stringify(data));
|
|
71
|
+
files?.forEach((f) => formData.append("files", f));
|
|
72
|
+
return (await apiFormDataInstance.post<Sample>("/samples", formData)).data;
|
|
73
|
+
},
|
|
69
74
|
onSuccess: () => {
|
|
70
75
|
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
71
76
|
toast.success(COMMON_MESSAGES.SAVE_SUCCESS);
|
|
@@ -76,8 +81,12 @@ export const usePostSample = () => {
|
|
|
76
81
|
export const usePutSample = () => {
|
|
77
82
|
const queryClient = useQueryClient();
|
|
78
83
|
return useMutation({
|
|
79
|
-
mutationFn: ({ id, data }: { id: number; data: SampleForm }) =>
|
|
80
|
-
|
|
84
|
+
mutationFn: async ({ id, data, files }: { id: number; data: SampleForm; files?: Array<File> }) => {
|
|
85
|
+
const formData = new FormData();
|
|
86
|
+
formData.append("request", JSON.stringify(data));
|
|
87
|
+
files?.forEach((f) => formData.append("files", f));
|
|
88
|
+
return (await apiFormDataInstance.put<Sample>(`/samples/${id}`, formData)).data;
|
|
89
|
+
},
|
|
81
90
|
onSuccess: () => {
|
|
82
91
|
void queryClient.invalidateQueries({ queryKey: [SAMPLE_QUERY_KEY] });
|
|
83
92
|
toast.success(COMMON_MESSAGES.UPDATE_SUCCESS);
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
Button,
|
|
9
9
|
confirmModal,
|
|
10
10
|
FilePreviewViewer,
|
|
11
|
-
getFileLabel,
|
|
12
11
|
toast,
|
|
13
12
|
useFilePreviewViewer,
|
|
14
13
|
} from "@farmzone/fz-react-ui";
|
|
@@ -301,21 +300,19 @@ export default function PostDetailPage() {
|
|
|
301
300
|
<p className="whitespace-pre-wrap text-sm leading-7 text-gray-800">{post.content}</p>
|
|
302
301
|
</div>
|
|
303
302
|
|
|
304
|
-
{/* TODO fix issues */}
|
|
305
303
|
{/* 첨부파일 미리보기 뷰어 */}
|
|
306
304
|
{postFiles.length > 0 && (
|
|
307
305
|
<>
|
|
308
|
-
<div className="grid grid-cols-
|
|
309
|
-
{postFiles
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
))}
|
|
306
|
+
<div className="m-4 grid grid-cols-8 gap-2 w-full">
|
|
307
|
+
{postFiles.map((postFile, idx) => (
|
|
308
|
+
<FilePreviewCard
|
|
309
|
+
key={idx}
|
|
310
|
+
src={blobUrls[idx] ?? ""}
|
|
311
|
+
fileName={previewFileNames[idx] ?? postFile.fileName}
|
|
312
|
+
fileType={previewFileTypes[idx]}
|
|
313
|
+
onClick={() => filePreview.open(idx)}
|
|
314
|
+
/>
|
|
315
|
+
))}
|
|
319
316
|
</div>
|
|
320
317
|
<FilePreviewViewer
|
|
321
318
|
{...filePreview.viewerProps}
|
|
@@ -399,7 +396,7 @@ export default function PostDetailPage() {
|
|
|
399
396
|
<div className="flex-1 space-y-0.5">
|
|
400
397
|
<div className="flex items-center gap-2">
|
|
401
398
|
<span className="text-sm font-semibold text-gray-800">
|
|
402
|
-
{reply.userName}
|
|
399
|
+
{reply.userName || "알수없음"}
|
|
403
400
|
</span>
|
|
404
401
|
<span className="text-xs text-gray-400">
|
|
405
402
|
{formatDateTime(reply.createdAt)}
|
|
@@ -454,7 +451,7 @@ export default function PostDetailPage() {
|
|
|
454
451
|
<div className="flex-1 space-y-0.5">
|
|
455
452
|
<div className="flex items-center gap-2">
|
|
456
453
|
<span className="text-sm font-semibold text-gray-800">
|
|
457
|
-
{deepReply.userName}
|
|
454
|
+
{deepReply.userName || "알수없음"}
|
|
458
455
|
</span>
|
|
459
456
|
<span className="text-xs text-gray-400">
|
|
460
457
|
{formatDateTime(deepReply.createdAt)}
|
|
@@ -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 {
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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="
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 {
|
|
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
|
-
|
|
52
|
-
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (sampleFiles.length === 0) {
|
|
76
|
+
setBlobUrls([]);
|
|
77
|
+
setPreviewFileNames([]);
|
|
78
|
+
setPreviewFileTypes([]);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
const createdUrls: Array<string> = [];
|
|
84
|
+
|
|
85
|
+
(async () => {
|
|
86
|
+
const results = await Promise.all(
|
|
87
|
+
sampleFiles.map(async (file) => {
|
|
88
|
+
try {
|
|
89
|
+
const { data: blob } = await apiFileInstance.get(file.filePath, { responseType: "blob" });
|
|
90
|
+
const url = URL.createObjectURL(blob);
|
|
91
|
+
createdUrls.push(url);
|
|
92
|
+
return { url, name: file.fileName, type: toPreviewFileType(file.fileType) };
|
|
93
|
+
} catch {
|
|
94
|
+
return { url: "", name: file.fileName, type: "unsupported" as const };
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
if (!cancelled) {
|
|
99
|
+
setBlobUrls(results.map((r) => r.url));
|
|
100
|
+
setPreviewFileNames(results.map((r) => r.name));
|
|
101
|
+
setPreviewFileTypes(results.map((r) => r.type));
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
createdUrls.forEach((url) => url && URL.revokeObjectURL(url));
|
|
108
|
+
};
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, [sampleFiles.length, sampleFiles[0]?.id]);
|
|
111
|
+
|
|
112
|
+
const filePreview = useFilePreviewViewer(blobUrls);
|
|
113
|
+
|
|
114
|
+
const openEditModal = () => {
|
|
115
|
+
setPendingFiles([]);
|
|
116
|
+
setExistingFiles(sample?.files ?? []);
|
|
117
|
+
setDeleteFileIds([]);
|
|
118
|
+
setIsEditOpen(true);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const closeEditModal = () => {
|
|
122
|
+
setIsEditOpen(false);
|
|
123
|
+
setPendingFiles([]);
|
|
124
|
+
setExistingFiles([]);
|
|
125
|
+
setDeleteFileIds([]);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleDeleteExistingFile = (fileId: number) => {
|
|
129
|
+
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
130
|
+
setDeleteFileIds((prev) => [...prev, fileId]);
|
|
131
|
+
};
|
|
53
132
|
|
|
54
133
|
const handleEdit = async (data: SampleFormData) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
? {
|
|
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 {
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 ? "
|
|
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]) : "
|
|
250
|
-
|
|
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-
|
|
30
|
+
"group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
|
|
30
31
|
className,
|
|
31
32
|
)}
|
|
32
33
|
>
|
|
@@ -50,7 +51,7 @@ export default function FilePreviewCard(props: FilePreviewCardProps) {
|
|
|
50
51
|
type="button"
|
|
51
52
|
onClick={onClick}
|
|
52
53
|
className={cn(
|
|
53
|
-
"group flex flex-col w-full h-
|
|
54
|
+
"group flex flex-col w-full h-30 rounded-lg border border-gray-200 overflow-hidden bg-white hover:border-gray-300 hover:shadow-md transition-all duration-200 text-left cursor-pointer",
|
|
54
55
|
className,
|
|
55
56
|
)}
|
|
56
57
|
>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FileResponse } from "./common";
|
|
2
|
+
|
|
1
3
|
export type SampleCategory = "BASIC" | "ADVANCED";
|
|
2
4
|
|
|
3
5
|
export interface Sample {
|
|
@@ -9,6 +11,7 @@ export interface Sample {
|
|
|
9
11
|
active: boolean;
|
|
10
12
|
createdAt: string;
|
|
11
13
|
updatedAt: string;
|
|
14
|
+
files?: Array<FileResponse>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export interface GetSamplesParams {
|
|
@@ -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
|
}
|