@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,541 +1,541 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { Activity, CalendarDays, Download, TriangleAlert, UserMinus, UserPlus, Users } from "lucide-react";
3
- import { Empty } from "@farmzone/fz-react-ui";
4
- import type { LucideIcon } from "lucide-react";
5
- import { useNavigate } from "react-router";
6
- import { Button } from "@farmzone/fz-react-ui";
7
-
8
- import ListHeader from "@/app/layout/ListHeader";
9
- import { useGetUserDashboard } from "@/app/api/queries";
10
- import type { DashboardAgeGroup, DashboardRecentUser } from "@/types";
11
- import { formatDateTime } from "@/shared/utils/format";
12
-
13
- const AVATAR_COLORS = ["bg-blue-400", "bg-emerald-400", "bg-violet-400", "bg-orange-400", "bg-pink-400"];
14
-
15
- // 컨테이너의 실제 px 크기를 측정 — SVG가 찌그러짐 없이 꽉 채움
16
- function useChartSize() {
17
- const ref = useRef<HTMLDivElement>(null);
18
- const [size, setSize] = useState({ width: 0, height: 0 });
19
- useEffect(() => {
20
- if (!ref.current) return;
21
- const ro = new ResizeObserver(([entry]) => {
22
- const { width, height } = entry.contentRect;
23
- setSize({ width: Math.floor(width), height: Math.floor(height) });
24
- });
25
- ro.observe(ref.current);
26
- return () => ro.disconnect();
27
- }, []);
28
- return { ref, ...size };
29
- }
30
-
31
- // ── Chart: 신규 가입자 추이 ────────────────────────────────────────
32
-
33
- interface JoinTrendChartProps {
34
- labels: Array<string>;
35
- joinedCounts: Array<number>;
36
- withdrawnCounts: Array<number>;
37
- }
38
-
39
- function JoinTrendChart({ labels, joinedCounts, withdrawnCounts }: JoinTrendChartProps) {
40
- const { ref, width: W, height: H } = useChartSize();
41
- const pL = 46,
42
- pR = 12,
43
- pT = 10,
44
- pB = 46;
45
- const pw = W - pL - pR;
46
- const ph = H - pT - pB;
47
-
48
- const allVals = [...joinedCounts, ...withdrawnCounts, 1];
49
- const MAX = Math.ceil(Math.max(...allVals) * 1.2) || 10;
50
- const step = Math.ceil(MAX / 5);
51
- const ySteps = Array.from({ length: 6 }, (_, i) => i * step);
52
-
53
- const gx = (i: number) => pL + (labels.length > 1 ? (i / (labels.length - 1)) * pw : pw / 2);
54
- const gy = (v: number) => pT + ((MAX - v) / MAX) * ph;
55
-
56
- const linePath = (data: Array<number>) =>
57
- data.map((v, i) => `${i === 0 ? "M" : "L"} ${gx(i).toFixed(1)} ${gy(v).toFixed(1)}`).join(" ");
58
-
59
- const areaPath = [
60
- linePath(joinedCounts),
61
- `L ${gx(labels.length - 1).toFixed(1)} ${gy(0).toFixed(1)}`,
62
- `L ${gx(0).toFixed(1)} ${gy(0).toFixed(1)} Z`,
63
- ].join(" ");
64
-
65
- const shortLabel = (s: string) => {
66
- const parts = s.split("-");
67
- return parts.length === 3 ? `${parts[1]}/${parts[2]}` : s;
68
- };
69
-
70
- const skipStep = Math.ceil(labels.length / 10);
71
- const showLabel = (i: number) => i % skipStep === 0 || i === labels.length - 1;
72
- const LABEL_Y = pT + ph + 13;
73
-
74
- return (
75
- <div ref={ref} className="w-full h-full">
76
- {W > 0 && H > 0 && (
77
- <svg width={W} height={H}>
78
- {ySteps.map((v) => (
79
- <line key={v} x1={pL} y1={gy(v)} x2={W - pR} y2={gy(v)} stroke="#f3f4f6" strokeWidth="1" />
80
- ))}
81
- {ySteps.map((v) => (
82
- <text key={v} x={pL - 6} y={gy(v) + 4} textAnchor="end" fontSize="10" fill="#9ca3af">
83
- {v}
84
- </text>
85
- ))}
86
- {labels.map((l, i) => {
87
- if (!showLabel(i)) return null;
88
- const lx = gx(i);
89
- return (
90
- <text
91
- key={l}
92
- x={lx}
93
- y={LABEL_Y}
94
- transform={`rotate(-40, ${lx}, ${LABEL_Y})`}
95
- textAnchor="end"
96
- fontSize="10"
97
- fill="#9ca3af"
98
- >
99
- {shortLabel(l)}
100
- </text>
101
- );
102
- })}
103
- <path d={areaPath} fill="#dbeafe" fillOpacity="0.45" />
104
- <path
105
- d={linePath(joinedCounts)}
106
- fill="none"
107
- stroke="#3b82f6"
108
- strokeWidth="2"
109
- strokeLinejoin="round"
110
- strokeLinecap="round"
111
- />
112
- <path
113
- d={linePath(withdrawnCounts)}
114
- fill="none"
115
- stroke="#ef4444"
116
- strokeWidth="1.5"
117
- strokeDasharray="5 3"
118
- strokeLinejoin="round"
119
- strokeLinecap="round"
120
- />
121
- {joinedCounts.map((v, i) => (
122
- <circle key={i} cx={gx(i)} cy={gy(v)} r="3.5" fill="white" stroke="#3b82f6" strokeWidth="2" />
123
- ))}
124
- {withdrawnCounts.map((v, i) => (
125
- <circle key={i} cx={gx(i)} cy={gy(v)} r="2.5" fill="white" stroke="#ef4444" strokeWidth="1.5" />
126
- ))}
127
- </svg>
128
- )}
129
- </div>
130
- );
131
- }
132
-
133
- // ── Chart: 일별 접속자 수 ──────────────────────────────────────────
134
-
135
- interface DauBarChartProps {
136
- labels: Array<string>;
137
- counts: Array<number>;
138
- }
139
-
140
- function DauBarChart({ labels, counts }: DauBarChartProps) {
141
- const { ref, width: W, height: H } = useChartSize();
142
- const pL = 44,
143
- pR = 8,
144
- pT = 8,
145
- pB = 44;
146
- const pw = W - pL - pR;
147
- const ph = H - pT - pB;
148
-
149
- const maxVal = Math.max(...counts, 1);
150
- const raw = maxVal * 1.15;
151
- const exp = Math.floor(Math.log10(raw));
152
- const unit = Math.pow(10, exp);
153
- const MAX =
154
- [1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => n * unit).find((c) => c >= raw) ?? unit * 10;
155
- const ySteps = Array.from({ length: 6 }, (_, i) => (i * MAX) / 5);
156
-
157
- const slotW = pw / (labels.length || 1);
158
- const barW = Math.min(slotW * 0.65, 18);
159
-
160
- const gy = (v: number) => pT + ((MAX - v) / MAX) * ph;
161
- const bx = (i: number) => pL + i * slotW + (slotW - barW) / 2;
162
-
163
- const shortLabel = (s: string) => {
164
- const parts = s.split("-");
165
- return parts.length === 3 ? `${parts[1]}/${parts[2]}` : s;
166
- };
167
-
168
- const skipStep = Math.ceil(labels.length / 10);
169
- const showLabel = (i: number) => i % skipStep === 0 || i === labels.length - 1;
170
- const LABEL_Y = pT + ph + 14;
171
-
172
- return (
173
- <div ref={ref} className="w-full h-full">
174
- {W > 0 && H > 0 && (
175
- <svg width={W} height={H}>
176
- {ySteps.map((v) => (
177
- <line key={v} x1={pL} y1={gy(v)} x2={W - pR} y2={gy(v)} stroke="#f3f4f6" strokeWidth="1" />
178
- ))}
179
- {ySteps.map((v) => (
180
- <text key={v} x={pL - 5} y={gy(v) + 4} textAnchor="end" fontSize="9" fill="#9ca3af">
181
- {v >= 10000
182
- ? `${(v / 1000).toFixed(0)}k`
183
- : v >= 1000
184
- ? `${(v / 1000) % 1 === 0 ? v / 1000 : (v / 1000).toFixed(1)}k`
185
- : v}
186
- </text>
187
- ))}
188
- {counts.map((v, i) => (
189
- <rect key={i} x={bx(i)} y={gy(v)} width={barW} height={(v / MAX) * ph} fill="#3b82f6" rx="2" />
190
- ))}
191
- {labels.map((l, i) => {
192
- if (!showLabel(i)) return null;
193
- const lx = bx(i) + barW / 2;
194
- return (
195
- <text
196
- key={l}
197
- x={lx}
198
- y={LABEL_Y}
199
- transform={`rotate(-40, ${lx}, ${LABEL_Y})`}
200
- textAnchor="end"
201
- fontSize="9"
202
- fill="#9ca3af"
203
- >
204
- {shortLabel(l)}
205
- </text>
206
- );
207
- })}
208
- </svg>
209
- )}
210
- </div>
211
- );
212
- }
213
-
214
- // ── Skeleton ───────────────────────────────────────────────────────
215
-
216
- function Skeleton({ className }: { className?: string }) {
217
- return <div className={`animate-pulse rounded bg-gray-100 ${className ?? ""}`} />;
218
- }
219
-
220
- // ── KPI Card ───────────────────────────────────────────────────────
221
-
222
- interface KpiCardProps {
223
- label: string;
224
- value: string;
225
- Icon: LucideIcon;
226
- iconColor: string;
227
- iconBg: string;
228
- }
229
-
230
- function KpiCard({ label, value, Icon, iconColor, iconBg }: KpiCardProps) {
231
- return (
232
- <div className="bg-white rounded-xl p-3 shadow-sm border border-gray-100">
233
- <div className="flex items-center justify-between mb-2">
234
- <span className="text-xs text-gray-500">{label}</span>
235
- <div className={`w-7 h-7 rounded-full ${iconBg} flex items-center justify-center`}>
236
- <Icon size={13} className={iconColor} />
237
- </div>
238
- </div>
239
- <div className="text-2xl font-bold text-gray-900 leading-none text-center">{value}</div>
240
- </div>
241
- );
242
- }
243
-
244
- // ── Gender Bar ─────────────────────────────────────────────────────
245
-
246
- interface GenderBarProps {
247
- maleRate: number;
248
- femaleRate: number;
249
- unknownRate: number;
250
- }
251
-
252
- function GenderBar({ maleRate, femaleRate, unknownRate }: GenderBarProps) {
253
- return (
254
- <>
255
- <div className="w-full h-2.5 rounded-full overflow-hidden flex">
256
- <div className="h-full bg-blue-500" style={{ width: `${maleRate}%` }} />
257
- <div className="h-full bg-pink-400" style={{ width: `${femaleRate}%` }} />
258
- {unknownRate > 0 && <div className="h-full bg-gray-300" style={{ width: `${unknownRate}%` }} />}
259
- </div>
260
- <div className="flex items-center gap-4 mt-1.5 mb-3 flex-wrap">
261
- <span className="flex items-center gap-1.5 text-xs text-gray-600">
262
- <span className="w-2 h-2 rounded-full bg-blue-500 shrink-0" />
263
- 남성 {maleRate.toFixed(1)}%
264
- </span>
265
- <span className="flex items-center gap-1.5 text-xs text-gray-600">
266
- <span className="w-2 h-2 rounded-full bg-pink-400 shrink-0" />
267
- 여성 {femaleRate.toFixed(1)}%
268
- </span>
269
- {unknownRate > 0 && (
270
- <span className="flex items-center gap-1.5 text-xs text-gray-600">
271
- <span className="w-2 h-2 rounded-full bg-gray-300 shrink-0" />
272
- 미상 {unknownRate.toFixed(1)}%
273
- </span>
274
- )}
275
- </div>
276
- </>
277
- );
278
- }
279
-
280
- // ── Age Groups ─────────────────────────────────────────────────────
281
-
282
- function AgeGroups({ groups }: { groups: Array<DashboardAgeGroup> }) {
283
- const maxRate = Math.max(...groups.map((g) => g.rate), 1);
284
- return (
285
- <div className="space-y-3">
286
- {groups.map((a) => (
287
- <div key={a.label} className="flex items-center gap-2">
288
- <span className="w-18 text-xs text-gray-500 shrink-0">{a.label}</span>
289
- <div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
290
- <div
291
- className="h-full bg-blue-400 rounded-full"
292
- style={{ width: `${(a.rate / maxRate) * 100}%` }}
293
- />
294
- </div>
295
- <span className="w-10 text-xs text-gray-500 text-right shrink-0">{a.rate.toFixed(1)}%</span>
296
- </div>
297
- ))}
298
- </div>
299
- );
300
- }
301
-
302
- // ── Recent Users Table ─────────────────────────────────────────────
303
-
304
- function RecentUsersTable({ users }: { users: Array<DashboardRecentUser> }) {
305
- const genderLabel = (g: string) => (g === "M" ? "남성" : g === "F" ? "여성" : g);
306
-
307
- return (
308
- <table className="w-full">
309
- <thead>
310
- <tr className="border-b border-gray-100">
311
- {["회원", "성별", "연령", "가입일", "상태"].map((h) => (
312
- <th key={h} className="pb-2 text-xs font-semibold text-gray-400 text-left">
313
- {h}
314
- </th>
315
- ))}
316
- </tr>
317
- </thead>
318
- <tbody>
319
- {users.map((u, idx) => (
320
- <tr key={u.id} className="border-b border-gray-50 last:border-0">
321
- <td className="py-2">
322
- <div className="flex items-center gap-2">
323
- <div
324
- className={`w-6 h-6 rounded-full ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-[9px] font-bold shrink-0`}
325
- >
326
- {u.name.slice(0, 2)}
327
- </div>
328
- <span className="text-sm text-gray-800 font-medium">{u.name}</span>
329
- </div>
330
- </td>
331
- <td className="py-2 text-sm text-gray-600">{genderLabel(u.gender)}</td>
332
- <td className="py-2 text-sm text-gray-600">{u.age}</td>
333
- <td className="py-2 text-xs text-gray-500 tabular-nums">{formatDateTime(u.createdAt)}</td>
334
- <td className="py-2">
335
- <span
336
- className={`px-2 py-0.5 rounded-full text-xs font-medium ${
337
- u.active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
338
- }`}
339
- >
340
- {u.active ? "활성" : "비활성"}
341
- </span>
342
- </td>
343
- </tr>
344
- ))}
345
- </tbody>
346
- </table>
347
- );
348
- }
349
-
350
- // ── Page ───────────────────────────────────────────────────────────
351
-
352
- export default function DashboardPage() {
353
- const navigate = useNavigate();
354
- const { data, isLoading } = useGetUserDashboard("30d");
355
-
356
- return (
357
- <div className="p-4 space-y-3">
358
- <ListHeader
359
- title="회원 대시보드"
360
- rightArea={
361
- <div className="flex items-center gap-2">
362
- <Button variant="outline">
363
- <CalendarDays size={14} className="mr-1" />
364
- 최근 30일
365
- </Button>
366
- <Button variant="file">
367
- <Download size={14} className="mr-1" />
368
- 다운로드
369
- </Button>
370
- </div>
371
- }
372
- />
373
-
374
- {/* KPI Cards */}
375
- <div className="grid grid-cols-4 gap-3 min-h-24">
376
- {isLoading ? (
377
- Array.from({ length: 4 }).map((_, i) => (
378
- <div key={i} className="bg-white rounded-xl p-3 shadow-sm border border-gray-100 space-y-2">
379
- <Skeleton className="h-3 w-20" />
380
- <Skeleton className="h-7 w-16" />
381
- </div>
382
- ))
383
- ) : (
384
- <>
385
- <KpiCard
386
- label="전체 회원수"
387
- value={(data?.summary.totalUsers ?? 0).toLocaleString()}
388
- Icon={Users}
389
- iconColor="text-blue-500"
390
- iconBg="bg-blue-50"
391
- />
392
- <KpiCard
393
- label="오늘 가입자"
394
- value={(data?.summary.todayJoinedUsers ?? 0).toLocaleString()}
395
- Icon={UserPlus}
396
- iconColor="text-green-500"
397
- iconBg="bg-green-50"
398
- />
399
- <KpiCard
400
- label="최근 접속자 (24h)"
401
- value={(data?.summary.recentActiveUsers ?? 0).toLocaleString()}
402
- Icon={Activity}
403
- iconColor="text-purple-500"
404
- iconBg="bg-purple-50"
405
- />
406
- <KpiCard
407
- label="탈퇴율"
408
- value={`${(data?.summary.withdrawalRate ?? 0).toFixed(2)}%`}
409
- Icon={UserMinus}
410
- iconColor="text-red-400"
411
- iconBg="bg-red-50"
412
- />
413
- </>
414
- )}
415
- </div>
416
-
417
- {/* Charts Row */}
418
- <div className="grid grid-cols-5 gap-3 h-90">
419
- {/* Join Trend */}
420
- <div className="col-span-3 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
421
- <div className="flex items-center justify-between">
422
- <h2 className="text-sm font-semibold text-gray-800">신규 가입자 추이</h2>
423
- <div className="flex items-center gap-4">
424
- <div className="flex items-center gap-1.5">
425
- <div className="w-3 h-0.5 bg-blue-500 rounded-full" />
426
- <span className="text-xs text-gray-500">신규 가입</span>
427
- </div>
428
- <div className="flex items-center gap-1.5">
429
- <svg width="14" height="4" className="shrink-0">
430
- <line
431
- x1="0"
432
- y1="2"
433
- x2="14"
434
- y2="2"
435
- stroke="#ef4444"
436
- strokeWidth="1.5"
437
- strokeDasharray="4 3"
438
- />
439
- </svg>
440
- <span className="text-xs text-gray-500">탈퇴</span>
441
- </div>
442
- </div>
443
- </div>
444
- <p className="text-xs text-gray-400 mt-1 mb-2">일별 신규 가입 vs 탈퇴</p>
445
- {/* 고정 높이 컨테이너 — SVG는 이 안에서 preserveAspectRatio="none"으로 채움 */}
446
- <div className="h-70">
447
- {isLoading || !data ? (
448
- <Skeleton className="h-full w-full" />
449
- ) : (
450
- <JoinTrendChart
451
- labels={data.trend.labels}
452
- joinedCounts={data.trend.joinedCounts}
453
- withdrawnCounts={data.trend.withdrawnCounts}
454
- />
455
- )}
456
- </div>
457
- </div>
458
-
459
- {/* Demographics */}
460
- <div className="col-span-2 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
461
- <h2 className="text-sm font-semibold text-gray-800">회원 기본 현황</h2>
462
- <p className="text-xs text-gray-400 mt-1 mb-3">성별 · 연령대 분포</p>
463
- {isLoading || !data ? (
464
- <div className="space-y-2">
465
- <Skeleton className="h-2.5 w-full" />
466
- <Skeleton className="h-3 w-28" />
467
- {Array.from({ length: 5 }).map((_, i) => (
468
- <Skeleton key={i} className="h-1.5 w-full mt-2" />
469
- ))}
470
- </div>
471
- ) : (
472
- <div className="flex gap-2 flex-col">
473
- <div>
474
- <p className="text-xs font-semibold text-gray-600">성별</p>
475
- <GenderBar
476
- maleRate={data.demographics.gender.maleRate}
477
- femaleRate={data.demographics.gender.femaleRate}
478
- unknownRate={data.demographics.gender.unknownRate}
479
- />
480
- </div>
481
- <div>
482
- <p className="text-xs font-semibold text-gray-600 mb-4">연령대</p>
483
- <AgeGroups groups={data.demographics.ageGroups} />
484
- </div>
485
- </div>
486
- )}
487
- </div>
488
- </div>
489
-
490
- {/* Bottom Row */}
491
- <div className="grid grid-cols-5 gap-3">
492
- {/* Recent Members */}
493
- <div className="col-span-3 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
494
- <div className="flex items-center justify-between mb-3">
495
- <div>
496
- <h2 className="text-sm font-semibold text-gray-800">최근 가입 회원</h2>
497
- <p className="text-xs text-gray-400 mt-0.5">최근 가입 순</p>
498
- </div>
499
- <Button
500
- variant="link"
501
- onClick={() => navigate("/user")}
502
- className="text-xs text-blue-500 hover:underline font-medium cursor-pointer"
503
- >
504
- 전체 보기
505
- </Button>
506
- </div>
507
- {isLoading || !data ? (
508
- <div className="space-y-2.5">
509
- {Array.from({ length: 5 }).map((_, i) => (
510
- <div key={i} className="flex items-center gap-3">
511
- <Skeleton className="w-6 h-6 rounded-full" />
512
- <Skeleton className="h-4 flex-1" />
513
- </div>
514
- ))}
515
- </div>
516
- ) : data.recentUsers.length === 0 ? (
517
- <Empty className="flex flex-1 justify-center gap-2 px-4 py-16">
518
- <TriangleAlert className="size-8 shrink-0 text-gray-500" strokeWidth={1.5} />
519
- <p className="text-sm text-gray-500">최근 가입 회원이 없습니다.</p>
520
- </Empty>
521
- ) : (
522
- <RecentUsersTable users={data.recentUsers} />
523
- )}
524
- </div>
525
-
526
- {/* DAU */}
527
- <div className="col-span-2 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
528
- <h2 className="text-sm font-semibold text-gray-800">일별 접속자 수</h2>
529
- <p className="text-xs text-gray-400 mt-0.5 mb-2">DAU 추이</p>
530
- <div className="h-44">
531
- {isLoading || !data ? (
532
- <Skeleton className="h-full w-full" />
533
- ) : (
534
- <DauBarChart labels={data.dailyActiveUsers.labels} counts={data.dailyActiveUsers.counts} />
535
- )}
536
- </div>
537
- </div>
538
- </div>
539
- </div>
540
- );
541
- }
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Activity, CalendarDays, Download, TriangleAlert, UserMinus, UserPlus, Users } from "lucide-react";
3
+ import { Empty } from "@farmzone/fz-react-ui";
4
+ import type { LucideIcon } from "lucide-react";
5
+ import { useNavigate } from "react-router";
6
+ import { Button } from "@farmzone/fz-react-ui";
7
+
8
+ import ListHeader from "@/app/layout/ListHeader";
9
+ import { useGetUserDashboard } from "@/app/api/queries";
10
+ import type { DashboardAgeGroup, DashboardRecentUser } from "@/types";
11
+ import { formatDateTime } from "@/shared/utils/format";
12
+
13
+ const AVATAR_COLORS = ["bg-blue-400", "bg-emerald-400", "bg-violet-400", "bg-orange-400", "bg-pink-400"];
14
+
15
+ // 컨테이너의 실제 px 크기를 측정 — SVG가 찌그러짐 없이 꽉 채움
16
+ function useChartSize() {
17
+ const ref = useRef<HTMLDivElement>(null);
18
+ const [size, setSize] = useState({ width: 0, height: 0 });
19
+ useEffect(() => {
20
+ if (!ref.current) return;
21
+ const ro = new ResizeObserver(([entry]) => {
22
+ const { width, height } = entry.contentRect;
23
+ setSize({ width: Math.floor(width), height: Math.floor(height) });
24
+ });
25
+ ro.observe(ref.current);
26
+ return () => ro.disconnect();
27
+ }, []);
28
+ return { ref, ...size };
29
+ }
30
+
31
+ // ── Chart: 신규 가입자 추이 ────────────────────────────────────────
32
+
33
+ interface JoinTrendChartProps {
34
+ labels: Array<string>;
35
+ joinedCounts: Array<number>;
36
+ withdrawnCounts: Array<number>;
37
+ }
38
+
39
+ function JoinTrendChart({ labels, joinedCounts, withdrawnCounts }: JoinTrendChartProps) {
40
+ const { ref, width: W, height: H } = useChartSize();
41
+ const pL = 46,
42
+ pR = 12,
43
+ pT = 10,
44
+ pB = 46;
45
+ const pw = W - pL - pR;
46
+ const ph = H - pT - pB;
47
+
48
+ const allVals = [...joinedCounts, ...withdrawnCounts, 1];
49
+ const MAX = Math.ceil(Math.max(...allVals) * 1.2) || 10;
50
+ const step = Math.ceil(MAX / 5);
51
+ const ySteps = Array.from({ length: 6 }, (_, i) => i * step);
52
+
53
+ const gx = (i: number) => pL + (labels.length > 1 ? (i / (labels.length - 1)) * pw : pw / 2);
54
+ const gy = (v: number) => pT + ((MAX - v) / MAX) * ph;
55
+
56
+ const linePath = (data: Array<number>) =>
57
+ data.map((v, i) => `${i === 0 ? "M" : "L"} ${gx(i).toFixed(1)} ${gy(v).toFixed(1)}`).join(" ");
58
+
59
+ const areaPath = [
60
+ linePath(joinedCounts),
61
+ `L ${gx(labels.length - 1).toFixed(1)} ${gy(0).toFixed(1)}`,
62
+ `L ${gx(0).toFixed(1)} ${gy(0).toFixed(1)} Z`,
63
+ ].join(" ");
64
+
65
+ const shortLabel = (s: string) => {
66
+ const parts = s.split("-");
67
+ return parts.length === 3 ? `${parts[1]}/${parts[2]}` : s;
68
+ };
69
+
70
+ const skipStep = Math.ceil(labels.length / 10);
71
+ const showLabel = (i: number) => i % skipStep === 0 || i === labels.length - 1;
72
+ const LABEL_Y = pT + ph + 13;
73
+
74
+ return (
75
+ <div ref={ref} className="w-full h-full">
76
+ {W > 0 && H > 0 && (
77
+ <svg width={W} height={H}>
78
+ {ySteps.map((v) => (
79
+ <line key={v} x1={pL} y1={gy(v)} x2={W - pR} y2={gy(v)} stroke="#f3f4f6" strokeWidth="1" />
80
+ ))}
81
+ {ySteps.map((v) => (
82
+ <text key={v} x={pL - 6} y={gy(v) + 4} textAnchor="end" fontSize="10" fill="#9ca3af">
83
+ {v}
84
+ </text>
85
+ ))}
86
+ {labels.map((l, i) => {
87
+ if (!showLabel(i)) return null;
88
+ const lx = gx(i);
89
+ return (
90
+ <text
91
+ key={l}
92
+ x={lx}
93
+ y={LABEL_Y}
94
+ transform={`rotate(-40, ${lx}, ${LABEL_Y})`}
95
+ textAnchor="end"
96
+ fontSize="10"
97
+ fill="#9ca3af"
98
+ >
99
+ {shortLabel(l)}
100
+ </text>
101
+ );
102
+ })}
103
+ <path d={areaPath} fill="#dbeafe" fillOpacity="0.45" />
104
+ <path
105
+ d={linePath(joinedCounts)}
106
+ fill="none"
107
+ stroke="#3b82f6"
108
+ strokeWidth="2"
109
+ strokeLinejoin="round"
110
+ strokeLinecap="round"
111
+ />
112
+ <path
113
+ d={linePath(withdrawnCounts)}
114
+ fill="none"
115
+ stroke="#ef4444"
116
+ strokeWidth="1.5"
117
+ strokeDasharray="5 3"
118
+ strokeLinejoin="round"
119
+ strokeLinecap="round"
120
+ />
121
+ {joinedCounts.map((v, i) => (
122
+ <circle key={i} cx={gx(i)} cy={gy(v)} r="3.5" fill="white" stroke="#3b82f6" strokeWidth="2" />
123
+ ))}
124
+ {withdrawnCounts.map((v, i) => (
125
+ <circle key={i} cx={gx(i)} cy={gy(v)} r="2.5" fill="white" stroke="#ef4444" strokeWidth="1.5" />
126
+ ))}
127
+ </svg>
128
+ )}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // ── Chart: 일별 접속자 수 ──────────────────────────────────────────
134
+
135
+ interface DauBarChartProps {
136
+ labels: Array<string>;
137
+ counts: Array<number>;
138
+ }
139
+
140
+ function DauBarChart({ labels, counts }: DauBarChartProps) {
141
+ const { ref, width: W, height: H } = useChartSize();
142
+ const pL = 44,
143
+ pR = 8,
144
+ pT = 8,
145
+ pB = 44;
146
+ const pw = W - pL - pR;
147
+ const ph = H - pT - pB;
148
+
149
+ const maxVal = Math.max(...counts, 1);
150
+ const raw = maxVal * 1.15;
151
+ const exp = Math.floor(Math.log10(raw));
152
+ const unit = Math.pow(10, exp);
153
+ const MAX =
154
+ [1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => n * unit).find((c) => c >= raw) ?? unit * 10;
155
+ const ySteps = Array.from({ length: 6 }, (_, i) => (i * MAX) / 5);
156
+
157
+ const slotW = pw / (labels.length || 1);
158
+ const barW = Math.min(slotW * 0.65, 18);
159
+
160
+ const gy = (v: number) => pT + ((MAX - v) / MAX) * ph;
161
+ const bx = (i: number) => pL + i * slotW + (slotW - barW) / 2;
162
+
163
+ const shortLabel = (s: string) => {
164
+ const parts = s.split("-");
165
+ return parts.length === 3 ? `${parts[1]}/${parts[2]}` : s;
166
+ };
167
+
168
+ const skipStep = Math.ceil(labels.length / 10);
169
+ const showLabel = (i: number) => i % skipStep === 0 || i === labels.length - 1;
170
+ const LABEL_Y = pT + ph + 14;
171
+
172
+ return (
173
+ <div ref={ref} className="w-full h-full">
174
+ {W > 0 && H > 0 && (
175
+ <svg width={W} height={H}>
176
+ {ySteps.map((v) => (
177
+ <line key={v} x1={pL} y1={gy(v)} x2={W - pR} y2={gy(v)} stroke="#f3f4f6" strokeWidth="1" />
178
+ ))}
179
+ {ySteps.map((v) => (
180
+ <text key={v} x={pL - 5} y={gy(v) + 4} textAnchor="end" fontSize="9" fill="#9ca3af">
181
+ {v >= 10000
182
+ ? `${(v / 1000).toFixed(0)}k`
183
+ : v >= 1000
184
+ ? `${(v / 1000) % 1 === 0 ? v / 1000 : (v / 1000).toFixed(1)}k`
185
+ : v}
186
+ </text>
187
+ ))}
188
+ {counts.map((v, i) => (
189
+ <rect key={i} x={bx(i)} y={gy(v)} width={barW} height={(v / MAX) * ph} fill="#3b82f6" rx="2" />
190
+ ))}
191
+ {labels.map((l, i) => {
192
+ if (!showLabel(i)) return null;
193
+ const lx = bx(i) + barW / 2;
194
+ return (
195
+ <text
196
+ key={l}
197
+ x={lx}
198
+ y={LABEL_Y}
199
+ transform={`rotate(-40, ${lx}, ${LABEL_Y})`}
200
+ textAnchor="end"
201
+ fontSize="9"
202
+ fill="#9ca3af"
203
+ >
204
+ {shortLabel(l)}
205
+ </text>
206
+ );
207
+ })}
208
+ </svg>
209
+ )}
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // ── Skeleton ───────────────────────────────────────────────────────
215
+
216
+ function Skeleton({ className }: { className?: string }) {
217
+ return <div className={`animate-pulse rounded bg-gray-100 ${className ?? ""}`} />;
218
+ }
219
+
220
+ // ── KPI Card ───────────────────────────────────────────────────────
221
+
222
+ interface KpiCardProps {
223
+ label: string;
224
+ value: string;
225
+ Icon: LucideIcon;
226
+ iconColor: string;
227
+ iconBg: string;
228
+ }
229
+
230
+ function KpiCard({ label, value, Icon, iconColor, iconBg }: KpiCardProps) {
231
+ return (
232
+ <div className="bg-white rounded-xl p-3 shadow-sm border border-gray-100">
233
+ <div className="flex items-center justify-between mb-2">
234
+ <span className="text-xs text-gray-500">{label}</span>
235
+ <div className={`w-7 h-7 rounded-full ${iconBg} flex items-center justify-center`}>
236
+ <Icon size={13} className={iconColor} />
237
+ </div>
238
+ </div>
239
+ <div className="text-2xl font-bold text-gray-900 leading-none text-center">{value}</div>
240
+ </div>
241
+ );
242
+ }
243
+
244
+ // ── Gender Bar ─────────────────────────────────────────────────────
245
+
246
+ interface GenderBarProps {
247
+ maleRate: number;
248
+ femaleRate: number;
249
+ unknownRate: number;
250
+ }
251
+
252
+ function GenderBar({ maleRate, femaleRate, unknownRate }: GenderBarProps) {
253
+ return (
254
+ <>
255
+ <div className="w-full h-2.5 rounded-full overflow-hidden flex">
256
+ <div className="h-full bg-blue-500" style={{ width: `${maleRate}%` }} />
257
+ <div className="h-full bg-pink-400" style={{ width: `${femaleRate}%` }} />
258
+ {unknownRate > 0 && <div className="h-full bg-gray-300" style={{ width: `${unknownRate}%` }} />}
259
+ </div>
260
+ <div className="flex items-center gap-4 mt-1.5 mb-3 flex-wrap">
261
+ <span className="flex items-center gap-1.5 text-xs text-gray-600">
262
+ <span className="w-2 h-2 rounded-full bg-blue-500 shrink-0" />
263
+ 남성 {maleRate.toFixed(1)}%
264
+ </span>
265
+ <span className="flex items-center gap-1.5 text-xs text-gray-600">
266
+ <span className="w-2 h-2 rounded-full bg-pink-400 shrink-0" />
267
+ 여성 {femaleRate.toFixed(1)}%
268
+ </span>
269
+ {unknownRate > 0 && (
270
+ <span className="flex items-center gap-1.5 text-xs text-gray-600">
271
+ <span className="w-2 h-2 rounded-full bg-gray-300 shrink-0" />
272
+ 미상 {unknownRate.toFixed(1)}%
273
+ </span>
274
+ )}
275
+ </div>
276
+ </>
277
+ );
278
+ }
279
+
280
+ // ── Age Groups ─────────────────────────────────────────────────────
281
+
282
+ function AgeGroups({ groups }: { groups: Array<DashboardAgeGroup> }) {
283
+ const maxRate = Math.max(...groups.map((g) => g.rate), 1);
284
+ return (
285
+ <div className="space-y-3">
286
+ {groups.map((a) => (
287
+ <div key={a.label} className="flex items-center gap-2">
288
+ <span className="w-18 text-xs text-gray-500 shrink-0">{a.label}</span>
289
+ <div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
290
+ <div
291
+ className="h-full bg-blue-400 rounded-full"
292
+ style={{ width: `${(a.rate / maxRate) * 100}%` }}
293
+ />
294
+ </div>
295
+ <span className="w-10 text-xs text-gray-500 text-right shrink-0">{a.rate.toFixed(1)}%</span>
296
+ </div>
297
+ ))}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ // ── Recent Users Table ─────────────────────────────────────────────
303
+
304
+ function RecentUsersTable({ users }: { users: Array<DashboardRecentUser> }) {
305
+ const genderLabel = (g: string) => (g === "M" ? "남성" : g === "F" ? "여성" : g);
306
+
307
+ return (
308
+ <table className="w-full">
309
+ <thead>
310
+ <tr className="border-b border-gray-100">
311
+ {["회원", "성별", "연령", "가입일", "상태"].map((h) => (
312
+ <th key={h} className="pb-2 text-xs font-semibold text-gray-400 text-left">
313
+ {h}
314
+ </th>
315
+ ))}
316
+ </tr>
317
+ </thead>
318
+ <tbody>
319
+ {users.map((u, idx) => (
320
+ <tr key={u.id} className="border-b border-gray-50 last:border-0">
321
+ <td className="py-2">
322
+ <div className="flex items-center gap-2">
323
+ <div
324
+ className={`w-6 h-6 rounded-full ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-[9px] font-bold shrink-0`}
325
+ >
326
+ {u.name.slice(0, 2)}
327
+ </div>
328
+ <span className="text-sm text-gray-800 font-medium">{u.name}</span>
329
+ </div>
330
+ </td>
331
+ <td className="py-2 text-sm text-gray-600">{genderLabel(u.gender)}</td>
332
+ <td className="py-2 text-sm text-gray-600">{u.age}</td>
333
+ <td className="py-2 text-xs text-gray-500 tabular-nums">{formatDateTime(u.createdAt)}</td>
334
+ <td className="py-2">
335
+ <span
336
+ className={`px-2 py-0.5 rounded-full text-xs font-medium ${
337
+ u.active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
338
+ }`}
339
+ >
340
+ {u.active ? "활성" : "비활성"}
341
+ </span>
342
+ </td>
343
+ </tr>
344
+ ))}
345
+ </tbody>
346
+ </table>
347
+ );
348
+ }
349
+
350
+ // ── Page ───────────────────────────────────────────────────────────
351
+
352
+ export default function DashboardPage() {
353
+ const navigate = useNavigate();
354
+ const { data, isLoading } = useGetUserDashboard("30d");
355
+
356
+ return (
357
+ <div className="p-4 space-y-3">
358
+ <ListHeader
359
+ title="회원 대시보드"
360
+ rightArea={
361
+ <div className="flex items-center gap-2">
362
+ <Button variant="outline">
363
+ <CalendarDays size={14} className="mr-1" />
364
+ 최근 30일
365
+ </Button>
366
+ <Button variant="file">
367
+ <Download size={14} className="mr-1" />
368
+ 다운로드
369
+ </Button>
370
+ </div>
371
+ }
372
+ />
373
+
374
+ {/* KPI Cards */}
375
+ <div className="grid grid-cols-4 gap-3 min-h-24">
376
+ {isLoading ? (
377
+ Array.from({ length: 4 }).map((_, i) => (
378
+ <div key={i} className="bg-white rounded-xl p-3 shadow-sm border border-gray-100 space-y-2">
379
+ <Skeleton className="h-3 w-20" />
380
+ <Skeleton className="h-7 w-16" />
381
+ </div>
382
+ ))
383
+ ) : (
384
+ <>
385
+ <KpiCard
386
+ label="전체 회원수"
387
+ value={(data?.summary.totalUsers ?? 0).toLocaleString()}
388
+ Icon={Users}
389
+ iconColor="text-blue-500"
390
+ iconBg="bg-blue-50"
391
+ />
392
+ <KpiCard
393
+ label="오늘 가입자"
394
+ value={(data?.summary.todayJoinedUsers ?? 0).toLocaleString()}
395
+ Icon={UserPlus}
396
+ iconColor="text-green-500"
397
+ iconBg="bg-green-50"
398
+ />
399
+ <KpiCard
400
+ label="최근 접속자 (24h)"
401
+ value={(data?.summary.recentActiveUsers ?? 0).toLocaleString()}
402
+ Icon={Activity}
403
+ iconColor="text-purple-500"
404
+ iconBg="bg-purple-50"
405
+ />
406
+ <KpiCard
407
+ label="탈퇴율"
408
+ value={`${(data?.summary.withdrawalRate ?? 0).toFixed(2)}%`}
409
+ Icon={UserMinus}
410
+ iconColor="text-red-400"
411
+ iconBg="bg-red-50"
412
+ />
413
+ </>
414
+ )}
415
+ </div>
416
+
417
+ {/* Charts Row */}
418
+ <div className="grid grid-cols-5 gap-3 h-90">
419
+ {/* Join Trend */}
420
+ <div className="col-span-3 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
421
+ <div className="flex items-center justify-between">
422
+ <h2 className="text-sm font-semibold text-gray-800">신규 가입자 추이</h2>
423
+ <div className="flex items-center gap-4">
424
+ <div className="flex items-center gap-1.5">
425
+ <div className="w-3 h-0.5 bg-blue-500 rounded-full" />
426
+ <span className="text-xs text-gray-500">신규 가입</span>
427
+ </div>
428
+ <div className="flex items-center gap-1.5">
429
+ <svg width="14" height="4" className="shrink-0">
430
+ <line
431
+ x1="0"
432
+ y1="2"
433
+ x2="14"
434
+ y2="2"
435
+ stroke="#ef4444"
436
+ strokeWidth="1.5"
437
+ strokeDasharray="4 3"
438
+ />
439
+ </svg>
440
+ <span className="text-xs text-gray-500">탈퇴</span>
441
+ </div>
442
+ </div>
443
+ </div>
444
+ <p className="text-xs text-gray-400 mt-1 mb-2">일별 신규 가입 vs 탈퇴</p>
445
+ {/* 고정 높이 컨테이너 — SVG는 이 안에서 preserveAspectRatio="none"으로 채움 */}
446
+ <div className="h-70">
447
+ {isLoading || !data ? (
448
+ <Skeleton className="h-full w-full" />
449
+ ) : (
450
+ <JoinTrendChart
451
+ labels={data.trend.labels}
452
+ joinedCounts={data.trend.joinedCounts}
453
+ withdrawnCounts={data.trend.withdrawnCounts}
454
+ />
455
+ )}
456
+ </div>
457
+ </div>
458
+
459
+ {/* Demographics */}
460
+ <div className="col-span-2 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
461
+ <h2 className="text-sm font-semibold text-gray-800">회원 기본 현황</h2>
462
+ <p className="text-xs text-gray-400 mt-1 mb-3">성별 · 연령대 분포</p>
463
+ {isLoading || !data ? (
464
+ <div className="space-y-2">
465
+ <Skeleton className="h-2.5 w-full" />
466
+ <Skeleton className="h-3 w-28" />
467
+ {Array.from({ length: 5 }).map((_, i) => (
468
+ <Skeleton key={i} className="h-1.5 w-full mt-2" />
469
+ ))}
470
+ </div>
471
+ ) : (
472
+ <div className="flex gap-2 flex-col">
473
+ <div>
474
+ <p className="text-xs font-semibold text-gray-600">성별</p>
475
+ <GenderBar
476
+ maleRate={data.demographics.gender.maleRate}
477
+ femaleRate={data.demographics.gender.femaleRate}
478
+ unknownRate={data.demographics.gender.unknownRate}
479
+ />
480
+ </div>
481
+ <div>
482
+ <p className="text-xs font-semibold text-gray-600 mb-4">연령대</p>
483
+ <AgeGroups groups={data.demographics.ageGroups} />
484
+ </div>
485
+ </div>
486
+ )}
487
+ </div>
488
+ </div>
489
+
490
+ {/* Bottom Row */}
491
+ <div className="grid grid-cols-5 gap-3">
492
+ {/* Recent Members */}
493
+ <div className="col-span-3 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
494
+ <div className="flex items-center justify-between mb-3">
495
+ <div>
496
+ <h2 className="text-sm font-semibold text-gray-800">최근 가입 회원</h2>
497
+ <p className="text-xs text-gray-400 mt-0.5">최근 가입 순</p>
498
+ </div>
499
+ <Button
500
+ variant="link"
501
+ onClick={() => navigate("/user")}
502
+ className="text-xs text-blue-500 hover:underline font-medium cursor-pointer"
503
+ >
504
+ 전체 보기
505
+ </Button>
506
+ </div>
507
+ {isLoading || !data ? (
508
+ <div className="space-y-2.5">
509
+ {Array.from({ length: 5 }).map((_, i) => (
510
+ <div key={i} className="flex items-center gap-3">
511
+ <Skeleton className="w-6 h-6 rounded-full" />
512
+ <Skeleton className="h-4 flex-1" />
513
+ </div>
514
+ ))}
515
+ </div>
516
+ ) : data.recentUsers.length === 0 ? (
517
+ <Empty className="flex flex-1 justify-center gap-2 px-4 py-16">
518
+ <TriangleAlert className="size-8 shrink-0 text-gray-500" strokeWidth={1.5} />
519
+ <p className="text-sm text-gray-500">최근 가입 회원이 없습니다.</p>
520
+ </Empty>
521
+ ) : (
522
+ <RecentUsersTable users={data.recentUsers} />
523
+ )}
524
+ </div>
525
+
526
+ {/* DAU */}
527
+ <div className="col-span-2 bg-white rounded-xl p-4 shadow-sm border border-gray-100">
528
+ <h2 className="text-sm font-semibold text-gray-800">일별 접속자 수</h2>
529
+ <p className="text-xs text-gray-400 mt-0.5 mb-2">DAU 추이</p>
530
+ <div className="h-44">
531
+ {isLoading || !data ? (
532
+ <Skeleton className="h-full w-full" />
533
+ ) : (
534
+ <DauBarChart labels={data.dailyActiveUsers.labels} counts={data.dailyActiveUsers.counts} />
535
+ )}
536
+ </div>
537
+ </div>
538
+ </div>
539
+ </div>
540
+ );
541
+ }