@farmzone/fz-template-react 1.0.3 → 1.0.5

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 (51) hide show
  1. package/README.md +46 -50
  2. package/package.json +1 -1
  3. package/template/.env.example +5 -5
  4. package/template/package.json +55 -55
  5. package/template/pnpm-lock.yaml +4214 -4214
  6. package/template/public/mockServiceWorker.js +349 -349
  7. package/template/src/app/api/api.ts +178 -178
  8. package/template/src/app/api/queries.ts +326 -321
  9. package/template/src/app/api/queryKey.ts +7 -7
  10. package/template/src/app/api/token.ts +7 -7
  11. package/template/src/app/layout/Layout.tsx +33 -33
  12. package/template/src/app/layout/ListContents.tsx +9 -9
  13. package/template/src/app/layout/ListHeader.tsx +41 -41
  14. package/template/src/app/layout/MultiTabNav.tsx +101 -101
  15. package/template/src/app/layout/Sidebar.tsx +33 -33
  16. package/template/src/app/layout/UserInfo.tsx +94 -94
  17. package/template/src/app/layout/tabSwitchStore.ts +11 -11
  18. package/template/src/app/router/Router.tsx +56 -56
  19. package/template/src/app/store/index.ts +26 -26
  20. package/template/src/index.tsx +21 -21
  21. package/template/src/mocks/browser.ts +17 -17
  22. package/template/src/mocks/handlers.ts +43 -43
  23. package/template/src/mocks/scenarios.ts +57 -57
  24. package/template/src/pages/dashboard/index.tsx +541 -541
  25. package/template/src/pages/error/Error.tsx +29 -29
  26. package/template/src/pages/error/NotFound.tsx +27 -27
  27. package/template/src/pages/login/index.tsx +317 -317
  28. package/template/src/pages/post/PostFormModal.tsx +128 -128
  29. package/template/src/pages/post/detail/index.tsx +548 -548
  30. package/template/src/pages/post/index.tsx +266 -267
  31. package/template/src/pages/sample/SampleFormModal.tsx +115 -77
  32. package/template/src/pages/sample/detail/index.tsx +400 -424
  33. package/template/src/pages/sample/index.tsx +278 -269
  34. package/template/src/pages/sample/modal/index.tsx +300 -253
  35. package/template/src/pages/system/log/index.tsx +173 -173
  36. package/template/src/pages/user/config/columns.tsx +102 -109
  37. package/template/src/pages/user/config/schema.ts +54 -54
  38. package/template/src/pages/user/index.tsx +641 -641
  39. package/template/src/shared/components/CommentInput.tsx +243 -243
  40. package/template/src/shared/config/text.ts +27 -27
  41. package/template/src/shared/utils/format.ts +11 -11
  42. package/template/src/types/auth.ts +10 -10
  43. package/template/src/types/comment.ts +33 -33
  44. package/template/src/types/common.ts +19 -19
  45. package/template/src/types/dashboard.ts +53 -53
  46. package/template/src/types/index.ts +16 -16
  47. package/template/src/types/log.ts +21 -21
  48. package/template/src/types/post.ts +32 -32
  49. package/template/src/types/sample.ts +29 -28
  50. package/template/src/types/user.ts +51 -51
  51. package/template/src/vite-env.d.ts +10 -10
