access logs + audio
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
VITE_TOKEN_NAME = 'admin_token'
|
VITE_TOKEN_NAME = 'admin_token'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token'
|
VITE_REFRESH_TOKEN_NAME = 'admin_refresh_token'
|
||||||
VITE_BASE_URL = 'https://api.danakcorp.com'
|
VITE_BASE_URL = 'https://api.danakcorp.com'
|
||||||
# VITE_BASE_URL = 'http://192.168.1.112:4001'
|
# VITE_BASE_URL = 'http://192.168.1.100:4001'
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { FC, useEffect, useRef } from 'react'
|
||||||
|
import Quill from 'quill'
|
||||||
|
import VideoBlot, { getEmbedUrl } from './VideoBlot'
|
||||||
|
import 'quill/dist/quill.snow.css'
|
||||||
|
|
||||||
|
// ثبت VideoBlot در Quill
|
||||||
|
Quill.register('formats/video', VideoBlot)
|
||||||
|
|
||||||
|
interface QuillEditorProps {
|
||||||
|
value?: string
|
||||||
|
onChange?: (content: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
minHeight?: string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuillEditor: FC<QuillEditorProps> = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder = '',
|
||||||
|
className = '',
|
||||||
|
minHeight = '200px',
|
||||||
|
id = 'quill-editor'
|
||||||
|
}) => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
const quillRef = useRef<Quill | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return
|
||||||
|
|
||||||
|
// پیکربندی toolbar سفارشی
|
||||||
|
const toolbarOptions = [
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
[{ 'header': 1 }, { 'header': 2 }],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'direction': 'rtl' }],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
|
||||||
|
const quill = new Quill(editorRef.current, {
|
||||||
|
theme: 'snow',
|
||||||
|
placeholder,
|
||||||
|
modules: {
|
||||||
|
toolbar: toolbarOptions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// تنظیم محتوای اولیه
|
||||||
|
if (value) {
|
||||||
|
quill.root.innerHTML = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// مدیریت تغییرات
|
||||||
|
quill.on('text-change', () => {
|
||||||
|
const html = quill.root.innerHTML
|
||||||
|
onChange?.(html)
|
||||||
|
})
|
||||||
|
|
||||||
|
quillRef.current = quill
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
quillRef.current = null
|
||||||
|
}
|
||||||
|
}, [placeholder])
|
||||||
|
|
||||||
|
// بروزرسانی محتوا از خارج
|
||||||
|
useEffect(() => {
|
||||||
|
if (quillRef.current && value !== quillRef.current.root.innerHTML) {
|
||||||
|
quillRef.current.root.innerHTML = value
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleYouTubeClick = () => {
|
||||||
|
const url = prompt('لینک ویدیو یوتیوب را وارد کنید:')
|
||||||
|
if (url && quillRef.current) {
|
||||||
|
const embedUrl = getEmbedUrl(url)
|
||||||
|
if (embedUrl) {
|
||||||
|
const range = quillRef.current.getSelection() || { index: 0, length: 0 }
|
||||||
|
quillRef.current.insertEmbed(range.index, 'video', {
|
||||||
|
url: embedUrl,
|
||||||
|
width: '100%',
|
||||||
|
height: '315'
|
||||||
|
})
|
||||||
|
quillRef.current.setSelection(range.index + 1)
|
||||||
|
} else {
|
||||||
|
alert('لینک ویدیو معتبر نیست')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAparatClick = () => {
|
||||||
|
const url = prompt('لینک ویدیو آپارات را وارد کنید:')
|
||||||
|
if (url && quillRef.current) {
|
||||||
|
const embedUrl = getEmbedUrl(url)
|
||||||
|
if (embedUrl) {
|
||||||
|
const range = quillRef.current.getSelection() || { index: 0, length: 0 }
|
||||||
|
quillRef.current.insertEmbed(range.index, 'video', {
|
||||||
|
url: embedUrl,
|
||||||
|
width: '100%',
|
||||||
|
height: '315'
|
||||||
|
})
|
||||||
|
quillRef.current.setSelection(range.index + 1)
|
||||||
|
} else {
|
||||||
|
alert('لینک ویدیو معتبر نیست')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* دکمههای ویدیو بالای ادیتور */}
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleYouTubeClick}
|
||||||
|
className="px-3 py-1 text-xs font-bold text-red-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||||
|
title="درج ویدیو یوتیوب"
|
||||||
|
>
|
||||||
|
📺 YouTube
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAparatClick}
|
||||||
|
className="px-3 py-1 text-xs font-bold text-blue-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||||
|
title="درج ویدیو آپارات"
|
||||||
|
>
|
||||||
|
🎬 آپارات
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
id={id}
|
||||||
|
style={{ minHeight }}
|
||||||
|
className="quill-editor-container"
|
||||||
|
/>
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
.ql-video-wrapper {
|
||||||
|
margin: 1em 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ql-video-wrapper iframe {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuillEditor
|
||||||
@@ -2,13 +2,40 @@ import { FC } from 'react'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
color: string
|
color: string
|
||||||
|
text?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusCircle: FC<Props> = (props: Props) => {
|
const StatusCircle: FC<Props> = ({ color, text, size = 'md' }) => {
|
||||||
return (
|
const sizeClasses = {
|
||||||
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
|
sm: 'size-2',
|
||||||
|
md: 'size-3',
|
||||||
|
lg: 'size-4'
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
const colorClasses = {
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
red: 'bg-red-500',
|
||||||
|
gray: 'bg-gray-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`${sizeClasses[size]} ${colorClasses[color as keyof typeof colorClasses] || 'bg-gray-500'} rounded-full`} />
|
||||||
|
<span className="text-sm font-medium">{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${sizeClasses[size]} rounded-full ${colorClasses[color as keyof typeof colorClasses] || 'bg-gray-500'}`}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ type Props = {
|
|||||||
preview?: string[],
|
preview?: string[],
|
||||||
onChangePreview?: (preview: string[]) => void,
|
onChangePreview?: (preview: string[]) => void,
|
||||||
getCover?: (url: string) => void,
|
getCover?: (url: string) => void,
|
||||||
coverUrl?: string
|
coverUrl?: string,
|
||||||
|
accept?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||||
@@ -35,7 +36,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [files])
|
}, [files])
|
||||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: props.accept ? { [props.accept]: [] } : undefined
|
||||||
|
})
|
||||||
|
|
||||||
const handleDelete = (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
const array = [...files]
|
const array = [...files]
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Quill from "quill";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const BlockEmbed = Quill.import("blots/block/embed") as any;
|
||||||
|
|
||||||
|
interface VideoData {
|
||||||
|
url: string;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoBlot extends BlockEmbed {
|
||||||
|
static blotName = "video";
|
||||||
|
static tagName = "div";
|
||||||
|
static className = "ql-video-wrapper";
|
||||||
|
|
||||||
|
static create(value: VideoData) {
|
||||||
|
const node = super.create();
|
||||||
|
node.classList.add("ql-video-wrapper");
|
||||||
|
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.setAttribute("src", value.url);
|
||||||
|
iframe.setAttribute("width", value.width || "100%");
|
||||||
|
iframe.setAttribute("height", value.height || "315");
|
||||||
|
iframe.setAttribute("frameborder", "0");
|
||||||
|
iframe.setAttribute("allowfullscreen", "true");
|
||||||
|
iframe.style.maxWidth = "100%";
|
||||||
|
iframe.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
node.appendChild(iframe);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
static value(node: HTMLElement) {
|
||||||
|
const iframe = node.querySelector("iframe");
|
||||||
|
if (!iframe) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: iframe.getAttribute("src") || "",
|
||||||
|
width: iframe.getAttribute("width") || "100%",
|
||||||
|
height: iframe.getAttribute("height") || "315",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// تابع برای تشخیص و تبدیل URL های ویدیو
|
||||||
|
export const getEmbedUrl = (url: string): string | null => {
|
||||||
|
// YouTube
|
||||||
|
const youtubeRegex = /(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/;
|
||||||
|
const youtubeMatch = url.match(youtubeRegex);
|
||||||
|
if (youtubeMatch) {
|
||||||
|
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aparat
|
||||||
|
const aparatRegex = /aparat\.com\/v\/([a-zA-Z0-9]+)/;
|
||||||
|
const aparatMatch = url.match(aparatRegex);
|
||||||
|
if (aparatMatch) {
|
||||||
|
return `https://www.aparat.com/video/video/embed/videohash/${aparatMatch[1]}/vt/frame`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر قبلاً embed URL است
|
||||||
|
if (
|
||||||
|
url.includes("youtube.com/embed/") ||
|
||||||
|
url.includes("aparat.com/video/video/embed/")
|
||||||
|
) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoBlot;
|
||||||
@@ -116,4 +116,10 @@ export const Pages = {
|
|||||||
detail: "/support/detail/",
|
detail: "/support/detail/",
|
||||||
planUsers: "/support/plan-users/",
|
planUsers: "/support/plan-users/",
|
||||||
},
|
},
|
||||||
|
accessLogs: {
|
||||||
|
list: "/access-logs/list",
|
||||||
|
stats: "/access-logs/stats",
|
||||||
|
errors: "/access-logs/errors",
|
||||||
|
permissions: "/access-logs/permissions",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const SideBarItemHasSubMenu = [
|
|||||||
"learning",
|
"learning",
|
||||||
"blog",
|
"blog",
|
||||||
"support",
|
"support",
|
||||||
|
"accessLogs",
|
||||||
];
|
];
|
||||||
|
|||||||
+11
-2
@@ -72,7 +72,8 @@
|
|||||||
"payments": "پرداخت ها",
|
"payments": "پرداخت ها",
|
||||||
"sliders": "اسلایدر",
|
"sliders": "اسلایدر",
|
||||||
"support": "پلن پشتیبانی",
|
"support": "پلن پشتیبانی",
|
||||||
"feedback": "گزارش ها"
|
"feedback": "گزارش ها",
|
||||||
|
"accessLogs": "لاگهای دسترسی"
|
||||||
},
|
},
|
||||||
"slider": {
|
"slider": {
|
||||||
"new_slider": "اسلایدر جدید",
|
"new_slider": "اسلایدر جدید",
|
||||||
@@ -107,7 +108,10 @@
|
|||||||
"create_plan": "ایجاد پلن",
|
"create_plan": "ایجاد پلن",
|
||||||
"plan_users": "کاربران پلن",
|
"plan_users": "کاربران پلن",
|
||||||
"plan_list": "لیست پلن ها",
|
"plan_list": "لیست پلن ها",
|
||||||
"guide": "راهنمای سرویس"
|
"guide": "راهنمای سرویس",
|
||||||
|
"access_logs_list": "لیست لاگها",
|
||||||
|
"access_logs_stats": "آمار لاگها",
|
||||||
|
"access_logs_errors": "لاگهای خطا"
|
||||||
},
|
},
|
||||||
"guide": {
|
"guide": {
|
||||||
"list_guide": "لیست راهنمای سرویس",
|
"list_guide": "لیست راهنمای سرویس",
|
||||||
@@ -390,6 +394,9 @@
|
|||||||
"content": "محتوا",
|
"content": "محتوا",
|
||||||
"blog_status": "وضعیت بلاگ",
|
"blog_status": "وضعیت بلاگ",
|
||||||
"featured_image": "تصویر شاخص",
|
"featured_image": "تصویر شاخص",
|
||||||
|
"audio_file": "فایل صوتی",
|
||||||
|
"current_audio_file": "فایل صوتی فعلی",
|
||||||
|
"audio_not_supported": "مرورگر شما از پخش صوت پشتیبانی نمیکند.",
|
||||||
"tags": "برچسب ها",
|
"tags": "برچسب ها",
|
||||||
"tag_hint": "جدا کردن با کاما یا کلید Enter ",
|
"tag_hint": "جدا کردن با کاما یا کلید Enter ",
|
||||||
"shareDate": "تاریخ انتشار",
|
"shareDate": "تاریخ انتشار",
|
||||||
@@ -431,6 +438,8 @@
|
|||||||
"enter_your_meta_title": "عنوان متا بلاگ را وارد کنید",
|
"enter_your_meta_title": "عنوان متا بلاگ را وارد کنید",
|
||||||
"meta_description": "توضیحات متا",
|
"meta_description": "توضیحات متا",
|
||||||
"enter_your_meta_description": "توضیحات متا بلاگ را وارد کنید",
|
"enter_your_meta_description": "توضیحات متا بلاگ را وارد کنید",
|
||||||
|
"enter_blog_summary": "خلاصه بلاگ را وارد کنید",
|
||||||
|
"enter_blog_content": "محتوای بلاگ را وارد کنید",
|
||||||
"comments": "نظرات",
|
"comments": "نظرات",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
"comment": "نظر",
|
"comment": "نظر",
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
DocumentText,
|
||||||
|
DocumentDownload,
|
||||||
|
Refresh,
|
||||||
|
Activity,
|
||||||
|
InfoCircle
|
||||||
|
} from "iconsax-react";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Pagination from "../../components/Pagination";
|
||||||
|
import TitleLine from "../../components/TitleLine";
|
||||||
|
import PageLoading from "../../components/PageLoading";
|
||||||
|
// import Error from "../../components/Error"; // Using custom error display
|
||||||
|
import FilterPanel from "./components/FilterPanel";
|
||||||
|
import AccessLogsTable from "./components/AccessLogsTable";
|
||||||
|
import LogDetailsModal from "./components/LogDetailsModal";
|
||||||
|
import { useAccessLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||||
|
import { AccessLog } from "./types/AccessLogTypes";
|
||||||
|
|
||||||
|
const AccessLogsList: React.FC = () => {
|
||||||
|
const {
|
||||||
|
logs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters,
|
||||||
|
exportLogs,
|
||||||
|
refetch,
|
||||||
|
} = useAccessLogs();
|
||||||
|
|
||||||
|
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||||
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetails = async (log: AccessLog) => {
|
||||||
|
await fetchLog(log.id);
|
||||||
|
setIsDetailsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetailsModal = () => {
|
||||||
|
setIsDetailsModalOpen(false);
|
||||||
|
clearLog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||||
|
updateFilters({ sortBy: field, sortOrder: order });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
updateFilters({ page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
await exportLogs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("خطا در خروجی گرفتن:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TitleLine title="لاگهای دسترسی" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<TitleLine title="لاگهای دسترسی" />
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
مشاهده و مدیریت تمام فعالیتهای سیستم
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={loading || !logs || logs.length === 0}
|
||||||
|
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<DocumentDownload size={16} />
|
||||||
|
خروجی CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-blue-100 rounded-lg">
|
||||||
|
<DocumentText size={20} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">کل لاگها</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(total || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-green-100 rounded-lg">
|
||||||
|
<Activity size={20} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">صفحه فعلی</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{filters.page || 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-purple-100 rounded-lg">
|
||||||
|
<InfoCircle size={20} className="text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">نمایش در صفحه</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{filters.limit || 20}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
|
onReset={resetFilters}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && <PageLoading />}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<AccessLogsTable
|
||||||
|
logs={logs}
|
||||||
|
loading={loading}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onSort={handleSort}
|
||||||
|
sortBy={filters.sortBy}
|
||||||
|
sortOrder={filters.sortOrder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > (filters.limit || 20) && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
currentPage={filters.page || 1}
|
||||||
|
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
<LogDetailsModal
|
||||||
|
log={selectedLog}
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onClose={handleCloseDetailsModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Empty State Info */}
|
||||||
|
{!loading && (!logs || logs.length === 0) && !error && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InfoCircle size={20} className="text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-blue-900">راهنما</h3>
|
||||||
|
<p className="text-sm text-blue-700 mt-1">
|
||||||
|
از فیلترهای بالا برای جستجوی دقیقتر استفاده کنید. میتوانید بر اساس نوع عملیات، کاربر، تاریخ و سایر معیارها جستجو کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessLogsList;
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Calendar,
|
||||||
|
Refresh,
|
||||||
|
TrendUp,
|
||||||
|
InfoCircle
|
||||||
|
} from "iconsax-react";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import DatePicker from "../../components/DatePicker";
|
||||||
|
import TitleLine from "../../components/TitleLine";
|
||||||
|
import PageLoading from "../../components/PageLoading";
|
||||||
|
// import Error from "../../components/Error"; // Using custom error display
|
||||||
|
import StatsDashboard from "./components/StatsDashboard";
|
||||||
|
import { useAccessLogStats } from "./hooks/useAccessLogs";
|
||||||
|
import { AccessLogStatsQueryDto } from "./types/AccessLogTypes";
|
||||||
|
|
||||||
|
const AccessLogsStats: React.FC = () => {
|
||||||
|
const { stats, topUsers, loading, error, fetchStats } = useAccessLogStats();
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<AccessLogStatsQueryDto>({
|
||||||
|
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 30 days ago
|
||||||
|
endDate: new Date().toISOString().split('T')[0], // today
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFilterChange = (newFilters: Partial<AccessLogStatsQueryDto>) => {
|
||||||
|
const updatedFilters = { ...filters, ...newFilters };
|
||||||
|
setFilters(updatedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchStats(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPresetDateRange = (days: number) => {
|
||||||
|
const endDate = new Date().toISOString().split('T')[0];
|
||||||
|
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
handleFilterChange({ startDate, endDate });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats(filters);
|
||||||
|
}, [filters, fetchStats]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TitleLine title="آمار لاگهای دسترسی" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<TitleLine title="آمار لاگهای دسترسی" />
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
تحلیل و بررسی آماری فعالیتهای سیستم
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Filter */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar size={20} className="text-gray-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">بازه زمانی</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
از تاریخ
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
defaulValue={filters.startDate || ""}
|
||||||
|
onChange={(value: string) => handleFilterChange({ startDate: value })}
|
||||||
|
placeholder="انتخاب تاریخ شروع"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
تا تاریخ
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
defaulValue={filters.endDate || ""}
|
||||||
|
onChange={(value: string) => handleFilterChange({ endDate: value })}
|
||||||
|
placeholder="انتخاب تاریخ پایان"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
حداکثر نتایج
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.limit || 10}
|
||||||
|
onChange={(e) => handleFilterChange({ limit: parseInt(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value={5}>5</option>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Date Range Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setPresetDateRange(1)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
امروز
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setPresetDateRange(7)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
7 روز گذشته
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setPresetDateRange(30)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
30 روز گذشته
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setPresetDateRange(90)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
3 ماه گذشته
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setPresetDateRange(365)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
سال گذشته
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && <PageLoading />}
|
||||||
|
|
||||||
|
{/* Stats Dashboard */}
|
||||||
|
{!loading && (
|
||||||
|
<StatsDashboard
|
||||||
|
stats={stats}
|
||||||
|
topUsers={topUsers}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
{!loading && (!stats?.operationStats.length || !topUsers?.topUsers.length) && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-blue-900 mb-2">اطلاعات</h3>
|
||||||
|
<div className="text-blue-700 space-y-2">
|
||||||
|
<p>• آمار بر اساس بازه زمانی انتخاب شده محاسبه میشود</p>
|
||||||
|
<p>• برای مشاهده آمار دقیقتر، بازه زمانی مناسب انتخاب کنید</p>
|
||||||
|
<p>• امکان خروجی گرفتن از آمار در قالب CSV موجود است</p>
|
||||||
|
<p>• آمار به صورت لحظهای بروزرسانی میشود</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional Stats Info */}
|
||||||
|
{!loading && stats && topUsers && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Activity size={20} className="text-blue-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">خلاصه عملیات</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">کل نوع عملیات:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{(stats?.operationStats?.length || 0).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">کل فعالیت:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{(stats?.operationStats?.reduce((sum: number, stat: any) => sum + stat.count, 0) || 0).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">میانگین فعالیت روزانه:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{filters.startDate && filters.endDate ?
|
||||||
|
Math.round(stats.operationStats.reduce((sum: number, stat: any) => sum + stat.count, 0) /
|
||||||
|
Math.max(1, Math.ceil((new Date(filters.endDate).getTime() - new Date(filters.startDate).getTime()) / (1000 * 60 * 60 * 24)))).toLocaleString('fa-IR')
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<TrendUp size={20} className="text-green-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">خلاصه کاربران</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">کل کاربران فعال:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{(topUsers?.topUsers?.length || 0).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">بیشترین فعالیت:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{topUsers.topUsers.length > 0 ?
|
||||||
|
(topUsers?.topUsers?.[0]?.activityCount || 0).toLocaleString('fa-IR') :
|
||||||
|
'0'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">میانگین فعالیت کاربر:</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{topUsers.topUsers.length > 0 ?
|
||||||
|
Math.round((topUsers?.topUsers?.reduce((sum: number, user: any) => sum + user.activityCount, 0) || 0) / (topUsers?.topUsers?.length || 1)).toLocaleString('fa-IR') :
|
||||||
|
'0'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessLogsStats;
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Warning2,
|
||||||
|
Refresh,
|
||||||
|
InfoCircle,
|
||||||
|
CloseCircle
|
||||||
|
} from "iconsax-react";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Pagination from "../../components/Pagination";
|
||||||
|
import TitleLine from "../../components/TitleLine";
|
||||||
|
import PageLoading from "../../components/PageLoading";
|
||||||
|
// import Error from "../../components/Error"; // Using custom error display
|
||||||
|
import FilterPanel from "./components/FilterPanel";
|
||||||
|
import AccessLogsTable from "./components/AccessLogsTable";
|
||||||
|
import LogDetailsModal from "./components/LogDetailsModal";
|
||||||
|
import { useErrorLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||||
|
import { AccessLog } from "./types/AccessLogTypes";
|
||||||
|
|
||||||
|
const ErrorLogs: React.FC = () => {
|
||||||
|
const {
|
||||||
|
errorLogs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
refetch,
|
||||||
|
} = useErrorLogs();
|
||||||
|
|
||||||
|
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||||
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetails = async (log: AccessLog) => {
|
||||||
|
await fetchLog(log.id);
|
||||||
|
setIsDetailsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetailsModal = () => {
|
||||||
|
setIsDetailsModalOpen(false);
|
||||||
|
clearLog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||||
|
updateFilters({ sortBy: field, sortOrder: order });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
updateFilters({ page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
updateFilters({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
operationType: undefined,
|
||||||
|
userType: undefined,
|
||||||
|
entityName: "",
|
||||||
|
entityId: "",
|
||||||
|
userId: "",
|
||||||
|
ipAddress: "",
|
||||||
|
endpoint: "",
|
||||||
|
httpMethod: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
search: "",
|
||||||
|
minExecutionTime: undefined,
|
||||||
|
maxExecutionTime: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count errors by status code
|
||||||
|
const errorsByStatusCode = (errorLogs || []).reduce((acc: Record<string, number>, log: AccessLog) => {
|
||||||
|
const statusCode = log.statusCode || 0;
|
||||||
|
const range = statusCode >= 500 ? "5xx" : statusCode >= 400 ? "4xx" : "other";
|
||||||
|
acc[range] = (acc[range] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Count recent errors (last 24 hours)
|
||||||
|
const recentErrors = (errorLogs || []).filter((log: AccessLog) => {
|
||||||
|
const logDate = new Date(log.createdAt);
|
||||||
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
return logDate > oneDayAgo;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TitleLine title="لاگهای خطا" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<TitleLine title="لاگهای خطا" />
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
مشاهده و تحلیل خطاهای سیستم
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||||
|
<Warning2 size={20} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">کل خطاها</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(total || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-orange-100 rounded-lg">
|
||||||
|
<CloseCircle size={20} className="text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">خطاهای 4xx</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(errorsByStatusCode["4xx"] || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||||
|
<Warning2 size={20} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">خطاهای 5xx</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(errorsByStatusCode["5xx"] || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-yellow-100 rounded-lg">
|
||||||
|
<InfoCircle size={20} className="text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">24 ساعت گذشته</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{recentErrors.toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Severity Alert */}
|
||||||
|
{errorsByStatusCode["5xx"] > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Warning2 size={20} className="text-red-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-red-900">هشدار خطاهای سرور</h3>
|
||||||
|
<p className="text-sm text-red-700 mt-1">
|
||||||
|
{errorsByStatusCode["5xx"]} خطای سرور (5xx) شناسایی شده است.
|
||||||
|
لطفاً این خطاها را بررسی و رفع کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Errors Alert */}
|
||||||
|
{recentErrors > 10 && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InfoCircle size={20} className="text-yellow-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-yellow-900">افزایش خطاها</h3>
|
||||||
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
|
{recentErrors} خطا در 24 ساعت گذشته رخ داده است.
|
||||||
|
ممکن است نیاز به بررسی بیشتر باشد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
|
onReset={resetFilters}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && <PageLoading />}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<AccessLogsTable
|
||||||
|
logs={errorLogs}
|
||||||
|
loading={loading}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onSort={handleSort}
|
||||||
|
sortBy={filters.sortBy}
|
||||||
|
sortOrder={filters.sortOrder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > (filters.limit || 20) && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
currentPage={filters.page || 1}
|
||||||
|
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
<LogDetailsModal
|
||||||
|
log={selectedLog}
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onClose={handleCloseDetailsModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Empty State Info */}
|
||||||
|
{!loading && (!errorLogs || errorLogs.length === 0) && !error && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InfoCircle size={24} className="text-green-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-green-900">عالی! هیچ خطایی یافت نشد</h3>
|
||||||
|
<p className="text-green-700 mt-1">
|
||||||
|
در بازه زمانی و فیلترهای انتخاب شده، هیچ خطایی ثبت نشده است.
|
||||||
|
این نشاندهنده عملکرد مناسب سیستم است.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Analysis Tips */}
|
||||||
|
{!loading && errorLogs && errorLogs.length > 0 && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-blue-900 mb-2">راهنمای تحلیل خطاها</h3>
|
||||||
|
<div className="text-blue-700 space-y-2">
|
||||||
|
<p>• <strong>خطاهای 4xx:</strong> مربوط به درخواستهای نامعتبر کاربران</p>
|
||||||
|
<p>• <strong>خطاهای 5xx:</strong> مربوط به مشکلات سرور که نیاز به رفع فوری دارند</p>
|
||||||
|
<p>• <strong>الگوهای تکراری:</strong> خطاهای مشابه ممکن است نشاندهنده مشکل سیستمی باشند</p>
|
||||||
|
<p>• <strong>زمانبندی:</strong> توجه به زمان وقوع خطاها برای شناسایی الگوهای زمانی</p>
|
||||||
|
<p>• <strong>IP و کاربران:</strong> شناسایی منابع خطاها برای بهبود تجربه کاربری</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorLogs;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Refresh,
|
||||||
|
InfoCircle,
|
||||||
|
TickCircle,
|
||||||
|
CloseCircle
|
||||||
|
} from "iconsax-react";
|
||||||
|
import Button from "../../components/Button";
|
||||||
|
import Pagination from "../../components/Pagination";
|
||||||
|
import TitleLine from "../../components/TitleLine";
|
||||||
|
import PageLoading from "../../components/PageLoading";
|
||||||
|
import FilterPanel from "./components/FilterPanel";
|
||||||
|
import AccessLogsTable from "./components/AccessLogsTable";
|
||||||
|
import LogDetailsModal from "./components/LogDetailsModal";
|
||||||
|
import { useErrorLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||||
|
import { AccessLog } from "./types/AccessLogTypes";
|
||||||
|
|
||||||
|
const PermissionLogs: React.FC = () => {
|
||||||
|
const {
|
||||||
|
errorLogs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
refetch,
|
||||||
|
} = useErrorLogs();
|
||||||
|
|
||||||
|
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||||
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetails = async (log: AccessLog) => {
|
||||||
|
await fetchLog(log.id);
|
||||||
|
setIsDetailsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetailsModal = () => {
|
||||||
|
setIsDetailsModalOpen(false);
|
||||||
|
clearLog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||||
|
updateFilters({ sortBy: field, sortOrder: order });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
updateFilters({ page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
updateFilters({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
operationType: undefined,
|
||||||
|
userType: undefined,
|
||||||
|
entityName: "",
|
||||||
|
entityId: "",
|
||||||
|
userId: "",
|
||||||
|
ipAddress: "",
|
||||||
|
endpoint: "",
|
||||||
|
httpMethod: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
search: "",
|
||||||
|
minExecutionTime: undefined,
|
||||||
|
maxExecutionTime: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter permission-related logs (assuming they have specific operation types or endpoints)
|
||||||
|
const permissionLogs = (errorLogs || []).filter((log: AccessLog) => {
|
||||||
|
return log.operationType?.includes('permission') ||
|
||||||
|
log.endpoint?.includes('permission') ||
|
||||||
|
log.entityName?.includes('role') ||
|
||||||
|
log.entityName?.includes('permission');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count permission actions
|
||||||
|
const permissionStats = permissionLogs.reduce((acc: Record<string, number>, log: AccessLog) => {
|
||||||
|
const action = log.operationType || 'unknown';
|
||||||
|
acc[action] = (acc[action] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Count recent permission changes (last 24 hours)
|
||||||
|
const recentPermissions = permissionLogs.filter((log: AccessLog) => {
|
||||||
|
const logDate = new Date(log.createdAt);
|
||||||
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
return logDate > oneDayAgo;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<TitleLine title="لاگهای مجوز" />
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<TitleLine title="لاگهای مجوز" />
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
مشاهده و تحلیل تغییرات مجوزها و نقشهای کاربری
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={refetch}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Shield size={20} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">کل تغییرات مجوز</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{permissionLogs.length.toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-green-100 rounded-lg">
|
||||||
|
<TickCircle size={20} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">مجوزهای اعطا شده</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(permissionStats['grant'] || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||||
|
<CloseCircle size={20} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">مجوزهای لغو شده</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{(permissionStats['revoke'] || 0).toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-yellow-100 rounded-lg">
|
||||||
|
<InfoCircle size={20} className="text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">24 ساعت گذشته</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">
|
||||||
|
{recentPermissions.toLocaleString('fa-IR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Alert */}
|
||||||
|
{recentPermissions > 5 && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield size={20} className="text-yellow-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-yellow-900">تغییرات زیاد مجوز</h3>
|
||||||
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
|
{recentPermissions} تغییر مجوز در 24 ساعت گذشته رخ داده است.
|
||||||
|
لطفاً این تغییرات را بررسی کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
|
onReset={resetFilters}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && <PageLoading />}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<AccessLogsTable
|
||||||
|
logs={permissionLogs}
|
||||||
|
loading={loading}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onSort={handleSort}
|
||||||
|
sortBy={filters.sortBy}
|
||||||
|
sortOrder={filters.sortOrder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > (filters.limit || 20) && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
currentPage={filters.page || 1}
|
||||||
|
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
<LogDetailsModal
|
||||||
|
log={selectedLog}
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onClose={handleCloseDetailsModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Empty State Info */}
|
||||||
|
{!loading && (!permissionLogs || permissionLogs.length === 0) && !error && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InfoCircle size={24} className="text-green-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-green-900">هیچ تغییر مجوزی یافت نشد</h3>
|
||||||
|
<p className="text-green-700 mt-1">
|
||||||
|
در بازه زمانی و فیلترهای انتخاب شده، هیچ تغییر مجوزی ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permission Analysis Tips */}
|
||||||
|
{!loading && permissionLogs && permissionLogs.length > 0 && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-blue-900 mb-2">راهنمای تحلیل مجوزها</h3>
|
||||||
|
<div className="text-blue-700 space-y-2">
|
||||||
|
<p>• <strong>تغییرات نقش:</strong> بررسی تغییرات نقشهای کاربری و مجوزهای مربوطه</p>
|
||||||
|
<p>• <strong>دسترسیهای حساس:</strong> نظارت بر دسترسیهای مهم و حساس سیستم</p>
|
||||||
|
<p>• <strong>الگوهای دسترسی:</strong> شناسایی الگوهای غیرعادی در دسترسیها</p>
|
||||||
|
<p>• <strong>زمانبندی:</strong> توجه به زمان تغییرات مجوزها</p>
|
||||||
|
<p>• <strong>کاربران:</strong> شناسایی کاربرانی که تغییرات مجوز زیادی دارند</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionLogs;
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Eye, ArrowUp, ArrowDown, Clock, User, DocumentText } from "iconsax-react";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
import StatusCircle from "../../../components/StatusCircle";
|
||||||
|
import Td from "../../../components/Td";
|
||||||
|
import {
|
||||||
|
AccessLog,
|
||||||
|
OperationType,
|
||||||
|
UserType,
|
||||||
|
OperationTypeLabels,
|
||||||
|
UserTypeLabels,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
|
||||||
|
interface AccessLogsTableProps {
|
||||||
|
logs: AccessLog[];
|
||||||
|
loading: boolean;
|
||||||
|
onViewDetails: (log: AccessLog) => void;
|
||||||
|
onSort: (field: string, order: "ASC" | "DESC") => void;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessLogsTable: React.FC<AccessLogsTableProps> = ({
|
||||||
|
logs,
|
||||||
|
loading,
|
||||||
|
onViewDetails,
|
||||||
|
onSort,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
}) => {
|
||||||
|
const getOperationTypeColor = (type: OperationType): string => {
|
||||||
|
const colors = {
|
||||||
|
CREATE: "green",
|
||||||
|
UPDATE: "blue",
|
||||||
|
DELETE: "red",
|
||||||
|
READ: "gray",
|
||||||
|
LOGIN: "purple",
|
||||||
|
LOGOUT: "orange",
|
||||||
|
APPROVE: "green",
|
||||||
|
REJECT: "red",
|
||||||
|
ACTIVATE: "green",
|
||||||
|
DEACTIVATE: "red",
|
||||||
|
EXPORT: "blue",
|
||||||
|
IMPORT: "blue",
|
||||||
|
BULK_UPDATE: "blue",
|
||||||
|
BULK_DELETE: "red",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "gray";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||||
|
const colors = {
|
||||||
|
SYSTEM: "bg-gray-100 text-gray-800",
|
||||||
|
ADMIN: "bg-red-100 text-red-800",
|
||||||
|
USER: "bg-blue-100 text-blue-800",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (statusCode?: number): string => {
|
||||||
|
if (!statusCode) return "bg-gray-100 text-gray-800";
|
||||||
|
if (statusCode >= 200 && statusCode < 300) return "bg-green-100 text-green-800";
|
||||||
|
if (statusCode >= 400 && statusCode < 500) return "bg-yellow-100 text-yellow-800";
|
||||||
|
if (statusCode >= 500) return "bg-red-100 text-red-800";
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExecutionTime = (time?: number): string => {
|
||||||
|
if (!time) return "-";
|
||||||
|
if (time < 1000) return `${time}ms`;
|
||||||
|
return `${(time / 1000).toFixed(2)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSortButton = (field: string, label: string) => (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => {
|
||||||
|
const newOrder = sortBy === field && sortOrder === "ASC" ? "DESC" : "ASC";
|
||||||
|
onSort(field, newOrder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sortBy === field ? (
|
||||||
|
sortOrder === "ASC" ? (
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">در حال بارگذاری...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logs || logs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<DocumentText size={48} className="text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 text-lg mb-2">هیچ لاگی یافت نشد</p>
|
||||||
|
<p className="text-gray-500">فیلترهای خود را تغییر دهید یا دوباره تلاش کنید</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||||
|
<table className='w-full text-sm'>
|
||||||
|
<thead className='thead'>
|
||||||
|
<tr>
|
||||||
|
<Td text="">
|
||||||
|
{renderSortButton("createdAt", "تاریخ و زمان")}
|
||||||
|
</Td>
|
||||||
|
<Td text="نوع عملیات" />
|
||||||
|
<Td text="نوع کاربر" />
|
||||||
|
<Td text="موجودیت" />
|
||||||
|
<Td text="توضیحات" />
|
||||||
|
<Td text="کاربر" />
|
||||||
|
<Td text="IP" />
|
||||||
|
<Td text="وضعیت" />
|
||||||
|
<Td text="زمان اجرا" />
|
||||||
|
<Td text="عملیات" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className='tr'>
|
||||||
|
<Td text="">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-gray-400" />
|
||||||
|
{formatDate(log.createdAt)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<StatusCircle
|
||||||
|
color={getOperationTypeColor(log.operationType)}
|
||||||
|
text={OperationTypeLabels[log.operationType]}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(log.userType)}`}>
|
||||||
|
{UserTypeLabels[log.userType]}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{log.entityName}</div>
|
||||||
|
{log.entityId && (
|
||||||
|
<div className="text-xs text-gray-500 truncate max-w-[100px]">
|
||||||
|
{log.entityId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<div className="truncate max-w-xs" title={log.actionDescription}>
|
||||||
|
{log.actionDescription || "-"}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
{log.user ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User size={16} className="text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{log.user.firstName} {log.user.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{log.user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">سیستم</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text={log.ipAddress || "-"} />
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<div>
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(log.statusCode)}`}>
|
||||||
|
{log.statusCode || "N/A"}
|
||||||
|
</span>
|
||||||
|
{log.errorMessage && (
|
||||||
|
<div className="text-xs text-red-600 mt-1 truncate max-w-[100px]" title={log.errorMessage}>
|
||||||
|
{log.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td text={formatExecutionTime(log.executionTime)} />
|
||||||
|
|
||||||
|
<Td text="">
|
||||||
|
<Button
|
||||||
|
onClick={() => onViewDetails(log)}
|
||||||
|
className="text-sm px-3 py-1 bg-transparent text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Eye size={16} className="ml-1" />
|
||||||
|
جزئیات
|
||||||
|
</Button>
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessLogsTable;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Filter, CloseSquare } from "iconsax-react";
|
||||||
|
import Input from "../../../components/Input";
|
||||||
|
import Select from "../../../components/Select";
|
||||||
|
import DatePicker from "../../../components/DatePicker";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
import {
|
||||||
|
AccessLogQuery,
|
||||||
|
OperationType,
|
||||||
|
UserType,
|
||||||
|
HttpMethod,
|
||||||
|
OperationTypeLabels,
|
||||||
|
UserTypeLabels,
|
||||||
|
HttpMethodLabels,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: AccessLogQuery;
|
||||||
|
onFiltersChange: (filters: Partial<AccessLogQuery>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
onReset,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const operationTypeOptions = Object.values(OperationType).map((type) => ({
|
||||||
|
value: type,
|
||||||
|
label: OperationTypeLabels[type],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const userTypeOptions = Object.values(UserType).map((type) => ({
|
||||||
|
value: type,
|
||||||
|
label: UserTypeLabels[type],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const httpMethodOptions = Object.values(HttpMethod).map((method) => ({
|
||||||
|
value: method,
|
||||||
|
label: HttpMethodLabels[method],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusCodeOptions = [
|
||||||
|
{ value: "200", label: "200 - موفق" },
|
||||||
|
{ value: "201", label: "201 - ایجاد شده" },
|
||||||
|
{ value: "400", label: "400 - درخواست نامعتبر" },
|
||||||
|
{ value: "401", label: "401 - عدم احراز هویت" },
|
||||||
|
{ value: "403", label: "403 - عدم دسترسی" },
|
||||||
|
{ value: "404", label: "404 - یافت نشد" },
|
||||||
|
{ value: "500", label: "500 - خطای سرور" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasActiveFilters = () => {
|
||||||
|
return Object.entries(filters).some(([key, value]) => {
|
||||||
|
if (key === "page" || key === "limit" || key === "sortBy" || key === "sortOrder") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value !== undefined && value !== null && value !== "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter size={20} className="text-gray-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">فیلترها</h3>
|
||||||
|
{hasActiveFilters() && (
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||||
|
فعال
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters() && (
|
||||||
|
<Button
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
<CloseSquare size={16} className="ml-1" />
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
جستجو
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="جستجو در توضیحات و نام موجودیت..."
|
||||||
|
value={filters.search || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ search: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operation Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
نوع عملیات
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
items={[{ value: "", label: "همه" }, ...operationTypeOptions]}
|
||||||
|
value={filters.operationType || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ operationType: e.target.value as OperationType })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
نوع کاربر
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
items={[{ value: "", label: "همه" }, ...userTypeOptions]}
|
||||||
|
value={filters.userType || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ userType: e.target.value as UserType })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entity Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
نام موجودیت
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="مثال: User, Blog, Service"
|
||||||
|
value={filters.entityName || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ entityName: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP Method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
متد HTTP
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
items={[{ value: "", label: "همه" }, ...httpMethodOptions]}
|
||||||
|
value={filters.httpMethod || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ httpMethod: e.target.value as HttpMethod })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Code */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
کد وضعیت
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
items={[{ value: "", label: "همه" }, ...statusCodeOptions]}
|
||||||
|
value={filters.statusCode?.toString() || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ statusCode: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IP Address */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
آدرس IP
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
value={filters.ipAddress || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ ipAddress: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User ID */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
شناسه کاربر
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="شناسه کاربر"
|
||||||
|
value={filters.userId || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ userId: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
از تاریخ
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
defaulValue={filters.startDate || ""}
|
||||||
|
onChange={(value: string) => onFiltersChange({ startDate: value })}
|
||||||
|
placeholder="انتخاب تاریخ شروع"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
تا تاریخ
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
defaulValue={filters.endDate || ""}
|
||||||
|
onChange={(value: string) => onFiltersChange({ endDate: value })}
|
||||||
|
placeholder="انتخاب تاریخ پایان"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min Execution Time */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
حداقل زمان اجرا (ms)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={filters.minExecutionTime?.toString() || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({
|
||||||
|
minExecutionTime: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
})}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Execution Time */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
حداکثر زمان اجرا (ms)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="1000"
|
||||||
|
value={filters.maxExecutionTime?.toString() || ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({
|
||||||
|
maxExecutionTime: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
})}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterPanel;
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
CloseSquare,
|
||||||
|
InfoCircle,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
Monitor,
|
||||||
|
Code,
|
||||||
|
Warning2,
|
||||||
|
DocumentText,
|
||||||
|
Activity
|
||||||
|
} from "iconsax-react";
|
||||||
|
import DefaulModal from "../../../components/DefaulModal";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
import StatusCircle from "../../../components/StatusCircle";
|
||||||
|
import {
|
||||||
|
AccessLog,
|
||||||
|
OperationType,
|
||||||
|
UserType,
|
||||||
|
OperationTypeLabels,
|
||||||
|
UserTypeLabels,
|
||||||
|
HttpMethodLabels,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
|
||||||
|
interface LogDetailsModalProps {
|
||||||
|
log: AccessLog | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogDetailsModal: React.FC<LogDetailsModalProps> = ({
|
||||||
|
log,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (!log) return null;
|
||||||
|
|
||||||
|
const getOperationTypeColor = (type: OperationType): string => {
|
||||||
|
const colors = {
|
||||||
|
CREATE: "green",
|
||||||
|
UPDATE: "blue",
|
||||||
|
DELETE: "red",
|
||||||
|
READ: "gray",
|
||||||
|
LOGIN: "purple",
|
||||||
|
LOGOUT: "orange",
|
||||||
|
APPROVE: "green",
|
||||||
|
REJECT: "red",
|
||||||
|
ACTIVATE: "green",
|
||||||
|
DEACTIVATE: "red",
|
||||||
|
EXPORT: "blue",
|
||||||
|
IMPORT: "blue",
|
||||||
|
BULK_UPDATE: "blue",
|
||||||
|
BULK_DELETE: "red",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "gray";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||||
|
const colors = {
|
||||||
|
SYSTEM: "bg-gray-100 text-gray-800",
|
||||||
|
ADMIN: "bg-red-100 text-red-800",
|
||||||
|
USER: "bg-blue-100 text-blue-800",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (statusCode?: number): string => {
|
||||||
|
if (!statusCode) return "bg-gray-100 text-gray-800";
|
||||||
|
if (statusCode >= 200 && statusCode < 300) return "bg-green-100 text-green-800";
|
||||||
|
if (statusCode >= 400 && statusCode < 500) return "bg-yellow-100 text-yellow-800";
|
||||||
|
if (statusCode >= 500) return "bg-red-100 text-red-800";
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExecutionTime = (time?: number): string => {
|
||||||
|
if (!time) return "-";
|
||||||
|
if (time < 1000) return `${time} میلیثانیه`;
|
||||||
|
return `${(time / 1000).toFixed(2)} ثانیه`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatJson = (jsonString?: string): string => {
|
||||||
|
if (!jsonString) return "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
return jsonString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoRow: React.FC<{
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}> = ({ icon, label, value, fullWidth = false }) => (
|
||||||
|
<div className={`${fullWidth ? "col-span-2" : ""}`}>
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 mt-0.5 text-gray-600">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 mb-1">{label}</p>
|
||||||
|
<div className="text-sm text-gray-700">{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CodeBlock: React.FC<{ title: string; content?: string }> = ({ title, content }) => {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<Code size={16} />
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||||
|
<pre className="text-sm text-green-400 whitespace-pre-wrap font-mono">
|
||||||
|
{formatJson(content)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal open={isOpen} close={onClose} width={800}>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<InfoCircle size={24} className="text-blue-600" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">جزئیات لاگ</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-sm px-3 py-1 bg-transparent text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<CloseSquare size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<InfoRow
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="نوع عملیات"
|
||||||
|
value={
|
||||||
|
<StatusCircle
|
||||||
|
color={getOperationTypeColor(log.operationType)}
|
||||||
|
text={OperationTypeLabels[log.operationType]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
icon={<User size={18} />}
|
||||||
|
label="نوع کاربر"
|
||||||
|
value={
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(log.userType)}`}>
|
||||||
|
{UserTypeLabels[log.userType]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
icon={<DocumentText size={18} />}
|
||||||
|
label="موجودیت"
|
||||||
|
value={
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{log.entityName}</div>
|
||||||
|
{log.entityId && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{log.entityId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
label="تاریخ و زمان"
|
||||||
|
value={formatDate(log.createdAt)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{log.actionDescription && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="توضیحات عملیات"
|
||||||
|
value={log.actionDescription}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Information */}
|
||||||
|
{log.user && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<User size={18} />
|
||||||
|
اطلاعات کاربر
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InfoRow
|
||||||
|
icon={<User size={18} />}
|
||||||
|
label="نام و نام خانوادگی"
|
||||||
|
value={`${log.user.firstName} ${log.user.lastName}`}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="ایمیل"
|
||||||
|
value={log.user.email}
|
||||||
|
/>
|
||||||
|
{log.user.phone && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="شماره تماس"
|
||||||
|
value={log.user.phone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="شناسه کاربر"
|
||||||
|
value={log.user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technical Details */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<Monitor size={18} />
|
||||||
|
جزئیات فنی
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{log.ipAddress && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Monitor size={18} />}
|
||||||
|
label="آدرس IP"
|
||||||
|
value={log.ipAddress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.endpoint && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Code size={18} />}
|
||||||
|
label="Endpoint"
|
||||||
|
value={log.endpoint}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.httpMethod && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Code size={18} />}
|
||||||
|
label="متد HTTP"
|
||||||
|
value={HttpMethodLabels[log.httpMethod]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="کد وضعیت"
|
||||||
|
value={
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(log.statusCode)}`}>
|
||||||
|
{log.statusCode || "N/A"}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{log.executionTime && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
label="زمان اجرا"
|
||||||
|
value={formatExecutionTime(log.executionTime)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.sessionId && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="شناسه جلسه"
|
||||||
|
value={log.sessionId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.requestId && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<InfoCircle size={18} />}
|
||||||
|
label="شناسه درخواست"
|
||||||
|
value={log.requestId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Information */}
|
||||||
|
{log.errorMessage && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-red-600 mb-3 flex items-center gap-2">
|
||||||
|
<Warning2 size={18} />
|
||||||
|
اطلاعات خطا
|
||||||
|
</h3>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-800">{log.errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Agent */}
|
||||||
|
{log.userAgent && (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Monitor size={18} />}
|
||||||
|
label="User Agent"
|
||||||
|
value={
|
||||||
|
<div className="text-xs font-mono bg-gray-100 p-2 rounded break-all">
|
||||||
|
{log.userAgent}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Changes */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<CodeBlock title="مقادیر قبلی" content={log.oldValues} />
|
||||||
|
<CodeBlock title="مقادیر جدید" content={log.newValues} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||||
|
<CodeBlock
|
||||||
|
title="متادیتا"
|
||||||
|
content={JSON.stringify(log.metadata, null, 2)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
بستن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogDetailsModal;
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
User,
|
||||||
|
TrendUp,
|
||||||
|
DocumentText,
|
||||||
|
Warning2
|
||||||
|
} from "iconsax-react";
|
||||||
|
import StatusCircle from "../../../components/StatusCircle";
|
||||||
|
import {
|
||||||
|
OperationStatsResponse,
|
||||||
|
TopUsersResponse,
|
||||||
|
OperationType,
|
||||||
|
UserType,
|
||||||
|
OperationTypeLabels,
|
||||||
|
UserTypeLabels,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
|
||||||
|
interface StatsDashboardProps {
|
||||||
|
stats: OperationStatsResponse | null;
|
||||||
|
topUsers: TopUsersResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsDashboard: React.FC<StatsDashboardProps> = ({
|
||||||
|
stats,
|
||||||
|
topUsers,
|
||||||
|
loading,
|
||||||
|
}) => {
|
||||||
|
const getOperationTypeColor = (type: OperationType): string => {
|
||||||
|
const colors = {
|
||||||
|
CREATE: "green",
|
||||||
|
UPDATE: "blue",
|
||||||
|
DELETE: "red",
|
||||||
|
READ: "gray",
|
||||||
|
LOGIN: "purple",
|
||||||
|
LOGOUT: "orange",
|
||||||
|
APPROVE: "green",
|
||||||
|
REJECT: "red",
|
||||||
|
ACTIVATE: "green",
|
||||||
|
DEACTIVATE: "red",
|
||||||
|
EXPORT: "blue",
|
||||||
|
IMPORT: "blue",
|
||||||
|
BULK_UPDATE: "blue",
|
||||||
|
BULK_DELETE: "red",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "gray";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||||
|
const colors = {
|
||||||
|
SYSTEM: "bg-gray-100 text-gray-800",
|
||||||
|
ADMIN: "bg-red-100 text-red-800",
|
||||||
|
USER: "bg-blue-100 text-blue-800",
|
||||||
|
};
|
||||||
|
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalOperations = stats?.operationStats.reduce((sum: number, stat: any) => sum + stat.count, 0) || 0;
|
||||||
|
const totalUsers = topUsers?.topUsers.length || 0;
|
||||||
|
|
||||||
|
// Group stats by operation type
|
||||||
|
const operationTypeTotals = stats?.operationStats.reduce((acc: Record<OperationType, number>, stat: any) => {
|
||||||
|
acc[stat.operationType as OperationType] = (acc[stat.operationType as OperationType] || 0) + stat.count;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<OperationType, number>) || {};
|
||||||
|
|
||||||
|
// Group stats by user type
|
||||||
|
const userTypeTotals = stats?.operationStats.reduce((acc: Record<UserType, number>, stat: any) => {
|
||||||
|
acc[stat.userType as UserType] = (acc[stat.userType as UserType] || 0) + stat.count;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<UserType, number>) || {};
|
||||||
|
|
||||||
|
const StatCard: React.FC<{
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
color: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}> = ({ icon, title, value, color, subtitle }) => (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`flex-shrink-0 p-3 rounded-lg bg-${color}-100`}>
|
||||||
|
<div className={`text-${color}-600`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mr-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{(value || 0).toLocaleString('fa-IR')}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 p-3 rounded-lg bg-gray-200 w-12 h-12"></div>
|
||||||
|
<div className="mr-4 flex-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-2"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={24} />}
|
||||||
|
title="کل عملیات"
|
||||||
|
value={totalOperations}
|
||||||
|
color="blue"
|
||||||
|
subtitle="در بازه زمانی انتخاب شده"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={<User size={24} />}
|
||||||
|
title="کاربران فعال"
|
||||||
|
value={totalUsers}
|
||||||
|
color="green"
|
||||||
|
subtitle="کاربران با فعالیت"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={24} />}
|
||||||
|
title="عملیات ایجاد"
|
||||||
|
value={(operationTypeTotals as any)[OperationType.CREATE] || 0}
|
||||||
|
color="green"
|
||||||
|
subtitle="عملیات جدید"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
icon={<Warning2 size={24} />}
|
||||||
|
title="عملیات حذف"
|
||||||
|
value={(operationTypeTotals as any)[OperationType.DELETE] || 0}
|
||||||
|
color="red"
|
||||||
|
subtitle="عملیات حذف شده"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Operation Types Chart */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Activity size={20} />
|
||||||
|
توزیع نوع عملیات
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{totalOperations.toLocaleString('fa-IR')} کل
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(operationTypeTotals)
|
||||||
|
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(([type, count]) => {
|
||||||
|
const percentage = totalOperations > 0 ? ((count as number) / totalOperations) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={type} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<StatusCircle
|
||||||
|
color={getOperationTypeColor(type as OperationType)}
|
||||||
|
text=""
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{OperationTypeLabels[type as OperationType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full bg-${getOperationTypeColor(type as OperationType)}-500`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 min-w-[3rem] text-left">
|
||||||
|
{(count as number).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 min-w-[3rem] text-left">
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Types Distribution */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<User size={20} />
|
||||||
|
توزیع نوع کاربر
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{totalOperations.toLocaleString('fa-IR')} کل
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(userTypeTotals)
|
||||||
|
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||||
|
.map(([type, count]) => {
|
||||||
|
const percentage = totalOperations > 0 ? ((count as number) / totalOperations) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={type} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(type as UserType)}`}>
|
||||||
|
{UserTypeLabels[type as UserType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-32 bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="h-3 rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-gray-900 min-w-[3rem] text-left">
|
||||||
|
{(count as number).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 min-w-[3rem] text-left">
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Users Table */}
|
||||||
|
{topUsers && topUsers.topUsers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<TrendUp size={20} />
|
||||||
|
فعالترین کاربران
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{topUsers.topUsers.length} کاربر
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
رتبه
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
کاربر
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
ایمیل
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
تعداد فعالیت
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
درصد
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{topUsers.topUsers.map((user, index) => {
|
||||||
|
const percentage = totalOperations > 0 ? (user.activityCount / totalOperations) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<tr key={user.userId} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 font-bold">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<User size={20} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||||
|
{(user.activityCount || 0).toLocaleString('fa-IR')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entity Activity */}
|
||||||
|
{stats && stats.operationStats.length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<DocumentText size={20} />
|
||||||
|
فعالیت موجودیتها
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{stats.operationStats.length} نوع موجودیت
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{stats.operationStats
|
||||||
|
.reduce((acc: Array<{ entityName: string; count: number }>, stat: any) => {
|
||||||
|
const existing = acc.find((item: any) => item.entityName === stat.entityName);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += stat.count;
|
||||||
|
} else {
|
||||||
|
acc.push({ entityName: stat.entityName, count: stat.count });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as { entityName: string; count: number }[])
|
||||||
|
.sort((a: any, b: any) => b.count - a.count)
|
||||||
|
.slice(0, 9)
|
||||||
|
.map((entity: any) => {
|
||||||
|
const percentage = totalOperations > 0 ? (entity.count / totalOperations) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={entity.entityName} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="font-medium text-gray-900">{entity.entityName}</h4>
|
||||||
|
<span className="text-sm font-semibold text-gray-700">
|
||||||
|
{(entity.count || 0).toLocaleString('fa-IR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-green-500"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{percentage.toFixed(1)}% از کل فعالیتها
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsDashboard;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
AccessLog,
|
||||||
|
AccessLogQuery,
|
||||||
|
AccessLogStatsQueryDto,
|
||||||
|
AccessLogsResponse,
|
||||||
|
OperationStatsResponse,
|
||||||
|
TopUsersResponse,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
import { accessLogsService } from "../service/AccessLogService";
|
||||||
|
|
||||||
|
export const useAccessLogs = () => {
|
||||||
|
const [logs, setLogs] = useState<AccessLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filters, setFilters] = useState<AccessLogQuery>({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(
|
||||||
|
async (newFilters?: AccessLogQuery) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentFilters = newFilters || filters;
|
||||||
|
const response: AccessLogsResponse = await accessLogsService.getLogs(
|
||||||
|
currentFilters
|
||||||
|
);
|
||||||
|
setLogs(response.accessLogs || []);
|
||||||
|
setTotal(response.count || 0);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error)?.message || "خطا در دریافت لاگها");
|
||||||
|
setLogs([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateFilters = useCallback(
|
||||||
|
(newFilters: Partial<AccessLogQuery>) => {
|
||||||
|
const updatedFilters = { ...filters, ...newFilters };
|
||||||
|
if (newFilters.page === undefined && Object.keys(newFilters).length > 0) {
|
||||||
|
updatedFilters.page = 1;
|
||||||
|
}
|
||||||
|
setFilters(updatedFilters);
|
||||||
|
},
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exportLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await accessLogsService.exportLogs(filters);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `access-logs-${new Date().toISOString().split("T")[0]}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error)?.message || "خطا در خروجی گرفتن از لاگها");
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters,
|
||||||
|
fetchLogs,
|
||||||
|
exportLogs,
|
||||||
|
refetch: () => fetchLogs(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAccessLogStats = () => {
|
||||||
|
const [stats, setStats] = useState<OperationStatsResponse | null>(null);
|
||||||
|
const [topUsers, setTopUsers] = useState<TopUsersResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(
|
||||||
|
async (filters: AccessLogStatsQueryDto = {}) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsResponse, topUsersResponse] = await Promise.all([
|
||||||
|
accessLogsService.getStats(filters),
|
||||||
|
accessLogsService.getTopUsers(filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats(statsResponse);
|
||||||
|
setTopUsers(topUsersResponse);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error)?.message || "خطا در دریافت آمار");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
topUsers,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchStats,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAccessLogDetail = () => {
|
||||||
|
const [log, setLog] = useState<AccessLog | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchLog = useCallback(async (id: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await accessLogsService.getLogById(id);
|
||||||
|
setLog(response.accessLog);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error)?.message || "خطا در دریافت جزئیات لاگ");
|
||||||
|
setLog(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearLog = useCallback(() => {
|
||||||
|
setLog(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
log,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchLog,
|
||||||
|
clearLog,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useErrorLogs = () => {
|
||||||
|
const [errorLogs, setErrorLogs] = useState<AccessLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filters, setFilters] = useState<AccessLogQuery>({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: "createdAt",
|
||||||
|
sortOrder: "DESC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchErrorLogs = useCallback(
|
||||||
|
async (newFilters?: AccessLogQuery) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentFilters = newFilters || filters;
|
||||||
|
const response: AccessLogsResponse = await accessLogsService.getErrorLogs(
|
||||||
|
currentFilters
|
||||||
|
);
|
||||||
|
setErrorLogs(response.accessLogs || []);
|
||||||
|
setTotal(response.count || 0);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error)?.message || "خطا در دریافت لاگهای خطا");
|
||||||
|
setErrorLogs([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateFilters = useCallback(
|
||||||
|
(newFilters: Partial<AccessLogQuery>) => {
|
||||||
|
const updatedFilters = { ...filters, ...newFilters };
|
||||||
|
if (newFilters.page === undefined && Object.keys(newFilters).length > 0) {
|
||||||
|
updatedFilters.page = 1;
|
||||||
|
}
|
||||||
|
setFilters(updatedFilters);
|
||||||
|
},
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchErrorLogs();
|
||||||
|
}, [fetchErrorLogs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorLogs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
total,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
fetchErrorLogs,
|
||||||
|
refetch: () => fetchErrorLogs(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import axios from "../../../config/axios";
|
||||||
|
import {
|
||||||
|
AccessLog,
|
||||||
|
AccessLogQuery,
|
||||||
|
AccessLogStatsQueryDto,
|
||||||
|
EntityIdQueryDto,
|
||||||
|
AccessLogsResponse,
|
||||||
|
OperationStatsResponse,
|
||||||
|
TopUsersResponse,
|
||||||
|
} from "../types/AccessLogTypes";
|
||||||
|
|
||||||
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
|
class AccessLogsService {
|
||||||
|
private baseUrl = "/access-logs";
|
||||||
|
|
||||||
|
private buildQueryParams(params: QueryParams): URLSearchParams {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
queryParams.append(key, value.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all access logs with filtering
|
||||||
|
async getLogs(filters: AccessLogQuery = {}): Promise<AccessLogsResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}?${params}`);
|
||||||
|
// Handle both response structures
|
||||||
|
if (response.data.data) {
|
||||||
|
return {
|
||||||
|
accessLogs: response.data.data.accessLogs,
|
||||||
|
count: response.data.data.pager.totalItems,
|
||||||
|
paginate: true,
|
||||||
|
data: response.data.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get operation statistics
|
||||||
|
async getStats(
|
||||||
|
filters: AccessLogStatsQueryDto = {}
|
||||||
|
): Promise<OperationStatsResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}/stats?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top users by activity
|
||||||
|
async getTopUsers(
|
||||||
|
filters: AccessLogStatsQueryDto = {}
|
||||||
|
): Promise<TopUsersResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}/top-users?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get error logs only
|
||||||
|
async getErrorLogs(
|
||||||
|
filters: AccessLogQuery = {}
|
||||||
|
): Promise<AccessLogsResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}/errors?${params}`);
|
||||||
|
// Handle both response structures
|
||||||
|
if (response.data.data) {
|
||||||
|
return {
|
||||||
|
accessLogs: response.data.data.accessLogs,
|
||||||
|
count: response.data.data.pager.totalItems,
|
||||||
|
paginate: true,
|
||||||
|
data: response.data.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get specific log by ID
|
||||||
|
async getLogById(id: string): Promise<{ accessLog: AccessLog }> {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs for specific user
|
||||||
|
async getUserLogs(
|
||||||
|
userId: string,
|
||||||
|
filters: AccessLogQuery = {}
|
||||||
|
): Promise<AccessLogsResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(
|
||||||
|
`${this.baseUrl}/user/${userId}?${params}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs for specific entity
|
||||||
|
async getEntityLogs(
|
||||||
|
entityName: string,
|
||||||
|
filters: EntityIdQueryDto = {}
|
||||||
|
): Promise<AccessLogsResponse> {
|
||||||
|
const params = this.buildQueryParams(filters as QueryParams);
|
||||||
|
const response = await axios.get(
|
||||||
|
`${this.baseUrl}/entity/${entityName}?${params}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export logs to CSV
|
||||||
|
async exportLogs(filters: AccessLogQuery = {}): Promise<Blob> {
|
||||||
|
const params = this.buildQueryParams({
|
||||||
|
...filters,
|
||||||
|
format: "csv",
|
||||||
|
} as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}/export?${params}`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get logs count for dashboard
|
||||||
|
async getLogsCount(filters: AccessLogQuery = {}): Promise<{ count: number }> {
|
||||||
|
const params = this.buildQueryParams({
|
||||||
|
...filters,
|
||||||
|
countOnly: true,
|
||||||
|
} as QueryParams);
|
||||||
|
const response = await axios.get(`${this.baseUrl}/count?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent logs (for real-time updates)
|
||||||
|
async getRecentLogs(lastId?: string): Promise<AccessLogsResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (lastId) params.append("after", lastId);
|
||||||
|
params.append("limit", "20");
|
||||||
|
params.append("sortBy", "createdAt");
|
||||||
|
params.append("sortOrder", "DESC");
|
||||||
|
|
||||||
|
const response = await axios.get(`${this.baseUrl}?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accessLogsService = new AccessLogsService();
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Enums
|
||||||
|
export enum OperationType {
|
||||||
|
CREATE = "CREATE",
|
||||||
|
UPDATE = "UPDATE",
|
||||||
|
DELETE = "DELETE",
|
||||||
|
READ = "READ",
|
||||||
|
LOGIN = "LOGIN",
|
||||||
|
LOGOUT = "LOGOUT",
|
||||||
|
APPROVE = "APPROVE",
|
||||||
|
REJECT = "REJECT",
|
||||||
|
ACTIVATE = "ACTIVATE",
|
||||||
|
DEACTIVATE = "DEACTIVATE",
|
||||||
|
EXPORT = "EXPORT",
|
||||||
|
IMPORT = "IMPORT",
|
||||||
|
BULK_UPDATE = "BULK_UPDATE",
|
||||||
|
BULK_DELETE = "BULK_DELETE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserType {
|
||||||
|
SYSTEM = "SYSTEM",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
USER = "USER",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HttpMethod {
|
||||||
|
GET = "GET",
|
||||||
|
POST = "POST",
|
||||||
|
PUT = "PUT",
|
||||||
|
PATCH = "PATCH",
|
||||||
|
DELETE = "DELETE",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main interfaces
|
||||||
|
export interface AccessLog {
|
||||||
|
id: string;
|
||||||
|
operationType: OperationType;
|
||||||
|
userType: UserType;
|
||||||
|
entityName: string;
|
||||||
|
entityId?: string;
|
||||||
|
actionDescription?: string;
|
||||||
|
oldValues?: string; // JSON string
|
||||||
|
newValues?: string; // JSON string
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
httpMethod?: HttpMethod;
|
||||||
|
statusCode?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
metadata?: Record<string, any>; // JSON object
|
||||||
|
userId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
executionTime?: number; // in milliseconds
|
||||||
|
createdAt: string; // ISO date string
|
||||||
|
updatedAt: string; // ISO date string
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query interfaces
|
||||||
|
export interface AccessLogQuery {
|
||||||
|
// Pagination
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
operationType?: OperationType;
|
||||||
|
userType?: UserType;
|
||||||
|
entityName?: string;
|
||||||
|
entityId?: string;
|
||||||
|
userId?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
httpMethod?: HttpMethod;
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
startDate?: string; // ISO date string
|
||||||
|
endDate?: string; // ISO date string
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search?: string; // Searches in actionDescription and entityName
|
||||||
|
|
||||||
|
// Performance
|
||||||
|
minExecutionTime?: number; // milliseconds
|
||||||
|
maxExecutionTime?: number; // milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessLogStatsQueryDto {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityIdQueryDto {
|
||||||
|
entityId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interfaces
|
||||||
|
export interface AccessLogsResponse {
|
||||||
|
accessLogs: AccessLog[];
|
||||||
|
count: number;
|
||||||
|
paginate: boolean;
|
||||||
|
data?: {
|
||||||
|
accessLogs: AccessLog[];
|
||||||
|
pager: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
prevPage: boolean;
|
||||||
|
nextPage: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationStatsResponse {
|
||||||
|
operationStats: Array<{
|
||||||
|
operationType: OperationType;
|
||||||
|
userType: UserType;
|
||||||
|
entityName: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
count: number;
|
||||||
|
paginate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopUsersResponse {
|
||||||
|
topUsers: Array<{
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
activityCount: number;
|
||||||
|
}>;
|
||||||
|
count: number;
|
||||||
|
paginate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI State interfaces
|
||||||
|
export interface AccessLogsState {
|
||||||
|
logs: AccessLog[];
|
||||||
|
stats: OperationStatsResponse | null;
|
||||||
|
topUsers: TopUsersResponse | null;
|
||||||
|
errorLogs: AccessLog[];
|
||||||
|
currentLog: AccessLog | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
filters: AccessLogQuery;
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persian translations
|
||||||
|
export const OperationTypeLabels: Record<OperationType, string> = {
|
||||||
|
[OperationType.CREATE]: "ایجاد",
|
||||||
|
[OperationType.UPDATE]: "بهروزرسانی",
|
||||||
|
[OperationType.DELETE]: "حذف",
|
||||||
|
[OperationType.READ]: "مشاهده",
|
||||||
|
[OperationType.LOGIN]: "ورود",
|
||||||
|
[OperationType.LOGOUT]: "خروج",
|
||||||
|
[OperationType.APPROVE]: "تایید",
|
||||||
|
[OperationType.REJECT]: "رد",
|
||||||
|
[OperationType.ACTIVATE]: "فعالسازی",
|
||||||
|
[OperationType.DEACTIVATE]: "غیرفعالسازی",
|
||||||
|
[OperationType.EXPORT]: "خروجی",
|
||||||
|
[OperationType.IMPORT]: "ورودی",
|
||||||
|
[OperationType.BULK_UPDATE]: "بهروزرسانی گروهی",
|
||||||
|
[OperationType.BULK_DELETE]: "حذف گروهی",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserTypeLabels: Record<UserType, string> = {
|
||||||
|
[UserType.SYSTEM]: "سیستم",
|
||||||
|
[UserType.ADMIN]: "مدیر",
|
||||||
|
[UserType.USER]: "کاربر",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HttpMethodLabels: Record<HttpMethod, string> = {
|
||||||
|
[HttpMethod.GET]: "GET",
|
||||||
|
[HttpMethod.POST]: "POST",
|
||||||
|
[HttpMethod.PUT]: "PUT",
|
||||||
|
[HttpMethod.PATCH]: "PATCH",
|
||||||
|
[HttpMethod.DELETE]: "DELETE",
|
||||||
|
};
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { FC, useEffect, useRef, useState } from 'react'
|
import { FC, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '../../components/Input'
|
import Input from '../../components/Input'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import SwitchComponent from '../../components/Switch'
|
import SwitchComponent from '../../components/Switch'
|
||||||
import { TickCircle } from 'iconsax-react'
|
import { TickCircle } from 'iconsax-react'
|
||||||
import "quill/dist/quill.snow.css";
|
|
||||||
// import Quill from 'quill';
|
|
||||||
import { useFormik } from 'formik'
|
import { useFormik } from 'formik'
|
||||||
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
|
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
|
||||||
import { useCreateBlog, useGetBlogCategories } from './hooks/useBlogData'
|
import { useCreateBlog, useGetBlogCategories } from './hooks/useBlogData'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import CheckBoxComponent from '../../components/CheckBoxComponent'
|
import CheckBoxComponent from '../../components/CheckBoxComponent'
|
||||||
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
|
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
|
||||||
import Quill from 'quill'
|
import QuillEditor from '../../components/QuillEditor'
|
||||||
import { useSingleUpload } from '../service/hooks/useServiceData'
|
import { useSingleUpload } from '../service/hooks/useServiceData'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { ErrorType } from '../../helpers/types'
|
import { ErrorType } from '../../helpers/types'
|
||||||
@@ -22,30 +20,9 @@ const CreateBlog: FC = () => {
|
|||||||
|
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
const [file, setFile] = useState<File>()
|
const [file, setFile] = useState<File>()
|
||||||
|
const [audioFile, setAudioFile] = useState<File>()
|
||||||
const singleSlug = useSingleUpload()
|
const singleSlug = useSingleUpload()
|
||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const audioUpload = useSingleUpload()
|
||||||
const editorRef2 = useRef<HTMLDivElement>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
const quill = new Quill('#editor', {
|
|
||||||
theme: 'snow',
|
|
||||||
});
|
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
|
||||||
const html = editorRef.current?.querySelector('.ql-editor')?.innerHTML || '';
|
|
||||||
formik.setFieldValue('previewContent', html);
|
|
||||||
});
|
|
||||||
const quill2 = new Quill('#editor2', {
|
|
||||||
theme: 'snow',
|
|
||||||
});
|
|
||||||
|
|
||||||
quill2.on('text-change', () => {
|
|
||||||
const html = editorRef2.current?.querySelector('.ql-editor')?.innerHTML || '';
|
|
||||||
formik.setFieldValue('content', html);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const createBlog = useCreateBlog()
|
const createBlog = useCreateBlog()
|
||||||
@@ -57,6 +34,7 @@ const CreateBlog: FC = () => {
|
|||||||
previewContent: '',
|
previewContent: '',
|
||||||
content: '',
|
content: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
audioUrl: '',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
metaDescription: '',
|
metaDescription: '',
|
||||||
@@ -80,6 +58,12 @@ const CreateBlog: FC = () => {
|
|||||||
toast.error(t('errors.required'))
|
toast.error(t('errors.required'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!audioFile) {
|
||||||
|
values.audioUrl = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// آپلود تصویر
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
await singleSlug.mutateAsync(formData, {
|
await singleSlug.mutateAsync(formData, {
|
||||||
@@ -88,6 +72,17 @@ const CreateBlog: FC = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// آپلود فایل صوتی در صورت وجود
|
||||||
|
if (audioFile) {
|
||||||
|
const audioFormData = new FormData()
|
||||||
|
audioFormData.append('file', audioFile)
|
||||||
|
await audioUpload.mutateAsync(audioFormData, {
|
||||||
|
onSuccess(data) {
|
||||||
|
values.audioUrl = data.data.url
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createBlog.mutate(values, {
|
createBlog.mutate(values, {
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
toast.success(t('success'))
|
toast.success(t('success'))
|
||||||
@@ -110,7 +105,7 @@ const CreateBlog: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
className='w-[172px]'
|
className='w-[172px]'
|
||||||
onClick={() => formik.handleSubmit()}
|
onClick={() => formik.handleSubmit()}
|
||||||
isLoading={createBlog.isPending || singleSlug.isPending}
|
isLoading={createBlog.isPending || singleSlug.isPending || audioUpload.isPending}
|
||||||
>
|
>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
<TickCircle size={20} color='white' />
|
<TickCircle size={20} color='white' />
|
||||||
@@ -141,7 +136,13 @@ const CreateBlog: FC = () => {
|
|||||||
|
|
||||||
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
|
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
|
||||||
<div className='mt-1'>
|
<div className='mt-1'>
|
||||||
<div className='min-h-[120px]' ref={editorRef} id='editor'></div>
|
<QuillEditor
|
||||||
|
id="preview-editor"
|
||||||
|
value={formik.values.previewContent}
|
||||||
|
onChange={(content) => formik.setFieldValue('previewContent', content)}
|
||||||
|
placeholder={t('blog.enter_blog_summary')}
|
||||||
|
minHeight="120px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -152,7 +153,13 @@ const CreateBlog: FC = () => {
|
|||||||
|
|
||||||
<p className='mt-6 text-sm'>{t('blog.content')}</p>
|
<p className='mt-6 text-sm'>{t('blog.content')}</p>
|
||||||
<div className='mt-1'>
|
<div className='mt-1'>
|
||||||
<div className='min-h-[200px]' ref={editorRef2} id='editor2'></div>
|
<QuillEditor
|
||||||
|
id="content-editor"
|
||||||
|
value={formik.values.content}
|
||||||
|
onChange={(content) => formik.setFieldValue('content', content)}
|
||||||
|
placeholder={t('blog.enter_blog_content')}
|
||||||
|
minHeight="200px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -216,7 +223,7 @@ const CreateBlog: FC = () => {
|
|||||||
checked={formik.values.categoryId === item.id}
|
checked={formik.values.categoryId === item.id}
|
||||||
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
|
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
|
||||||
/>
|
/>
|
||||||
<div>{item.title}</div>
|
<div className='text-[13px]'>{item.title}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -231,6 +238,16 @@ const CreateBlog: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='mt-8'>
|
||||||
|
<p className='text-sm'>{t('blog.audio_file')}</p>
|
||||||
|
<div className='mt-4'>
|
||||||
|
<UploadBoxDraggble
|
||||||
|
label={t('blog.audio_file')}
|
||||||
|
onChange={(files) => setAudioFile(files[0])}
|
||||||
|
accept="audio/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className='mt-6 text-sm'>{t('blog.tags')}</p>
|
<p className='mt-6 text-sm'>{t('blog.tags')}</p>
|
||||||
<div className='mt-6'>
|
<div className='mt-6'>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
+55
-41
@@ -1,18 +1,16 @@
|
|||||||
import { FC, useEffect, useRef, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '../../components/Input'
|
import Input from '../../components/Input'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import SwitchComponent from '../../components/Switch'
|
import SwitchComponent from '../../components/Switch'
|
||||||
import { TickCircle } from 'iconsax-react'
|
import { TickCircle } from 'iconsax-react'
|
||||||
import "quill/dist/quill.snow.css";
|
|
||||||
// import Quill from 'quill';
|
|
||||||
import { useFormik } from 'formik'
|
import { useFormik } from 'formik'
|
||||||
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
|
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
|
||||||
import { useGetBlogCategories, useGetBlogDetail, useUpdateBlog } from './hooks/useBlogData'
|
import { useGetBlogCategories, useGetBlogDetail, useUpdateBlog } from './hooks/useBlogData'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import CheckBoxComponent from '../../components/CheckBoxComponent'
|
import CheckBoxComponent from '../../components/CheckBoxComponent'
|
||||||
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
|
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
|
||||||
import Quill from 'quill'
|
import QuillEditor from '../../components/QuillEditor'
|
||||||
import { useSingleUpload } from '../service/hooks/useServiceData'
|
import { useSingleUpload } from '../service/hooks/useServiceData'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { ErrorType } from '../../helpers/types'
|
import { ErrorType } from '../../helpers/types'
|
||||||
@@ -23,31 +21,10 @@ const UpdateBlog: FC = () => {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
const [file, setFile] = useState<File>()
|
const [file, setFile] = useState<File>()
|
||||||
|
const [audioFile, setAudioFile] = useState<File>()
|
||||||
const singleSlug = useSingleUpload()
|
const singleSlug = useSingleUpload()
|
||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const audioUpload = useSingleUpload()
|
||||||
const editorRef2 = useRef<HTMLDivElement>(null)
|
|
||||||
const getBlogDetail = useGetBlogDetail(id || '')
|
const getBlogDetail = useGetBlogDetail(id || '')
|
||||||
useEffect(() => {
|
|
||||||
const quill = new Quill('#editor', {
|
|
||||||
theme: 'snow',
|
|
||||||
});
|
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
|
||||||
const html = editorRef.current?.querySelector('.ql-editor')?.innerHTML || '';
|
|
||||||
formik.setFieldValue('previewContent', html);
|
|
||||||
});
|
|
||||||
const quill2 = new Quill('#editor2', {
|
|
||||||
theme: 'snow',
|
|
||||||
});
|
|
||||||
|
|
||||||
quill2.on('text-change', () => {
|
|
||||||
const html = editorRef2.current?.querySelector('.ql-editor')?.innerHTML || '';
|
|
||||||
formik.setFieldValue('content', html);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const updateBlog = useUpdateBlog()
|
const updateBlog = useUpdateBlog()
|
||||||
@@ -59,6 +36,7 @@ const UpdateBlog: FC = () => {
|
|||||||
previewContent: '',
|
previewContent: '',
|
||||||
content: '',
|
content: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
audioUrl: '',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
metaDescription: '',
|
metaDescription: '',
|
||||||
@@ -78,6 +56,7 @@ const UpdateBlog: FC = () => {
|
|||||||
slug: Yup.string().required(t('errors.required')),
|
slug: Yup.string().required(t('errors.required')),
|
||||||
}),
|
}),
|
||||||
onSubmit: async (values) => {
|
onSubmit: async (values) => {
|
||||||
|
// آپلود تصویر در صورت انتخاب فایل جدید
|
||||||
if (file) {
|
if (file) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -88,6 +67,20 @@ const UpdateBlog: FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!audioFile) {
|
||||||
|
values.audioUrl = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// آپلود فایل صوتی در صورت وجود
|
||||||
|
if (audioFile) {
|
||||||
|
const audioFormData = new FormData()
|
||||||
|
audioFormData.append('file', audioFile)
|
||||||
|
await audioUpload.mutateAsync(audioFormData, {
|
||||||
|
onSuccess(data) {
|
||||||
|
values.audioUrl = data.data.url
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
updateBlog.mutate({ id: id || '', params: values }, {
|
updateBlog.mutate({ id: id || '', params: values }, {
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -105,16 +98,6 @@ const UpdateBlog: FC = () => {
|
|||||||
if (getBlogDetail.data) {
|
if (getBlogDetail.data) {
|
||||||
formik.setValues(getBlogDetail.data?.data?.blog)
|
formik.setValues(getBlogDetail.data?.data?.blog)
|
||||||
formik.setFieldValue('categoryId', getBlogDetail.data?.data?.blog.category.id)
|
formik.setFieldValue('categoryId', getBlogDetail.data?.data?.blog.category.id)
|
||||||
|
|
||||||
const editorElement = editorRef.current?.querySelector('.ql-editor');
|
|
||||||
if (editorElement) {
|
|
||||||
editorElement.innerHTML = getBlogDetail.data?.data?.blog?.previewContent || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorElement2 = editorRef2.current?.querySelector('.ql-editor');
|
|
||||||
if (editorElement2) {
|
|
||||||
editorElement2.innerHTML = getBlogDetail.data?.data?.blog?.content || '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getBlogDetail.data])
|
}, [getBlogDetail.data])
|
||||||
@@ -129,7 +112,7 @@ const UpdateBlog: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
className='w-[172px]'
|
className='w-[172px]'
|
||||||
onClick={() => formik.handleSubmit()}
|
onClick={() => formik.handleSubmit()}
|
||||||
isLoading={updateBlog.isPending || singleSlug.isPending}
|
isLoading={updateBlog.isPending || singleSlug.isPending || audioUpload.isPending}
|
||||||
>
|
>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
<TickCircle size={20} color='white' />
|
<TickCircle size={20} color='white' />
|
||||||
@@ -160,7 +143,13 @@ const UpdateBlog: FC = () => {
|
|||||||
|
|
||||||
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
|
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
|
||||||
<div className='mt-1'>
|
<div className='mt-1'>
|
||||||
<div className='min-h-[120px]' ref={editorRef} id='editor'></div>
|
<QuillEditor
|
||||||
|
id="preview-editor-update"
|
||||||
|
value={formik.values.previewContent}
|
||||||
|
onChange={(content) => formik.setFieldValue('previewContent', content)}
|
||||||
|
placeholder={t('blog.enter_blog_summary')}
|
||||||
|
minHeight="120px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -171,7 +160,13 @@ const UpdateBlog: FC = () => {
|
|||||||
|
|
||||||
<p className='mt-6 text-sm'>{t('blog.content')}</p>
|
<p className='mt-6 text-sm'>{t('blog.content')}</p>
|
||||||
<div className='mt-1'>
|
<div className='mt-1'>
|
||||||
<div className='min-h-[200px]' ref={editorRef2} id='editor2'></div>
|
<QuillEditor
|
||||||
|
id="content-editor-update"
|
||||||
|
value={formik.values.content}
|
||||||
|
onChange={(content) => formik.setFieldValue('content', content)}
|
||||||
|
placeholder={t('blog.enter_blog_content')}
|
||||||
|
minHeight="200px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -235,7 +230,7 @@ const UpdateBlog: FC = () => {
|
|||||||
checked={formik.values.categoryId === item.id}
|
checked={formik.values.categoryId === item.id}
|
||||||
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
|
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
|
||||||
/>
|
/>
|
||||||
<div>{item.title}</div>
|
<div className='text-[13px]'>{item.title}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -250,6 +245,25 @@ const UpdateBlog: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='mt-8'>
|
||||||
|
<p className='text-sm'>{t('blog.audio_file')}</p>
|
||||||
|
<div className='mt-4'>
|
||||||
|
<UploadBoxDraggble
|
||||||
|
label={t('blog.audio_file')}
|
||||||
|
onChange={(files) => setAudioFile(files[0])}
|
||||||
|
accept="audio/*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formik.values.audioUrl && (
|
||||||
|
<div className='mt-2'>
|
||||||
|
<p className='text-xs text-gray-500'>{t('blog.current_audio_file')}:</p>
|
||||||
|
<audio controls className='w-full mt-1'>
|
||||||
|
<source src={formik.values.audioUrl} type="audio/mpeg" />
|
||||||
|
{t('blog.audio_not_supported')}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className='mt-6 text-sm'>{t('blog.tags')}</p>
|
<p className='mt-6 text-sm'>{t('blog.tags')}</p>
|
||||||
<div className='mt-6'>
|
<div className='mt-6'>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type CreateBlogType = {
|
|||||||
previewContent: string;
|
previewContent: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
audioUrl?: string;
|
||||||
metaTitle: string;
|
metaTitle: string;
|
||||||
metaDescription: string;
|
metaDescription: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ import List from '../pages/guide/List'
|
|||||||
import CreateGuide from '../pages/guide/Create'
|
import CreateGuide from '../pages/guide/Create'
|
||||||
import UpdateGuide from '../pages/guide/Update.tsx'
|
import UpdateGuide from '../pages/guide/Update.tsx'
|
||||||
import ListFeedback from '../pages/feedback/List'
|
import ListFeedback from '../pages/feedback/List'
|
||||||
|
import AccessLogsList from '../pages/accessLogs/AccessLogsList'
|
||||||
|
import AccessLogsStats from '../pages/accessLogs/AccessLogsStats'
|
||||||
|
import ErrorLogs from '../pages/accessLogs/ErrorLogs'
|
||||||
|
import PermissionLogs from '../pages/accessLogs/PermissionLogs'
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
|
|
||||||
const { hasSubMenu } = useSharedStore()
|
const { hasSubMenu } = useSharedStore()
|
||||||
@@ -157,6 +161,10 @@ const MainRouter: FC = () => {
|
|||||||
<Route path={Pages.services.guide + ':id/create'} element={<CreateGuide />} />
|
<Route path={Pages.services.guide + ':id/create'} element={<CreateGuide />} />
|
||||||
<Route path={Pages.services.guide + ':id/:guideId'} element={<UpdateGuide />} />
|
<Route path={Pages.services.guide + ':id/:guideId'} element={<UpdateGuide />} />
|
||||||
<Route path={Pages.feedback.list} element={<ListFeedback />} />
|
<Route path={Pages.feedback.list} element={<ListFeedback />} />
|
||||||
|
<Route path={Pages.accessLogs.list} element={<AccessLogsList />} />
|
||||||
|
<Route path={Pages.accessLogs.stats} element={<AccessLogsStats />} />
|
||||||
|
<Route path={Pages.accessLogs.errors} element={<ErrorLogs />} />
|
||||||
|
<Route path={Pages.accessLogs.permissions} element={<PermissionLogs />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-2
@@ -2,7 +2,7 @@ import { FC, useEffect } from 'react'
|
|||||||
import LogoImage from '../assets/images/logo.svg'
|
import LogoImage from '../assets/images/logo.svg'
|
||||||
import LogoSmall from '../assets/images/logo-small.svg'
|
import LogoSmall from '../assets/images/logo-small.svg'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Card, Code, CodeCircle, DocumentLike, DocumentText, Element3, Gallery, Headphone, Home2, Logout, Message, Messages3, Money3, NotificationStatus, People, Profile, Receipt21, Setting2, SmsTracking, Teacher, TicketDiscount, UserSquare } from 'iconsax-react'
|
import { Card, Code, CodeCircle, DocumentLike, DocumentText, Element3, Gallery, Headphone, Home2, Logout, Message, Messages3, Money3, NotificationStatus, People, Profile, Receipt21, Setting2, SmsTracking, Teacher, TicketDiscount, UserSquare, Activity } from 'iconsax-react'
|
||||||
import SideBarItem from './SideBarItem'
|
import SideBarItem from './SideBarItem'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Pages } from '../config/Pages'
|
import { Pages } from '../config/Pages'
|
||||||
@@ -18,6 +18,7 @@ import LearningSubMenu from './components/LearningSubMenu'
|
|||||||
import BlogSubMenu from './components/BlogSubMenu'
|
import BlogSubMenu from './components/BlogSubMenu'
|
||||||
import { useGetProfile } from '../pages/profile/hooks/useProfileData'
|
import { useGetProfile } from '../pages/profile/hooks/useProfileData'
|
||||||
import SupportSubMenu from './components/SupportSubMenu'
|
import SupportSubMenu from './components/SupportSubMenu'
|
||||||
|
import AccessLogsSubMenu from './components/AccessLogsSubMenu'
|
||||||
const SideBar: FC = () => {
|
const SideBar: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
@@ -209,6 +210,17 @@ const SideBar: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='text-xs text-[#8C90A3]'>
|
||||||
|
<SideBarItem
|
||||||
|
icon={<Activity variant={isActive('access-logs') ? 'Bold' : 'Outline'} color={isActive('access-logs') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title={t('sidebar.accessLogs')}
|
||||||
|
isActive={isActive('access-logs')}
|
||||||
|
link={Pages.accessLogs.list}
|
||||||
|
name='accessLogs'
|
||||||
|
activeName='logs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='text-xs text-[#8C90A3]'>
|
<div className='text-xs text-[#8C90A3]'>
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<DocumentText variant={isActive('criticisms') ? 'Bold' : 'Outline'} color={isActive('criticisms') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<DocumentText variant={isActive('criticisms') ? 'Bold' : 'Outline'} color={isActive('criticisms') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
@@ -335,7 +347,9 @@ const SideBar: FC = () => {
|
|||||||
<BlogSubMenu />
|
<BlogSubMenu />
|
||||||
: subMenuName === 'support' ?
|
: subMenuName === 'support' ?
|
||||||
<SupportSubMenu />
|
<SupportSubMenu />
|
||||||
: null
|
: subMenuName === 'accessLogs' ?
|
||||||
|
<AccessLogsSubMenu />
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { Pages } from '../../config/Pages'
|
||||||
|
import SideBarItem from '../SideBarItem'
|
||||||
|
import { DocumentText, Activity, Warning2, Shield } from 'iconsax-react'
|
||||||
|
|
||||||
|
const AccessLogsSubMenu: FC = () => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
const location = useLocation()
|
||||||
|
const iconSizeSideBar = 20
|
||||||
|
|
||||||
|
const isActive = (name: string) => {
|
||||||
|
return location.pathname.includes(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col h-full'>
|
||||||
|
<div className='px-6 py-4 text-header text-sm font-normal'>
|
||||||
|
{t('sidebar.accessLogs')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-1 px-6'>
|
||||||
|
<div className='text-xs text-[#8C90A3] space-y-2'>
|
||||||
|
<SideBarItem
|
||||||
|
icon={<Activity variant={isActive('access-logs/list') ? 'Bold' : 'Outline'} color={isActive('access-logs/list') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title="لیست لاگها"
|
||||||
|
isActive={isActive('access-logs/list')}
|
||||||
|
link={Pages.accessLogs.list}
|
||||||
|
activeName='access_logs_list'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={<DocumentText variant={isActive('access-logs/stats') ? 'Bold' : 'Outline'} color={isActive('access-logs/stats') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title="آمار لاگها"
|
||||||
|
isActive={isActive('access-logs/stats')}
|
||||||
|
link={Pages.accessLogs.stats}
|
||||||
|
activeName='access_logs_stats'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={<Warning2 variant={isActive('access-logs/errors') ? 'Bold' : 'Outline'} color={isActive('access-logs/errors') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title="لاگهای خطا"
|
||||||
|
isActive={isActive('access-logs/errors')}
|
||||||
|
link={Pages.accessLogs.errors}
|
||||||
|
activeName='access_logs_errors'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={<Shield variant={isActive('access-logs/permissions') ? 'Bold' : 'Outline'} color={isActive('access-logs/permissions') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
|
title="لاگهای مجوز"
|
||||||
|
isActive={isActive('access-logs/permissions')}
|
||||||
|
link={Pages.accessLogs.permissions}
|
||||||
|
activeName='access_logs_permissions'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccessLogsSubMenu
|
||||||
Reference in New Issue
Block a user