@axboot-mcp/mcp-server 1.0.0

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 (78) hide show
  1. package/CLAUDE.md +119 -0
  2. package/MCP_TOOL_PLAN.md +710 -0
  3. package/MCP_USAGE.md +914 -0
  4. package/README.md +168 -0
  5. package/REPOSITORY_CONVENTIONS.md +250 -0
  6. package/SEARCH_PARAMS_MCP_TOOL_COMPLETE_PLAN.md +646 -0
  7. package/SEARCH_PARAMS_PLAN.md +2570 -0
  8. package/STORE_PATTERNS.md +1178 -0
  9. package/debug-dto.js +72 -0
  10. package/generate-banner-store.js +62 -0
  11. package/generation-plan.json +2176 -0
  12. package/generation-results.json +1817 -0
  13. package/package.json +45 -0
  14. package/scripts/batch-generate-all.js +159 -0
  15. package/scripts/batch-generate-mcp.js +329 -0
  16. package/scripts/batch-generate-stores-v2.js +272 -0
  17. package/scripts/batch-generate-stores.js +179 -0
  18. package/scripts/batch-plan.json +3810 -0
  19. package/scripts/batch-process.py +90 -0
  20. package/scripts/batch-regenerate.js +356 -0
  21. package/scripts/direct-generate.js +227 -0
  22. package/scripts/execute-batches.js +1911 -0
  23. package/scripts/generate-all-stores.js +144 -0
  24. package/scripts/generate-stores-mcp.js +161 -0
  25. package/scripts/generate-stores-v2.js +450 -0
  26. package/scripts/generate-stores-v3.js +412 -0
  27. package/scripts/generate-stores-v4.js +521 -0
  28. package/scripts/generate-stores.js +382 -0
  29. package/scripts/repos-to-process.json +1899 -0
  30. package/src/config/nh-layout-patterns.ts +166 -0
  31. package/src/docs/HOOK_GENERATION_PLAN.md +2226 -0
  32. package/src/docs/NH_STORE_PATTERNS.md +297 -0
  33. package/src/docs/README.md +216 -0
  34. package/src/docs/index.ts +28 -0
  35. package/src/docs/loader.ts +568 -0
  36. package/src/docs/patterns.json +419 -0
  37. package/src/docs/practical-examples.md +732 -0
  38. package/src/docs/quick-start.md +257 -0
  39. package/src/docs/requirements-analysis-guide.md +364 -0
  40. package/src/docs/rules.json +321 -0
  41. package/src/docs/store-pattern-analysis.md +664 -0
  42. package/src/docs/store-patterns-rules.md +1168 -0
  43. package/src/docs/store-patterns-usage-guide.md +1835 -0
  44. package/src/docs/troubleshooting.md +544 -0
  45. package/src/docs/type-selection-guide.md +572 -0
  46. package/src/docs//354/202/254/354/232/251/353/262/225/AntD-/354/273/264/355/217/254/353/204/214/355/212/270-/354/202/254/354/232/251/353/262/225.md +1515 -0
  47. package/src/docs//354/202/254/354/232/251/353/262/225/DataGrid-/354/202/254/354/232/251/353/262/225.md +866 -0
  48. package/src/docs//354/202/254/354/232/251/353/262/225/FormItem-/354/202/254/354/232/251/353/262/225.md +903 -0
  49. package/src/docs//354/202/254/354/232/251/353/262/225/FormModal-/354/202/254/354/232/251/353/262/225.md +1155 -0
  50. package/src/docs//354/202/254/354/232/251/353/262/225/MCP-/353/260/224/354/235/264/353/270/214/354/275/224/353/224/251-/352/260/200/354/235/264/353/223/234.md +1133 -0
  51. package/src/docs//354/202/254/354/232/251/353/262/225/MSW-Mock-/353/215/260/354/235/264/355/204/260-/354/202/254/354/232/251/353/262/225.md +579 -0
  52. package/src/docs//354/202/254/354/232/251/353/262/225/Search-/354/273/264/355/217/254/353/204/214/355/212/270-/354/202/254/354/232/251/353/262/225.md +738 -0
  53. package/src/docs//354/202/254/354/232/251/353/262/225/Store-/355/214/250/355/204/264-/354/202/254/354/232/251/353/262/225.md +1135 -0
  54. package/src/docs//354/202/254/354/232/251/353/262/225//355/231/224/353/251/264/352/265/254/354/204/261-/355/203/200/354/236/205/353/263/204-/352/260/234/353/260/234/354/210/234/354/204/234.md +1805 -0
  55. package/src/docs//354/202/254/354/232/251/353/262/225//355/231/224/353/251/264/355/203/200/354/236/205/353/263/204-/352/260/234/353/260/234-/355/224/204/353/241/254/355/224/204/355/212/270-/352/260/200/354/235/264/353/223/234.md +946 -0
  56. package/src/docs//354/202/254/354/232/251/353/262/225//355/231/225/354/236/245/355/231/224/353/251/264/355/203/200/354/236/205/353/263/204-/354/203/201/354/204/270-/355/224/204/353/241/254/355/224/204/355/212/270/352/260/200/354/235/264/353/223/234.md +2422 -0
  57. package/src/features/store-features.ts +232 -0
  58. package/src/handlers/analyze-requirements.ts +403 -0
  59. package/src/handlers/analyze.ts +1373 -0
  60. package/src/handlers/generate-from-requirements.ts +250 -0
  61. package/src/handlers/generate-hook.ts +950 -0
  62. package/src/handlers/generate-interactive.ts +840 -0
  63. package/src/handlers/generate-listdatagrid.ts +521 -0
  64. package/src/handlers/generate-multi-stores.ts +577 -0
  65. package/src/handlers/generate-requirements-from-layout.ts +160 -0
  66. package/src/handlers/generate-search-params.ts +717 -0
  67. package/src/handlers/generate.ts +911 -0
  68. package/src/handlers/list-templates.ts +104 -0
  69. package/src/handlers/scan-metadata.ts +485 -0
  70. package/src/handlers/suggest-layout.ts +326 -0
  71. package/src/index.ts +959 -0
  72. package/src/prompts/search-params.md +793 -0
  73. package/src/templates/index.ts +107 -0
  74. package/src/templates/unified.ts +462 -0
  75. package/store-generation-error-patterns.md +225 -0
  76. package/test/useAgentStore.ts +136 -0
  77. package/test-server.js +78 -0
  78. package/tsconfig.json +20 -0
