@farmzone/fz-template-react 1.0.6 → 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 -54
  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 -335
  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 -545
  37. package/template/src/pages/post/index.tsx +266 -266
  38. package/template/src/pages/sample/SampleFormModal.tsx +188 -188
  39. package/template/src/pages/sample/detail/index.tsx +551 -517
  40. package/template/src/pages/sample/index.tsx +298 -298
  41. package/template/src/pages/sample/modal/index.tsx +308 -308
  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 -650
  46. package/template/src/shared/components/CommentInput.tsx +243 -243
  47. package/template/src/shared/components/FilePreviewCard.tsx +71 -71
  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 -33
  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,308 +1,308 @@
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
-
248
- const blob = new Blob([res.data], {
249
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
250
- });
251
-
252
- const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
253
- const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
254
- const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "샘플 일괄등록 업로딩양식.xlsx";
255
-
256
- const url = URL.createObjectURL(blob);
257
- const anchor = document.createElement("a");
258
- anchor.href = url;
259
- anchor.download = fileName;
260
- document.body.appendChild(anchor);
261
- anchor.click();
262
- document.body.removeChild(anchor);
263
- URL.revokeObjectURL(url);
264
- } finally {
265
- setIsDownloading(false);
266
- }
267
- };
268
-
269
- return (
270
- <div className="p-6">
271
- <ListHeader title="샘플 모달" />
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">아래 버튼을 클릭하여 개체 상세 모달을 확인하세요.</p>
275
- <Button variant="save" onClick={() => setIsOpen(true)}>
276
- 개체 상세 보기
277
- </Button>
278
- </div>
279
- </div>
280
- <div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
281
- <div className="text-center">
282
- <p className="mb-4 text-sm text-gray-500">
283
- 아래 버튼을 클릭하여 템플릿 다운로드 기능을 확인하세요.
284
- </p>
285
- <div className="flex flex-col gap-4">
286
- <Button
287
- variant="outline"
288
- onClick={() => handleExcelTemplateDownload("SAMPLE")}
289
- disabled={isDownloading}
290
- >
291
- <Download className="mr-1.5 h-4 w-4" />
292
- 엑셀 업로드 양식 다운로드 (SAMPLE)
293
- </Button>
294
- {/* <Button
295
- variant="outline"
296
- onClick={() => handleExcelTemplateDownload("POST")}
297
- disabled={isDownloading}
298
- >
299
- <Download className="mr-1.5 h-4 w-4" />
300
- 엑셀 업로드 양식 다운로드 (POST)
301
- </Button> */}
302
- </div>
303
- </div>
304
- </div>
305
- <CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
306
- </div>
307
- );
308
- }
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
+
248
+ const blob = new Blob([res.data], {
249
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
250
+ });
251
+
252
+ const disposition = (res.headers as Record<string, string>)["content-disposition"] ?? "";
253
+ const fileNameMatch = /filename\*?=['"]?(?:UTF-8'')?([^;'"]+)['"]?/i.exec(disposition);
254
+ const fileName = fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : "샘플 일괄등록 업로딩양식.xlsx";
255
+
256
+ const url = URL.createObjectURL(blob);
257
+ const anchor = document.createElement("a");
258
+ anchor.href = url;
259
+ anchor.download = fileName;
260
+ document.body.appendChild(anchor);
261
+ anchor.click();
262
+ document.body.removeChild(anchor);
263
+ URL.revokeObjectURL(url);
264
+ } finally {
265
+ setIsDownloading(false);
266
+ }
267
+ };
268
+
269
+ return (
270
+ <div className="p-6">
271
+ <ListHeader title="샘플 모달" />
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">아래 버튼을 클릭하여 개체 상세 모달을 확인하세요.</p>
275
+ <Button variant="save" onClick={() => setIsOpen(true)}>
276
+ 개체 상세 보기
277
+ </Button>
278
+ </div>
279
+ </div>
280
+ <div className="mt-6 flex items-center justify-center rounded-xl border border-dashed border-gray-300 bg-white p-16">
281
+ <div className="text-center">
282
+ <p className="mb-4 text-sm text-gray-500">
283
+ 아래 버튼을 클릭하여 템플릿 다운로드 기능을 확인하세요.
284
+ </p>
285
+ <div className="flex flex-col gap-4">
286
+ <Button
287
+ variant="outline"
288
+ onClick={() => handleExcelTemplateDownload("SAMPLE")}
289
+ disabled={isDownloading}
290
+ >
291
+ <Download className="mr-1.5 h-4 w-4" />
292
+ 엑셀 업로드 양식 다운로드 (SAMPLE)
293
+ </Button>
294
+ {/* <Button
295
+ variant="outline"
296
+ onClick={() => handleExcelTemplateDownload("POST")}
297
+ disabled={isDownloading}
298
+ >
299
+ <Download className="mr-1.5 h-4 w-4" />
300
+ 엑셀 업로드 양식 다운로드 (POST)
301
+ </Button> */}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ <CattleDetailModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
306
+ </div>
307
+ );
308
+ }