This commit is contained in:
2025-07-25 19:52:17 +09:00
20 changed files with 878 additions and 114 deletions
@@ -0,0 +1,2 @@
import { UserStatisticsPage } from "@/pages/user-statistics";
export default UserStatisticsPage;
@@ -47,7 +47,7 @@ function AddAdminDialog() {
const [open, setOpen] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState<UserSearchParams>({
keywordType: "",
keywordType: "userName",
keyword: "",
});
const [selectedGroupId, setSelectedGroupId] = useState("");
@@ -193,6 +193,7 @@ function AddAdminDialog() {
<form onSubmit={handleSearch} className="flex items-center gap-2">
<Select
value={userSearchQuery.keywordType}
onValueChange={(value) =>
setUserSearchQuery({ ...userSearchQuery, keywordType: value })
}
@@ -201,8 +202,8 @@ function AddAdminDialog() {
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId">아이디</SelectItem>
<SelectItem value="userName">이름</SelectItem>
<SelectItem value="userId">아이디</SelectItem>
</SelectContent>
</Select>
<Input
+10 -16
View File
@@ -7,7 +7,6 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Button,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
@@ -15,10 +14,11 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { columns } from "@/pages/apilog/model/table-columns";
import { ApiLogApi, type ApiLogParams } from "@/entities/apilog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/apilog/model/table-columns";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = {
from?: Date | undefined;
@@ -133,6 +133,11 @@ function ApiLogPage() {
setDateRange(presetDateRange || defaultDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return (
<div className="flex-1">
<div className="card card-border">
@@ -148,20 +153,9 @@ function ApiLogPage() {
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDatePicker
value={dateRange.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
setPresetDateRange("");
}}
/>
~
<InputDatePicker
value={dateRange.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
setPresetDateRange("");
}}
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
<Input
@@ -110,10 +110,16 @@ function DashboardPage() {
<div className="absolute left-0 top-0 p-6 tracking-tighter text-white">
<h3 className="text-2xl font-medium">총 회원수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.memberTotCnt ?? "-"}
{data?.memberTotCnt
? data.memberTotCnt.toLocaleString()
: "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
금일 신규 가입자 {data?.memberTodayCnt ?? "-"}명
금일 신규 가입자
{data?.memberTodayCnt
? data.memberTodayCnt.toLocaleString()
: "-"}
명
</p>
</div>
{/* 차트 영역 */}
@@ -131,10 +137,12 @@ function DashboardPage() {
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">전체 게시물</h3>
<div className="mt-2 text-6xl font-bold">
{data?.pstTotCnt ?? "-"}
{data?.pstTotCnt ? data.pstTotCnt.toLocaleString() : "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
금일 +{data?.pstTodayCnt ?? "-"} 등록
금일 +
{data?.pstTodayCnt ? data.pstTodayCnt.toLocaleString() : "-"}{" "}
등록
</p>
</div>
{/* 차트 영역 */}
@@ -152,10 +160,16 @@ function DashboardPage() {
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">오늘 접속자 수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.accessTodayCnt ?? "-"}
{data?.accessTodayCnt
? data.accessTodayCnt.toLocaleString()
: "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
전일 대비 +{data?.accessBefDayPer ?? "-"}%
전일 대비 +
{data?.accessBefDayPer
? data.accessBefDayPer.toLocaleString()
: "-"}
%
</p>
</div>
{/* 차트 영역 */}
@@ -173,7 +187,7 @@ function DashboardPage() {
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">API 호출 건수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.apiTodayCnt ?? "-"}
{data?.apiTodayCnt ? data.apiTodayCnt.toLocaleString() : "-"}
</div>
<p className="mt-1 text-2xl opacity-80">금일 누적</p>
</div>
@@ -221,7 +235,10 @@ function DashboardPage() {
</div>
<div className="mt-5">
<div className="text-5xl font-bold">
{data?.waitMemberCnt}건
{data?.waitMemberCnt
? data.waitMemberCnt.toLocaleString()
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
신규 가입 승인 대기
@@ -253,7 +270,9 @@ function DashboardPage() {
<span className="text-2xl font-medium">메뉴 승인</span>
</div>
<div className="mt-5">
<div className="text-5xl font-bold">3건</div>
<div className="text-5xl font-bold">
{(3).toLocaleString()}건
</div>
<div className="mt-2 text-2xl text-gray-500">
매뉴 수정 승인 대기
</div>
@@ -283,7 +302,10 @@ function DashboardPage() {
</div>
<div className="mt-5">
<div className="text-5xl font-bold">
{data?.waitCntnCnt}건
{data?.waitCntnCnt
? data.waitCntnCnt.toLocaleString()
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
컨텐츠 수정 승인 대기
@@ -303,19 +325,19 @@ function DashboardPage() {
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">신규 사용자 가입</span>
<span>1분 전</span>
<span>{(1).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">메뉴 수정 요청</span>
<span>5분 전</span>
<span>{(5).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">게시물 신고</span>
<span>10분 전</span>
<span>{(10).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">컨텐츠 승인 요청</span>
<span>15분 전</span>
<span>{(15).toLocaleString()}분 전</span>
</div>
</div>
</div>
@@ -328,20 +350,22 @@ function DashboardPage() {
<div className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">3월 정기 시스템 점검</span>
<span className="font-medium">
{(3).toLocaleString()}월 정기 시스템 점검
</span>
<span>내일</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">서버 업데이트 예정</span>
<span>3일 후</span>
<span>{(3).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">백업 스케줄</span>
<span>5일 후</span>
<span>{(5).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">정기 보안 점검</span>
<span>7일 후</span>
<span>{(7).toLocaleString()}일 후</span>
</div>
</div>
</div>
+2 -2
View File
@@ -161,11 +161,11 @@ function GroupForm({ form }: GroupFormProps) {
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="mngrY" />
<Label htmlFor="mngrY">Y</Label>
<Label htmlFor="mngrY">예</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="mngrN" />
<Label htmlFor="mngrN">N</Label>
<Label htmlFor="mngrN">아니오</Label>
</div>
</RadioGroup>
</FormControl>
@@ -1,6 +1,6 @@
"use client";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -43,7 +43,7 @@ const GroupsDialog = memo(
const { data: group } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId],
queryFn: () => GroupApi.findGroupById(groupId!),
enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출
enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출
});
const { mutateAsync: createGroup } = useMutationWrapper({
@@ -77,7 +77,7 @@ const GroupsDialog = memo(
delYn: group?.delYn ?? "",
authrtId: group?.authrtId ?? "",
siteId: group?.siteId ?? "",
mngrYn: group?.mngrYn ?? "",
mngrYn: group?.mngrYn ?? "Y",
},
});
@@ -91,6 +91,10 @@ const GroupsDialog = memo(
if (await confirm(confirmMessage)) callback(data);
};
useEffect(() => {
if (open) form.reset();
}, [open]);
return (
<Dialog open={open} onOpenChange={setOpen} {...props}>
<DialogTrigger asChild>{children}</DialogTrigger>
@@ -23,6 +23,7 @@ function CreatePage() {
resolver: zodResolver(imageFormSchema),
defaultValues: {
atflYn: "Y",
useYn: "Y",
},
});
@@ -124,6 +124,7 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) {
{...field}
type="number"
min={0}
max={9999}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(parseInt(e.target.value))
+10 -16
View File
@@ -7,7 +7,6 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Button,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
@@ -16,10 +15,11 @@ import {
SelectValue,
LoadingWithImage,
} from "@common/components";
import { columns } from "@/pages/loginlog/model/table-columns";
import { LoginlogApi, type LoginlogParams } from "@/entities/loginlog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/loginlog/model/table-columns";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = {
from?: Date | undefined;
@@ -141,6 +141,11 @@ function LoginlogPage() {
setDateRange(presetDateRange || defaultDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return (
<div className="flex-1">
<div className="card card-border">
@@ -156,20 +161,9 @@ function LoginlogPage() {
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDatePicker
value={dateRange.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
setPresetDateRange("");
}}
/>
~
<InputDatePicker
value={dateRange.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
setPresetDateRange("");
}}
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
<Input
@@ -7,7 +7,6 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Button,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
@@ -15,13 +14,14 @@ import {
SelectTrigger,
SelectValue,
} from "@common/components";
import { columns } from "@/pages/privacylog/model/table-columns";
import {
PrivacyLogPrivacy,
type PrivacyLogParams,
} from "@/entities/privacylog";
import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/privacylog/model/table-columns";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = {
from?: Date | undefined;
@@ -132,6 +132,11 @@ function PrivacyLogPage() {
setDateRange(presetDateRange || defaultDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return (
<div className="flex-1">
<div className="card card-border">
@@ -147,20 +152,9 @@ function PrivacyLogPage() {
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDatePicker
value={dateRange.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
setPresetDateRange("");
}}
/>
~
<InputDatePicker
value={dateRange.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
setPresetDateRange("");
}}
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">조회정보</Label>
<Input
+16 -19
View File
@@ -13,7 +13,6 @@ import {
import {
Button,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
@@ -31,8 +30,9 @@ import {
} from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog";
@@ -58,7 +58,7 @@ function RecentPage() {
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange | undefined>({
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
@@ -169,7 +169,7 @@ function RecentPage() {
};
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange | undefined;
let presetDateRange: DateRange;
switch (value) {
case "1W":
presetDateRange = {
@@ -190,12 +190,20 @@ function RecentPage() {
};
break;
default:
presetDateRange = undefined;
presetDateRange = {
from: undefined,
to: undefined,
};
}
setPresetDateRange(value);
setDateRange(presetDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const onSoftDeletePost = async () => {
const { resolved, value: delRsn } =
await prompt("삭제 사유를 입력해주세요.");
@@ -260,20 +268,9 @@ function RecentPage() {
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDatePicker
value={dateRange?.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
setPresetDateRange("");
}}
/>
~
<InputDatePicker
value={dateRange?.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
setPresetDateRange("");
}}
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">게시판</Label>
<Select
@@ -0,0 +1 @@
export { UserStatisticsApi } from "./user-statistics-service";
@@ -0,0 +1,138 @@
import {
type AccessLog,
type AccessLogParams,
type AccessLogService,
type PageableList,
} from "../model/user-statistics-interface";
// 목 데이터
const mockAccessLogs: AccessLog[] = [
{
no: 1,
accessTime: "2024-01-15 14:30:25",
accessIp: "192.168.1.100",
accessRegion: "서울특별시",
urlPath: "/index",
browser: "Chrome",
site: "SITE1",
accessType: "메인",
},
{
no: 2,
accessTime: "2024-01-15 14:28:15",
accessIp: "203.241.50.123",
accessRegion: "부산광역시",
urlPath: "/board/list",
browser: "Edge",
site: "SITE2",
accessType: "게시판",
},
{
no: 3,
accessTime: "2024-01-15 14:25:42",
accessIp: "175.223.45.67",
accessRegion: "대구광역시",
urlPath: "/statistics",
browser: "Safari",
site: "SITE1",
accessType: "통계",
},
{
no: 4,
accessTime: "2024-01-15 14:22:18",
accessIp: "211.252.87.45",
accessRegion: "인천광역시",
urlPath: "/contents/view",
browser: "Firefox",
site: "SITE3",
accessType: "컨텐츠",
},
{
no: 5,
accessTime: "2024-01-15 14:20:33",
accessIp: "121.254.12.89",
accessRegion: "광주광역시",
urlPath: "/search",
browser: "Chrome",
site: "SITE1",
accessType: "검색",
},
];
// 목 API 서비스
class UserStatisticsApiImpl implements AccessLogService {
async getAccessLogList(
params: AccessLogParams,
): Promise<PageableList<AccessLog>> {
// 실제 API 호출 대신 목 데이터 반환
const { page = 0, size = 10 } = params;
// 필터링 로직 (실제로는 서버에서 처리)
let filteredLogs = [...mockAccessLogs];
if (params.urlPath) {
filteredLogs = filteredLogs.filter((log) =>
log.urlPath.toLowerCase().includes(params.urlPath!.toLowerCase()),
);
}
if (params.accessIp) {
filteredLogs = filteredLogs.filter((log) =>
log.accessIp.includes(params.accessIp!),
);
}
if (params.accessRegion) {
filteredLogs = filteredLogs.filter((log) =>
log.accessRegion.includes(params.accessRegion!),
);
}
if (params.browser) {
filteredLogs = filteredLogs.filter(
(log) => log.browser.toLowerCase() === params.browser!.toLowerCase(),
);
}
if (params.siteId) {
filteredLogs = filteredLogs.filter((log) => log.site === params.siteId);
}
// 페이지네이션
const start = page * size;
const end = start + size;
const paginatedLogs = filteredLogs.slice(start, end);
// 목 응답 생성
return {
content: paginatedLogs,
pageable: {
pageNumber: page,
pageSize: size,
sort: {
empty: true,
sorted: false,
unsorted: true,
},
offset: page * size,
paged: true,
unpaged: false,
},
last: (page + 1) * size >= filteredLogs.length,
totalPages: Math.ceil(filteredLogs.length / size),
totalElements: filteredLogs.length,
size,
number: page,
sort: {
empty: true,
sorted: false,
unsorted: true,
},
first: page === 0,
numberOfElements: paginatedLogs.length,
empty: paginatedLogs.length === 0,
};
}
}
export const UserStatisticsApi = new UserStatisticsApiImpl();
@@ -0,0 +1 @@
export { UserStatisticsPage } from "./ui/user-statistics-page";
@@ -0,0 +1,148 @@
import { type ChartData, type ChartOptions } from "chart.js";
// 일별 접속 현황 차트 데이터
export const monthlyStatisticsData = {
labels: [
"1일",
"2일",
"3일",
"4일",
"5일",
"6일",
"7일",
"8일",
"9일",
"10일",
"11일",
"12일",
"13일",
"14일",
"15일",
],
datasets: [
{
type: "bar" as const,
label: "페이지뷰",
data: [
850, 1000, 750, 750, 700, 750, 780, 800, 850, 900, 1200, 1700, 2300,
2100, 2600,
],
backgroundColor: "rgba(54, 162, 235, 0.8)",
order: 2,
},
{
type: "line" as const,
label: "신규 접속자",
data: [
700, 600, 500, 550, 500, 550, 500, 550, 600, 850, 1200, 1500, 1700,
2300, 2200,
],
borderColor: "rgba(255, 99, 132, 0.8)",
borderWidth: 2,
tension: 0.4,
pointRadius: 3,
order: 0,
},
{
type: "line" as const,
label: "총 접속자",
data: [
200, 300, 400, 500, 600, 500, 550, 500, 400, 500, 350, 800, 900, 1200,
1300,
],
borderColor: "rgba(75, 192, 192, 0.8)",
borderWidth: 2,
tension: 0.4,
pointRadius: 3,
order: 1,
},
],
};
export const monthlyStatisticsOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top" as const,
},
tooltip: {
mode: "index" as const,
intersect: false,
},
},
};
// 사이트별 접속자 차트 데이터
export const siteVisitorsData: ChartData<"bar"> = {
labels: ["SITE1", "SITE2", "SITE3", "SITE4", "SITE5"],
datasets: [
{
label: "사이트별 접속자",
data: [2500, 2000, 1500, 1200, 800],
backgroundColor: [
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(255, 205, 86, 0.8)",
],
borderWidth: 1,
},
],
};
export const siteVisitorsOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y" as const,
plugins: {
legend: {
display: false,
},
},
};
// 브라우저별 접속자 차트 데이터
export const browserStatisticsData: ChartData<"doughnut"> = {
labels: ["Chrome", "Edge", "Safari", "Firefox", "Internet Explorer"],
datasets: [
{
data: [45, 25, 15, 10, 5],
backgroundColor: [
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(255, 205, 86, 0.8)",
],
borderColor: [
"rgba(54, 162, 235, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 99, 132, 1)",
"rgba(255, 159, 64, 1)",
"rgba(255, 205, 86, 1)",
],
borderWidth: 1,
},
],
};
export const browserStatisticsOptions: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
},
};
export const chartData = {
monthlyStatisticsData,
monthlyStatisticsOptions,
siteVisitorsData,
siteVisitorsOptions,
browserStatisticsData,
browserStatisticsOptions,
};
@@ -0,0 +1,39 @@
import { createColumnHelper } from "@tanstack/react-table";
import { type AccessLog } from "./user-statistics-interface";
const columnHelper = createColumnHelper<AccessLog>();
export const columns = [
columnHelper.accessor("no", {
header: "No",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessTime", {
header: "접속일시",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessIp", {
header: "접속IP",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessRegion", {
header: "접속지역",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("urlPath", {
header: "URL경로",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("browser", {
header: "브라우저",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("site", {
header: "사이트",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessType", {
header: "메뉴명",
cell: (info) => info.getValue(),
}),
];
@@ -0,0 +1,56 @@
export interface AccessLog {
no: number;
accessTime: string;
accessIp: string;
accessRegion: string;
urlPath: string;
browser: string;
site: string;
accessType: string;
}
export interface AccessLogParams {
page?: number;
size?: number;
urlPath?: string;
accessIp?: string;
accessRegion?: string;
browser?: string;
accessDateStart?: string;
accessDateEnd?: string;
siteId?: string;
sort?: string;
}
export interface AccessLogService {
getAccessLogList(params: AccessLogParams): Promise<PageableList<AccessLog>>;
}
export interface PageableList<T> {
content: T[];
pageable: {
pageNumber: number;
pageSize: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
paged: boolean;
unpaged: boolean;
};
last: boolean;
totalPages: number;
totalElements: number;
size: number;
number: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
first: boolean;
numberOfElements: number;
empty: boolean;
}
@@ -0,0 +1,324 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Filler,
Legend,
Tooltip,
type ChartData,
} from "chart.js";
import { Bar, Doughnut } from "react-chartjs-2";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { SiteSelect } from "@/entities/site";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { UserStatisticsApi } from "../api";
import { chartData } from "../model/chart-data";
import { columns } from "../model/table-columns";
import { type AccessLogParams } from "../model/user-statistics-interface";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Filler,
Legend,
Tooltip,
);
type DateRange = {
from?: Date | undefined;
to?: Date | undefined;
};
function UserStatisticsPage() {
const defaultDateRange: DateRange = {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
};
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [keywordType, setKeywordType] = useState("");
const [keyword, setKeyword] = useState("");
const [presetDateRange, setPresetDateRange] = useState("1W");
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [searchParams, setSearchParams] = useState<AccessLogParams>({
urlPath: "",
accessIp: "",
accessRegion: "",
browser: "",
accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
accessDateEnd: dayjs().format("YYYY-MM-DD"),
sort: "accessTime,desc",
});
const query = useQuery({
queryKey: ["user-statistics", searchParams, pagination],
queryFn: () =>
UserStatisticsApi.getAccessLogList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
},
dataTableOptions: {
viewPagination: true,
},
});
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange;
switch (value) {
case "1W":
presetDateRange = {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "1M":
presetDateRange = {
from: dayjs().subtract(30, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "3M":
presetDateRange = {
from: dayjs().subtract(90, "day").toDate(),
to: dayjs().toDate(),
};
break;
default:
presetDateRange = defaultDateRange;
}
setPresetDateRange(value);
setDateRange(presetDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
// 검색 조건에 따라 검색 파라미터 설정
const params: AccessLogParams = {
sort: "accessTime,desc",
accessDateStart: dateRange.from
? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
accessDateEnd: dateRange.to
? dayjs(dateRange.to).format("YYYY-MM-DD")
: "",
};
// 검색어 타입에 따라 파라미터 설정
switch (keywordType) {
case "urlPath":
params.urlPath = keyword;
break;
case "ip":
params.accessIp = keyword;
break;
case "region":
params.accessRegion = keyword;
break;
case "browser":
params.browser = keyword;
break;
}
setSearchParams(params);
};
const handleReset = () => {
setKeywordType("");
setKeyword("");
setDateRange(defaultDateRange);
setPresetDateRange("1W");
setSearchParams({
urlPath: "",
accessIp: "",
accessRegion: "",
browser: "",
accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
accessDateEnd: dayjs().format("YYYY-MM-DD"),
sort: "accessTime,desc",
});
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">날짜</Label>
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1W">일주일</SelectItem>
<SelectItem value="1M">1개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">사이트</Label>
<SiteSelect enabledAllSite className="w-[16rem]" />
<Label className="ml-4 mr-2 text-label">검색</Label>
<Select
value={keywordType}
onValueChange={(value) => setKeywordType(value)}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="urlPath">URL경로</SelectItem>
<SelectItem value="ip">접속IP</SelectItem>
<SelectItem value="region">접속지역</SelectItem>
<SelectItem value="browser">브라우저</SelectItem>
</SelectContent>
</Select>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{/* 차트 영역 */}
<div className="grid grid-cols-2">
{/* 일별 접속 현황 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">일별 접속 현황</h3>
</div>
<div className="h-96 p-4">
<Bar
data={chartData.monthlyStatisticsData as ChartData<"bar">}
options={chartData.monthlyStatisticsOptions}
/>
</div>
</div>
<div className="grid grid-cols-2">
{/* 사이트별 접속자 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">사이트별 접속자</h3>
</div>
<div className="h-96 p-4">
<Bar
data={chartData.siteVisitorsData}
options={chartData.siteVisitorsOptions}
/>
</div>
</div>
{/* 브라우저별 접속자 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">브라우저별 접속자</h3>
</div>
<div className="h-96 p-4">
<Doughnut
data={chartData.browserStatisticsData}
options={chartData.browserStatisticsOptions}
/>
</div>
</div>
</div>
</div>
{/* 접속 로그 상세 */}
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">접속 로그 상세</h3>
<div className="flex gap-2">
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(parseInt(value));
}}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}개씩 보기
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
</div>
);
}
export { UserStatisticsPage };
+20 -22
View File
@@ -9,7 +9,6 @@ import {
Button,
Checkbox,
Input,
InputDatePicker,
Label,
Select,
SelectContent,
@@ -25,6 +24,7 @@ import {
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { columns } from "../model/table-columns";
type DateRange = {
@@ -40,7 +40,7 @@ function UsersPage() {
pageSize: 10,
});
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange | undefined>({
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
@@ -62,7 +62,7 @@ function UsersPage() {
});
const { data: userSttsStats } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"],
queryFn: () => UserApi.selectUserSttsStats(),
});
@@ -145,7 +145,7 @@ function UsersPage() {
};
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange | undefined;
let presetDateRange: DateRange;
switch (value) {
case "1M":
presetDateRange = {
@@ -166,15 +166,24 @@ function UsersPage() {
};
break;
default:
presetDateRange = undefined;
presetDateRange = {
from: undefined,
to: undefined,
};
}
setPresetDateRange(value);
setDateRange(presetDateRange);
};
const getUserSttsStatsCnt = (code: string): number => {
if (!Array.isArray(userSttsStats)) return 0;
return userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0;
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const getUserSttsStatsCnt = (code: string): string => {
if (!Array.isArray(userSttsStats)) return "0";
const count = userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0;
return count.toLocaleString();
};
const handleUserStateCodeChange = (code: string, checked: boolean) => {
@@ -292,20 +301,9 @@ function UsersPage() {
<SelectItem value="6M">6개월</SelectItem>
</SelectContent>
</Select>
<InputDatePicker
value={dateRange?.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
setPresetDateRange("");
}}
/>
~
<InputDatePicker
value={dateRange?.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
setPresetDateRange("");
}}
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
</div>
</div>
@@ -0,0 +1,47 @@
import { InputDatePicker } from "@common/components";
import { useEffect } from "react";
import { useAlertStore } from "../lib";
type DateRange = {
from?: Date;
to?: Date;
};
type InputDateRangePickerProps = {
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
};
function InputDateRangePicker({
dateRange,
setDateRange,
}: InputDateRangePickerProps) {
const alert = useAlertStore((state) => state.setMessage);
useEffect(() => {
if (dateRange.from && dateRange.to && dateRange.from > dateRange.to) {
alert("시작일보다 종료일이 커야 합니다.");
setDateRange({ ...dateRange, to: dateRange.from });
}
}, [dateRange]);
return (
<>
<InputDatePicker
value={dateRange.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
}}
/>
~
<InputDatePicker
value={dateRange.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
}}
/>
</>
);
}
export { InputDateRangePicker };