@farmzone/fz-template-react 1.0.5 → 1.0.7

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