Merge remote-tracking branch 'origin/main' into iipbs/sn/dev
This commit is contained in:
@@ -6,8 +6,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
Input,
|
||||||
|
InputDatePicker,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -21,7 +21,7 @@ import { ENDPOINT } from "@/shared/config";
|
|||||||
import { columns } from "@/pages/apilog/model/table-columns";
|
import { columns } from "@/pages/apilog/model/table-columns";
|
||||||
|
|
||||||
type DateRange = {
|
type DateRange = {
|
||||||
from: Date | undefined;
|
from?: Date | undefined;
|
||||||
to?: Date | undefined;
|
to?: Date | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function ApiLogPage() {
|
|||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
const [presetDateRange, setPresetDateRange] = useState("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [apiHttpPmhd, setApiHttpPmhd] = useState("");
|
const [apiHttpPmhd, setApiHttpPmhd] = useState("");
|
||||||
@@ -84,8 +85,10 @@ function ApiLogPage() {
|
|||||||
...searchParams,
|
...searchParams,
|
||||||
userId,
|
userId,
|
||||||
apiHttpPmhd,
|
apiHttpPmhd,
|
||||||
dmndDtStart: dayjs(dateRange.from).format("YYYY-MM-DD"),
|
dmndDtStart: dateRange.from
|
||||||
dmndDtEnd: dayjs(dateRange.to).format("YYYY-MM-DD"),
|
? dayjs(dateRange.from).format("YYYY-MM-DD")
|
||||||
|
: "",
|
||||||
|
dmndDtEnd: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ function ApiLogPage() {
|
|||||||
default:
|
default:
|
||||||
presetDateRange = undefined;
|
presetDateRange = undefined;
|
||||||
}
|
}
|
||||||
|
setPresetDateRange(value);
|
||||||
setDateRange(presetDateRange || defaultDateRange);
|
setDateRange(presetDateRange || defaultDateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,23 +138,31 @@ function ApiLogPage() {
|
|||||||
<div className="card card-border">
|
<div className="card card-border">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
||||||
<Label className="mr-2">조회기간</Label>
|
<Label className="mr-2">조회기간</Label>
|
||||||
<DateRangePicker
|
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
|
||||||
value={dateRange}
|
<SelectTrigger className="w-[12rem]">
|
||||||
onValueChange={(value) => setDateRange(value || defaultDateRange)}
|
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||||
>
|
</SelectTrigger>
|
||||||
<div className="card card-border flex items-center gap-2">
|
<SelectContent>
|
||||||
<Select onValueChange={handlePresetDateRange}>
|
<SelectItem value="1W">일주일</SelectItem>
|
||||||
<SelectTrigger>
|
<SelectItem value="1M">1개월</SelectItem>
|
||||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
<SelectItem value="3M">3개월</SelectItem>
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
<SelectItem value="1W">일주일</SelectItem>
|
<InputDatePicker
|
||||||
<SelectItem value="1M">1개월</SelectItem>
|
value={dateRange.from}
|
||||||
<SelectItem value="3M">3개월</SelectItem>
|
onChange={(date) => {
|
||||||
</SelectContent>
|
setDateRange({ ...dateRange, from: date });
|
||||||
</Select>
|
setPresetDateRange("");
|
||||||
</div>
|
}}
|
||||||
</DateRangePicker>
|
/>
|
||||||
|
~
|
||||||
|
<InputDatePicker
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={(date) => {
|
||||||
|
setDateRange({ ...dateRange, to: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
|
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
|
||||||
<Input
|
<Input
|
||||||
value={userId}
|
value={userId}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { client } from "@/shared/api";
|
||||||
|
import { ENDPOINT } from "@/shared/config";
|
||||||
|
import type { Dashboard, DashboardService } from "../model/dashboard-interface";
|
||||||
|
|
||||||
|
export class DashboardApiService implements DashboardService {
|
||||||
|
public async getDashboard(): Promise<Dashboard> {
|
||||||
|
const response = await client.get<Dashboard>(
|
||||||
|
ENDPOINT.CMS_SERVICE.DASHBOARD,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardApi = new DashboardApiService();
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ChartData, ChartDataset } from "chart.js";
|
||||||
|
import { getCssVariableValue } from "@common/components";
|
||||||
|
import type {
|
||||||
|
MenuStatisticsContent,
|
||||||
|
SiteStatisticsContent,
|
||||||
|
} from "../model/dashboard-interface";
|
||||||
|
|
||||||
|
export function processSiteAccessData(
|
||||||
|
siteAccessList: SiteStatisticsContent[],
|
||||||
|
): ChartData<"line">["datasets"] {
|
||||||
|
const siteGroups: Record<string, ChartDataset> = siteAccessList.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (!acc[item.siteNm]) {
|
||||||
|
acc[item.siteNm] = {
|
||||||
|
label: item.siteNm,
|
||||||
|
data: Array(24).fill(0), // 24시간 데이터 초기화
|
||||||
|
borderColor: getCssVariableValue(
|
||||||
|
`--chart-${Math.floor(Math.random() * 5) + 1}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간대별 접속 카운트 누적
|
||||||
|
const hourIndex = parseInt(item.accessHour, 10);
|
||||||
|
if (!isNaN(hourIndex) && hourIndex >= 0 && hourIndex < 24) {
|
||||||
|
acc[item.siteNm]!.data[hourIndex] = item.accessCnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, ChartDataset>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(siteGroups) as ChartData<"line">["datasets"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processMenuAccessData(
|
||||||
|
menuAccessList: MenuStatisticsContent[],
|
||||||
|
): ChartData<"bar">["datasets"] {
|
||||||
|
const data = menuAccessList.map((item) => item.accessCnt);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "메뉴별 접속 횟수",
|
||||||
|
data,
|
||||||
|
backgroundColor: "rgb(32, 148, 250)",
|
||||||
|
},
|
||||||
|
] as ChartData<"bar">["datasets"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { type ChartData, type ChartOptions } from "chart.js";
|
||||||
|
|
||||||
|
const commonData = {
|
||||||
|
labels: ["1", "2", "3", "4", "5", "6", "7"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const chartData: Record<
|
||||||
|
string,
|
||||||
|
{ data: ChartData<"line" | "bar">; options: ChartOptions<"line" | "bar"> }
|
||||||
|
> = {
|
||||||
|
memberChartData: {
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
...commonData.datasets[0],
|
||||||
|
data: [8, 10, 7, 12, 9, 13, 12],
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.6)",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
...commonOptions.scales,
|
||||||
|
y: {
|
||||||
|
...commonOptions.scales.y,
|
||||||
|
min: 5,
|
||||||
|
max: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
postChartData: {
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
...commonData.datasets[0],
|
||||||
|
data: [5200, 5350, 5450, 5520, 5600, 5650, 5678],
|
||||||
|
borderColor: "rgba(86, 138, 255, 1)",
|
||||||
|
backgroundColor: "rgba(86, 138, 255, 0.1)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
visitorChartData: {
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
...commonData.datasets[0],
|
||||||
|
data: [1800, 2100, 1950, 2300, 2150, 2250, 2345],
|
||||||
|
borderColor: "rgba(149, 131, 252)",
|
||||||
|
backgroundColor: "rgba(149, 131, 252, 0.1)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apiChartData: {
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
...commonData.datasets[0],
|
||||||
|
data: [41000, 42500, 43200, 44100, 44800, 45200, 45678],
|
||||||
|
borderColor: "rgba(220, 103, 206)",
|
||||||
|
backgroundColor: "rgba(220, 103, 206, 0.1)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
siteAccessChartData: {
|
||||||
|
data: {
|
||||||
|
labels: Array.from({ length: 24 }, (_, i) =>
|
||||||
|
i.toString().padStart(2, "0"),
|
||||||
|
),
|
||||||
|
datasets: [],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "top",
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
text: "시간대",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
text: "접속자 수",
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
menuAccessChartData: {
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "top",
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
text: "메뉴명",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
text: "접속 횟수",
|
||||||
|
},
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface SiteStatisticsContent {
|
||||||
|
siteId: string;
|
||||||
|
siteNm: string;
|
||||||
|
accessHour: string;
|
||||||
|
accessCnt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuStatisticsContent {
|
||||||
|
menuCd: number;
|
||||||
|
menuNm: string;
|
||||||
|
accessCnt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
memberTotCnt: number;
|
||||||
|
memberTodayCnt: number;
|
||||||
|
pstTotCnt: number;
|
||||||
|
pstTodayCnt: number;
|
||||||
|
accessTodayCnt: number;
|
||||||
|
accessBefDayPer: number;
|
||||||
|
apiTodayCnt: number;
|
||||||
|
waitMemberCnt: number;
|
||||||
|
waitCntnCnt: number;
|
||||||
|
notiList: [];
|
||||||
|
schdlList: [];
|
||||||
|
siteAccessList: SiteStatisticsContent[];
|
||||||
|
menuAccessList: MenuStatisticsContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardService {
|
||||||
|
getDashboard(): Promise<Dashboard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Badge } from "@common/components";
|
||||||
|
import type { RecentPost } from "@/entities/post";
|
||||||
|
|
||||||
|
const noticePostTableColumns: ColumnDef<RecentPost>[] = [
|
||||||
|
{
|
||||||
|
header: "번호",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
if (row.original.upndFixYn === "Y") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color="orange"
|
||||||
|
className="flex size-8 items-center justify-center"
|
||||||
|
>
|
||||||
|
<i className="diveicon di-volume-up-fill" />
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const totalRows = table.getRowCount();
|
||||||
|
const currentPage = table.getState().pagination.pageIndex;
|
||||||
|
const pageSize = table.getState().pagination.pageSize;
|
||||||
|
return totalRows - (currentPage * pageSize + row.index);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "pstTtl",
|
||||||
|
header: "제목",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "wrtrNm",
|
||||||
|
header: "작성자",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "regDt",
|
||||||
|
header: "작성일",
|
||||||
|
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inqNocs",
|
||||||
|
header: "조회수",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentPostTableColumns: ColumnDef<RecentPost>[] = [
|
||||||
|
{
|
||||||
|
header: "번호",
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const totalRows = table.getRowCount();
|
||||||
|
const currentPage = table.getState().pagination.pageIndex;
|
||||||
|
const pageSize = table.getState().pagination.pageSize;
|
||||||
|
return totalRows - (currentPage * pageSize + row.index);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "pstTtl",
|
||||||
|
header: "제목",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "wrtrNm",
|
||||||
|
header: "작성자",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "regDt",
|
||||||
|
header: "작성일",
|
||||||
|
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inqNocs",
|
||||||
|
header: "조회수",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export { noticePostTableColumns, recentPostTableColumns };
|
||||||
@@ -1,5 +1,409 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getCoreRowModel } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
type ChartData,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Bar, Line } from "react-chartjs-2";
|
||||||
|
import { PostApi, type RecentPost } from "@/entities/post";
|
||||||
|
import { ENDPOINT } from "@/shared/config";
|
||||||
|
import { useDataTable } from "@/shared/lib";
|
||||||
|
import { DashboardApi } from "../api/dashboard-service";
|
||||||
|
import { chartData } from "../model/chart-data";
|
||||||
|
import {
|
||||||
|
noticePostTableColumns,
|
||||||
|
recentPostTableColumns,
|
||||||
|
} from "../model/table-columns";
|
||||||
|
import { processMenuAccessData, processSiteAccessData } from "../lib/utils";
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
);
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
return <></>;
|
const router = useRouter();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: [ENDPOINT.CMS_SERVICE.DASHBOARD],
|
||||||
|
queryFn: () => DashboardApi.getDashboard(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const noticePostQuery = useQuery({
|
||||||
|
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
|
||||||
|
queryFn: () =>
|
||||||
|
PostApi.getRecentPostList({
|
||||||
|
page: 0,
|
||||||
|
size: 5,
|
||||||
|
keywordType: "bbsTypeCd",
|
||||||
|
keyword: "BBSNOTICE",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentPostQuery = useQuery({
|
||||||
|
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
|
||||||
|
queryFn: () =>
|
||||||
|
PostApi.getRecentPostList({
|
||||||
|
page: 0,
|
||||||
|
size: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNoticePostRowClick = (row: RecentPost) => {
|
||||||
|
router.push(`/bbs/bbsnotice/${row.bbsId}/${row.pstNo}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecentPostRowClick = (row: RecentPost) => {
|
||||||
|
router.push(
|
||||||
|
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { tableComponent: noticePostTableComponent } = useDataTable({
|
||||||
|
query: noticePostQuery,
|
||||||
|
tableOptions: {
|
||||||
|
columns: noticePostTableColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
},
|
||||||
|
dataTableOptions: {
|
||||||
|
variant: "line",
|
||||||
|
onRowClick: handleNoticePostRowClick,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { tableComponent: recentPostTableComponent } = useDataTable({
|
||||||
|
query: recentPostQuery,
|
||||||
|
tableOptions: {
|
||||||
|
columns: recentPostTableColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
},
|
||||||
|
dataTableOptions: {
|
||||||
|
variant: "line",
|
||||||
|
onRowClick: handleRecentPostRowClick,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{/* 첫번째 라인 */}
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{/* 총 회원수 카드 */}
|
||||||
|
<div className="card card-border h-80 !bg-primary !p-0">
|
||||||
|
<div className="relative h-full">
|
||||||
|
<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 ?? "-"}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl opacity-80">
|
||||||
|
금일 신규 가입자 {data?.memberTodayCnt ?? "-"}명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="h-full">
|
||||||
|
<Line
|
||||||
|
data={chartData.memberChartData!.data as ChartData<"line">}
|
||||||
|
options={chartData.memberChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 전체 게시물 카드 */}
|
||||||
|
<div className="card card-border h-80 !p-0">
|
||||||
|
<div className="relative h-full">
|
||||||
|
<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 ?? "-"}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl opacity-80">
|
||||||
|
금일 +{data?.pstTodayCnt ?? "-"} 등록
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="h-full">
|
||||||
|
<Line
|
||||||
|
data={chartData.postChartData!.data as ChartData<"line">}
|
||||||
|
options={chartData.postChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 오늘 접속자 수 카드 */}
|
||||||
|
<div className="card card-border h-80 !p-0">
|
||||||
|
<div className="relative h-full">
|
||||||
|
<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 ?? "-"}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl opacity-80">
|
||||||
|
전일 대비 +{data?.accessBefDayPer ?? "-"}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="h-full">
|
||||||
|
<Line
|
||||||
|
data={chartData.visitorChartData!.data as ChartData<"line">}
|
||||||
|
options={chartData.visitorChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* API 호출 건수 카드 */}
|
||||||
|
<div className="card card-border h-80 !p-0">
|
||||||
|
<div className="relative h-full">
|
||||||
|
<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 ?? "-"}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl opacity-80">금일 누적</p>
|
||||||
|
</div>
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="h-full">
|
||||||
|
<Line
|
||||||
|
data={chartData.apiChartData!.data as ChartData<"line">}
|
||||||
|
options={chartData.apiChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 두번째 라인 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{/* 승인 대기 알림 */}
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="text-2xl font-medium">승인 대기 알림</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 사용자 승인 */}
|
||||||
|
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-red-500"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-medium">사용자 승인</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-5xl font-bold">
|
||||||
|
{data?.waitMemberCnt}건
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl text-gray-500">
|
||||||
|
신규 가입 승인 대기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 매뉴 승인 */}
|
||||||
|
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7"></rect>
|
||||||
|
<rect x="14" y="3" width="7" height="7"></rect>
|
||||||
|
<rect x="14" y="14" width="7" height="7"></rect>
|
||||||
|
<rect x="3" y="14" width="7" height="7"></rect>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-medium">메뉴 승인</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-5xl font-bold">3건</div>
|
||||||
|
<div className="mt-2 text-2xl text-gray-500">
|
||||||
|
매뉴 수정 승인 대기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 컨텐츠 승인 */}
|
||||||
|
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-yellow-500"
|
||||||
|
>
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-medium">컨텐츠 승인</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-5xl font-bold">
|
||||||
|
{data?.waitCntnCnt}건
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl text-gray-500">
|
||||||
|
컨텐츠 수정 승인 대기
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{/* 실시간 알림 */}
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="text-2xl font-medium">실시간 알림</h3>
|
||||||
|
</div>
|
||||||
|
<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">신규 사용자 가입</span>
|
||||||
|
<span>1분 전</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<span className="font-medium">메뉴 수정 요청</span>
|
||||||
|
<span>5분 전</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<span className="font-medium">게시물 신고</span>
|
||||||
|
<span>10분 전</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">컨텐츠 승인 요청</span>
|
||||||
|
<span>15분 전</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 일정 알림 */}
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="text-2xl font-medium">일정 알림</h3>
|
||||||
|
</div>
|
||||||
|
<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>내일</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<span className="font-medium">서버 업데이트 예정</span>
|
||||||
|
<span>3일 후</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<span className="font-medium">백업 스케줄</span>
|
||||||
|
<span>5일 후</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">정기 보안 점검</span>
|
||||||
|
<span>7일 후</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 세번째 라인 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{/* 접속자 통계 */}
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="text-2xl font-medium">접속자 통계</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-80 p-4">
|
||||||
|
<Line
|
||||||
|
data={{
|
||||||
|
...chartData.siteAccessChartData!.data,
|
||||||
|
datasets: processSiteAccessData(data?.siteAccessList ?? []),
|
||||||
|
}}
|
||||||
|
options={chartData.siteAccessChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 메뉴별 접속 통계 */}
|
||||||
|
<div className="card card-border">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="text-2xl font-medium">메뉴별 접속 통계</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-80 p-4">
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels:
|
||||||
|
data?.menuAccessList?.map((item) => item.menuNm) || [],
|
||||||
|
datasets: processMenuAccessData(data?.menuAccessList || []),
|
||||||
|
}}
|
||||||
|
options={chartData.menuAccessChartData!.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 네번째 라인 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{/* 공지사항 */}
|
||||||
|
<div className="card card-border !p-0">
|
||||||
|
<div className="card-header !m-0 !border-none">
|
||||||
|
<h3 className="text-2xl font-medium">공지사항</h3>
|
||||||
|
</div>
|
||||||
|
{noticePostTableComponent}
|
||||||
|
</div>
|
||||||
|
{/* 신규 게시글 */}
|
||||||
|
<div className="card card-border !p-0">
|
||||||
|
<div className="card-header !m-0 !border-none">
|
||||||
|
<h3 className="text-2xl font-medium">신규 게시글</h3>
|
||||||
|
</div>
|
||||||
|
{recentPostTableComponent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DashboardPage };
|
export { DashboardPage };
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
Input,
|
||||||
|
InputDatePicker,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,7 +22,7 @@ import { ENDPOINT } from "@/shared/config";
|
|||||||
import { columns } from "@/pages/loginlog/model/table-columns";
|
import { columns } from "@/pages/loginlog/model/table-columns";
|
||||||
|
|
||||||
type DateRange = {
|
type DateRange = {
|
||||||
from: Date | undefined;
|
from?: Date | undefined;
|
||||||
to?: Date | undefined;
|
to?: Date | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,13 +32,12 @@ function LoginlogPage() {
|
|||||||
to: dayjs().toDate(),
|
to: dayjs().toDate(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
|
||||||
const [isManualLoading, setIsManualLoading] = useState(false);
|
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
const [presetDateRange, setPresetDateRange] = useState("");
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [errOcrnYn, setErrOcrnYn] = useState("N");
|
const [errOcrnYn, setErrOcrnYn] = useState("N");
|
||||||
const [searchParams, setSearchParams] = useState<LoginlogParams>({
|
const [searchParams, setSearchParams] = useState<LoginlogParams>({
|
||||||
@@ -48,6 +47,7 @@ function LoginlogPage() {
|
|||||||
searchDateTo: dayjs().format("YYYY-MM-DD"),
|
searchDateTo: dayjs().format("YYYY-MM-DD"),
|
||||||
sort: "lgnDt,desc",
|
sort: "lgnDt,desc",
|
||||||
});
|
});
|
||||||
|
const [isManualLoading, setIsManualLoading] = useState(false);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [ENDPOINT.USER_SERVICE.LOGINLOG, searchParams, pagination],
|
queryKey: [ENDPOINT.USER_SERVICE.LOGINLOG, searchParams, pagination],
|
||||||
@@ -88,8 +88,12 @@ function LoginlogPage() {
|
|||||||
...searchParams,
|
...searchParams,
|
||||||
userId,
|
userId,
|
||||||
errOcrnYn,
|
errOcrnYn,
|
||||||
searchDateFrom: dayjs(dateRange.from).format("YYYY-MM-DD"),
|
searchDateFrom: dateRange.from
|
||||||
searchDateTo: dayjs(dateRange.to).format("YYYY-MM-DD"),
|
? dayjs(dateRange.from).format("YYYY-MM-DD")
|
||||||
|
: "",
|
||||||
|
searchDateTo: dateRange.to
|
||||||
|
? dayjs(dateRange.to).format("YYYY-MM-DD")
|
||||||
|
: "",
|
||||||
});
|
});
|
||||||
query.refetch().finally(() => {
|
query.refetch().finally(() => {
|
||||||
setIsManualLoading(false);
|
setIsManualLoading(false);
|
||||||
@@ -133,6 +137,7 @@ function LoginlogPage() {
|
|||||||
default:
|
default:
|
||||||
presetDateRange = undefined;
|
presetDateRange = undefined;
|
||||||
}
|
}
|
||||||
|
setPresetDateRange(value);
|
||||||
setDateRange(presetDateRange || defaultDateRange);
|
setDateRange(presetDateRange || defaultDateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,23 +146,31 @@ function LoginlogPage() {
|
|||||||
<div className="card card-border">
|
<div className="card card-border">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
||||||
<Label className="mr-2">조회기간</Label>
|
<Label className="mr-2">조회기간</Label>
|
||||||
<DateRangePicker
|
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
|
||||||
value={dateRange}
|
<SelectTrigger className="w-[12rem]">
|
||||||
onValueChange={(value) => setDateRange(value || defaultDateRange)}
|
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||||
>
|
</SelectTrigger>
|
||||||
<div className="card card-border flex items-center gap-2">
|
<SelectContent>
|
||||||
<Select onValueChange={handlePresetDateRange}>
|
<SelectItem value="1W">일주일</SelectItem>
|
||||||
<SelectTrigger>
|
<SelectItem value="1M">1개월</SelectItem>
|
||||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
<SelectItem value="3M">3개월</SelectItem>
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
<SelectItem value="1W">일주일</SelectItem>
|
<InputDatePicker
|
||||||
<SelectItem value="1M">1개월</SelectItem>
|
value={dateRange.from}
|
||||||
<SelectItem value="3M">3개월</SelectItem>
|
onChange={(date) => {
|
||||||
</SelectContent>
|
setDateRange({ ...dateRange, from: date });
|
||||||
</Select>
|
setPresetDateRange("");
|
||||||
</div>
|
}}
|
||||||
</DateRangePicker>
|
/>
|
||||||
|
~
|
||||||
|
<InputDatePicker
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={(date) => {
|
||||||
|
setDateRange({ ...dateRange, to: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
|
<Label className="ml-4 mr-2 text-label">사용자ID</Label>
|
||||||
<Input
|
<Input
|
||||||
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
|
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ function MenuPage() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||||
});
|
});
|
||||||
|
form.reset();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
Input,
|
||||||
|
InputDatePicker,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,7 +24,7 @@ import { ENDPOINT } from "@/shared/config";
|
|||||||
import { columns } from "@/pages/privacylog/model/table-columns";
|
import { columns } from "@/pages/privacylog/model/table-columns";
|
||||||
|
|
||||||
type DateRange = {
|
type DateRange = {
|
||||||
from: Date | undefined;
|
from?: Date | undefined;
|
||||||
to?: Date | undefined;
|
to?: Date | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ function PrivacyLogPage() {
|
|||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
const [presetDateRange, setPresetDateRange] = useState("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
|
||||||
const [inqInfoKeyword, setInqInfoKeyword] = useState("");
|
const [inqInfoKeyword, setInqInfoKeyword] = useState("");
|
||||||
const [searchParams, setSearchParams] = useState<PrivacyLogParams>({
|
const [searchParams, setSearchParams] = useState<PrivacyLogParams>({
|
||||||
@@ -85,8 +86,10 @@ function PrivacyLogPage() {
|
|||||||
setSearchParams({
|
setSearchParams({
|
||||||
...searchParams,
|
...searchParams,
|
||||||
inqInfoKeyword,
|
inqInfoKeyword,
|
||||||
inqDtStart: dayjs(dateRange.from).format("YYYY-MM-DD"),
|
inqDtStart: dateRange.from
|
||||||
inqDtEnd: dayjs(dateRange.to).format("YYYY-MM-DD"),
|
? dayjs(dateRange.from).format("YYYY-MM-DD")
|
||||||
|
: "",
|
||||||
|
inqDtEnd: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : "",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ function PrivacyLogPage() {
|
|||||||
default:
|
default:
|
||||||
presetDateRange = undefined;
|
presetDateRange = undefined;
|
||||||
}
|
}
|
||||||
|
setPresetDateRange(value);
|
||||||
setDateRange(presetDateRange || defaultDateRange);
|
setDateRange(presetDateRange || defaultDateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,30 +137,37 @@ function PrivacyLogPage() {
|
|||||||
<div className="card card-border">
|
<div className="card card-border">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
||||||
<Label className="mr-2">조회기간</Label>
|
<Label className="mr-2">조회기간</Label>
|
||||||
<DateRangePicker
|
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
|
||||||
value={dateRange}
|
<SelectTrigger className="w-[12rem]">
|
||||||
onValueChange={(value) => setDateRange(value || defaultDateRange)}
|
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||||
>
|
</SelectTrigger>
|
||||||
<div className="card card-border flex items-center gap-2">
|
<SelectContent>
|
||||||
<Select onValueChange={handlePresetDateRange}>
|
<SelectItem value="1W">일주일</SelectItem>
|
||||||
<SelectTrigger>
|
<SelectItem value="1M">1개월</SelectItem>
|
||||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
<SelectItem value="3M">3개월</SelectItem>
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
<SelectItem value="1W">일주일</SelectItem>
|
<InputDatePicker
|
||||||
<SelectItem value="1M">1개월</SelectItem>
|
value={dateRange.from}
|
||||||
<SelectItem value="3M">3개월</SelectItem>
|
onChange={(date) => {
|
||||||
</SelectContent>
|
setDateRange({ ...dateRange, from: date });
|
||||||
</Select>
|
setPresetDateRange("");
|
||||||
</div>
|
}}
|
||||||
</DateRangePicker>
|
/>
|
||||||
|
~
|
||||||
|
<InputDatePicker
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={(date) => {
|
||||||
|
setDateRange({ ...dateRange, to: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Label className="ml-4 mr-2 text-label">조회정보</Label>
|
<Label className="ml-4 mr-2 text-label">조회정보</Label>
|
||||||
<Input
|
<Input
|
||||||
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
|
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
|
||||||
value={inqInfoKeyword}
|
value={inqInfoKeyword}
|
||||||
onChange={(e) => setInqInfoKeyword(e.target.value)}
|
onChange={(e) => setInqInfoKeyword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button className="h-[3.2rem]" type="submit">
|
<Button className="h-[3.2rem]" type="submit">
|
||||||
조회
|
조회
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
Input,
|
||||||
|
InputDatePicker,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -37,7 +37,7 @@ import { columns } from "../model/table-columns";
|
|||||||
import { MovePostDialog } from "./move-post-dialog";
|
import { MovePostDialog } from "./move-post-dialog";
|
||||||
|
|
||||||
type DateRange = {
|
type DateRange = {
|
||||||
from: Date | undefined;
|
from?: Date | undefined;
|
||||||
to?: Date | undefined;
|
to?: Date | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,22 +51,21 @@ function RecentPage() {
|
|||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const alert = useAlertStore((state) => state.setMessage);
|
const alert = useAlertStore((state) => state.setMessage);
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
|
||||||
from: undefined,
|
|
||||||
to: undefined,
|
|
||||||
});
|
|
||||||
const [tableSort, setTableSort] = useState("regDt,desc");
|
const [tableSort, setTableSort] = useState("regDt,desc");
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [presetDateRange, setPresetDateRange] = useState("");
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||||
|
from: undefined,
|
||||||
|
to: undefined,
|
||||||
|
});
|
||||||
const [keywordType, setKeywordType] = useState("");
|
const [keywordType, setKeywordType] = useState("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [bbsId, setBbsId] = useState<string | null>(null);
|
const [bbsId, setBbsId] = useState<string | null>(null);
|
||||||
const [delYn, setDelYn] = useState("N");
|
const [delYn, setDelYn] = useState("N");
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useState<RecentPostParams>({
|
const [searchParams, setSearchParams] = useState<RecentPostParams>({
|
||||||
keywordType: "",
|
keywordType: "",
|
||||||
keyword: "",
|
keyword: "",
|
||||||
@@ -193,6 +192,7 @@ function RecentPage() {
|
|||||||
default:
|
default:
|
||||||
presetDateRange = undefined;
|
presetDateRange = undefined;
|
||||||
}
|
}
|
||||||
|
setPresetDateRange(value);
|
||||||
setDateRange(presetDateRange);
|
setDateRange(presetDateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,21 +250,31 @@ function RecentPage() {
|
|||||||
<div className="card card-border">
|
<div className="card card-border">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
|
||||||
<Label className="mr-2">날짜</Label>
|
<Label className="mr-2">날짜</Label>
|
||||||
<DateRangePicker value={dateRange} onValueChange={setDateRange}>
|
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
|
||||||
<div className="card card-border flex items-center gap-2">
|
<SelectTrigger className="w-[12rem]">
|
||||||
<Select onValueChange={handlePresetDateRange}>
|
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||||
<SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="1W">일주일</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="1M">1개월</SelectItem>
|
||||||
<SelectItem value="1W">일주일</SelectItem>
|
<SelectItem value="3M">3개월</SelectItem>
|
||||||
<SelectItem value="1M">1개월</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="3M">3개월</SelectItem>
|
</Select>
|
||||||
</SelectContent>
|
<InputDatePicker
|
||||||
</Select>
|
value={dateRange?.from}
|
||||||
</div>
|
onChange={(date) => {
|
||||||
</DateRangePicker>
|
setDateRange({ ...dateRange, from: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
~
|
||||||
|
<InputDatePicker
|
||||||
|
value={dateRange?.to}
|
||||||
|
onChange={(date) => {
|
||||||
|
setDateRange({ ...dateRange, to: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Label className="ml-4 mr-2 text-label">게시판</Label>
|
<Label className="ml-4 mr-2 text-label">게시판</Label>
|
||||||
<Select
|
<Select
|
||||||
value={bbsId?.toString() || ""}
|
value={bbsId?.toString() || ""}
|
||||||
@@ -281,7 +291,6 @@ function RecentPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Label className="ml-4 mr-2 text-label">상태</Label>
|
<Label className="ml-4 mr-2 text-label">상태</Label>
|
||||||
<Select value={delYn} onValueChange={(value) => setDelYn(value)}>
|
<Select value={delYn} onValueChange={(value) => setDelYn(value)}>
|
||||||
<SelectTrigger className="w-[10rem]">
|
<SelectTrigger className="w-[10rem]">
|
||||||
@@ -292,7 +301,6 @@ function RecentPage() {
|
|||||||
<SelectItem value="Y">삭제</SelectItem>
|
<SelectItem value="Y">삭제</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Label className="ml-4 mr-2 text-label">검색</Label>
|
<Label className="ml-4 mr-2 text-label">검색</Label>
|
||||||
<Select
|
<Select
|
||||||
value={keywordType}
|
value={keywordType}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
Input,
|
||||||
|
InputDatePicker,
|
||||||
Label,
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -28,7 +28,7 @@ import { useDataTable } from "@/shared/lib";
|
|||||||
import { columns } from "../model/table-columns";
|
import { columns } from "../model/table-columns";
|
||||||
|
|
||||||
type DateRange = {
|
type DateRange = {
|
||||||
from: Date | undefined;
|
from?: Date | undefined;
|
||||||
to?: Date | undefined;
|
to?: Date | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ function UsersPage() {
|
|||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
const [presetDateRange, setPresetDateRange] = useState("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||||
from: undefined,
|
from: undefined,
|
||||||
to: undefined,
|
to: undefined,
|
||||||
@@ -167,6 +168,7 @@ function UsersPage() {
|
|||||||
default:
|
default:
|
||||||
presetDateRange = undefined;
|
presetDateRange = undefined;
|
||||||
}
|
}
|
||||||
|
setPresetDateRange(value);
|
||||||
setDateRange(presetDateRange);
|
setDateRange(presetDateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,23 +279,34 @@ function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="mr-2 text-label">기간</Label>
|
<Label className="mr-2 text-label">기간</Label>
|
||||||
<DateRangePicker value={dateRange} onValueChange={setDateRange}>
|
<Select
|
||||||
<div className="card card-border flex items-center gap-2">
|
value={presetDateRange}
|
||||||
<Select onValueChange={handlePresetDateRange}>
|
onValueChange={handlePresetDateRange}
|
||||||
<SelectTrigger>
|
>
|
||||||
<SelectValue
|
<SelectTrigger className="w-[12rem]">
|
||||||
placeholder="기간 선택"
|
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||||
className="w-[24rem]"
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
</SelectTrigger>
|
<SelectItem value="1M">1개월</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="3M">3개월</SelectItem>
|
||||||
<SelectItem value="1M">1개월</SelectItem>
|
<SelectItem value="6M">6개월</SelectItem>
|
||||||
<SelectItem value="3M">3개월</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="6M">6개월</SelectItem>
|
</Select>
|
||||||
</SelectContent>
|
<InputDatePicker
|
||||||
</Select>
|
value={dateRange?.from}
|
||||||
</div>
|
onChange={(date) => {
|
||||||
</DateRangePicker>
|
setDateRange({ ...dateRange, from: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
~
|
||||||
|
<InputDatePicker
|
||||||
|
value={dateRange?.to}
|
||||||
|
onChange={(date) => {
|
||||||
|
setDateRange({ ...dateRange, to: date });
|
||||||
|
setPresetDateRange("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const ENDPOINT = {
|
|||||||
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
|
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
|
||||||
},
|
},
|
||||||
CMS_SERVICE: {
|
CMS_SERVICE: {
|
||||||
|
DASHBOARD: "/cms-service/api/v1/dashboard",
|
||||||
SITES: "/cms-service/api/v1/sites",
|
SITES: "/cms-service/api/v1/sites",
|
||||||
IPS: "/cms-service/api/v1/site-ips",
|
IPS: "/cms-service/api/v1/site-ips",
|
||||||
DOMAINS: "/cms-service/api/v1/site-dmns",
|
DOMAINS: "/cms-service/api/v1/site-dmns",
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
NEXT_PUBLIC_SITE_ID = SN
|
NEXT_PUBLIC_SITE_ID = SN
|
||||||
NEXT_PUBLIC_PORT = 11302
|
|
||||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
|
||||||
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
|
||||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
|
||||||
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
|
||||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver_swl
|
|
||||||
NEXT_PUBLIC_DC_URL = https://apigateway.rsonesoft.com #자료관 데이터
|
|
||||||
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
||||||
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
||||||
NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||||
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
|
NEXT_PUBLIC_PORT = 3000
|
||||||
|
NEXT_PUBLIC_PROXY_HOST = https://sn.hasuon.or.kr
|
||||||
|
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000
|
||||||
|
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
||||||
|
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
||||||
|
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
|
||||||
|
NEXT_PUBLIC_DC_URL = https://apigateway.rsonesoft.com #자료관 데이터
|
||||||
|
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.8",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,10 +42,15 @@ GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z
|
|||||||
GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`;
|
GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`;
|
||||||
GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`;
|
GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`;
|
||||||
|
|
||||||
// TODO: NGII 정보
|
// 인터넷망: NGII 정보
|
||||||
GisApp.NGII_API_KEY = '04trYP9_xwLAfALjwZ-B8g'; // NGII에서 발급받은 API 키
|
GisApp.NGII_API_KEY = '04trYP9_xwLAfALjwZ-B8g'; // NGII에서 발급받은 API 키
|
||||||
GisApp.NGII_WMTS_URL = `https://map.ngii.go.kr/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
|
GisApp.NGII_WMTS_URL = `https://map.ngii.go.kr/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
|
||||||
|
|
||||||
|
//행정망: NGII 정보
|
||||||
|
//GisApp.NGII_API_KEY = 'BDE2BEF39DAFEC293AB6730150F222AC'; // NGII에서 발급받은 API 키
|
||||||
|
//GisApp.NGII_WMTS_URL = `http://10.98.25.94:18080/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
|
||||||
|
|
||||||
|
|
||||||
GisApp.EMAP_URL = '/emap/';
|
GisApp.EMAP_URL = '/emap/';
|
||||||
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
|
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
|
||||||
GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png';
|
GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png';
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getApiClient } from "./client";
|
import { getApiClient } from "./client";
|
||||||
import { ENDPOINT } from "../config";
|
import { ENDPOINT } from "../config";
|
||||||
|
import { getConfig } from "@common/config";
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
menuCd: number;
|
menuCd: number;
|
||||||
@@ -26,6 +27,8 @@ export interface TreeMenu extends MenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = getApiClient();
|
const client = getApiClient();
|
||||||
|
const { PROXY_HOST } = getConfig();
|
||||||
|
const BASE_URL = `${PROXY_HOST}/server`;
|
||||||
|
|
||||||
export const getMenuList = async (siteId: string) => {
|
export const getMenuList = async (siteId: string) => {
|
||||||
const response = await client.get<MenuItem[]>(
|
const response = await client.get<MenuItem[]>(
|
||||||
@@ -40,3 +43,18 @@ export const getPreviewTreeMenuList = async (siteId: string) => {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAnonymouseMenuList = async (
|
||||||
|
siteId: string,
|
||||||
|
): Promise<MenuItem[]> => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BASE_URL}/${ENDPOINT.CMS_SERVICE.MENU_ANONYMOUS}/user/${siteId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getApiClient } from "./client";
|
import { getConfig } from "@common/config";
|
||||||
import { ENDPOINT } from "../config";
|
import { ENDPOINT } from "../config";
|
||||||
|
|
||||||
export interface SiteInfo {
|
export interface SiteInfo {
|
||||||
@@ -23,11 +23,18 @@ export interface SiteInfo {
|
|||||||
mdfrNm: string;
|
mdfrNm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = getApiClient();
|
const { PROXY_HOST } = getConfig();
|
||||||
|
const BASE_URL = `${PROXY_HOST}/server`;
|
||||||
|
|
||||||
export const getSiteInfo = async (siteId: string) => {
|
export const getSiteInfo = async (siteId: string) => {
|
||||||
const response = await client.get<SiteInfo>(
|
const response = await fetch(
|
||||||
`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
|
`${BASE_URL}/${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const ENDPOINT = {
|
|||||||
CMS_SERVICE: {
|
CMS_SERVICE: {
|
||||||
MENUS: "/cms-service/api/v1/menus",
|
MENUS: "/cms-service/api/v1/menus",
|
||||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||||
|
MENU_ANONYMOUS: "/cms-service/api/v1/menu/anonymous",
|
||||||
SITES: "/cms-service/api/v1/sites",
|
SITES: "/cms-service/api/v1/sites",
|
||||||
POPUPS: "/cms-service/api/v1/popups",
|
POPUPS: "/cms-service/api/v1/popups",
|
||||||
IPS: "/cms-service/api/v1/site-ips",
|
IPS: "/cms-service/api/v1/site-ips",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "./header.css";
|
|||||||
import { useEffect, useCallback, useState } from "react";
|
import { useEffect, useCallback, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { images } from "@common/assets";
|
import { images } from "@common/assets";
|
||||||
|
import { getConfig } from "@common/config";
|
||||||
import { ENDPOINT } from "../../config";
|
import { ENDPOINT } from "../../config";
|
||||||
import { setCookie, getCookie } from "../../lib";
|
import { setCookie, getCookie } from "../../lib";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,10 @@ import { Button } from "../button";
|
|||||||
import { SidebarTrigger } from "../sidebar";
|
import { SidebarTrigger } from "../sidebar";
|
||||||
import { Skeleton } from "../skeleton";
|
import { Skeleton } from "../skeleton";
|
||||||
|
|
||||||
const siteId = getCookie("site-id-key") ?? "";
|
const appConfig = getConfig();
|
||||||
|
const siteId = appConfig.SITE_ID ?? "";
|
||||||
|
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
|
||||||
|
const isLoggedIn = !!userId;
|
||||||
|
|
||||||
export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -30,7 +34,7 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
|||||||
const { data: userInfo } = useQuery({
|
const { data: userInfo } = useQuery({
|
||||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||||
queryFn: () => getUserInfo(),
|
queryFn: () => getUserInfo(),
|
||||||
enabled: !viewOnly,
|
enabled: isLoggedIn && !viewOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: siteInfo } = useQuery({
|
const { data: siteInfo } = useQuery({
|
||||||
@@ -50,6 +54,7 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
|||||||
const { data: userPopupList } = useQuery({
|
const { data: userPopupList } = useQuery({
|
||||||
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS],
|
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS],
|
||||||
queryFn: () => getUserPopupList(),
|
queryFn: () => getUserPopupList(),
|
||||||
|
enabled: isLoggedIn && !viewOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -130,30 +135,36 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
{mounted ? (
|
||||||
<DropdownMenuTrigger asChild>
|
isLoggedIn && (
|
||||||
<Button
|
<DropdownMenu>
|
||||||
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
|
<DropdownMenuTrigger asChild>
|
||||||
data-trigger="dropdown"
|
<Button
|
||||||
>
|
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
|
||||||
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
data-trigger="dropdown"
|
||||||
{userInfo?.userName}
|
>
|
||||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
||||||
</Button>
|
{userInfo?.userName}
|
||||||
</DropdownMenuTrigger>
|
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
<DropdownMenuContent>
|
</Button>
|
||||||
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
<DropdownMenuContent>
|
||||||
<p className="text-base text-label">최종접속일</p>
|
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
|
||||||
<p>{userInfo?.lastLoginDate}</p>
|
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||||
</div>
|
<p className="text-base text-label">최종접속일</p>
|
||||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
<p>{userInfo?.lastLoginDate}</p>
|
||||||
<p className="text-base text-label">접속 IP</p>
|
</div>
|
||||||
<p></p>
|
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||||
</div>
|
<p className="text-base text-label">접속 IP</p>
|
||||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
<p></p>
|
||||||
</DropdownMenuContent>
|
</div>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./carousel";
|
|||||||
export * from "./checkbox";
|
export * from "./checkbox";
|
||||||
export * from "./data-table";
|
export * from "./data-table";
|
||||||
export * from "./date-picker";
|
export * from "./date-picker";
|
||||||
|
export * from "./input-date-picker";
|
||||||
export * from "./date-range-picker";
|
export * from "./date-range-picker";
|
||||||
export * from "./dialog";
|
export * from "./dialog";
|
||||||
export * from "./dropdown-menu";
|
export * from "./dropdown-menu";
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "../button";
|
||||||
|
import { Calendar } from "../calendar";
|
||||||
|
import { Input } from "../input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||||
|
|
||||||
|
import "./input-date-picker.css";
|
||||||
|
|
||||||
|
export interface InputDatePickerProps {
|
||||||
|
value?: Date;
|
||||||
|
defaultValue?: Date;
|
||||||
|
onChange?: (date: Date | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputDatePicker({
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: InputDatePickerProps) {
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [internalDate, setInternalDate] = useState<Date | undefined>(
|
||||||
|
defaultValue,
|
||||||
|
);
|
||||||
|
const [month, setMonth] = useState<Date | undefined>(
|
||||||
|
isControlled ? controlledValue : defaultValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 실제로 사용할 date 값 (제어/비제어 모두 지원)
|
||||||
|
const date = isControlled ? controlledValue : internalDate;
|
||||||
|
|
||||||
|
// 표시할 문자열 값
|
||||||
|
const [displayValue, setDisplayValue] = useState(date);
|
||||||
|
|
||||||
|
// 제어 컴포넌트일 경우 외부 값 변경 감지
|
||||||
|
useEffect(() => {
|
||||||
|
if (isControlled) {
|
||||||
|
setDisplayValue(controlledValue);
|
||||||
|
setMonth(controlledValue);
|
||||||
|
}
|
||||||
|
}, [isControlled, controlledValue]);
|
||||||
|
|
||||||
|
// 내부 상태 변경 핸들러
|
||||||
|
const handleDateChange = (newDate: Date | undefined) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalDate(newDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(newDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayValue(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="relative flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={displayValue ? dayjs(displayValue).format("YYYY-MM-DD") : ""}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`input-date-picker bg-background pr-10 ${className || ""}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newDate = new Date(e.target.value);
|
||||||
|
setDisplayValue(newDate);
|
||||||
|
|
||||||
|
if (isValidDate(newDate)) {
|
||||||
|
handleDateChange(newDate);
|
||||||
|
setMonth(newDate);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
id="date-picker"
|
||||||
|
variant="ghost"
|
||||||
|
color="white"
|
||||||
|
disabled={disabled}
|
||||||
|
className="absolute right-2 top-1/2 size-7 -translate-y-1/2 rounded-[0.4rem] border-none p-0"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="!size-full" />
|
||||||
|
<span className="sr-only">Select date</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto overflow-hidden p-0"
|
||||||
|
align="end"
|
||||||
|
alignOffset={-8}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
month={month}
|
||||||
|
onMonthChange={setMonth}
|
||||||
|
onSelect={(newDate) => {
|
||||||
|
handleDateChange(newDate);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDate(date: Date | undefined) {
|
||||||
|
if (!date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !isNaN(date.getTime());
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.input-date-picker::-webkit-calendar-picker-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { getConfig } from "@common/config";
|
||||||
import { getParentIds } from "./lib";
|
import { getParentIds } from "./lib";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -22,7 +23,17 @@ import {
|
|||||||
} from "../collapsible";
|
} from "../collapsible";
|
||||||
import { ENDPOINT } from "../../config";
|
import { ENDPOINT } from "../../config";
|
||||||
import { cn, getCookie, setCookie } from "../../lib";
|
import { cn, getCookie, setCookie } from "../../lib";
|
||||||
import { getMenuList, getPreviewTreeMenuList, type MenuItem } from "../../api";
|
import {
|
||||||
|
getAnonymouseMenuList,
|
||||||
|
getMenuList,
|
||||||
|
getPreviewTreeMenuList,
|
||||||
|
type MenuItem,
|
||||||
|
} from "../../api";
|
||||||
|
|
||||||
|
const appConfig = getConfig();
|
||||||
|
const appSiteId = appConfig.SITE_ID ?? "";
|
||||||
|
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
|
||||||
|
const isLoggedIn = !!userId;
|
||||||
|
|
||||||
export function LNB({
|
export function LNB({
|
||||||
viewOnly,
|
viewOnly,
|
||||||
@@ -42,24 +53,34 @@ export function LNB({
|
|||||||
handleMenuClick?: (item: MenuItem) => void;
|
handleMenuClick?: (item: MenuItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [defaultOpenMenuIds, setDefaultOpenMenuIds] = useState<number[]>([]);
|
const [defaultOpenMenuIds, setDefaultOpenMenuIds] = useState<number[]>([]);
|
||||||
|
const [currentMenuCode, setCurrentMenuCode] = useState<number | null>(null);
|
||||||
const prevPathRef = useRef(currentPath);
|
const prevPathRef = useRef(currentPath);
|
||||||
|
|
||||||
const cookieSiteId = getCookie("site-id-key") ?? "";
|
const siteId = propSiteId ?? appSiteId;
|
||||||
const siteId = propSiteId ?? cookieSiteId;
|
|
||||||
|
|
||||||
const { data: menuListData } = useQuery({
|
const { data: menuListData } = useQuery({
|
||||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||||
queryFn: () => getMenuList(siteId),
|
queryFn: () => getMenuList(siteId),
|
||||||
enabled: !viewOnly && !previewMode,
|
enabled: isLoggedIn && !viewOnly && !previewMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: previewTreeMenuListData } = useQuery({
|
const { data: previewTreeMenuListData } = useQuery({
|
||||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId, "preview"],
|
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId, "preview"],
|
||||||
queryFn: () => getPreviewTreeMenuList(siteId),
|
queryFn: () => getPreviewTreeMenuList(siteId),
|
||||||
enabled: previewMode,
|
enabled: isLoggedIn && previewMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuList = previewMode ? previewTreeMenuListData : menuListData;
|
const { data: anonymousMenuListData } = useQuery({
|
||||||
|
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ANONYMOUS, siteId],
|
||||||
|
queryFn: () => getAnonymouseMenuList(siteId),
|
||||||
|
enabled: !isLoggedIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuList = isLoggedIn
|
||||||
|
? previewMode
|
||||||
|
? previewTreeMenuListData
|
||||||
|
: menuListData
|
||||||
|
: anonymousMenuListData;
|
||||||
|
|
||||||
function isPathMatched(path: string) {
|
function isPathMatched(path: string) {
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
@@ -70,6 +91,7 @@ export function LNB({
|
|||||||
if (!menuList || !currentPath) return;
|
if (!menuList || !currentPath) return;
|
||||||
|
|
||||||
const matchedMenuIds: number[] = [];
|
const matchedMenuIds: number[] = [];
|
||||||
|
let matchedMenuCode: number | null = null;
|
||||||
|
|
||||||
const findMatchedMenus = (items: MenuItem[]) => {
|
const findMatchedMenus = (items: MenuItem[]) => {
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -77,25 +99,32 @@ export function LNB({
|
|||||||
const parentIds = getParentIds(item.menuCd)(menuList);
|
const parentIds = getParentIds(item.menuCd)(menuList);
|
||||||
matchedMenuIds.push(...parentIds, item.menuCd);
|
matchedMenuIds.push(...parentIds, item.menuCd);
|
||||||
}
|
}
|
||||||
|
if (item.children) findMatchedMenus(item.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (item.children) {
|
const findMatchedMenuCode = (items: MenuItem[]) => {
|
||||||
findMatchedMenus(item.children);
|
items.forEach((item) => {
|
||||||
}
|
if (matchedMenuCode) return;
|
||||||
|
if (currentPath === item.urlPath) matchedMenuCode = item.menuCd;
|
||||||
|
if (item.children) findMatchedMenuCode(item.children);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
findMatchedMenus(menuList);
|
findMatchedMenus(menuList);
|
||||||
|
findMatchedMenuCode(menuList);
|
||||||
|
|
||||||
if (matchedMenuIds.length > 0) {
|
if (matchedMenuIds.length > 0)
|
||||||
setDefaultOpenMenuIds((prev) => [
|
setDefaultOpenMenuIds((prev) => [
|
||||||
...new Set([...prev, ...matchedMenuIds]),
|
...new Set([...prev, ...matchedMenuIds]),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
if (prevPathRef.current !== currentPath) {
|
if (prevPathRef.current !== currentPath) {
|
||||||
setDefaultOpenMenuIds(matchedMenuIds);
|
setDefaultOpenMenuIds(matchedMenuIds);
|
||||||
prevPathRef.current = currentPath;
|
prevPathRef.current = currentPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (matchedMenuCode) setCurrentMenuCode(matchedMenuCode);
|
||||||
}, [menuList, currentPath]);
|
}, [menuList, currentPath]);
|
||||||
|
|
||||||
function MenuItem({
|
function MenuItem({
|
||||||
@@ -106,7 +135,8 @@ export function LNB({
|
|||||||
isSubMenu?: boolean;
|
isSubMenu?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { open: isSidebarOpen, setOpen } = useSidebar();
|
const { open: isSidebarOpen, setOpen } = useSidebar();
|
||||||
const isMatched = isPathMatched(item.urlPath);
|
const isMatched =
|
||||||
|
isPathMatched(item.urlPath) && currentMenuCode === item.menuCd;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!isSidebarOpen) setOpen(true);
|
if (!isSidebarOpen) setOpen(true);
|
||||||
|
|||||||
Generated
+10836
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user