access logs + audio

This commit is contained in:
hamid zarghami
2025-09-02 17:00:37 +03:30
parent 860f7c446f
commit 0e16af827f
25 changed files with 3394 additions and 83 deletions
+157
View File
@@ -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
+31 -4
View File
@@ -2,13 +2,40 @@ import { FC } from 'react'
type Props = {
color: string
text?: string
size?: 'sm' | 'md' | 'lg'
}
const StatusCircle: FC<Props> = (props: Props) => {
return (
<div style={{ background: props.color }} className='size-1.5 rounded-full'>
const StatusCircle: FC<Props> = ({ color, text, size = 'md' }) => {
const sizeClasses = {
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'}`}
/>
)
}
+6 -2
View File
@@ -12,7 +12,8 @@ type Props = {
preview?: string[],
onChangePreview?: (preview: string[]) => void,
getCover?: (url: string) => void,
coverUrl?: string
coverUrl?: string,
accept?: string
}
const UploadBoxDraggble: FC<Props> = (props: Props) => {
@@ -35,7 +36,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: props.accept ? { [props.accept]: [] } : undefined
})
const handleDelete = (index: number) => {
const array = [...files]
+73
View File
@@ -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;
+6
View File
@@ -116,4 +116,10 @@ export const Pages = {
detail: "/support/detail/",
planUsers: "/support/plan-users/",
},
accessLogs: {
list: "/access-logs/list",
stats: "/access-logs/stats",
errors: "/access-logs/errors",
permissions: "/access-logs/permissions",
},
};
+1
View File
@@ -7,4 +7,5 @@ export const SideBarItemHasSubMenu = [
"learning",
"blog",
"support",
"accessLogs",
];
+11 -2
View File
@@ -72,7 +72,8 @@
"payments": "پرداخت ها",
"sliders": "اسلایدر",
"support": "پلن پشتیبانی",
"feedback": "گزارش ها"
"feedback": "گزارش ها",
"accessLogs": "لاگ‌های دسترسی"
},
"slider": {
"new_slider": "اسلایدر جدید",
@@ -107,7 +108,10 @@
"create_plan": "ایجاد پلن",
"plan_users": "کاربران پلن",
"plan_list": "لیست پلن ها",
"guide": "راهنمای سرویس"
"guide": "راهنمای سرویس",
"access_logs_list": "لیست لاگ‌ها",
"access_logs_stats": "آمار لاگ‌ها",
"access_logs_errors": "لاگ‌های خطا"
},
"guide": {
"list_guide": "لیست راهنمای سرویس",
@@ -390,6 +394,9 @@
"content": "محتوا",
"blog_status": "وضعیت بلاگ",
"featured_image": "تصویر شاخص",
"audio_file": "فایل صوتی",
"current_audio_file": "فایل صوتی فعلی",
"audio_not_supported": "مرورگر شما از پخش صوت پشتیبانی نمی‌کند.",
"tags": "برچسب ها",
"tag_hint": "جدا کردن با کاما یا کلید Enter ",
"shareDate": "تاریخ انتشار",
@@ -431,6 +438,8 @@
"enter_your_meta_title": "عنوان متا بلاگ را وارد کنید",
"meta_description": "توضیحات متا",
"enter_your_meta_description": "توضیحات متا بلاگ را وارد کنید",
"enter_blog_summary": "خلاصه بلاگ را وارد کنید",
"enter_blog_content": "محتوای بلاگ را وارد کنید",
"comments": "نظرات",
"user": "کاربر",
"comment": "نظر",
+219
View File
@@ -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;
+279
View File
@@ -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;
+303
View File
@@ -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;
+293
View File
@@ -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;
+232
View File
@@ -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",
};
+48 -31
View File
@@ -1,18 +1,16 @@
import { FC, useEffect, useRef, useState } from 'react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '../../components/Input'
import Button from '../../components/Button'
import SwitchComponent from '../../components/Switch'
import { TickCircle } from 'iconsax-react'
import "quill/dist/quill.snow.css";
// import Quill from 'quill';
import { useFormik } from 'formik'
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
import { useCreateBlog, useGetBlogCategories } from './hooks/useBlogData'
import * as Yup from 'yup'
import CheckBoxComponent from '../../components/CheckBoxComponent'
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
import Quill from 'quill'
import QuillEditor from '../../components/QuillEditor'
import { useSingleUpload } from '../service/hooks/useServiceData'
import { toast } from 'react-toastify'
import { ErrorType } from '../../helpers/types'
@@ -22,30 +20,9 @@ const CreateBlog: FC = () => {
const { t } = useTranslation('global')
const [file, setFile] = useState<File>()
const [audioFile, setAudioFile] = useState<File>()
const singleSlug = useSingleUpload()
const editorRef = useRef<HTMLDivElement>(null)
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 audioUpload = useSingleUpload()
const navigate = useNavigate()
const createBlog = useCreateBlog()
@@ -57,6 +34,7 @@ const CreateBlog: FC = () => {
previewContent: '',
content: '',
imageUrl: '',
audioUrl: '',
isActive: false,
isPinned: false,
metaDescription: '',
@@ -80,6 +58,12 @@ const CreateBlog: FC = () => {
toast.error(t('errors.required'))
return
}
if (!audioFile) {
values.audioUrl = undefined
}
// آپلود تصویر
const formData = new FormData()
formData.append('file', file)
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, {
onSuccess() {
toast.success(t('success'))
@@ -110,7 +105,7 @@ const CreateBlog: FC = () => {
<Button
className='w-[172px]'
onClick={() => formik.handleSubmit()}
isLoading={createBlog.isPending || singleSlug.isPending}
isLoading={createBlog.isPending || singleSlug.isPending || audioUpload.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={20} color='white' />
@@ -141,7 +136,13 @@ const CreateBlog: FC = () => {
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
<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>
{
@@ -152,7 +153,13 @@ const CreateBlog: FC = () => {
<p className='mt-6 text-sm'>{t('blog.content')}</p>
<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>
{
@@ -216,7 +223,7 @@ const CreateBlog: FC = () => {
checked={formik.values.categoryId === item.id}
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
/>
<div>{item.title}</div>
<div className='text-[13px]'>{item.title}</div>
</div>
)
})
@@ -231,6 +238,16 @@ const CreateBlog: FC = () => {
/>
</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>
<div className='mt-6'>
<Input
+55 -41
View File
@@ -1,18 +1,16 @@
import { FC, useEffect, useRef, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '../../components/Input'
import Button from '../../components/Button'
import SwitchComponent from '../../components/Switch'
import { TickCircle } from 'iconsax-react'
import "quill/dist/quill.snow.css";
// import Quill from 'quill';
import { useFormik } from 'formik'
import { BlogCategoryType, CreateBlogType } from './types/BlogTypes'
import { useGetBlogCategories, useGetBlogDetail, useUpdateBlog } from './hooks/useBlogData'
import * as Yup from 'yup'
import CheckBoxComponent from '../../components/CheckBoxComponent'
import UploadBoxDraggble from '../../components/UploadBoxDraggble'
import Quill from 'quill'
import QuillEditor from '../../components/QuillEditor'
import { useSingleUpload } from '../service/hooks/useServiceData'
import { toast } from 'react-toastify'
import { ErrorType } from '../../helpers/types'
@@ -23,31 +21,10 @@ const UpdateBlog: FC = () => {
const { id } = useParams()
const { t } = useTranslation('global')
const [file, setFile] = useState<File>()
const [audioFile, setAudioFile] = useState<File>()
const singleSlug = useSingleUpload()
const editorRef = useRef<HTMLDivElement>(null)
const editorRef2 = useRef<HTMLDivElement>(null)
const audioUpload = useSingleUpload()
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 updateBlog = useUpdateBlog()
@@ -59,6 +36,7 @@ const UpdateBlog: FC = () => {
previewContent: '',
content: '',
imageUrl: '',
audioUrl: '',
isActive: false,
isPinned: false,
metaDescription: '',
@@ -78,6 +56,7 @@ const UpdateBlog: FC = () => {
slug: Yup.string().required(t('errors.required')),
}),
onSubmit: async (values) => {
// آپلود تصویر در صورت انتخاب فایل جدید
if (file) {
const formData = new FormData()
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 }, {
onSuccess() {
@@ -105,16 +98,6 @@ const UpdateBlog: FC = () => {
if (getBlogDetail.data) {
formik.setValues(getBlogDetail.data?.data?.blog)
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
}, [getBlogDetail.data])
@@ -129,7 +112,7 @@ const UpdateBlog: FC = () => {
<Button
className='w-[172px]'
onClick={() => formik.handleSubmit()}
isLoading={updateBlog.isPending || singleSlug.isPending}
isLoading={updateBlog.isPending || singleSlug.isPending || audioUpload.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={20} color='white' />
@@ -160,7 +143,13 @@ const UpdateBlog: FC = () => {
<p className='mt-6 text-sm'>{t('blog.blog_summary')}</p>
<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>
{
@@ -171,7 +160,13 @@ const UpdateBlog: FC = () => {
<p className='mt-6 text-sm'>{t('blog.content')}</p>
<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>
{
@@ -235,7 +230,7 @@ const UpdateBlog: FC = () => {
checked={formik.values.categoryId === item.id}
onChange={(e) => formik.setFieldValue('categoryId', e.target.checked ? item.id : '')}
/>
<div>{item.title}</div>
<div className='text-[13px]'>{item.title}</div>
</div>
)
})
@@ -250,6 +245,25 @@ const UpdateBlog: FC = () => {
/>
</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>
<div className='mt-6'>
<Input
+1
View File
@@ -3,6 +3,7 @@ export type CreateBlogType = {
previewContent: string;
content: string;
imageUrl: string;
audioUrl?: string;
metaTitle: string;
metaDescription: string;
tags: string[];
+8
View File
@@ -73,6 +73,10 @@ import List from '../pages/guide/List'
import CreateGuide from '../pages/guide/Create'
import UpdateGuide from '../pages/guide/Update.tsx'
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 { hasSubMenu } = useSharedStore()
@@ -157,6 +161,10 @@ const MainRouter: FC = () => {
<Route path={Pages.services.guide + ':id/create'} element={<CreateGuide />} />
<Route path={Pages.services.guide + ':id/:guideId'} element={<UpdateGuide />} />
<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>
</div>
</div>
+16 -2
View File
@@ -2,7 +2,7 @@ import { FC, useEffect } from 'react'
import LogoImage from '../assets/images/logo.svg'
import LogoSmall from '../assets/images/logo-small.svg'
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 { useLocation } from 'react-router-dom'
import { Pages } from '../config/Pages'
@@ -18,6 +18,7 @@ import LearningSubMenu from './components/LearningSubMenu'
import BlogSubMenu from './components/BlogSubMenu'
import { useGetProfile } from '../pages/profile/hooks/useProfileData'
import SupportSubMenu from './components/SupportSubMenu'
import AccessLogsSubMenu from './components/AccessLogsSubMenu'
const SideBar: FC = () => {
const { t } = useTranslation('global')
@@ -209,6 +210,17 @@ const SideBar: FC = () => {
/>
</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]'>
<SideBarItem
icon={<DocumentText variant={isActive('criticisms') ? 'Bold' : 'Outline'} color={isActive('criticisms') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
@@ -335,7 +347,9 @@ const SideBar: FC = () => {
<BlogSubMenu />
: subMenuName === 'support' ?
<SupportSubMenu />
: null
: subMenuName === 'accessLogs' ?
<AccessLogsSubMenu />
: null
}
</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