@@ -1,253 +1,300 @@
1
- import { useState, type ReactNode } from "react";
2
- import { Button, Modal, Tab } from "@farmzone/fz-react-ui";
3
- import { ArrowRight, Clock, Maximize2, Minimize2, X } from "lucide-react";
4
- import ListHeader from "@/app/layout/ListHeader";
5
-
6
- const CATTLE = {
7
- id: "KOR-00212345678",
8
- farm: "송아지-34567",
9
- gender: "",
10
- birthDate: "2026-03-01",
11
- breedingType: "비육우",
12
- status: "활동",
13
- barn: "우사1",
14
- farmName: "목장1",
15
- registrationCategory: "협동",
16
- registrationNumber: "12345678",
17
- currentStatus: "상정 중",
18
- };
19
-
20
- const HISTORY = [
21
- { id: 1, date: "2026-04-10", title: "사료 교체", desc: "비육 간등", active: true },
22
- { id: 2, date: "2026-05-15", title: "예방 접종", desc: "구제역", active: false },
23
- { id: 3, date: "2026-04-01", title: "정기 체중 측정", desc: "118kg (+30kg/일)", active: false },
24
- ];
25
-
26
- const TABS = ["기본 정보", "혈통 정보", "유전 능력", "질병 관리", "체중 관리"];
27
-
28
- function InfoRow({
29
- left,
30
- right,
31
- }: {
32
- left: { label: string; value: ReactNode; accent?: boolean };
33
- right: { label: string; value: ReactNode; accent?: boolean };
34
- }) {
35
- return (
36
- <div className="grid grid-cols-2 border-b border-gray-100 last:border-0">
37
- <div className="flex items-center gap-3 border-r border-gray-100 px-4 py-3">
38
- <span className="w-24 shrink-0 text-sm text-gray-500">{left.label}</span>
39
- <span className={`text-sm font-medium ${left.accent ? "text-green-600" : "text-gray-800"}`}>
40
- {left.value}
41
- </span>
42
- </div>
43
- <div className="flex items-center gap-3 px-4 py-3">
44
- <span className="w-20 shrink-0 text-sm text-gray-500">{right.label}</span>
45
- <span className={`text-sm font-medium ${right.accent ? "text-green-600" : "text-gray-800"}`}>
46
- {right.value}
47
- </span>
48
- </div>
49
- </div>
50
- );
51
- }
52
-
53
- function CattleDetailModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
54
- const [activeTab, setActiveTab] = useState(TABS[0]);
55
- const [isMaximized, setIsMaximized] = useState(false);
56
-
57
- const handleClose = () => {
58
- setIsMaximized(false);
59
- onClose();
60
- };
61
-
62
- return (
63
- <Modal
64
- isOpen={isOpen}
65
- onClose={handleClose}
66
- contentClassName={
67
- isMaximized
68
- ? "w-screen h-screen max-w-none max-h-none rounded-none bg-white overflow-hidden"
69
- : "w-200 max-w-[80vw] h-200 max-h-[90vh] rounded-xl bg-white overflow-hidden"
70
- }
71
- >
72
- {/* Header */}
73
- <div className="border-b border-gray-100 px-6 py-4">
74
- <div className="flex items-center justify-between gap-4">
75
- <div className="flex items-center gap-0 overflow-x-auto">
76
- {/* ID + status */}
77
- <div className="shrink-0 pr-6">
78
- <h2 className="text-xl font-bold text-gray-900">{CATTLE.id}</h2>
79
- <div className="mt-1.5 flex items-center gap-2">
80
- <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
81
- {CATTLE.status}
82
- </span>
83
- <span className="flex items-center gap-1 text-xs text-gray-400">
84
- <span>{CATTLE.barn}</span>
85
- <ArrowRight className="h-3 w-3" />
86
- <span>{CATTLE.farmName}</span>
87
- </span>
88
- </div>
89
- </div>
90
-
91
- {/* Divider */}
92
- <div className="mx-1 h-10 w-px shrink-0 bg-gray-200" />
93
-
94
- {/* Meta fields */}
95
- <div className="flex items-center">
96
- <div className="px-5">
97
- <p className="text-xs text-gray-400">목장</p>
98
- <p className="mt-0.5 text-sm font-semibold text-gray-800">{CATTLE.farm}</p>
99
- </div>
100
- <div className="h-8 w-px shrink-0 bg-gray-200" />
101
- <div className="px-5">
102
- <p className="text-xs text-gray-400">성별</p>
103
- <p className="mt-0.5 text-sm font-semibold text-gray-800">{CATTLE.gender}</p>
104
- </div>
105
- <div className="h-8 w-px shrink-0 bg-gray-200" />
106
- <div className="px-5">
107
- <p className="text-xs text-gray-400">생년월일</p>
108
- <p className="mt-0.5 text-sm font-semibold text-green-600">{CATTLE.birthDate}</p>
109
- </div>
110
- <div className="h-8 w-px shrink-0 bg-gray-200" />
111
- <div className="px-5">
112
- <p className="text-xs text-gray-400">사육 구분</p>
113
- <p className="mt-0.5 text-sm font-bold text-gray-900">{CATTLE.breedingType}</p>
114
- </div>
115
- </div>
116
- </div>
117
-
118
- {/* Action buttons */}
119
- <div className="flex shrink-0 items-center gap-0.5">
120
- <Button
121
- variant="ghost"
122
- size="icon"
123
- onClick={() => setIsMaximized((v) => !v)}
124
- className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
125
- >
126
- {isMaximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
127
- </Button>
128
- <Button
129
- variant="ghost"
130
- size="icon"
131
- onClick={handleClose}
132
- className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
133
- >
134
- <X className="h-4 w-4" />
135
- </Button>
136
- </div>
137
- </div>
138
- </div>
139
-
140
- {/* Tabs */}
141
- <Tab
142
- tabs={TABS.map((tab) => ({ label: tab, value: tab }))}
143
- activeTab={activeTab}
144
- onChange={setActiveTab}
145
- showContent={false}
146
- showIndicator
147
- tabListClassName="px-4"
148
- tabClassName="p-4 text-body hover:text-blue-500 hover:bg-gray-50"
149
- indicatorClassName="h-0.5 bg-blue-400"
150
- />
151
-
152
- {/* Body */}
153
- <div className="flex max-h-[500px] border-t-1 border-gray-200">
154
- {/* Main content */}
155
- <div className="flex-1 overflow-y-auto p-5">
156
- {activeTab === "기본 정보" ? (
157
- <>
158
- <div className="mb-4 overflow-hidden rounded-xs border border-gray-200">
159
- <div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-semibold text-gray-700">
160
- 상세 정보
161
- </div>
162
- <InfoRow
163
- left={{ label: "개체식별번호", value: CATTLE.id }}
164
- right={{ label: "목장", value: CATTLE.farm }}
165
- />
166
- <InfoRow
167
- left={{ label: "성별", value: CATTLE.gender }}
168
- right={{ label: "생년월일", value: CATTLE.birthDate }}
169
- />
170
- <InfoRow
171
- left={{ label: "등록구분", value: CATTLE.registrationCategory }}
172
- right={{ label: "등록번호", value: CATTLE.registrationNumber }}
173
- />
174
- <InfoRow
175
- left={{ label: "사육구분", value: CATTLE.breedingType }}
176
- right={{
177
- label: "현재 상태",
178
- value: (
179
- <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
180
- {CATTLE.currentStatus}
181
- </span>
182
- ),
183
- }}
184
- />
185
- </div>
186
-
187
- <div className="overflow-hidden rounded-lg border border-gray-200">
188
- <div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wider text-gray-500">
189
- Asset Visualization
190
- </div>
191
- <div className="flex h-44 items-center justify-center bg-gray-100">
192
- <img
193
- src="https://placehold.co/780x176/d1d5db/9ca3af?text=Cattle+Image"
194
- alt="cattle visualization"
195
- className="h-full w-full object-cover"
196
- />
197
- </div>
198
- </div>
199
- </>
200
- ) : (
201
- <div className="flex h-40 items-center justify-center text-sm text-gray-400">준비 중입니다.</div>
202
- )}
203
- </div>
204
-
205
- {/* Recent history sidebar */}
206
- <div className="w-52 shrink-0 overflow-y-auto border-l border-gray-200 p-4">
207
- <div className="mb-4 flex items-center gap-2">
208
- <Clock className="h-4 w-4 text-gray-500" />
209
- <span className="text-sm font-semibold text-gray-700">최근 이력</span>
210
- </div>
211
- <div>
212
- {HISTORY.map((item, i) => (
213
- <div key={item.id} className="relative flex gap-3 pb-5 last:pb-0">
214
- {i < HISTORY.length - 1 && (
215
- <div className="absolute left-[7px] top-4 h-full w-px bg-gray-200" />
216
- )}
217
- <div
218
- className={`mt-0.5 h-[15px] w-[15px] shrink-0 rounded-full ${
219
- item.active ? "bg-green-500" : "bg-gray-300"
220
- }`}
221
- />
222
- <div>
223
- <p className="text-xs text-gray-400">{item.date}</p>
224
- <p className="mt-0.5 text-sm font-semibold text-gray-800">{item.title}</p>
225
- <p className="text-xs text-gray-500">{item.desc}</p>
226
- </div>
227
- </div>
228
- ))}
229
- </div>
230
- </div>
231
- </div>
232
- </Modal>
233
- );
234
- }
235
-
236
- export default function SampleModalPage() {
237
- const [isOpen, setIsOpen] = useState(false);
238
-
239
- return (
240
- <div className="p-6">
241
- <ListHeader title="샘플 모달" />
242
- <div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
243
- <div className="text-center">
244
- <p className="mb-4 text-sm text-gray-500">아래 버튼을 클릭하여 개체 상세 모달을 확인하세요.</p>
245
- <Button variant="save" onClick={() => setIsOpen(true)}>
246
- 개체 상세 보기
247
- </Button>
248
- </div>
249
- </div>
250
- <CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
251
- </div>
252
- );
253
- }
1
+ import { useState, type ReactNode } from "react";
2
+ import { Button, Modal, Tab } from "@farmzone/fz-react-ui";
3
+ import { ArrowRight, Clock, Download, Maximize2, Minimize2, X } from "lucide-react";
4
+ import { apiInstance } from "@/app/api/api";
5
+ import ListHeader from "@/app/layout/ListHeader";
6
+
7
+ const CATTLE = {
8
+ id: "KOR-00212345678",
9
+ farm: "송아지-34567",
10
+ gender: "",
11
+ birthDate: "2026-03-01",
12
+ breedingType: "비육우",
13
+ status: "활동",
14
+ barn: "우사1",
15
+ farmName: "목장1",
16
+ registrationCategory: "협동",
17
+ registrationNumber: "12345678",
18
+ currentStatus: "상정 중",
19
+ };
20
+
21
+ const HISTORY = [
22
+ { id: 1, date: "2026-04-10", title: "사료 교체", desc: "비육 간등", active: true },
23
+ { id: 2, date: "2026-05-15", title: "예방 접종", desc: "구제역", active: false },
24
+ { id: 3, date: "2026-04-01", title: "정기 체중 측정", desc: "118kg (+30kg/일)", active: false },
25
+ ];
26
+
27
+ const TABS = ["기본 정보", "혈통 정보", "유전 능력", "질병 관리", "체중 관리"];
28
+
29
+ function InfoRow({
30
+ left,
31
+ right,
32
+ }: {
33
+ left: { label: string; value: ReactNode; accent?: boolean };
34
+ right: { label: string; value: ReactNode; accent?: boolean };
35
+ }) {
36
+ return (
37
+ <div className="grid grid-cols-2 border-b border-gray-100 last:border-0">
38
+ <div className="flex items-center gap-3 border-r border-gray-100 px-4 py-3">
39
+ <span className="w-24 shrink-0 text-sm text-gray-500">{left.label}</span>
40
+ <span className={`text-sm font-medium ${left.accent ? "text-green-600" : "text-gray-800"}`}>
41
+ {left.value}
42
+ </span>
43
+ </div>
44
+ <div className="flex items-center gap-3 px-4 py-3">
45
+ <span className="w-20 shrink-0 text-sm text-gray-500">{right.label}</span>
46
+ <span className={`text-sm font-medium ${right.accent ? "text-green-600" : "text-gray-800"}`}>
47
+ {right.value}
48
+ </span>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ function CattleDetailModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
55
+ const [activeTab, setActiveTab] = useState(TABS[0]);
56
+ const [isMaximized, setIsMaximized] = useState(false);
57
+
58
+ const handleClose = () => {
59
+ setIsMaximized(false);
60
+ onClose();
61
+ };
62
+
63
+ return (
64
+ <Modal
65
+ isOpen={isOpen}
66
+ onClose={handleClose}
67
+ contentClassName={
68
+ isMaximized
69
+ ? "w-screen h-screen max-w-none max-h-none rounded-none bg-white overflow-hidden"
70
+ : "w-200 max-w-[80vw] h-200 max-h-[90vh] rounded-xl bg-white overflow-hidden"
71
+ }
72
+ >
73
+ {/* Header */}
74
+ <div className="border-b border-gray-100 px-6 py-4">
75
+ <div className="flex items-center justify-between gap-4">
76
+ <div className="flex items-center gap-0 overflow-x-auto">
77
+ {/* ID + status */}
78
+ <div className="shrink-0 pr-6">
79
+ <h2 className="text-xl font-bold text-gray-900">{CATTLE.id}</h2>
80
+ <div className="mt-1.5 flex items-center gap-2">
81
+ <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
82
+ {CATTLE.status}
83
+ </span>
84
+ <span className="flex items-center gap-1 text-xs text-gray-400">
85
+ <span>{CATTLE.barn}</span>
86
+ <ArrowRight className="h-3 w-3" />
87
+ <span>{CATTLE.farmName}</span>
88
+ </span>
89
+ </div>
90
+ </div>
91
+
92
+ {/* Divider */}
93
+ <div className="mx-1 h-10 w-px shrink-0 bg-gray-200" />
94
+
95
+ {/* Meta fields */}
96
+ <div className="flex items-center">
97
+ <div className="px-5">
98
+ <p className="text-xs text-gray-400">목장</p>
99
+ <p className="mt-0.5 text-sm font-semibold text-gray-800">{CATTLE.farm}</p>
100
+ </div>
101
+ <div className="h-8 w-px shrink-0 bg-gray-200" />
102
+ <div className="px-5">
103
+ <p className="text-xs text-gray-400">성별</p>
104
+ <p className="mt-0.5 text-sm font-semibold text-gray-800">{CATTLE.gender}</p>
105
+ </div>
106
+ <div className="h-8 w-px shrink-0 bg-gray-200" />
107
+ <div className="px-5">
108
+ <p className="text-xs text-gray-400">생년월일</p>
109
+ <p className="mt-0.5 text-sm font-semibold text-green-600">{CATTLE.birthDate}</p>
110
+ </div>
111
+ <div className="h-8 w-px shrink-0 bg-gray-200" />
112
+ <div className="px-5">
113
+ <p className="text-xs text-gray-400">사육 구분</p>
114
+ <p className="mt-0.5 text-sm font-bold text-gray-900">{CATTLE.breedingType}</p>
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ {/* Action buttons */}
120
+ <div className="flex shrink-0 items-center gap-0.5">
121
+ <Button
122
+ variant="ghost"
123
+ size="icon"
124
+ onClick={() => setIsMaximized((v) => !v)}
125
+ className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
126
+ >
127
+ {isMaximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
128
+ </Button>
129
+ <Button
130
+ variant="ghost"
131
+ size="icon"
132
+ onClick={handleClose}
133
+ className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
134
+ >
135
+ <X className="h-4 w-4" />
136
+ </Button>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Tabs */}
142
+ <Tab
143
+ tabs={TABS.map((tab) => ({ label: tab, value: tab }))}
144
+ activeTab={activeTab}
145
+ onChange={setActiveTab}
146
+ showContent={false}
147
+ showIndicator
148
+ tabListClassName="px-4"
149
+ tabClassName="p-4 text-body hover:text-blue-500 hover:bg-gray-50"
150
+ indicatorClassName="h-0.5 bg-blue-400"
151
+ />
152
+
153
+ {/* Body */}
154
+ <div className="flex max-h-[500px] border-t-1 border-gray-200">
155
+ {/* Main content */}
156
+ <div className="flex-1 overflow-y-auto p-5">
157
+ {activeTab === "기본 정보" ? (
158
+ <>
159
+ <div className="mb-4 overflow-hidden rounded-xs border border-gray-200">
160
+ <div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-sm font-semibold text-gray-700">
161
+ 상세 정보
162
+ </div>
163
+ <InfoRow
164
+ left={{ label: "개체식별번호", value: CATTLE.id }}
165
+ right={{ label: "목장", value: CATTLE.farm }}
166
+ />
167
+ <InfoRow
168
+ left={{ label: "성별", value: CATTLE.gender }}
169
+ right={{ label: "생년월일", value: CATTLE.birthDate }}
170
+ />
171
+ <InfoRow
172
+ left={{ label: "등록구분", value: CATTLE.registrationCategory }}
173
+ right={{ label: "등록번호", value: CATTLE.registrationNumber }}
174
+ />
175
+ <InfoRow
176
+ left={{ label: "사육구분", value: CATTLE.breedingType }}
177
+ right={{
178
+ label: "현재 상태",
179
+ value: (
180
+ <span className="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
181
+ {CATTLE.currentStatus}
182
+ </span>
183
+ ),
184
+ }}
185
+ />
186
+ </div>
187
+
188
+ <div className="overflow-hidden rounded-lg border border-gray-200">
189
+ <div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-wider text-gray-500">
190
+ Asset Visualization
191
+ </div>
192
+ <div className="flex h-44 items-center justify-center bg-gray-100">
193
+ <img
194
+ src="https://placehold.co/780x176/d1d5db/9ca3af?text=Cattle+Image"
195
+ alt="cattle visualization"
196
+ className="h-full w-full object-cover"
197
+ />
198
+ </div>
199
+ </div>
200
+ </>
201
+ ) : (
202
+ <div className="flex h-40 items-center justify-center text-sm text-gray-400">준비 중입니다.</div>
203
+ )}
204
+ </div>
205
+
206
+ {/* Recent history sidebar */}
207
+ <div className="w-52 shrink-0 overflow-y-auto border-l border-gray-200 p-4">
208
+ <div className="mb-4 flex items-center gap-2">
209
+ <Clock className="h-4 w-4 text-gray-500" />
210
+ <span className="text-sm font-semibold text-gray-700">최근 이력</span>
211
+ </div>
212
+ <div>
213
+ {HISTORY.map((item, i) => (
214
+ <div key={item.id} className="relative flex gap-3 pb-5 last:pb-0">
215
+ {i < HISTORY.length - 1 && (
216
+ <div className="absolute left-[7px] top-4 h-full w-px bg-gray-200" />
217
+ )}
218
+ <div
219
+ className={`mt-0.5 h-[15px] w-[15px] shrink-0 rounded-full ${
220
+ item.active ? "bg-green-500" : "bg-gray-300"
221
+ }`}
222
+ />
223
+ <div>
224
+ <p className="text-xs text-gray-400">{item.date}</p>
225
+ <p className="mt-0.5 text-sm font-semibold text-gray-800">{item.title}</p>
226
+ <p className="text-xs text-gray-500">{item.desc}</p>
227
+ </div>
228
+ </div>
229
+ ))}
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </Modal>
234
+ );
235
+ }
236
+
237
+ export default function SampleModalPage() {
238
+ const [isOpen, setIsOpen] = useState(false);
239
+ const [isDownloading, setIsDownloading] = useState(false);
240
+
241
+ const handleExcelTemplateDownload = async (menuCode: string) => {
242
+ setIsDownloading(true);
243
+ try {
244
+ const res = await apiInstance.get<Blob>(`/excel/template?menuCode=${menuCode}`, {
245
+ responseType: "blob",
246
+ });
247
+ const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
248
+ const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
249
+ const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "엑셀_업로드_양식.xlsx";
250
+ const url = URL.createObjectURL(res.data);
251
+ const anchor = document.createElement("a");
252
+ anchor.href = url;
253
+ anchor.download = fileName;
254
+ anchor.click();
255
+ URL.revokeObjectURL(url);
256
+ } finally {
257
+ setIsDownloading(false);
258
+ }
259
+ };
260
+
261
+ return (
262
+ <div className="p-6">
263
+ <ListHeader title="샘플 모달" />
264
+ <div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
265
+ <div className="text-center">
266
+ <p className="mb-4 text-sm text-gray-500">아래 버튼을 클릭하여 개체 상세 모달을 확인하세요.</p>
267
+ <Button variant="save" onClick={() => setIsOpen(true)}>
268
+ 개체 상세 보기
269
+ </Button>
270
+ </div>
271
+ </div>
272
+ <div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
273
+ <div className="text-center">
274
+ <p className="mb-4 text-sm text-gray-500">
275
+ 아래 버튼을 클릭하여 템플릿 다운로드 기능을 확인하세요.
276
+ </p>
277
+ <div className="flex flex-col gap-4">
278
+ <Button
279
+ variant="outline"
280
+ onClick={() => handleExcelTemplateDownload("SAMPLE")}
281
+ disabled={isDownloading}
282
+ >
283
+ <Download className="mr-1.5 h-4 w-4" />
284
+ 엑셀 업로드 양식 다운로드 (SAMPLE)
285
+ </Button>
286
+ {/* <Button
287
+ variant="outline"
288
+ onClick={() => handleExcelTemplateDownload("POST")}
289
+ disabled={isDownloading}
290
+ >
291
+ <Download className="mr-1.5 h-4 w-4" />
292
+ 엑셀 업로드 양식 다운로드 (POST)
293
+ </Button> */}
294
+ </div>
295
+ </div>
296
+ </div>
297
+ <CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
298
+ </div>
299
+ );
300
+ }