@farmzone/fz-template-react 1.0.0 → 1.0.2
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.
- package/README.md +12 -14
- package/bin/create.js +10 -4
- package/package.json +8 -2
- package/template/.env.example +5 -0
- package/template/eslint.config.js +4 -1
- package/template/index.css +15 -2
- package/template/index.html +1 -1
- package/template/package.json +55 -41
- package/template/public/favicon.ico +0 -0
- package/template/public/mockServiceWorker.js +349 -0
- package/template/src/app/App.tsx +2 -0
- package/template/src/app/api/api.ts +178 -0
- package/template/src/app/api/queries.ts +321 -0
- package/template/src/app/api/queryKey.ts +7 -0
- package/template/src/app/api/token.ts +7 -0
- package/template/src/app/layout/Layout.tsx +33 -16
- package/template/src/app/layout/ListContents.tsx +9 -0
- package/template/src/app/layout/ListHeader.tsx +41 -0
- package/template/src/app/layout/MultiTabNav.tsx +101 -0
- package/template/src/app/layout/Sidebar.tsx +33 -53
- package/template/src/app/layout/UserInfo.tsx +94 -0
- package/template/src/app/layout/menu.ts +46 -21
- package/template/src/app/layout/tabSwitchStore.ts +11 -0
- package/template/src/app/router/Router.tsx +54 -28
- package/template/src/app/store/index.ts +26 -0
- package/template/src/index.tsx +21 -12
- package/template/src/mocks/browser.ts +17 -0
- package/template/src/mocks/handlers.ts +43 -0
- package/template/src/mocks/scenarios.ts +57 -0
- package/template/src/pages/dashboard/index.tsx +541 -8
- package/template/src/pages/error/Error.tsx +29 -17
- package/template/src/pages/error/NotFound.tsx +27 -17
- package/template/src/pages/login/index.tsx +317 -0
- package/template/src/pages/post/PostFormModal.tsx +128 -0
- package/template/src/pages/post/detail/index.tsx +548 -0
- package/template/src/pages/post/index.tsx +267 -0
- package/template/src/pages/sample/SampleFormModal.tsx +77 -0
- package/template/src/pages/sample/detail/index.tsx +424 -0
- package/template/src/pages/sample/index.tsx +269 -0
- package/template/src/pages/system/log/index.tsx +173 -0
- package/template/src/pages/user/config/columns.tsx +109 -0
- package/template/src/pages/user/config/schema.ts +54 -0
- package/template/src/pages/user/index.tsx +641 -0
- package/template/src/shared/components/CommentInput.tsx +243 -0
- package/template/src/shared/components/FilePreviewCard.tsx +70 -0
- package/template/src/shared/config/text.ts +27 -0
- package/template/src/shared/config/type.ts +40 -0
- package/template/src/shared/utils/format.ts +11 -0
- package/template/src/types/auth.ts +10 -0
- package/template/src/types/comment.ts +33 -0
- package/template/src/types/common.ts +19 -0
- package/template/src/types/dashboard.ts +53 -0
- package/template/src/types/index.ts +16 -0
- package/template/src/types/log.ts +21 -0
- package/template/src/types/post.ts +32 -0
- package/template/src/types/sample.ts +28 -0
- package/template/src/types/user.ts +51 -0
- package/template/src/vite-env.d.ts +10 -0
- package/template/gitignore +0 -32
|
@@ -1,8 +1,541 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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,17 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button } from "@farmzone/fz-react-ui";
|
|
3
|
-
|
|
4
|
-
export default function ErrorPage() {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
import { Button } from "@farmzone/fz-react-ui";
|
|
3
|
+
|
|
4
|
+
export default function ErrorPage() {
|
|
5
|
+
const handleRetry = () => {
|
|
6
|
+
window.location.href = "/";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
11
|
+
<div className="flex flex-col items-center w-125 pb-6 shadow-lg rounded-lg bg-gray-50">
|
|
12
|
+
<div className="flex flex-col mt-14 mb-10 items-center gap-3">
|
|
13
|
+
<TriangleAlert size={60} strokeWidth={1.2} />
|
|
14
|
+
<p className="text-2xl font-bold">정상적인 접근이 아닙니다.</p>
|
|
15
|
+
<div className="flex flex-col gap-3">
|
|
16
|
+
<p className="text-lg text-gray-500">
|
|
17
|
+
화면 출력 중 에러가 발생하였습니다. 관리자에게 문의 주세요.
|
|
18
|
+
</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex gap-3 w-full px-6">
|
|
22
|
+
<Button variant="save" className="w-full py-2.5 font-semibold" onClick={handleRetry}>
|
|
23
|
+
다시 시도하기
|
|
24
|
+
</Button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button } from "@farmzone/fz-react-ui";
|
|
3
|
-
|
|
4
|
-
export default function NotFoundPage() {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import { TriangleAlert } from "lucide-react";
|
|
2
|
+
import { Button } from "@farmzone/fz-react-ui";
|
|
3
|
+
|
|
4
|
+
export default function NotFoundPage() {
|
|
5
|
+
const handleGoHome = () => {
|
|
6
|
+
window.location.href = "/";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
11
|
+
<div className="flex flex-col items-center w-125 pb-6 shadow-lg rounded-lg bg-gray-50">
|
|
12
|
+
<div className="flex flex-col mt-14 mb-10 items-center gap-3">
|
|
13
|
+
<TriangleAlert size={60} strokeWidth={1.2} />
|
|
14
|
+
<p className="text-2xl font-bold">정상적인 접근이 아닙니다.</p>
|
|
15
|
+
<div className="flex flex-col gap-3">
|
|
16
|
+
<p className="text-lg text-gray-500">입력하신 주소가 잘못되었거나 접근 권한이 없습니다.</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div className="flex gap-3 w-full px-6">
|
|
20
|
+
<Button variant="save" className="w-full py-2.5 font-semibold" onClick={handleGoHome}>
|
|
21
|
+
홈으로 이동
|
|
22
|
+
</Button>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|