@farmzone/fz-template-react 1.0.0 → 1.0.1

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.
Files changed (58) hide show
  1. package/bin/create.js +10 -4
  2. package/package.json +8 -2
  3. package/template/.env.example +5 -0
  4. package/template/eslint.config.js +4 -1
  5. package/template/index.css +15 -2
  6. package/template/index.html +1 -1
  7. package/template/package.json +55 -41
  8. package/template/public/favicon.ico +0 -0
  9. package/template/public/mockServiceWorker.js +349 -0
  10. package/template/src/app/App.tsx +2 -0
  11. package/template/src/app/api/api.ts +178 -0
  12. package/template/src/app/api/queries.ts +321 -0
  13. package/template/src/app/api/queryKey.ts +7 -0
  14. package/template/src/app/api/token.ts +7 -0
  15. package/template/src/app/layout/Layout.tsx +33 -16
  16. package/template/src/app/layout/ListContents.tsx +9 -0
  17. package/template/src/app/layout/ListHeader.tsx +41 -0
  18. package/template/src/app/layout/MultiTabNav.tsx +101 -0
  19. package/template/src/app/layout/Sidebar.tsx +33 -53
  20. package/template/src/app/layout/UserInfo.tsx +94 -0
  21. package/template/src/app/layout/menu.ts +46 -21
  22. package/template/src/app/layout/tabSwitchStore.ts +11 -0
  23. package/template/src/app/router/Router.tsx +54 -28
  24. package/template/src/app/store/index.ts +26 -0
  25. package/template/src/index.tsx +21 -12
  26. package/template/src/mocks/browser.ts +17 -0
  27. package/template/src/mocks/handlers.ts +43 -0
  28. package/template/src/mocks/scenarios.ts +57 -0
  29. package/template/src/pages/dashboard/index.tsx +541 -8
  30. package/template/src/pages/error/Error.tsx +29 -17
  31. package/template/src/pages/error/NotFound.tsx +27 -17
  32. package/template/src/pages/login/index.tsx +317 -0
  33. package/template/src/pages/post/PostFormModal.tsx +128 -0
  34. package/template/src/pages/post/detail/index.tsx +548 -0
  35. package/template/src/pages/post/index.tsx +267 -0
  36. package/template/src/pages/sample/SampleFormModal.tsx +77 -0
  37. package/template/src/pages/sample/detail/index.tsx +424 -0
  38. package/template/src/pages/sample/index.tsx +269 -0
  39. package/template/src/pages/system/log/index.tsx +173 -0
  40. package/template/src/pages/user/config/columns.tsx +109 -0
  41. package/template/src/pages/user/config/schema.ts +54 -0
  42. package/template/src/pages/user/index.tsx +641 -0
  43. package/template/src/shared/components/CommentInput.tsx +243 -0
  44. package/template/src/shared/components/FilePreviewCard.tsx +70 -0
  45. package/template/src/shared/config/text.ts +27 -0
  46. package/template/src/shared/config/type.ts +40 -0
  47. package/template/src/shared/utils/format.ts +11 -0
  48. package/template/src/types/auth.ts +10 -0
  49. package/template/src/types/comment.ts +33 -0
  50. package/template/src/types/common.ts +19 -0
  51. package/template/src/types/dashboard.ts +53 -0
  52. package/template/src/types/index.ts +16 -0
  53. package/template/src/types/log.ts +21 -0
  54. package/template/src/types/post.ts +32 -0
  55. package/template/src/types/sample.ts +28 -0
  56. package/template/src/types/user.ts +51 -0
  57. package/template/src/vite-env.d.ts +10 -0
  58. package/template/gitignore +0 -32