@@ -0,0 +1,1155 @@
1
+ # FormModal 컴포넌트 사용법
2
+
3
+ FormModal은 AXBoot 프레임워크 기반의 모달 창에서 폼 데이터를 등록/수정/삭제할 수 있는 표준화된 컴포넌트 패턴입니다. AXModal과 Ant Design Form을 조합하여 일관성 있는 사용자 경험을 제공합니다.
4
+
5
+ ## 기본 구조
6
+
7
+ ```tsx
8
+ import { AXModal } from "@core/components/AXModal";
9
+ import { Loading } from "@core/components/common";
10
+ import { useModalStore } from "@axboot/stores";
11
+ import { Form, Button, Input, Space } from "antd";
12
+
13
+ interface DtoItem extends YourDataType {}
14
+
15
+ export interface ModalRequest {
16
+ query?: Record<string, any>;
17
+ }
18
+
19
+ export interface ModalResponse {
20
+ save?: boolean;
21
+ delete?: boolean;
22
+ }
23
+
24
+ interface Props {
25
+ open: boolean;
26
+ onOk: (value: ModalResponse) => ModalResponse;
27
+ onCancel: (reason?: any) => void;
28
+ params: ModalRequest;
29
+ afterClose: () => void;
30
+ }
31
+
32
+ function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
33
+ // Modal 구현
34
+ }
35
+
36
+ export async function openFormModal(params: ModalRequest = {}) {
37
+ const openModal = useModalStore.getState().openModal;
38
+ return await openModal<ModalResponse>((open, resolve, reject, onClose, afterClose) => (
39
+ <FormModal open={open} onOk={resolve} onCancel={reject} afterClose={afterClose} params={params} />
40
+ ));
41
+ }
42
+ ```
43
+
44
+ ## 사용 예시
45
+
46
+ ### 1. 기본 CRUD 모달 (부서 관리)
47
+
48
+ ```tsx
49
+ import { AXModal } from "@core/components/AXModal";
50
+ import { Loading } from "@core/components/common";
51
+ import { confirmDialog } from "@core/components/dialogs";
52
+ import { useAntApp } from "@core/hooks";
53
+ import { useModalStore } from "@axboot/stores";
54
+ import { Button, Col, Form, Input, Radio, Row, Space } from "antd";
55
+ import { useDidMountEffect, useI18n } from "hooks";
56
+ import React, { useState } from "react";
57
+ import { errorHandling } from "utils";
58
+ import { TrtmntDept, TrtmntDeptService } from "../../../../services";
59
+
60
+ interface DtoItem extends TrtmntDept {}
61
+
62
+ function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
63
+ const { t } = useI18n();
64
+ const { messageApi } = useAntApp();
65
+
66
+ const [deleteSpinning, setDeleteSpinning] = useState(false);
67
+ const [saveSpinning, setSaveSpinning] = useState(false);
68
+ const [form] = Form.useForm();
69
+
70
+ // 저장 처리
71
+ const handleSave = React.useCallback(async () => {
72
+ try {
73
+ await form.validateFields();
74
+ setSaveSpinning(true);
75
+ const formValues = form.getFieldsValue();
76
+
77
+ await TrtmntDeptService.postTrtmntDeptSaveTrtmntDept({
78
+ ...formValues,
79
+ });
80
+
81
+ onOk({ save: true });
82
+ } catch (e) {
83
+ await errorHandling(e);
84
+ } finally {
85
+ setSaveSpinning(false);
86
+ }
87
+ }, [form, onOk]);
88
+
89
+ // 삭제 처리
90
+ const handleDelete = React.useCallback(async () => {
91
+ try {
92
+ await confirmDialog({
93
+ content: t("정말 삭제하시겠습니까?"),
94
+ });
95
+
96
+ if (!params.query?.trtmntDeptNo) return;
97
+ setDeleteSpinning(true);
98
+
99
+ await TrtmntDeptService.postTrtmntDeptDeleteTrtmntDept({
100
+ trtmntDeptNo: params.query.trtmntDeptNo,
101
+ });
102
+
103
+ onOk({ delete: true });
104
+ } catch (e) {
105
+ await errorHandling(e);
106
+ } finally {
107
+ setDeleteSpinning(false);
108
+ }
109
+ }, [onOk, params.query?.trtmntDeptNo, t]);
110
+
111
+ // 초기 데이터 설정
112
+ useDidMountEffect(() => {
113
+ form.setFieldsValue(params.query);
114
+ });
115
+
116
+ return (
117
+ <AXModal width={350} {...{ open, onCancel, onOk: onOk as any, afterClose }}>
118
+ <AXModal.Header title={params.query ? "부서 수정" : "부서 등록"} />
119
+
120
+ <AXModal.Body>
121
+ <Form form={form} layout="vertical" onFinish={handleSave} initialValues={{ useYn: "Y" }}>
122
+ <FormItem name="trtmntDeptNo" noStyle>
123
+ <Input type="hidden" />
124
+ </FormItem>
125
+
126
+ <Row gutter={12}>
127
+ <Col span={24}>
128
+ <FormItem name="trtmntDeptNm" label={t("부서명")} rules={[{ required: true }]}>
129
+ <Input />
130
+ </FormItem>
131
+ </Col>
132
+ <Col span={24}>
133
+ <FormItem
134
+ name="pgSvcMidValue"
135
+ label={t("MID")}
136
+ rules={[{ required: true }]}
137
+ tooltip="KG이니시스의 MID정보를 입력해 주세요."
138
+ >
139
+ <Input />
140
+ </FormItem>
141
+ </Col>
142
+ <Col xs={24} sm={12}>
143
+ <FormItem label={t("사용여부")} name="useYn" rules={[{ required: true }]}>
144
+ <Radio.Group
145
+ options={[
146
+ { value: "Y", label: "Y" },
147
+ { value: "N", label: "N" },
148
+ ]}
149
+ />
150
+ </FormItem>
151
+ </Col>
152
+ </Row>
153
+ </Form>
154
+ </AXModal.Body>
155
+
156
+ <AXModal.Footer>
157
+ <Space>
158
+ <Button type="primary" onClick={form.submit} loading={saveSpinning}>
159
+ {t("btn.저장")}
160
+ </Button>
161
+ <Button onClick={handleDelete} loading={deleteSpinning} color="danger" variant="solid">
162
+ {t("btn.삭제")}
163
+ </Button>
164
+ <Button onClick={onCancel}>{t("btn.취소")}</Button>
165
+ </Space>
166
+ </AXModal.Footer>
167
+ </AXModal>
168
+ );
169
+ }
170
+ ```
171
+
172
+ ### 2. 복합 탭 구조 모달 (제품 마스터)
173
+
174
+ ```tsx
175
+ import { AXModal } from "@core/components/AXModal";
176
+ import { Loading } from "@core/components/common";
177
+ import { confirmDialog } from "@core/components/dialogs";
178
+ import { useAntApp } from "@core/hooks";
179
+ import { useModalStore } from "@axboot/stores";
180
+ import { Button, Form, Input, Radio, Select, Space, Tabs } from "antd";
181
+ import { useDidMountEffect, useI18n } from "hooks";
182
+ import React, { useState } from "react";
183
+ import { errorHandling } from "utils";
184
+ import { ProdgrpMstRes, ProductGroupMasterService } from "../../../../services";
185
+
186
+ interface DtoItem extends ProdgrpMstRes {}
187
+
188
+ function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
189
+ const { t } = useI18n();
190
+ const { messageApi } = useAntApp();
191
+ const [form] = Form.useForm();
192
+
193
+ const [saveSpinning, setSaveSpinning] = useState(false);
194
+ const [duplSpinning, setDuplSpinning] = useState(false);
195
+ const [duplCheck, setDuplCheck] = useState(false);
196
+ const [activeTabType, setActiveTabType] = useState("스펙");
197
+ const [saveRequestValue, setSaveRequestValue] = React.useState<DtoItem>({});
198
+
199
+ // 폼 값 변경 감지
200
+ const onValuesChange = React.useCallback(
201
+ (_changedValues: any, values: Record<string, any>) => {
202
+ if ("prodgrpMastrNm" in _changedValues) {
203
+ setDuplCheck(false);
204
+ }
205
+ setSaveRequestValue({ ...saveRequestValue, ...values });
206
+ },
207
+ [saveRequestValue],
208
+ );
209
+
210
+ // 중복확인 처리
211
+ const handleDuplication = React.useCallback(async () => {
212
+ try {
213
+ setDuplSpinning(true);
214
+ const nm = form.getFieldValue("prodgrpMastrNm");
215
+
216
+ if (!nm) {
217
+ throw new Error("제품군 마스터명을 입력해 주세요.");
218
+ }
219
+
220
+ const result = await ProductGroupMasterService.postProductGroupMasterDuplprodgrpMstNm({
221
+ prodgrpMastrNm: nm,
222
+ });
223
+
224
+ setDuplCheck(result.rs.duplYn === "N");
225
+
226
+ if (result.rs.duplYn === "Y") {
227
+ messageApi.error("이미 사용중인 이름입니다.");
228
+ } else if (result.rs.duplYn === "N") {
229
+ messageApi.info("사용할 수 있는 이름입니다.");
230
+ }
231
+ } catch (e) {
232
+ await errorHandling(e);
233
+ } finally {
234
+ setDuplSpinning(false);
235
+ }
236
+ }, [form, messageApi]);
237
+
238
+ // 저장 처리
239
+ const handleSave = React.useCallback(async () => {
240
+ try {
241
+ setSaveSpinning(true);
242
+ await form.validateFields();
243
+
244
+ if (!duplCheck) {
245
+ throw new Error("제품군마스터명 중복확인을 해주세요.");
246
+ }
247
+
248
+ const values = form.getFieldsValue();
249
+ const dto01 = (values.specItmDtos || saveRequestValue.specItmDtos) ?? [];
250
+ const dto02 = (values.indvdlztnItmDtos || saveRequestValue.indvdlztnItmDtos) ?? [];
251
+
252
+ if (dto01.length === 0 && dto02.length === 0) {
253
+ throw new Error("하나 이상의 항목을 입력해 주세요");
254
+ }
255
+
256
+ const apiParams: DtoItem = {
257
+ ...values,
258
+ specItmDtos: dto01.map((item, idx) => ({ ...item, expsrSn: idx })),
259
+ indvdlztnItmDtos: dto02.map((item, idx) => ({ ...item, expsrSn: idx })),
260
+ };
261
+
262
+ await ProductGroupMasterService.postProductGroupMasterSaveprodgrpMst(apiParams);
263
+ onOk({ save: true });
264
+ } catch (e) {
265
+ await errorHandling(e);
266
+ } finally {
267
+ setSaveSpinning(false);
268
+ }
269
+ }, [duplCheck, form, onOk, saveRequestValue]);
270
+
271
+ return (
272
+ <AXModal width={1200} {...{ open, onCancel, onOk: onOk as any, afterClose }}>
273
+ <AXModal.Header title={params.query ? "제품군 마스터 수정" : "제품군 마스터 등록"}>
274
+ <Space>
275
+ <Button onClick={onCancel}>{t("btn.취소")}</Button>
276
+ <Button type="primary" onClick={handleSave} loading={saveSpinning}>
277
+ {t("btn.저장")}
278
+ </Button>
279
+ </Space>
280
+ </AXModal.Header>
281
+
282
+ <AXModal.Body>
283
+ <Form<DtoItem>
284
+ form={form}
285
+ layout="vertical"
286
+ onFinish={handleSave}
287
+ onValuesChange={onValuesChange}
288
+ initialValues={{
289
+ useYn: "N",
290
+ bassExpsrYn: "N",
291
+ specItmDtos: [],
292
+ indvdlztnItmDtos: [],
293
+ }}
294
+ >
295
+ <Row gutter={24}>
296
+ <Col span={10}>
297
+ <FormItem label={t("제품군마스터명")} required>
298
+ <Space.Compact style={{ width: "100%" }}>
299
+ <FormItem name="prodgrpMastrNm" rules={[{ required: true }]} noStyle>
300
+ <Input />
301
+ </FormItem>
302
+ <Button onClick={handleDuplication} loading={duplSpinning} disabled={duplCheck}>
303
+ 중복확인
304
+ </Button>
305
+ </Space.Compact>
306
+ </FormItem>
307
+ </Col>
308
+ <Col>
309
+ <FormItem name="useYn" label="사용여부" rules={[{ required: true }]}>
310
+ <Radio.Group
311
+ options={[
312
+ { value: "Y", label: "Y" },
313
+ { value: "N", label: "N" },
314
+ ]}
315
+ />
316
+ </FormItem>
317
+ </Col>
318
+ </Row>
319
+
320
+ {/* 탭 구조 */}
321
+ <Tabs
322
+ items={Object.values(MasterDetailTabType).map((value) => ({
323
+ key: value,
324
+ label: value,
325
+ }))}
326
+ activeKey={activeTabType}
327
+ type="line"
328
+ size="small"
329
+ onChange={(key) => {
330
+ setActiveTabType(key as MasterDetailTabType);
331
+ }}
332
+ />
333
+
334
+ <MasterDetailPanel tabType={activeTabType} form={form} />
335
+ </Form>
336
+ </AXModal.Body>
337
+ </AXModal>
338
+ );
339
+ }
340
+ ```
341
+
342
+ ### 3. 파일 업로드 기능 모달 (앱 배너)
343
+
344
+ ```tsx
345
+ import { AXModal } from "@core/components/AXModal";
346
+ import { Loading } from "@core/components/common";
347
+ import { DtPicker } from "@core/components/dtPicker";
348
+ import { DtPickerType } from "@core/components/dtPicker/types";
349
+ import { useAntApp } from "@core/hooks";
350
+ import { useModalStore } from "@axboot/stores";
351
+ import { Button, Form, Input, Switch, InputNumber } from "antd";
352
+ import { useUploadService } from "hooks/useUploadService";
353
+ import React, { useState } from "react";
354
+ import { errorHandling, dateRangeToDts } from "utils";
355
+ import { AppBanner, AppBannerMgmtService } from "services";
356
+ import { ImgFileUploaderById } from "components/formItems/ImgFileUploaderById";
357
+ import { UploadGuideLine } from "components/common/UploadGuideLine";
358
+
359
+ interface DtoItem extends Omit<AppBanner, "dspyYn"> {
360
+ dt_range?: [string, string];
361
+ dspyYn?: string | boolean;
362
+ }
363
+
364
+ function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
365
+ const { t } = useI18n();
366
+ const { messageApi } = useAntApp();
367
+ const [form] = Form.useForm<DtoItem>();
368
+ const [saveSpinning, setSaveSpinning] = useState(false);
369
+
370
+ // 파일 업로드 서비스
371
+ const {
372
+ flUploadId: img_flUploadId,
373
+ files: img_files,
374
+ commitFiles: img_commitFiles,
375
+ setFiles: img_setFiles,
376
+ uploadFile: img_uploadFile,
377
+ setDeleted: img_setDeleted,
378
+ handleDownload: img_handleDownload,
379
+ spinning: img_spinning,
380
+ } = useUploadService({ flUploadId: params.query?.imageFlNo });
381
+
382
+ // 저장 처리
383
+ const handleSave = React.useCallback(async () => {
384
+ try {
385
+ setSaveSpinning(true);
386
+ const values = await form.validateFields();
387
+
388
+ const saved_img_files = img_files?.filter((item) => item.__status__ !== "D")?.[0];
389
+
390
+ const saveItem: AppBanner = {
391
+ ...params.query,
392
+ ...values,
393
+ ...dateRangeToDts(values.dt_range, {
394
+ expsrBgnDtm: DT_FORMAT.DATETIME,
395
+ expsrEndDtm: DT_FORMAT.DATETIME,
396
+ }),
397
+ imageFlNo: img_flUploadId,
398
+ imageFlSn: saved_img_files?.flSn || 1,
399
+ dspyYn: values.dspyYn ? "Y" : "N",
400
+ };
401
+
402
+ if (!saved_img_files) {
403
+ throw new Error(t("이미지를 등록해주세요."));
404
+ }
405
+
406
+ await img_commitFiles();
407
+ await AppBannerMgmtService.postAppBannerMgmtSave(saveItem);
408
+
409
+ messageApi.success(t("저장되었습니다."));
410
+ onOk({ save: true });
411
+ } catch (e) {
412
+ await errorHandling(e);
413
+ } finally {
414
+ setSaveSpinning(false);
415
+ }
416
+ }, [form, params.query, img_files, img_flUploadId, img_commitFiles, onOk, messageApi, t]);
417
+
418
+ return (
419
+ <AXModal width={1000} {...{ open, onCancel, onOk: onOk as any, afterClose }}>
420
+ <AXModal.Header title={isEditMode ? t("앱 스플래시 배너 수정") : t("앱 스플래시 배너 등록")}>
421
+ <Button type="primary" onClick={handleSave} loading={saveSpinning}>
422
+ {t("btn.저장")}
423
+ </Button>
424
+ </AXModal.Header>
425
+
426
+ <AXModal.Body>
427
+ <Form form={form} layout="vertical" onFinish={handleSave}>
428
+ <Row gutter={16}>
429
+ <Col span={12}>
430
+ <FormItem name="appBannerNm" label={t("배너명")} rules={[{ required: true }]}>
431
+ <Input placeholder={t("배너명을 입력하세요")} />
432
+ </FormItem>
433
+ </Col>
434
+ <Col span={12}>
435
+ <FormItem name="dspyYn" label={t("전시여부")} valuePropName="checked">
436
+ <Switch checkedChildren="전시" unCheckedChildren="미전시" />
437
+ </FormItem>
438
+ </Col>
439
+ <Col span={12}>
440
+ <FormItem
441
+ name="dt_range"
442
+ label={t("노출기간")}
443
+ rules={[{ required: true, message: t("노출기간을 선택해주세요") }]}
444
+ >
445
+ <DtPicker type={DtPickerType.DATE_RANGE} placeholder={[t("노출 시작일시"), t("노출 종료일시")]} />
446
+ </FormItem>
447
+ </Col>
448
+ <Col span={12}>
449
+ <FormItem name="expsrSecndValue" label={t("배너노출시간설정")} rules={[{ required: true }]}>
450
+ <InputNumber
451
+ min={1}
452
+ max={60}
453
+ addonAfter="초"
454
+ placeholder={t("노출할 초값을 입력하세요")}
455
+ style={{ width: "20%" }}
456
+ />
457
+ </FormItem>
458
+ </Col>
459
+ <Col span={12}>
460
+ <FormItem label={t("스플래시 배너 이미지")} required>
461
+ <div style={{ display: "flex", alignItems: "flex-start", gap: "16px" }}>
462
+ <div style={{ flex: "0 0 auto" }}>
463
+ <ImgFileUploaderById
464
+ value={img_files}
465
+ spinning={img_spinning}
466
+ onChange={img_setFiles}
467
+ handleUpload={img_uploadFile}
468
+ handleDelete={img_setDeleted}
469
+ handleDownload={img_handleDownload}
470
+ accept="image/png,image/svg+xml"
471
+ />
472
+ </div>
473
+ <div style={{ flex: "1 1 auto" }}>
474
+ <UploadGuideLine type="appSplashBanner" />
475
+ </div>
476
+ </div>
477
+ </FormItem>
478
+ </Col>
479
+ </Row>
480
+ </Form>
481
+ </AXModal.Body>
482
+ </AXModal>
483
+ );
484
+ }
485
+ ```
486
+
487
+ ### 4. 복합 데이터 처리 모달 (타임딜 혜택)
488
+
489
+ ```tsx
490
+ import { AXModal } from "@core/components/AXModal";
491
+ import { Loading } from "@core/components/common";
492
+ import { useAntApp } from "@core/hooks";
493
+ import { useModalStore } from "@axboot/stores";
494
+ import { Button, Form, Input, InputNumber, Select, Table, Space } from "antd";
495
+ import { useDidMountEffect, useI18n } from "hooks";
496
+ import React, { useState, useCallback } from "react";
497
+ import { errorHandling } from "utils";
498
+ import { BnefResTimeDeal, BnefService, PrdRes } from "../../../../services";
499
+ import { openSelectProductModal } from "../../../../modals/select-product/SelectProductModal";
500
+
501
+ interface PrdItem extends PrdRes {}
502
+ interface DtoItem extends BnefResTimeDeal {
503
+ dateRange?: [string, string];
504
+ bnefDscntStupValue?: number;
505
+ bnefDscntStupTpcd?: string;
506
+ bnefStupQty?: number;
507
+ sleLmttStupYn?: string;
508
+ amtTrncClcd?: string;
509
+ }
510
+
511
+ function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
512
+ const { t } = useI18n();
513
+ const { messageApi } = useAntApp();
514
+ const [form] = Form.useForm();
515
+
516
+ const [prdList, setPrdList] = React.useState<PrdItem[]>([]);
517
+ const [discountValues, setDiscountValues] = React.useState({
518
+ bnefDscntStupValue: undefined,
519
+ bnefDscntStupTpcd: "10",
520
+ amtTrncClcd: "10",
521
+ });
522
+ const [saveSpinning, setSaveSpinning] = useState(false);
523
+
524
+ // 할인가 계산 함수
525
+ const calculateDiscountedPrice = React.useCallback(
526
+ (salePrice: number, discountValue: number, discountType: string, truncationType: string) => {
527
+ if (!discountValue || discountValue === 0) {
528
+ return salePrice;
529
+ }
530
+
531
+ let discountedPrice: number;
532
+
533
+ if (discountType === "10") { // 정률할인
534
+ discountedPrice = salePrice * (1 - discountValue / 100);
535
+ } else { // 정액할인
536
+ discountedPrice = salePrice - discountValue;
537
+ }
538
+
539
+ // 절삭 처리
540
+ if (truncationType === "10") { // 원단위 절삭
541
+ discountedPrice = Math.floor(discountedPrice);
542
+ } else if (truncationType === "20") { // 십원단위 절삭
543
+ discountedPrice = Math.floor(discountedPrice / 10) * 10;
544
+ } else if (truncationType === "30") { // 백원단위 절삭
545
+ discountedPrice = Math.floor(discountedPrice / 100) * 100;
546
+ }
547
+
548
+ return Math.max(discountedPrice, 0);
549
+ },
550
+ [],
551
+ );
552
+
553
+ // 상품 선택 모달
554
+ const handleSelectProduct = useCallback(async () => {
555
+ try {
556
+ const result = await openSelectProductModal({});
557
+ if (result?.selectedProducts) {
558
+ const newProducts = result.selectedProducts.map((product: PrdRes) => ({
559
+ ...product,
560
+ discountedPrice: calculateDiscountedPrice(
561
+ product.salePrc || 0,
562
+ discountValues.bnefDscntStupValue || 0,
563
+ discountValues.bnefDscntStupTpcd || "10",
564
+ discountValues.amtTrncClcd || "10"
565
+ ),
566
+ }));
567
+ setPrdList([...prdList, ...newProducts]);
568
+ }
569
+ } catch (e) {
570
+ await errorHandling(e);
571
+ }
572
+ }, [prdList, calculateDiscountedPrice, discountValues]);
573
+
574
+ // 상품 목록 컬럼 정의
575
+ const productColumns = [
576
+ {
577
+ title: "이미지",
578
+ dataIndex: "thumbImgUrl",
579
+ width: 80,
580
+ render: (url: string) => <ThumbPreview url={url} size={50} />,
581
+ },
582
+ {
583
+ title: "상품명",
584
+ dataIndex: "prdNm",
585
+ width: 200,
586
+ },
587
+ {
588
+ title: "정가",
589
+ dataIndex: "stdPrc",
590
+ width: 100,
591
+ render: (value: number) => formatterNumber(value),
592
+ },
593
+ {
594
+ title: "판매가",
595
+ dataIndex: "salePrc",
596
+ width: 100,
597
+ render: (value: number) => formatterNumber(value),
598
+ },
599
+ {
600
+ title: "할인가",
601
+ dataIndex: "discountedPrice",
602
+ width: 100,
603
+ render: (value: number) => formatterNumber(value),
604
+ },
605
+ {
606
+ title: "관리",
607
+ width: 80,
608
+ render: (_: any, record: PrdItem, index: number) => (
609
+ <Button
610
+ size="small"
611
+ danger
612
+ onClick={() => {
613
+ const newList = [...prdList];
614
+ newList.splice(index, 1);
615
+ setPrdList(newList);
616
+ }}
617
+ >
618
+ 삭제
619
+ </Button>
620
+ ),
621
+ },
622
+ ];
623
+
624
+ // 저장 처리
625
+ const handleSave = React.useCallback(async () => {
626
+ try {
627
+ setSaveSpinning(true);
628
+ const values = await form.validateFields();
629
+
630
+ if (prdList.length === 0) {
631
+ throw new Error("상품을 선택해주세요.");
632
+ }
633
+
634
+ const saveData = {
635
+ ...values,
636
+ ...discountValues,
637
+ prdList: prdList.map(item => ({
638
+ prdNo: item.prdNo,
639
+ discountedPrice: item.discountedPrice,
640
+ })),
641
+ };
642
+
643
+ await BnefService.postBnefSaveTimeDeal(saveData);
644
+ messageApi.success(t("저장되었습니다."));
645
+ onOk({ save: true });
646
+ } catch (e) {
647
+ await errorHandling(e);
648
+ } finally {
649
+ setSaveSpinning(false);
650
+ }
651
+ }, [form, prdList, discountValues, onOk, messageApi, t]);
652
+
653
+ return (
654
+ <AXModal width={1400} {...{ open, onCancel, onOk: onOk as any, afterClose }}>
655
+ <AXModal.Header title="타임딜 혜택 설정">
656
+ <Space>
657
+ <Button onClick={onCancel}>{t("btn.취소")}</Button>
658
+ <Button type="primary" onClick={handleSave} loading={saveSpinning}>
659
+ {t("btn.저장")}
660
+ </Button>
661
+ </Space>
662
+ </AXModal.Header>
663
+
664
+ <AXModal.Body>
665
+ <Form form={form} layout="vertical">
666
+ {/* 기본 정보 */}
667
+ <Divider orientation="left">기본 정보</Divider>
668
+ <Row gutter={16}>
669
+ <Col span={8}>
670
+ <FormItem name="bnefNm" label="혜택명" rules={[{ required: true }]}>
671
+ <Input />
672
+ </FormItem>
673
+ </Col>
674
+ <Col span={8}>
675
+ <FormItem name="dateRange" label="이벤트 기간" rules={[{ required: true }]}>
676
+ <DtPicker type={DtPickerType.DATE_TIME_RANGE} />
677
+ </FormItem>
678
+ </Col>
679
+ </Row>
680
+
681
+ {/* 할인 설정 */}
682
+ <Divider orientation="left">할인 설정</Divider>
683
+ <Row gutter={16}>
684
+ <Col span={6}>
685
+ <FormItem label="할인 타입">
686
+ <Select
687
+ value={discountValues.bnefDscntStupTpcd}
688
+ onChange={(value) => setDiscountValues(prev => ({ ...prev, bnefDscntStupTpcd: value }))}
689
+ options={[
690
+ { value: "10", label: "정률할인(%)" },
691
+ { value: "20", label: "정액할인(원)" },
692
+ ]}
693
+ />
694
+ </FormItem>
695
+ </Col>
696
+ <Col span={6}>
697
+ <FormItem label="할인 값">
698
+ <InputNumber
699
+ style={{ width: "100%" }}
700
+ value={discountValues.bnefDscntStupValue}
701
+ onChange={(value) => setDiscountValues(prev => ({ ...prev, bnefDscntStupValue: value }))}
702
+ min={0}
703
+ addonAfter={discountValues.bnefDscntStupTpcd === "10" ? "%" : "원"}
704
+ />
705
+ </FormItem>
706
+ </Col>
707
+ <Col span={6}>
708
+ <FormItem label="절삭 단위">
709
+ <Select
710
+ value={discountValues.amtTrncClcd}
711
+ onChange={(value) => setDiscountValues(prev => ({ ...prev, amtTrncClcd: value }))}
712
+ options={[
713
+ { value: "10", label: "원단위" },
714
+ { value: "20", label: "십원단위" },
715
+ { value: "30", label: "백원단위" },
716
+ ]}
717
+ />
718
+ </FormItem>
719
+ </Col>
720
+ </Row>
721
+
722
+ {/* 상품 목록 */}
723
+ <Divider orientation="left">적용 상품</Divider>
724
+ <Space style={{ marginBottom: 16 }}>
725
+ <Button onClick={handleSelectProduct}>상품 선택</Button>
726
+ <span>총 {prdList.length}개 상품</span>
727
+ </Space>
728
+
729
+ <Table
730
+ columns={productColumns}
731
+ dataSource={prdList}
732
+ rowKey="prdNo"
733
+ pagination={false}
734
+ size="small"
735
+ scroll={{ y: 300 }}
736
+ />
737
+ </Form>
738
+ </AXModal.Body>
739
+ </AXModal>
740
+ );
741
+ }
742
+ ```
743
+
744
+ ### 5. 읽기 전용 상세보기 모달 (다이렉트 렌탈)
745
+
746
+ ```tsx
747
+ import { AXModal } from "@core/components/AXModal";
748
+ import { Loading } from "@core/components/common";
749
+ import { useAntApp } from "@core/hooks";
750
+ import { useModalStore } from "@axboot/stores";
751
+ import { Button, Descriptions, Form, Input, Space } from "antd";
752
+ import { useDidMountEffect, useI18n, useLink } from "hooks";
753
+ import React, { useState } from "react";
754
+ import { errorHandling } from "utils";
755
+ import { RmsOrderDrt, RmsOrderDrtService, MemberService } from "services";
756
+ import { useAppMenu } from "../../../../router";
757
+
758
+ interface DtoItem extends RmsOrderDrt {}
759
+
760
+ export function FormModal({ open, onOk, onCancel, afterClose, params }: Props) {
761
+ const { t } = useI18n();
762
+ const { messageApi } = useAntApp();
763
+ const [form] = Form.useForm();
764
+
765
+ const [saveSpinning, setSaveSpinning] = React.useState(false);
766
+ const [detailSpinning, setDetailSpinning] = useState(false);
767
+ const [memberTypeLabel, setMemberTypeLabel] = useState<string>("");
768
+ const { MENUS_LIST } = useAppMenu();
769
+ const { linkByMenu } = useLink();
770
+
771
+ // 저장 처리 (메모만 수정 가능)
772
+ const handleSave = React.useCallback(async () => {
773
+ setSaveSpinning(true);
774
+ try {
775
+ const formValues = await form.validateFields();
776
+
777
+ if (params.query?.ifSn) {
778
+ await RmsOrderDrtService.postRmsOrderDrtUpdatermsOrderDrtMemo({
779
+ ifSn: params.query.ifSn,
780
+ mngrMemoCtnts: formValues.mngrMemoCtnts || "",
781
+ });
782
+
783
+ messageApi.success(t("저장되었습니다."));
784
+ onOk({ save: true });
785
+ }
786
+ } catch (err: any) {
787
+ await errorHandling(err);
788
+ } finally {
789
+ setSaveSpinning(false);
790
+ }
791
+ }, [messageApi, t, form, params.query, onOk]);
792
+
793
+ // 상세 정보 로드
794
+ useDidMountEffect(() => {
795
+ (async () => {
796
+ if (params.query?.ifSn) {
797
+ setDetailSpinning(true);
798
+ try {
799
+ const result = await RmsOrderDrtService.postRmsOrderDrtDetailRmsOrderDrt({
800
+ ifSn: params.query.ifSn,
801
+ });
802
+ form.setFieldsValue(result.rs);
803
+
804
+ // 회원구분 조회
805
+ if (params.query?.usrNo) {
806
+ try {
807
+ const memberResult = await MemberService.postMemberDtlViewMember({
808
+ usrNo: params.query.usrNo,
809
+ });
810
+ if (memberResult.rs?.usrTpcd) {
811
+ const usrTpcdLabel = USR_TPCD?.options?.find(
812
+ (code) => code.value === memberResult.rs.usrTpcd
813
+ )?.label || "";
814
+ const niasLabel = memberResult.rs.niasYn === "Y" ? "(통합회원)" : "";
815
+ setMemberTypeLabel(usrTpcdLabel + niasLabel);
816
+ }
817
+ } catch (err) {
818
+ setMemberTypeLabel("");
819
+ }
820
+ }
821
+ } catch (err) {
822
+ await errorHandling(err);
823
+ } finally {
824
+ setDetailSpinning(false);
825
+ }
826
+ }
827
+ })();
828
+ });
829
+
830
+ // 연관 메뉴로 이동
831
+ const handleLink = React.useCallback(
832
+ async (progId, no) => {
833
+ const menu = MENUS_LIST.find((m) => m.progId === progId);
834
+ if (!menu) return;
835
+ linkByMenu(menu, { USR_NO: no });
836
+ },
837
+ [MENUS_LIST, linkByMenu],
838
+ );
839
+
840
+ return (
841
+ <AXModal width={1400} {...{ open, onCancel, onOk: onOk as any, afterClose }}>
842
+ <AXModal.Header title={t("다이렉트렌탈 주문내역 상세")}>
843
+ <Space>
844
+ <Button type="primary" onClick={handleSave} loading={saveSpinning}>
845
+ {t("저장")}
846
+ </Button>
847
+ <Button onClick={onCancel}>{t("취소")}</Button>
848
+ </Space>
849
+ </AXModal.Header>
850
+
851
+ <AXModal.Body>
852
+ <Form form={form} layout="vertical">
853
+ {/* 신청정보 */}
854
+ <FormTitle>신청정보</FormTitle>
855
+ <Descriptions
856
+ bordered
857
+ column={3}
858
+ size="small"
859
+ style={{ width: "100%" }}
860
+ styles={{
861
+ label: { textAlign: "left", minWidth: 100, width: 140 },
862
+ content: { textAlign: "left" }
863
+ }}
864
+ items={[
865
+ { label: t("주문번호"), children: params.query?.ifId },
866
+ {
867
+ label: t("회원명"),
868
+ children: (
869
+ <a onClick={() => handleLink("MEMBER_LIST", params.query?.usrNo)}>
870
+ {params.query?.usrNm}
871
+ </a>
872
+ )
873
+ },
874
+ { label: t("회원구분"), children: memberTypeLabel },
875
+ { label: t("신청일시"), children: dayjs(params.query?.appDtm).format("YYYY-MM-DD HH:mm:ss") },
876
+ { label: t("연락처"), children: params.query?.usrHpNo },
877
+ { label: t("이메일"), children: params.query?.usrEmailAddr },
878
+ // ... 더 많은 필드들
879
+ ]}
880
+ />
881
+
882
+ {/* 관리자 메모 (편집 가능) */}
883
+ <FormTitle style={{ marginTop: 24 }}>관리자 메모</FormTitle>
884
+ <FormItem name="mngrMemoCtnts">
885
+ <Input.TextArea rows={4} placeholder="관리자 메모를 입력하세요" />
886
+ </FormItem>
887
+ </Form>
888
+
889
+ <Loading active={detailSpinning} />
890
+ </AXModal.Body>
891
+ </AXModal>
892
+ );
893
+ }
894
+
895
+ const FormTitle = styled.div`
896
+ font-size: 16px;
897
+ font-weight: bold;
898
+ margin: 16px 0 8px 0;
899
+ padding: 8px 0;
900
+ border-bottom: 1px solid #d9d9d9;
901
+ `;
902
+ ```
903
+
904
+ ## 주요 패턴
905
+
906
+ ### 1. 상태 관리
907
+
908
+ ```tsx
909
+ // 로딩 상태
910
+ const [saveSpinning, setSaveSpinning] = useState(false);
911
+ const [deleteSpinning, setDeleteSpinning] = useState(false);
912
+ const [detailSpinning, setDetailSpinning] = useState(false);
913
+
914
+ // 폼 관리
915
+ const [form] = Form.useForm();
916
+
917
+ // 커스텀 상태
918
+ const [duplCheck, setDuplCheck] = useState(false);
919
+ const [activeTabType, setActiveTabType] = useState("기본");
920
+ ```
921
+
922
+ ### 2. 데이터 로딩
923
+
924
+ ```tsx
925
+ useDidMountEffect(() => {
926
+ if (params.query?.id) {
927
+ // 수정 모드 - 상세 데이터 로드
928
+ (async () => {
929
+ try {
930
+ setDetailSpinning(true);
931
+ const result = await YourService.getDetail({ id: params.query.id });
932
+ form.setFieldsValue(result.data);
933
+ } catch (e) {
934
+ await errorHandling(e);
935
+ } finally {
936
+ setDetailSpinning(false);
937
+ }
938
+ })();
939
+ } else {
940
+ // 신규 모드 - 기본값 설정
941
+ form.setFieldsValue({ status: "Y", priority: 1 });
942
+ }
943
+ });
944
+ ```
945
+
946
+ ### 3. 저장/삭제 처리
947
+
948
+ ```tsx
949
+ const handleSave = React.useCallback(async () => {
950
+ try {
951
+ setSaveSpinning(true);
952
+ const values = await form.validateFields();
953
+
954
+ // 데이터 가공
955
+ const saveData = {
956
+ ...params.query, // 기존 데이터 (수정 시)
957
+ ...values, // 폼 데이터
958
+ // 추가 처리 로직
959
+ };
960
+
961
+ await YourService.save(saveData);
962
+ messageApi.success(t("저장되었습니다."));
963
+ onOk({ save: true });
964
+ } catch (e) {
965
+ await errorHandling(e);
966
+ } finally {
967
+ setSaveSpinning(false);
968
+ }
969
+ }, [form, params, onOk]);
970
+
971
+ const handleDelete = React.useCallback(async () => {
972
+ try {
973
+ await confirmDialog({
974
+ content: t("정말 삭제하시겠습니까?"),
975
+ });
976
+
977
+ setDeleteSpinning(true);
978
+ await YourService.delete({ id: params.query.id });
979
+
980
+ messageApi.success(t("삭제되었습니다."));
981
+ onOk({ delete: true });
982
+ } catch (e) {
983
+ await errorHandling(e);
984
+ } finally {
985
+ setDeleteSpinning(false);
986
+ }
987
+ }, [params.query?.id, onOk]);
988
+ ```
989
+
990
+ ### 4. 파일 업로드 통합
991
+
992
+ ```tsx
993
+ // useUploadService 훅 사용
994
+ const {
995
+ flUploadId,
996
+ files,
997
+ commitFiles,
998
+ setFiles,
999
+ uploadFile,
1000
+ setDeleted,
1001
+ handleDownload,
1002
+ spinning: fileSpinning,
1003
+ } = useUploadService({
1004
+ flUploadId: params.query?.fileUploadId
1005
+ });
1006
+
1007
+ // 저장 시 파일 커밋
1008
+ const handleSave = React.useCallback(async () => {
1009
+ try {
1010
+ const values = await form.validateFields();
1011
+
1012
+ // 파일 검증
1013
+ const savedFiles = files?.filter((item) => item.__status__ !== "D");
1014
+ if (savedFiles?.length === 0) {
1015
+ throw new Error(t("파일을 업로드해주세요."));
1016
+ }
1017
+
1018
+ // 파일 커밋
1019
+ await commitFiles();
1020
+
1021
+ // 데이터 저장
1022
+ const saveData = {
1023
+ ...values,
1024
+ fileUploadId: flUploadId,
1025
+ fileSeq: savedFiles[0]?.flSn || 1,
1026
+ };
1027
+
1028
+ await YourService.save(saveData);
1029
+ onOk({ save: true });
1030
+ } catch (e) {
1031
+ await errorHandling(e);
1032
+ }
1033
+ }, [form, files, commitFiles, flUploadId, onOk]);
1034
+ ```
1035
+
1036
+ ## 모달 호출 방법
1037
+
1038
+ ```tsx
1039
+ // 신규 등록
1040
+ const handleNew = async () => {
1041
+ const result = await openFormModal({});
1042
+ if (result?.save) {
1043
+ await refreshList();
1044
+ }
1045
+ };
1046
+
1047
+ // 수정
1048
+ const handleEdit = async (item: DataType) => {
1049
+ const result = await openFormModal({
1050
+ query: item
1051
+ });
1052
+ if (result?.save || result?.delete) {
1053
+ await refreshList();
1054
+ }
1055
+ };
1056
+
1057
+ // 복사 모드
1058
+ const handleCopy = async (item: DataType) => {
1059
+ const result = await openFormModal({
1060
+ query: { ...item, id: undefined },
1061
+ copyMode: "true"
1062
+ });
1063
+ if (result?.save) {
1064
+ await refreshList();
1065
+ }
1066
+ };
1067
+ ```
1068
+
1069
+ ## 주요 컴포넌트
1070
+
1071
+ ### Form 컴포넌트들
1072
+ - **Input**: 텍스트 입력
1073
+ - **InputNumber**: 숫자 입력
1074
+ - **Select**: 드롭다운 선택
1075
+ - **Radio.Group**: 라디오 버튼
1076
+ - **Switch**: 토글 스위치
1077
+ - **DtPicker**: 날짜/시간 선택
1078
+ - **ImgFileUploaderById**: 이미지 업로드
1079
+ - **MultiFileUploaderById**: 다중 파일 업로드
1080
+ - **SynapEditorMiniComponent**: 에디터
1081
+
1082
+ ### 레이아웃 컴포넌트들
1083
+ - **Row, Col**: 그리드 레이아웃
1084
+ - **Space**: 간격 조정
1085
+ - **Divider**: 구분선
1086
+ - **Tabs**: 탭 구조
1087
+ - **Descriptions**: 설명 목록
1088
+ - **Table**: 데이터 테이블
1089
+
1090
+ ## 모범 사례
1091
+
1092
+ ### 1. 일관된 인터페이스
1093
+ ```tsx
1094
+ export interface ModalRequest {
1095
+ query?: Record<string, any>;
1096
+ copyMode?: boolean;
1097
+ }
1098
+
1099
+ export interface ModalResponse {
1100
+ save?: boolean;
1101
+ delete?: boolean;
1102
+ }
1103
+ ```
1104
+
1105
+ ### 2. 에러 처리
1106
+ ```tsx
1107
+ try {
1108
+ // 작업 수행
1109
+ } catch (e) {
1110
+ await errorHandling(e); // 통합 에러 처리
1111
+ } finally {
1112
+ setSpinning(false);
1113
+ }
1114
+ ```
1115
+
1116
+ ### 3. 폼 검증
1117
+ ```tsx
1118
+ // 기본 검증
1119
+ <FormItem name="name" rules={[{ required: true }]}>
1120
+ <Input />
1121
+ </FormItem>
1122
+
1123
+ // 커스텀 검증
1124
+ const handleSave = async () => {
1125
+ await form.validateFields();
1126
+
1127
+ // 추가 검증 로직
1128
+ if (!customValidation) {
1129
+ throw new Error("커스텀 검증 실패");
1130
+ }
1131
+
1132
+ // 저장 처리
1133
+ };
1134
+ ```
1135
+
1136
+ ### 4. 로딩 상태
1137
+ ```tsx
1138
+ <Button type="primary" onClick={handleSave} loading={saveSpinning}>
1139
+ {t("btn.저장")}
1140
+ </Button>
1141
+
1142
+ <Loading active={detailSpinning} />
1143
+ ```
1144
+
1145
+ ## 주의사항
1146
+
1147
+ 1. **메모리 누수 방지**: `useDidMountEffect` 사용으로 마운트 시에만 실행
1148
+ 2. **상태 초기화**: 모달이 닫힐 때 상태 정리
1149
+ 3. **폼 검증**: 필수 필드와 커스텀 검증 로직 구현
1150
+ 4. **에러 처리**: 통합된 `errorHandling` 함수 사용
1151
+ 5. **로딩 상태**: 사용자 경험을 위한 적절한 로딩 표시
1152
+ 6. **권한 체크**: `programFn`을 통한 기능별 권한 확인
1153
+ 7. **다국어 지원**: `useI18n` 훅 활용
1154
+
1155
+ 이러한 패턴을 따라서 일관성 있고 유지보수하기 쉬운 FormModal을 구현할 수 있습니다.