@@ -0,0 +1,641 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useFormContext } from "react-hook-form";
3
+ import { X } from "lucide-react";
4
+ import {
5
+ Badge,
6
+ FileUploader,
7
+ Button,
8
+ confirmModal,
9
+ DetailContent,
10
+ Modal,
11
+ ModalBody,
12
+ ModalFooter,
13
+ ModalIconHeader,
14
+ PageFilter,
15
+ Select,
16
+ SubmitForm,
17
+ Table,
18
+ useDetailController,
19
+ type DetailField,
20
+ type FormFieldConfig,
21
+ } from "@farmzone/fz-react-ui";
22
+
23
+ import {
24
+ useGetUsers,
25
+ useGetUser,
26
+ usePostUser,
27
+ usePutUser,
28
+ useDeleteUser,
29
+ useDeleteUsers,
30
+ checkUserIdAvailable,
31
+ } from "@/app/api/queries";
32
+ import type { User, UserRole } from "@/types";
33
+ import ListContents from "@/app/layout/ListContents";
34
+ import ListHeader from "@/app/layout/ListHeader";
35
+ import { formatDateTime } from "@/shared/utils/format";
36
+ import { getUserColumns } from "./config/columns";
37
+ import {
38
+ userCreateSchema,
39
+ userEditSchema,
40
+ type UserCreateFormData,
41
+ type UserEditFormData,
42
+ } from "./config/schema";
43
+
44
+ const PAGE_SIZE_OPTIONS = [
45
+ { label: "15", value: "15" },
46
+ { label: "30", value: "30" },
47
+ { label: "60", value: "60" },
48
+ { label: "100", value: "100" },
49
+ ];
50
+
51
+ // --- 필터 ---
52
+
53
+ interface UserFilterParams {
54
+ role: string;
55
+ keywordType: string;
56
+ keyword: string;
57
+ dateType: string;
58
+ startDate: string;
59
+ endDate: string;
60
+ }
61
+
62
+ const EMPTY_USER_FILTER: UserFilterParams = {
63
+ role: "",
64
+ keywordType: "name",
65
+ keyword: "",
66
+ dateType: "createdAt",
67
+ startDate: "",
68
+ endDate: "",
69
+ };
70
+
71
+ const ROLE_OPTIONS = [
72
+ { label: "관리자", value: "ADMIN" },
73
+ { label: "일반", value: "USER" },
74
+ ];
75
+
76
+ const KEYWORD_TYPE_OPTIONS = [
77
+ { label: "성명", value: "name" },
78
+ { label: "아이디", value: "userId" },
79
+ ];
80
+
81
+ const DATE_TYPE_OPTIONS = [
82
+ { label: "생성일", value: "createdAt" },
83
+ { label: "최근접속일", value: "lastLoginAt" },
84
+ ];
85
+
86
+ // --- 폼 옵션 ---
87
+
88
+ const ROLE_SUBMIT_OPTIONS = [
89
+ { label: "관리자", value: "ADMIN" },
90
+ { label: "일반", value: "USER" },
91
+ ];
92
+
93
+ const GENDER_OPTIONS = [
94
+ { label: "남", value: "M" },
95
+ { label: "여", value: "F" },
96
+ ];
97
+
98
+ const CREATE_DEFAULT_VALUES: UserCreateFormData = {
99
+ userId: "",
100
+ name: "",
101
+ password: "",
102
+ passwordCheck: "",
103
+ role: "USER",
104
+ gender: "",
105
+ birthday: "",
106
+ phone: "",
107
+ };
108
+
109
+ // --- 상세 뷰 타입 ---
110
+
111
+ interface UserViewData {
112
+ userId: string;
113
+ name: string;
114
+ role: string;
115
+ gender: string;
116
+ birthday: string;
117
+ phone: string;
118
+ active: boolean;
119
+ createdAt: string;
120
+ lastLoginAt: string;
121
+ }
122
+
123
+ function toViewData(user: User): UserViewData {
124
+ return {
125
+ userId: user.userId,
126
+ name: user.name,
127
+ role: user.role,
128
+ gender: user.gender ?? "",
129
+ birthday: user.birthday ?? "",
130
+ phone: user.phone ?? "",
131
+ active: user.active,
132
+ createdAt: formatDateTime(user.createdAt),
133
+ lastLoginAt: formatDateTime(user.lastLoginAt),
134
+ };
135
+ }
136
+
137
+ const USER_READ_FIELDS: Array<DetailField<UserViewData>> = [
138
+ { key: "userId", label: "아이디" },
139
+ { key: "name", label: "성명" },
140
+ {
141
+ key: "role",
142
+ label: "구분",
143
+ render: ({ value }: { value: string }) => (
144
+ <span className="text-sm text-gray-900">{value === "ADMIN" ? "관리자" : "일반"}</span>
145
+ ),
146
+ },
147
+ {
148
+ key: "active",
149
+ label: "사용여부",
150
+ render: ({ value }: { value: boolean }) => (
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"}`}
154
+ />
155
+ ),
156
+ },
157
+ {
158
+ key: "gender",
159
+ label: "성별",
160
+ render: ({ value }: { value: string }) => (
161
+ <span className="text-sm text-gray-900">{value === "M" ? "남" : value === "F" ? "여" : "-"}</span>
162
+ ),
163
+ },
164
+ { key: "birthday", label: "생년월일" },
165
+ { key: "phone", label: "연락처" },
166
+ { key: "createdAt", label: "등록일시" },
167
+ { key: "lastLoginAt", label: "최근접속일" },
168
+ ];
169
+
170
+ const USER_EDIT_FIELDS: Array<FormFieldConfig<UserEditFormData>> = [
171
+ { name: "userId", label: "아이디", readOnly: true, colspan: 4 },
172
+ { name: "name", label: "성명", required: true, maxLength: 50, colspan: 4 },
173
+ {
174
+ name: "password",
175
+ type: "password",
176
+ label: "비밀번호 변경",
177
+ placeholder: "변경 시에만 입력하세요",
178
+ maxLength: 100,
179
+ revalidateFields: ["passwordCheck"],
180
+ colspan: 4,
181
+ },
182
+ {
183
+ name: "passwordCheck",
184
+ type: "password",
185
+ label: "비밀번호 확인",
186
+ placeholder: "변경 시에만 입력하세요",
187
+ maxLength: 100,
188
+ colspan: 4,
189
+ },
190
+ { name: "active", type: "switch", label: "사용여부", colspan: 4 },
191
+ { name: "role", type: "radio", label: "구분", options: ROLE_SUBMIT_OPTIONS, colspan: 4 },
192
+ { name: "gender", type: "radio", label: "성별", options: GENDER_OPTIONS, colspan: 4 },
193
+ { name: "birthday", type: "date", label: "생년월일", colspan: 4 },
194
+ { name: "phone", label: "연락처", colspan: 4, maxLength: 20 },
195
+ ];
196
+
197
+ // --- 모달 모드 ---
198
+
199
+ type ModalMode = "detail" | "create";
200
+
201
+ function UserIdChecker() {
202
+ const { watch, setError, clearErrors } = useFormContext();
203
+ const userId = watch("userId") as string;
204
+
205
+ useEffect(() => {
206
+ if (!userId) {
207
+ clearErrors("userId");
208
+ return;
209
+ }
210
+ const timer = setTimeout(async () => {
211
+ const available = await checkUserIdAvailable(userId);
212
+ if (available) {
213
+ clearErrors("userId");
214
+ } else {
215
+ setError("userId", { type: "manual", message: "이미 사용 중인 아이디입니다." });
216
+ }
217
+ }, 500);
218
+ return () => clearTimeout(timer);
219
+ }, [userId, setError, clearErrors]);
220
+
221
+ return null;
222
+ }
223
+
224
+ export default function UserPage() {
225
+ const [page, setPage] = useState(0);
226
+ const [pageSize, setPageSize] = useState(15);
227
+ const [filterParams, setFilterParams] = useState<UserFilterParams>(EMPTY_USER_FILTER);
228
+ const [submittedFilter, setSubmittedFilter] = useState<UserFilterParams>(EMPTY_USER_FILTER);
229
+
230
+ const [modalMode, setModalMode] = useState<ModalMode | null>(null);
231
+ const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
232
+ const [selectedKeys, setSelectedKeys] = useState<Array<string | number>>([]);
233
+ const [profilePhoto, setProfilePhoto] = useState<Array<File>>([]);
234
+ const [editProfilePhoto, setEditProfilePhoto] = useState<Array<File>>([]);
235
+ const [deleteFileIds, setDeleteFileIds] = useState<Array<number>>([]);
236
+
237
+ const controller = useDetailController();
238
+
239
+ const { data, isLoading } = useGetUsers({
240
+ page,
241
+ size: pageSize,
242
+ role: submittedFilter.role || undefined,
243
+ keywordType: submittedFilter.keyword ? submittedFilter.keywordType : undefined,
244
+ keyword: submittedFilter.keyword || undefined,
245
+ dateType: submittedFilter.startDate || submittedFilter.endDate ? submittedFilter.dateType : undefined,
246
+ startDate: submittedFilter.startDate || undefined,
247
+ endDate: submittedFilter.endDate || undefined,
248
+ });
249
+
250
+ const { data: detailUser } = useGetUser(modalMode === "detail" ? selectedUserId : null);
251
+ const { mutateAsync: postUser, isPending: isCreating } = usePostUser();
252
+ const { mutateAsync: putUser } = usePutUser();
253
+ const { mutateAsync: deleteUser } = useDeleteUser();
254
+ const { mutateAsync: deleteUsers } = useDeleteUsers();
255
+
256
+ const totalElements = data?.totalElements ?? 0;
257
+ const totalPages = data?.totalPages ?? 0;
258
+ const content = data?.content ?? [];
259
+
260
+ const openCreate = () => {
261
+ setModalMode("create");
262
+ };
263
+
264
+ const openDetail = (user: User) => {
265
+ setSelectedUserId(user.id);
266
+ controller.toRead();
267
+ setModalMode("detail");
268
+ };
269
+
270
+ const openDetailInEditMode = (user: User) => {
271
+ setSelectedUserId(user.id);
272
+ controller.toEdit();
273
+ setModalMode("detail");
274
+ };
275
+
276
+ const closeModal = () => {
277
+ controller.toRead();
278
+ setModalMode(null);
279
+ setSelectedUserId(null);
280
+ setProfilePhoto([]);
281
+ setEditProfilePhoto([]);
282
+ setDeleteFileIds([]);
283
+ };
284
+
285
+ // TODO FormFieldConfig에 autoComplete="off" prop 추가하여 적용 뒤 삭제 필요
286
+ useEffect(() => {
287
+ if (modalMode !== "create") return;
288
+ const timer = setTimeout(() => {
289
+ const form = document.getElementById("user-create-form");
290
+ if (!form) return;
291
+ form.setAttribute("autocomplete", "off");
292
+ form.querySelectorAll("input").forEach((input) => {
293
+ input.setAttribute("autocomplete", input.type === "password" ? "new-password" : "off");
294
+ });
295
+ }, 0);
296
+ return () => clearTimeout(timer);
297
+ }, [modalMode]);
298
+
299
+ const handleCreateFormSubmit = async (formData: UserCreateFormData) => {
300
+ const available = await checkUserIdAvailable(formData.userId);
301
+ if (!available) return;
302
+ await postUser({
303
+ data: {
304
+ userId: formData.userId,
305
+ name: formData.name,
306
+ password: formData.password,
307
+ role: formData.role as UserRole,
308
+ gender: formData.gender || undefined,
309
+ birthday: formData.birthday || undefined,
310
+ phone: formData.phone || undefined,
311
+ },
312
+ file: profilePhoto[0],
313
+ });
314
+ closeModal();
315
+ };
316
+
317
+ const handleDetailEditSave = async (formData: UserEditFormData) => {
318
+ if (!selectedUserId) return;
319
+ await putUser({
320
+ id: selectedUserId,
321
+ data: {
322
+ name: formData.name,
323
+ role: formData.role as UserRole,
324
+ active: formData.active,
325
+ gender: formData.gender || undefined,
326
+ birthday: formData.birthday || undefined,
327
+ phone: formData.phone || undefined,
328
+ ...(formData.password ? { password: formData.password } : {}),
329
+ ...(deleteFileIds.length > 0 ? { deleteFileIds } : {}),
330
+ },
331
+ file: editProfilePhoto[0],
332
+ });
333
+ };
334
+
335
+ const handleDelete = (user: User) => {
336
+ confirmModal({
337
+ content: `"${user.name}" 사용자를 삭제하시겠습니까?`,
338
+ onOk: async () => {
339
+ await deleteUser(user.id);
340
+ closeModal();
341
+ },
342
+ onCancel: () => {},
343
+ className: "max-w-100",
344
+ });
345
+ };
346
+
347
+ const displayUser = detailUser ?? content.find((u) => u.id === selectedUserId);
348
+ const viewData = displayUser ? toViewData(displayUser) : undefined;
349
+
350
+ const columns = getUserColumns({
351
+ onEdit: openDetailInEditMode,
352
+ onDelete: handleDelete,
353
+ totalElements,
354
+ page,
355
+ pageSize,
356
+ });
357
+
358
+ return (
359
+ <div className="p-6">
360
+ <ListHeader
361
+ title="사용자 관리"
362
+ rightArea={
363
+ <div className="flex items-center gap-2">
364
+ <Button variant="save" onClick={openCreate}>
365
+ 등록
366
+ </Button>
367
+ <Button
368
+ variant="delete"
369
+ disabled={selectedKeys.length === 0}
370
+ onClick={() => {
371
+ confirmModal({
372
+ content: `선택한 ${selectedKeys.length}개 항목을 삭제하시겠습니까?`,
373
+ onOk: async () => {
374
+ await deleteUsers(selectedKeys.map(Number));
375
+ setSelectedKeys([]);
376
+ },
377
+ onCancel: () => {},
378
+ className: "max-w-100",
379
+ });
380
+ }}
381
+ >
382
+ 삭제
383
+ </Button>
384
+ </div>
385
+ }
386
+ />
387
+ <ListContents>
388
+ <PageFilter
389
+ values={filterParams}
390
+ onChange={(updates: Partial<UserFilterParams>) =>
391
+ setFilterParams((prev) => ({ ...prev, ...updates }))
392
+ }
393
+ onSubmit={() => {
394
+ setPage(0);
395
+ setSelectedKeys([]);
396
+ setSubmittedFilter(filterParams);
397
+ }}
398
+ onReset={() => {
399
+ setFilterParams(EMPTY_USER_FILTER);
400
+ setSubmittedFilter(EMPTY_USER_FILTER);
401
+ setPage(0);
402
+ setPageSize(15);
403
+ setSelectedKeys([]);
404
+ }}
405
+ rows={[
406
+ {
407
+ options: [
408
+ { type: "select", key: "role", label: "구분", placeholder: "전체", options: ROLE_OPTIONS },
409
+ { type: "select", key: "dateType", label: "기간", options: DATE_TYPE_OPTIONS },
410
+ { type: "date-range", key: "startDate", endKey: "endDate" },
411
+ ],
412
+ },
413
+ {
414
+ options: [
415
+ { type: "select", key: "keywordType", label: "검색", options: KEYWORD_TYPE_OPTIONS },
416
+ { type: "input", key: "keyword", placeholder: "검색어 입력" },
417
+ ],
418
+ },
419
+ ]}
420
+ />
421
+ <Table
422
+ columns={columns}
423
+ data={content}
424
+ isLoading={isLoading}
425
+ rowKey="id"
426
+ onRowClick={openDetail}
427
+ paginationInfo={{
428
+ page,
429
+ totalPages,
430
+ onPageChange: (newPage: number) => {
431
+ setPage(newPage);
432
+ setSelectedKeys([]);
433
+ },
434
+ }}
435
+ checkboxInfo={{
436
+ selectedKeys,
437
+ onSelectionChange: setSelectedKeys,
438
+ }}
439
+ renderFooter={{
440
+ renderLeft: (
441
+ <p className="text-sm text-gray-600">
442
+ 총 <span className="font-semibold text-gray-900">{totalElements}</span>건
443
+ </p>
444
+ ),
445
+ renderRight: (
446
+ <div className="flex items-center gap-2.5 pr-5">
447
+ <p className="text-sm font-medium text-gray-700">페이지 개수</p>
448
+ <Select
449
+ value={String(pageSize)}
450
+ options={PAGE_SIZE_OPTIONS}
451
+ onChange={(val: string) => {
452
+ setPageSize(Number(val));
453
+ setPage(0);
454
+ setSelectedKeys([]);
455
+ }}
456
+ />
457
+ </div>
458
+ ),
459
+ }}
460
+ />
461
+ </ListContents>
462
+
463
+ {/* 상세/수정 모달 */}
464
+ <Modal
465
+ isOpen={modalMode === "detail"}
466
+ onClose={closeModal}
467
+ contentClassName="max-w-3xl rounded-xl bg-white"
468
+ >
469
+ <ModalIconHeader
470
+ type={controller.mode === "edit" ? "edit" : "detail"}
471
+ title={controller.mode === "edit" ? "사용자 수정" : "사용자 상세"}
472
+ onClose={closeModal}
473
+ />
474
+ <ModalBody className="px-6 py-5">
475
+ <div className="overflow-hidden border border-gray-200">
476
+ <DetailContent
477
+ key={selectedUserId}
478
+ controller={controller}
479
+ data={viewData}
480
+ readFields={USER_READ_FIELDS}
481
+ editSchema={userEditSchema}
482
+ editFields={USER_EDIT_FIELDS}
483
+ onSave={handleDetailEditSave}
484
+ layout="compact"
485
+ />
486
+ </div>
487
+ {(controller.mode === "edit" || !!displayUser?.files?.length) && (
488
+ <div className="mt-4 overflow-hidden rounded-lg border border-gray-200">
489
+ <div className="border-b border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700">
490
+ 프로필 사진
491
+ </div>
492
+ <div className="p-4">
493
+ {controller.mode === "edit" ? (
494
+ <>
495
+ {displayUser?.files?.[0] && !deleteFileIds.includes(displayUser.files[0].id) && (
496
+ <div className="mb-3">
497
+ <p className="mb-1 text-xs text-gray-500">현재 사진</p>
498
+ <div className="relative inline-block">
499
+ <img
500
+ src={`${import.meta.env.VITE_APP_API_HOST}${displayUser.files[0].filePath}`}
501
+ alt="현재 프로필 사진"
502
+ className="h-20 w-20 rounded-lg object-cover"
503
+ />
504
+ <button
505
+ type="button"
506
+ onClick={() => setDeleteFileIds((prev) => [...prev, displayUser.files![0].id])}
507
+ className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white hover:bg-red-600"
508
+ aria-label="현재 사진 삭제"
509
+ >
510
+ <X size={11} />
511
+ </button>
512
+ </div>
513
+ </div>
514
+ )}
515
+ <FileUploader files={editProfilePhoto} onChange={setEditProfilePhoto} />
516
+ </>
517
+ ) : (
518
+ <img
519
+ src={`${import.meta.env.VITE_APP_API_HOST}${displayUser!.files![0].filePath}`}
520
+ alt="프로필 사진"
521
+ className="h-32 w-32 rounded-lg object-cover"
522
+ />
523
+ )}
524
+ </div>
525
+ </div>
526
+ )}
527
+ </ModalBody>
528
+ <ModalFooter className="flex justify-end gap-2 border-t border-gray-200 bg-neutral-50 px-5 py-3">
529
+ {controller.mode === "edit" ? (
530
+ <>
531
+ <Button
532
+ variant="outline"
533
+ onClick={() => {
534
+ controller.toRead();
535
+ setEditProfilePhoto([]);
536
+ setDeleteFileIds([]);
537
+ }}
538
+ disabled={controller.isSaving}
539
+ >
540
+ 취소
541
+ </Button>
542
+ <Button type="submit" form={controller.formId} variant="save" disabled={controller.isSaving}>
543
+ {controller.isSaving ? "저장 중..." : "저장"}
544
+ </Button>
545
+ </>
546
+ ) : (
547
+ <>
548
+ {displayUser && (
549
+ <Button variant="save" onClick={() => controller.toEdit()}>
550
+ 수정
551
+ </Button>
552
+ )}
553
+ {displayUser && (
554
+ <Button variant="delete" onClick={() => handleDelete(displayUser)}>
555
+ 삭제
556
+ </Button>
557
+ )}
558
+ <Button variant="outline" onClick={closeModal}>
559
+ 닫기
560
+ </Button>
561
+ </>
562
+ )}
563
+ </ModalFooter>
564
+ </Modal>
565
+
566
+ {/* 등록 모달 */}
567
+ <Modal
568
+ isOpen={modalMode === "create"}
569
+ onClose={closeModal}
570
+ contentClassName="max-w-3xl rounded-xl bg-white"
571
+ >
572
+ <ModalIconHeader type="create" title="사용자 등록" onClose={closeModal} />
573
+ <ModalBody className="px-6 py-5">
574
+ <div className="overflow-hidden border border-gray-200">
575
+ <div className="overflow-y-auto max-h-[80vh]">
576
+ <SubmitForm
577
+ formId="user-create-form"
578
+ schema={userCreateSchema}
579
+ defaultValues={CREATE_DEFAULT_VALUES}
580
+ onSubmit={handleCreateFormSubmit}
581
+ >
582
+ <UserIdChecker />
583
+ <SubmitForm.Row formKey="userId" label="아이디" required maxLength={50} colspan={4} />
584
+ <SubmitForm.Row formKey="name" label="성명" required maxLength={50} colspan={4} />
585
+ <SubmitForm.Row
586
+ formKey="password"
587
+ formType="password"
588
+ label="비밀번호"
589
+ required
590
+ maxLength={100}
591
+ revalidateFields={["passwordCheck"]}
592
+ colspan={4}
593
+ />
594
+ <SubmitForm.Row
595
+ formKey="passwordCheck"
596
+ formType="password"
597
+ label="비밀번호 확인"
598
+ required
599
+ maxLength={100}
600
+ colspan={4}
601
+ />
602
+ <SubmitForm.Row
603
+ formKey="role"
604
+ formType="radio"
605
+ label="구분"
606
+ options={ROLE_SUBMIT_OPTIONS}
607
+ colspan={4}
608
+ />
609
+ <SubmitForm.Row
610
+ formKey="gender"
611
+ formType="radio"
612
+ label="성별"
613
+ options={GENDER_OPTIONS}
614
+ colspan={4}
615
+ />
616
+ <SubmitForm.Row formKey="birthday" formType="date" label="생년월일" colspan={4} />
617
+ <SubmitForm.Row formKey="phone" label="연락처" colspan={4} maxLength={20} />
618
+ </SubmitForm>
619
+ </div>
620
+ </div>
621
+ <div className="mt-4 overflow-hidden rounded-lg border border-gray-200">
622
+ <div className="border-b border-gray-200 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700">
623
+ 프로필 사진
624
+ </div>
625
+ <div className="p-4">
626
+ <FileUploader files={profilePhoto} onChange={setProfilePhoto} />
627
+ </div>
628
+ </div>
629
+ </ModalBody>
630
+ <ModalFooter className="flex justify-end gap-2 border-t border-gray-200 bg-neutral-50 px-5 py-3">
631
+ <Button type="submit" form="user-create-form" variant="save" disabled={isCreating}>
632
+ 등록
633
+ </Button>
634
+ <Button variant="outline" onClick={closeModal}>
635
+ 취소
636
+ </Button>
637
+ </ModalFooter>
638
+ </Modal>
639
+ </div>
640
+ );
641
+ }